mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 20:35:02 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd9e9d87f | ||
|
|
6e2fee462f | ||
|
|
cec92d0be6 | ||
|
|
b407990699 | ||
|
|
fc36438f03 | ||
|
|
0fb6473a4b | ||
|
|
a730ad1924 | ||
|
|
42c7b7f886 | ||
|
|
85fb5b436d | ||
|
|
c81f38d7ca | ||
|
|
be487ce65f | ||
|
|
0e0ebc7c9e | ||
|
|
7f3967d288 | ||
|
|
ade902f195 | ||
|
|
1bd94ac2b0 | ||
|
|
7deb338d4c | ||
|
|
2fa0e4b169 | ||
|
|
d11239fd95 | ||
|
|
d30aa16beb |
@@ -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
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which uses React Native’s press lifecycle on native with a short press delay so scrolling cancels pending feedback. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which owns the pressed feedback: pass the pressed fill as `highlightStyle` and never paint it in your own style. iOS and web paint that fill; Android draws the platform ripple over the content instead, clipped to the row's corners. On native, feedback waits for a short press delay so a scroll that claims the touch shows none. Content that knocks out of the row's surface, such as a badge ring, follows the fill through `onHighlightChange`. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ than downloading a published desktop release.
|
||||
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying agent and project JSON metadata and configuration. Both this setup and `PASEO_DEV_SEED_HOME` use the same seeder, which disables `agents.resumeOnStartup` in the copied config. Startup repairs copied running records without resuming agents or sending recovery prompts. Schedules and heartbeats (stored under `schedules/`), runtime files, identities, sockets, and logs are not copied. Existing nonempty homes are left unchanged unless explicitly reset. Copied agents still reference the original provider sessions and working directories: manually prompting one reaches that real session.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
|
||||
@@ -61,6 +61,10 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
|
||||
example because the agent could not be loaded or the requested mode could not be set, leaves no
|
||||
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
|
||||
as an unfinished delivery.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
|
||||
@@ -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 ");
|
||||
|
||||
@@ -14,6 +14,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
archivedToast,
|
||||
archiveFromRowButton,
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
resizeSidebarToMinimum,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
scrollSidebarListToEnd,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
workspaceTitleWidth,
|
||||
@@ -119,6 +121,41 @@ test.describe("Workspace archive undo", () => {
|
||||
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
|
||||
});
|
||||
|
||||
test("a long list ends just below its last row, and the toast over it moves no row", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
const fillers: Array<{ path: string; cleanup: () => Promise<void> }> = [];
|
||||
const fillerProjectIds: string[] = [];
|
||||
try {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const repo = await createTempGitRepo(`archive-toast-filler-${index}-`);
|
||||
fillers.push(repo);
|
||||
fillerProjectIds.push((await openProjectViaDaemon(client, repo.path)).projectId);
|
||||
}
|
||||
const worktree = await seedWorktree(page, "archive-toast-long-list");
|
||||
|
||||
const resting = await scrollSidebarListToEnd(page);
|
||||
expect(resting.listBottom - resting.rowBottoms.at(-1)!).toBeLessThan(resting.rowHeight);
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await page.mouse.move(0, 0);
|
||||
const withToast = await scrollSidebarListToEnd(page);
|
||||
const toastBox = await archivedToast(page).boundingBox();
|
||||
if (!toastBox) throw new Error("Toast is not laid out");
|
||||
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(withToast.listBottom);
|
||||
|
||||
await outlastUndoWindow(page);
|
||||
await expect(archivedToast(page)).toHaveCount(0);
|
||||
expect(await scrollSidebarListToEnd(page)).toEqual(withToast);
|
||||
} finally {
|
||||
for (const projectId of fillerProjectIds) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
for (const repo of fillers) await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("an archive that fails after the sidebar was replaced still shows its error", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface OlderTimelinePagesGate {
|
||||
getRepeatedEntryCount(): number;
|
||||
getOwnedEntryCountContaining(text: string): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaemonHydrationGate {
|
||||
@@ -371,15 +370,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
const ownedEntries = new Map<string, string>();
|
||||
const releasedPages = new Set<number>();
|
||||
const delayedForwards = new Map<number, Array<() => void>>();
|
||||
const requestWaiters = new Map<number, Array<() => void>>();
|
||||
|
||||
const resolveRequestWaiters = () => {
|
||||
for (const [count, resolvers] of requestWaiters) {
|
||||
if (requestCount < count) continue;
|
||||
requestWaiters.delete(count);
|
||||
for (const resolve of resolvers) resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const routePattern =
|
||||
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
|
||||
@@ -393,7 +383,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
sessionMessage.direction === "before"
|
||||
) {
|
||||
requestCount += 1;
|
||||
resolveRequestWaiters();
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
@@ -439,14 +428,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||
delayedForwards.delete(pageNumber);
|
||||
},
|
||||
waitForRequestCount(count) {
|
||||
if (requestCount >= count) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const resolvers = requestWaiters.get(count) ?? [];
|
||||
resolvers.push(resolve);
|
||||
requestWaiters.set(count, resolvers);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -427,7 +427,11 @@ export async function holdOlderHistoryPages(
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
await expect
|
||||
.poll(() => gate.getRequestCount(), {
|
||||
message: `older history requests ${count} page(s)`,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(count);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
|
||||
@@ -162,3 +162,38 @@ export function createGatedTeardown(): GatedTeardown {
|
||||
release: () => writeFileSync(releasePath, "release\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function archivedToast(page: Page) {
|
||||
return page.getByTestId("workspace-archived-toast");
|
||||
}
|
||||
|
||||
export interface SidebarListLayout {
|
||||
/** Bottom edge of the list's visible area. */
|
||||
listBottom: number;
|
||||
/** Bottom edges of the visible project and workspace rows, top to bottom. */
|
||||
rowBottoms: number[];
|
||||
rowHeight: number;
|
||||
}
|
||||
|
||||
/** Scrolls the sidebar's workspace list to its end and reads where its rows sit. */
|
||||
export async function scrollSidebarListToEnd(page: Page): Promise<SidebarListLayout> {
|
||||
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
await list.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
return list.evaluate((element) => {
|
||||
const visible = element.getBoundingClientRect();
|
||||
const rows = Array.from(
|
||||
element.querySelectorAll<HTMLElement>(
|
||||
'[data-testid^="sidebar-workspace-row-"],[data-testid^="sidebar-project-row-"]',
|
||||
),
|
||||
)
|
||||
.map((row) => row.getBoundingClientRect())
|
||||
.filter((row) => row.height > 0 && row.bottom > visible.top && row.top < visible.bottom);
|
||||
return {
|
||||
listBottom: visible.bottom,
|
||||
rowBottoms: rows.map((row) => row.bottom).sort((a, b) => a - b),
|
||||
rowHeight: Math.min(...rows.map((row) => row.height)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -49,8 +49,21 @@ afterEach(() => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
function mountTimeline(historyMounted: StreamItem[]) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
type OlderHistoryInput = Pick<
|
||||
StreamRenderInput,
|
||||
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
|
||||
>;
|
||||
|
||||
interface MountTimelineOptions {
|
||||
olderHistory?: OlderHistoryInput;
|
||||
isMobileBreakpoint?: boolean;
|
||||
}
|
||||
|
||||
function mountTimeline(
|
||||
historyMounted: StreamItem[],
|
||||
{ olderHistory, isMobileBreakpoint = true }: MountTimelineOptions = {},
|
||||
) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const input: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
@@ -76,6 +89,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
...olderHistory,
|
||||
};
|
||||
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
|
||||
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
|
||||
@@ -100,8 +114,20 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
scrollTo: (top: number) => {
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
// A wheel listener runs before the browser applies the scroll the wheel causes.
|
||||
wheelUpTo: (top: number) => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
distanceFromEnd: () =>
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
|
||||
scrollTop: () => scrollContainer.scrollTop,
|
||||
scrollUp: (distance: number) =>
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -distance }));
|
||||
scrollContainer.scrollTop -= distance;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
}),
|
||||
// Resize observers and the hold's settle run on animation frames.
|
||||
settle: () =>
|
||||
act(async () => {
|
||||
@@ -113,6 +139,53 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
}
|
||||
|
||||
describe("web disclosure hold", () => {
|
||||
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
|
||||
"keeps the reading position when mounted history becomes virtualized (compact: $isMobileBreakpoint)",
|
||||
async (options) => {
|
||||
const history = Array.from({ length: 80 }, (_, index) => row(index));
|
||||
const timeline = mountTimeline(history, options);
|
||||
await timeline.settle();
|
||||
timeline.scrollUp(600);
|
||||
await timeline.settle();
|
||||
const offset = timeline.scrollTop();
|
||||
const readingTop = timeline.rowTop("message-64");
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
});
|
||||
await timeline.settle();
|
||||
|
||||
expect(timeline.scrollTop()).toBe(offset);
|
||||
expect(timeline.rowTop("message-64")).toBe(readingTop);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
|
||||
"keeps following output when mounted history becomes virtualized at the end (compact: $isMobileBreakpoint)",
|
||||
async (options) => {
|
||||
const history = Array.from({ length: 80 }, (_, index) => row(index));
|
||||
const timeline = mountTimeline(history, options);
|
||||
await timeline.settle();
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
});
|
||||
await timeline.settle();
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
liveHead: [row(80)],
|
||||
});
|
||||
await timeline.settle();
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
@@ -208,3 +281,32 @@ describe("web disclosure hold", () => {
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web history start", () => {
|
||||
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
|
||||
let olderHistoryLoads = 0;
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history, {
|
||||
olderHistory: {
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch:1",
|
||||
onNearHistoryStart: async () => {
|
||||
olderHistoryLoads += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
await timeline.settle();
|
||||
// Opening a row at the bottom stops following output without any scroll.
|
||||
timeline.hold("message-5", () =>
|
||||
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(olderHistoryLoads).toBe(0);
|
||||
|
||||
timeline.wheelUpTo(0);
|
||||
await timeline.settle();
|
||||
|
||||
expect(olderHistoryLoads).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,6 +457,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
// History can become virtualized after the reader has already scrolled the mounted rows.
|
||||
initialOffset: () => scrollContainerRef.current?.scrollTop ?? 0,
|
||||
initialRect: {
|
||||
width: scrollContainerRef.current?.clientWidth ?? 0,
|
||||
height: scrollContainerRef.current?.clientHeight ?? 0,
|
||||
@@ -605,6 +607,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
|
||||
}, []);
|
||||
|
||||
const hasUpwardInputEvidence = useCallback(() => {
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
return (
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current
|
||||
);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) {
|
||||
return;
|
||||
@@ -791,16 +802,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
const hasUpwardInputEvidence =
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current;
|
||||
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
|
||||
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
setFollowOutput(true);
|
||||
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
|
||||
} else if (followOutputRef.current && userScrolledUp) {
|
||||
stopFollowingOutputFromUserIntent();
|
||||
} else if (userScrolledUp) {
|
||||
// Wheel listeners can run before or after the browser applies the scroll, so the
|
||||
// upward scroll itself re-arms loading when it reaches the start.
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
@@ -811,8 +822,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasUpwardInputEvidence,
|
||||
isJumpSettling,
|
||||
readingAnchor,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
@@ -11,6 +11,10 @@ export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
|
||||
|
||||
// A drop remounts every row between the drag's start and end slots (see the draggable-flatlist
|
||||
// patch), so state held inside a rendered row resets when a drop moves it. Keep state that must
|
||||
// survive a reorder in the list's owner, keyed by row.
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
@@ -117,6 +121,10 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
// A list that doesn't scroll shows every row, so it renders them all in its first commit.
|
||||
// FlatList's default first render is 10 rows; a nested list remounted by a drop would paint
|
||||
// 10 rows, then grow on a later commit.
|
||||
initialNumToRender={scrollEnabled ? undefined : data.length}
|
||||
extraData={extraData}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
dragGestureHostPresented={gestureHostPresented}
|
||||
|
||||
@@ -54,11 +54,7 @@ export function SettingsRowHeader({
|
||||
}: SettingsRowHeaderProps) {
|
||||
const layout = useSettingsRowLayout();
|
||||
const rowStyle = useCallback(
|
||||
({ hovered, pressed }: HoverState) => [
|
||||
layout.row,
|
||||
!disabled && hovered && styles.hovered,
|
||||
!disabled && pressed && styles.pressed,
|
||||
],
|
||||
({ hovered }: HoverState) => [layout.row, !disabled && hovered && styles.hovered],
|
||||
[disabled, layout],
|
||||
);
|
||||
const chevronStyle = useMemo(
|
||||
|
||||
@@ -462,7 +462,9 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
|
||||
const items = entries(Array(count).fill("done"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
|
||||
);
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.current.canToggle).toBe(count > 7);
|
||||
});
|
||||
@@ -472,7 +474,7 @@ describe("sidebar group visibility", () => {
|
||||
(status) => {
|
||||
const items = entries([...Array(11).fill("done"), status]);
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, policy }),
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
@@ -497,6 +499,7 @@ describe("sidebar group visibility", () => {
|
||||
useLimitedSidebarGroup({
|
||||
items,
|
||||
selection: null,
|
||||
expanded: false,
|
||||
policy: createWorkspaceVisibilityPolicy(entriesByKey),
|
||||
}),
|
||||
);
|
||||
@@ -518,7 +521,9 @@ describe("sidebar group visibility", () => {
|
||||
"needs_input",
|
||||
"failed",
|
||||
]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
|
||||
);
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
});
|
||||
@@ -532,9 +537,10 @@ describe("sidebar group visibility", () => {
|
||||
"done",
|
||||
"attention",
|
||||
]);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
|
||||
{ initialProps: { rows: items } },
|
||||
const { result, rerender } = renderHook(
|
||||
({ rows, expanded }) =>
|
||||
useLimitedSidebarGroup({ items: rows, selection: null, expanded, policy }),
|
||||
{ initialProps: { rows: items, expanded: false } },
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
@@ -548,16 +554,15 @@ describe("sidebar group visibility", () => {
|
||||
"row-10",
|
||||
"row-12",
|
||||
]);
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ rows: items, expanded: true });
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
|
||||
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })), expanded: true });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
expect(result.current.expanded).toBe(true);
|
||||
rerender({ rows: items.slice(0, 3) });
|
||||
rerender({ rows: items.slice(0, 3), expanded: true });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
rerender({ rows: items.toReversed() });
|
||||
rerender({ rows: items.toReversed(), expanded: true });
|
||||
expect(result.current.visibleItems).toEqual(items.toReversed());
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ rows: items.toReversed(), expanded: false });
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-12",
|
||||
"row-11",
|
||||
@@ -568,15 +573,25 @@ describe("sidebar group visibility", () => {
|
||||
"row-6",
|
||||
]);
|
||||
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
|
||||
rerender({ rows: [newest, ...items] });
|
||||
rerender({ rows: [newest, ...items], expanded: false });
|
||||
expect(result.current.visibleItems[0]).toBe(newest);
|
||||
expect(result.current.visibleItems).toHaveLength(10);
|
||||
expect(result.current.expanded).toBe(false);
|
||||
act(() => result.current.toggleExpanded());
|
||||
unmount();
|
||||
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
expect(remounted.result.current.expanded).toBe(false);
|
||||
expect(remounted.result.current.visibleItems).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("shows the reveal its owner passes in on every mount", () => {
|
||||
// A native drop remounts every project block it moves, so the group holds no reveal of its
|
||||
// own. Only the native list remounts rows; the browser list never does, so no test here can
|
||||
// drive that remount.
|
||||
const items = entries([...Array(7).fill("done"), "running", "done", "failed", "done"]);
|
||||
const first = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
|
||||
);
|
||||
expect(first.result.current.visibleItems).toEqual(items);
|
||||
first.unmount();
|
||||
const remounted = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
|
||||
);
|
||||
expect(remounted.result.current.visibleItems).toEqual(items);
|
||||
});
|
||||
|
||||
it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
|
||||
@@ -602,6 +617,7 @@ describe("sidebar group visibility", () => {
|
||||
useLimitedSidebarGroup({
|
||||
items: [...items.slice(0, 11), last],
|
||||
selection: null,
|
||||
expanded: false,
|
||||
policy,
|
||||
}),
|
||||
{ initialProps: { last: items[11] } },
|
||||
@@ -641,12 +657,15 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it("preserves the twenty-row pinned policy regardless of status", () => {
|
||||
const items = entries(Array(21).fill("running"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
|
||||
const { result, rerender } = renderHook(
|
||||
({ expanded }) => useLimitedSidebarGroup({ items, selection: null, expanded }),
|
||||
{ initialProps: { expanded: false } },
|
||||
);
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ expanded: true });
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ expanded: false });
|
||||
expect(result.current.visibleItems).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -368,12 +367,10 @@ function projectKebabStyle({
|
||||
|
||||
function getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
@@ -382,7 +379,6 @@ function getProjectWorkspaceRowStyle({
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -820,7 +816,6 @@ function NewWorkspaceGhostRow({
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.newWorkspaceGhostRow,
|
||||
hovered && !pressed && styles.newWorkspaceGhostRowHovered,
|
||||
pressed && styles.newWorkspaceGhostRowPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
@@ -833,6 +828,7 @@ function NewWorkspaceGhostRow({
|
||||
})}
|
||||
onPress={handlePress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.newWorkspaceGhostRowPressed}
|
||||
testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
@@ -883,7 +879,7 @@ function ProjectHeaderRow({
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const localDaemonServerId = useLocalDaemonServerId();
|
||||
@@ -930,25 +926,13 @@ function ProjectHeaderRow({
|
||||
setContextMenuOpen(open);
|
||||
if (open) setIsHovered(false);
|
||||
}, []);
|
||||
const handleProjectPressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleProjectPressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const projectRowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
const projectRowStyle = useMemo(
|
||||
() => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
],
|
||||
[isDragging, selected, isHovered],
|
||||
);
|
||||
@@ -961,7 +945,7 @@ function ProjectHeaderRow({
|
||||
iconDataUri={iconDataUri}
|
||||
statusBucket={statusBucket}
|
||||
projectViewKey={project.viewKey}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered })}
|
||||
chevron={chevron}
|
||||
showChevron={isHovered && chevron !== null}
|
||||
isArchiving={isArchiving}
|
||||
@@ -1007,9 +991,10 @@ function ProjectHeaderRow({
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1033,9 +1018,10 @@ function ProjectHeaderRow({
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1091,7 +1077,7 @@ function WorkspaceRowInner({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -1111,17 +1097,6 @@ function WorkspaceRowInner({
|
||||
}
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleWorkspacePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const isEditingTitle = titleEditor !== null;
|
||||
@@ -1135,13 +1110,8 @@ function WorkspaceRowInner({
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
const workspaceRowStyle = getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, selected, isHovered });
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
@@ -1176,10 +1146,11 @@ function WorkspaceRowInner({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
|
||||
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={isEditingTitle ? undefined : interaction.handlePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
|
||||
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
|
||||
onPressOut={isEditingTitle ? undefined : interaction.handlePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -1280,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,
|
||||
});
|
||||
@@ -1557,6 +1529,8 @@ function ProjectBlock({
|
||||
showShortcutBadges,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleCollapsed,
|
||||
workspacesExpanded,
|
||||
onToggleWorkspacesExpanded,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
onWorkspaceReorder,
|
||||
@@ -1583,6 +1557,9 @@ function ProjectBlock({
|
||||
showShortcutBadges: boolean;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleCollapsed: (projectViewKey: string) => void;
|
||||
/** "Show more" on this project's workspaces, owned by the list so a drop's remount keeps it. */
|
||||
workspacesExpanded: boolean;
|
||||
onToggleWorkspacesExpanded: (projectViewKey: string) => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
@@ -1607,12 +1584,11 @@ function ProjectBlock({
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: project.workspaces,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
expanded: workspacesExpanded,
|
||||
policy: visibilityPolicy,
|
||||
});
|
||||
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
@@ -1770,6 +1746,10 @@ function ProjectBlock({
|
||||
onToggleCollapsed(project.viewKey);
|
||||
}, [onToggleCollapsed, project.viewKey]);
|
||||
|
||||
const handleToggleWorkspacesExpanded = useCallback(() => {
|
||||
onToggleWorkspacesExpanded(project.viewKey);
|
||||
}, [onToggleWorkspacesExpanded, project.viewKey]);
|
||||
|
||||
let projectChildren = null;
|
||||
if (!collapsed) {
|
||||
if (project.workspaces.length > 0) {
|
||||
@@ -1806,7 +1786,7 @@ function ProjectBlock({
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
onPress={handleToggleWorkspacesExpanded}
|
||||
testID={`sidebar-project-show-more-${project.viewKey}`}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1876,6 +1856,8 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.workspacesExpanded === next.workspacesExpanded &&
|
||||
previous.onToggleWorkspacesExpanded === next.onToggleWorkspacesExpanded &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
||||
@@ -2204,15 +2186,29 @@ function ProjectModeList({
|
||||
const selectionEnabled = isWorkspaceRoute;
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||
const [pinnedChatsExpanded, setPinnedChatsExpanded] = useState(false);
|
||||
const togglePinnedChatsExpanded = useCallback(
|
||||
() => setPinnedChatsExpanded((current) => !current),
|
||||
[],
|
||||
);
|
||||
const [expandedProjectKeys, setExpandedProjectKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const toggleProjectWorkspacesExpanded = useCallback((projectViewKey: string) => {
|
||||
setExpandedProjectKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(projectViewKey)) next.add(projectViewKey);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const {
|
||||
leadingItem: leadingPinnedChat,
|
||||
visibleItems: visiblePinnedChats,
|
||||
expanded: pinnedChatsExpanded,
|
||||
canToggle: canTogglePinnedChats,
|
||||
toggleExpanded: togglePinnedChatsExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: pinnedChats,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
expanded: pinnedChatsExpanded,
|
||||
});
|
||||
const nativeScrollGestureProps = useMemo(
|
||||
() =>
|
||||
@@ -2368,6 +2364,8 @@ function ProjectModeList({
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onToggleCollapsed={onToggleProjectCollapsed}
|
||||
workspacesExpanded={expandedProjectKeys.has(item.viewKey)}
|
||||
onToggleWorkspacesExpanded={toggleProjectWorkspacesExpanded}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
manualSort={manualSort}
|
||||
parentGestureRef={parentGestureRef}
|
||||
@@ -2389,6 +2387,8 @@ function ProjectModeList({
|
||||
},
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
expandedProjectKeys,
|
||||
toggleProjectWorkspacesExpanded,
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
manualSort,
|
||||
@@ -2592,9 +2592,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
|
||||
// Schedules icon across the divider; their layout boxes have different insets.
|
||||
paddingTop: 2,
|
||||
// Keep the last row scrollable above the sidebar toast, including a wrapped Undo row.
|
||||
// This space stays present across toast changes so the scroll range never jumps.
|
||||
paddingBottom: theme.spacing[24],
|
||||
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
|
||||
// a toast shows: changing it moves the rows when the toast leaves.
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
projectListContainer: {
|
||||
width: "100%",
|
||||
|
||||
@@ -39,7 +39,6 @@ export function SidebarGroupToggleRow({
|
||||
styles.row,
|
||||
indented && sidebarWorkspaceRowStyles.rowIndented,
|
||||
hovered && !pressed && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[indented],
|
||||
);
|
||||
@@ -50,6 +49,7 @@ export function SidebarGroupToggleRow({
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.rowPressed}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
|
||||
@@ -12,16 +12,17 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
*/
|
||||
export function getSidebarRowBackdrop({
|
||||
isDragging = false,
|
||||
isPressed = false,
|
||||
isHighlighted = false,
|
||||
selected = false,
|
||||
isHovered = false,
|
||||
}: {
|
||||
isDragging?: boolean;
|
||||
isPressed?: boolean;
|
||||
/** The press highlight is painting the row's pressed fill. */
|
||||
isHighlighted?: boolean;
|
||||
selected?: boolean;
|
||||
isHovered?: boolean;
|
||||
}): SidebarSurfaceBackdrop {
|
||||
if (isDragging || isPressed) return "surface2";
|
||||
if (isDragging || isHighlighted) return "surface2";
|
||||
if (selected) return "surfaceSidebarSelected";
|
||||
if (isHovered) return "surfaceSidebarHover";
|
||||
return "surfaceSidebar";
|
||||
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -44,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";
|
||||
@@ -155,13 +148,20 @@ export function SidebarStatusWorkspaceList({
|
||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
const [pinnedWorkspacesExpanded, setPinnedWorkspacesExpanded] = useState(false);
|
||||
const togglePinnedWorkspacesExpanded = useCallback(
|
||||
() => setPinnedWorkspacesExpanded((current) => !current),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingPinnedWorkspace,
|
||||
visibleItems: visiblePinnedWorkspaces,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
canToggle: canTogglePinnedWorkspaces,
|
||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
|
||||
} = useLimitedSidebarGroup({
|
||||
items: pinnedWorkspaces,
|
||||
selection: activeWorkspaceSelection,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
});
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges
|
||||
? shortcutIndexByWorkspaceKey
|
||||
@@ -366,15 +366,19 @@ function StatusGroupRows({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const [workspacesExpanded, setWorkspacesExpanded] = useState(false);
|
||||
const toggleWorkspacesExpanded = useCallback(
|
||||
() => setWorkspacesExpanded((current) => !current),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: group.rows,
|
||||
selection: activeWorkspaceSelection,
|
||||
expanded: workspacesExpanded,
|
||||
policy: STATUS_GROUP_VISIBILITY,
|
||||
});
|
||||
|
||||
@@ -460,12 +464,8 @@ function StatusGroupHeader({
|
||||
}, [group.key, toggleWorkspaceGroupCollapsed]);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const rowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.statusGroupRow,
|
||||
isHovered && styles.statusGroupRowHovered,
|
||||
pressed && styles.statusGroupRowPressed,
|
||||
],
|
||||
const rowStyle = useMemo(
|
||||
() => [styles.statusGroupRow, isHovered && styles.statusGroupRowHovered],
|
||||
[isHovered],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
@@ -477,6 +477,7 @@ function StatusGroupHeader({
|
||||
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
|
||||
accessibilityState={accessibilityState}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.statusGroupRowPressed}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-status-group-${group.key}`}
|
||||
>
|
||||
@@ -654,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,
|
||||
});
|
||||
@@ -826,7 +828,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const {
|
||||
role: _dragRole,
|
||||
@@ -851,17 +853,6 @@ function StatusWorkspaceRowInnerContent({
|
||||
}
|
||||
onPress();
|
||||
}, [didLongPressRef, onPress]);
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
startDragPress?.(event);
|
||||
},
|
||||
[startDragPress],
|
||||
);
|
||||
const handlePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
endDragPress?.();
|
||||
}, [endDragPress]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame
|
||||
@@ -887,13 +878,12 @@ function StatusWorkspaceRowInnerContent({
|
||||
showShortcut,
|
||||
});
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
isDragging,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
@@ -928,10 +918,11 @@ function StatusWorkspaceRowInnerContent({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handlePressIn}
|
||||
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={isEditingTitle ? undefined : startDragPress}
|
||||
onTouchMove={isEditingTitle ? undefined : moveDragPress}
|
||||
onPressOut={isEditingTitle ? undefined : handlePressOut}
|
||||
onPressOut={isEditingTitle ? undefined : endDragPress}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -1068,13 +1059,11 @@ function StatusWorkspaceActionSlot({
|
||||
}
|
||||
|
||||
function getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
isDragging,
|
||||
}: {
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
inStatusGroup: boolean;
|
||||
@@ -1086,7 +1075,6 @@ function getStatusWorkspaceRowStyle({
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1101,8 +1089,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
|
||||
paddingTop: 2,
|
||||
// Match project grouping: permanent room to scroll the final row above sidebar toasts.
|
||||
paddingBottom: theme.spacing[24],
|
||||
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
|
||||
// a toast shows: changing it moves the rows when the toast leaves.
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
pinnedSection: {
|
||||
marginBottom: theme.spacing[1],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
selectSidebarGroupVisibility,
|
||||
type SidebarGroupItem,
|
||||
@@ -12,23 +12,22 @@ const PINNED_POLICY = { kind: "pinned" } as const;
|
||||
interface LimitedSidebarGroupInput<T> {
|
||||
items: readonly T[];
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
/**
|
||||
* Whether the user revealed the whole group. The caller owns it: a native drop remounts the
|
||||
* rows it moved, and a reveal held inside a row would reset with them.
|
||||
*/
|
||||
expanded: boolean;
|
||||
policy?: SidebarGroupVisibilityPolicy<T>;
|
||||
}
|
||||
|
||||
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
|
||||
items,
|
||||
selection,
|
||||
expanded,
|
||||
policy = PINNED_POLICY,
|
||||
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
|
||||
expanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
} {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibility = useMemo(
|
||||
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> {
|
||||
return useMemo(
|
||||
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
|
||||
[expanded, items, policy, selection],
|
||||
);
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
return { ...visibility, expanded, toggleExpanded };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import {
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableProps,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewProps,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
type MenuTriggerState,
|
||||
} from "@/components/ui/menu";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import type { PressHighlightProps } from "@/components/ui/press-highlight.types";
|
||||
|
||||
/**
|
||||
* A menu opened by a long press or a right click, anchored to the point of the gesture rather
|
||||
@@ -109,8 +109,7 @@ export function ContextMenuTrigger({
|
||||
triggerRef,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style"> & {
|
||||
highlightStyle?: StyleProp<ViewStyle>;
|
||||
Omit<PressHighlightProps, "style"> & {
|
||||
style?: TriggerStyleProp;
|
||||
enabled?: boolean;
|
||||
enabledOnMobile?: boolean;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { forwardRef, useCallback, useMemo } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
type PressableStateCallbackType,
|
||||
type View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
|
||||
interface RipplePressableProps extends Omit<PressableProps, "style"> {
|
||||
rippleColor?: string;
|
||||
// `withUnistyles` flattens a `style` prop, which would drop the caller's state callback.
|
||||
pressableStyle: PressableProps["style"];
|
||||
}
|
||||
|
||||
// A foreground ripple tints the content with the surface, so nothing inside needs to know the
|
||||
// row is pressed.
|
||||
const RipplePressable = withUnistyles(
|
||||
forwardRef<View, RipplePressableProps>(function RipplePressable(
|
||||
{ rippleColor, pressableStyle, ...props },
|
||||
ref,
|
||||
) {
|
||||
const ripple = useMemo(
|
||||
() => (rippleColor ? { color: rippleColor, foreground: true } : undefined),
|
||||
[rippleColor],
|
||||
);
|
||||
return <Pressable {...props} ref={ref} style={pressableStyle} android_ripple={ripple} />;
|
||||
}),
|
||||
);
|
||||
|
||||
// Android's own `ViewConfiguration.getTapTimeout()`: a native list in a scroll view waits this long
|
||||
// before showing pressed, so a scroll that claims the touch first cancels it.
|
||||
const TAP_TIMEOUT_MS = 100;
|
||||
|
||||
const rippleColorMapping = (theme: Theme) => ({ rippleColor: theme.colors.interactionHighlight });
|
||||
|
||||
/**
|
||||
* The platform ripple replaces `highlightStyle`. Pressable starts it from JS after the press
|
||||
* delay, then the animation runs on the UI thread without a React commit.
|
||||
*/
|
||||
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
|
||||
{
|
||||
highlightStyle,
|
||||
onHighlightChange: _onHighlightChange,
|
||||
style,
|
||||
unstable_pressDelay = TAP_TIMEOUT_MS,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const hasRipple = highlightStyle != null;
|
||||
const rippleStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
hasRipple && styles.rippleBorderReset,
|
||||
typeof style === "function" ? style(state) : style,
|
||||
hasRipple && styles.rippleClip,
|
||||
],
|
||||
[style, hasRipple],
|
||||
);
|
||||
return (
|
||||
<RipplePressable
|
||||
{...props}
|
||||
ref={ref}
|
||||
pressableStyle={rippleStyle}
|
||||
uniProps={hasRipple ? rippleColorMapping : undefined}
|
||||
unstable_pressDelay={unstable_pressDelay}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// React Native 0.86 sends NaN when a style drops `borderWidth`, and an overflow-hidden view then
|
||||
// clips to a NaN padding box and stops painting. A sidebar row loses its drag border on drop.
|
||||
// An explicit 0 under the caller's style sends 0 instead. Fixed upstream in
|
||||
// facebook/react-native#57789; remove once the app ships a React Native release with it.
|
||||
rippleBorderReset: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
// React Native draws ripples in the view's rectangle. With overflow hidden, the view clips its
|
||||
// children to its rounded padding box, and that clip is still on the canvas when Android draws
|
||||
// the foreground, so the ripple follows the corners.
|
||||
rippleClip: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { ArrowUp } from "lucide-react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { PressHighlight } from "./press-highlight.native";
|
||||
import { PressHighlight as AndroidPressHighlight } from "./press-highlight.android";
|
||||
import { PressHighlight as WebPressHighlight } from "./press-highlight";
|
||||
import { FloatingActionButton } from "./floating-action-button";
|
||||
|
||||
const rowStyle = { backgroundColor: "rgb(255, 255, 255)", borderRadius: 8 };
|
||||
@@ -13,6 +15,22 @@ afterEach(cleanup);
|
||||
|
||||
function onPress() {}
|
||||
|
||||
function recordPresses() {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
onPressIn: () => calls.push("in"),
|
||||
onPressOut: () => calls.push("out"),
|
||||
};
|
||||
}
|
||||
|
||||
const borderedRowStyle = { ...rowStyle, borderWidth: 1, borderColor: "rgb(0, 0, 0)" };
|
||||
|
||||
function recordHighlights() {
|
||||
const calls: boolean[] = [];
|
||||
return { calls, record: (highlighted: boolean) => calls.push(highlighted) };
|
||||
}
|
||||
|
||||
it("keeps a floating action opaque while showing its held-press feedback", async () => {
|
||||
const { getByRole } = render(
|
||||
<FloatingActionButton icon={ArrowUp} accessibilityLabel="Jump to file" onPress={onPress} />,
|
||||
@@ -31,30 +49,147 @@ it("keeps a floating action opaque while showing its held-press feedback", async
|
||||
await waitFor(() => expect(getComputedStyle(button).backgroundColor).toBe(restingFill));
|
||||
});
|
||||
|
||||
it("uses the pressable's pressed state for the native row fill and its existing corners", () => {
|
||||
const { getByTestId, rerender } = render(
|
||||
it("paints the native row fill while pressed and keeps its existing corners", async () => {
|
||||
const { getByTestId } = render(
|
||||
<GestureHandlerRootView>
|
||||
<PressHighlight
|
||||
testID="row"
|
||||
testOnly_pressed
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
/>
|
||||
<PressHighlight testID="row" style={rowStyle} highlightStyle={highlightStyle} />
|
||||
</GestureHandlerRootView>,
|
||||
);
|
||||
const row = getByTestId("row");
|
||||
|
||||
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(20, 30, 40)");
|
||||
expect(getComputedStyle(getByTestId("row")).borderRadius).toBe("8px");
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)"));
|
||||
expect(getComputedStyle(row).borderRadius).toBe("8px");
|
||||
|
||||
rerender(
|
||||
<GestureHandlerRootView>
|
||||
<PressHighlight
|
||||
testID="row"
|
||||
testOnly_pressed={false}
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
/>
|
||||
</GestureHandlerRootView>,
|
||||
);
|
||||
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)"));
|
||||
});
|
||||
|
||||
it("paints the pressed fill on web and reports it, so a caller does not paint its own", async () => {
|
||||
const highlights = recordHighlights();
|
||||
const { getByTestId } = render(
|
||||
<WebPressHighlight
|
||||
testID="row"
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
onHighlightChange={highlights.record}
|
||||
onPress={onPress}
|
||||
/>,
|
||||
);
|
||||
const row = getByTestId("row");
|
||||
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)"));
|
||||
expect(highlights.calls).toEqual([true]);
|
||||
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)"));
|
||||
expect(highlights.calls).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("leaves the Android row surface alone while pressed and clips the ripple to its corners", () => {
|
||||
const highlights = recordHighlights();
|
||||
const { getByTestId } = render(
|
||||
<AndroidPressHighlight
|
||||
testID="row"
|
||||
testOnly_pressed
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
onHighlightChange={highlights.record}
|
||||
/>,
|
||||
);
|
||||
const row = getComputedStyle(getByTestId("row"));
|
||||
|
||||
expect(row.backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
expect(row.borderRadius).toBe("8px");
|
||||
expect(row.overflow).toBe("hidden");
|
||||
expect(highlights.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the caller's border and press handlers on the Android ripple row", async () => {
|
||||
const presses = recordPresses();
|
||||
const { getByTestId } = render(
|
||||
<AndroidPressHighlight
|
||||
testID="row"
|
||||
style={borderedRowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
onPressIn={presses.onPressIn}
|
||||
onPressOut={presses.onPressOut}
|
||||
onPress={onPress}
|
||||
/>,
|
||||
);
|
||||
const row = getByTestId("row");
|
||||
|
||||
expect(getComputedStyle(row).borderTopWidth).toBe("1px");
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(presses.calls).toEqual(["in", "out"]));
|
||||
});
|
||||
|
||||
it("paints and reports nothing on web without a pressed fill, such as a row being renamed", () => {
|
||||
const highlights = recordHighlights();
|
||||
const { getByTestId } = render(
|
||||
<WebPressHighlight
|
||||
testID="row"
|
||||
style={rowStyle}
|
||||
onHighlightChange={highlights.record}
|
||||
onPress={onPress}
|
||||
/>,
|
||||
);
|
||||
const row = getByTestId("row");
|
||||
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
expect(highlights.calls).toEqual([]);
|
||||
});
|
||||
|
||||
const midPress = { ...recordPresses(), highlights: recordHighlights() };
|
||||
|
||||
function PressedRow({ filled }: { filled: boolean }) {
|
||||
return (
|
||||
<WebPressHighlight
|
||||
testID="row"
|
||||
style={rowStyle}
|
||||
highlightStyle={filled ? highlightStyle : undefined}
|
||||
onHighlightChange={midPress.highlights.record}
|
||||
onPressIn={midPress.onPressIn}
|
||||
onPressOut={midPress.onPressOut}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function resetMidPress() {
|
||||
midPress.calls.length = 0;
|
||||
midPress.highlights.calls.length = 0;
|
||||
}
|
||||
|
||||
it("keeps a press's fill and report together when the fill appears mid-press", async () => {
|
||||
resetMidPress();
|
||||
const { getByTestId, rerender } = render(<PressedRow filled={false} />);
|
||||
const row = getByTestId("row");
|
||||
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
await waitFor(() => expect(midPress.calls).toEqual(["in"]));
|
||||
rerender(<PressedRow filled />);
|
||||
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(midPress.calls).toEqual(["in", "out"]));
|
||||
expect(midPress.highlights.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a press's fill and report together when the fill goes away mid-press", async () => {
|
||||
resetMidPress();
|
||||
const { getByTestId, rerender } = render(<PressedRow filled />);
|
||||
const row = getByTestId("row");
|
||||
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
await waitFor(() => expect(midPress.calls).toEqual(["in"]));
|
||||
rerender(<PressedRow filled={false} />);
|
||||
expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)");
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(midPress.calls).toEqual(["in", "out"]));
|
||||
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
expect(midPress.highlights.calls).toEqual([true, false]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type {
|
||||
GestureResponderEvent,
|
||||
PressableStateCallbackType,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from "react-native";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
|
||||
type PressFillProps = Pick<
|
||||
PressHighlightProps,
|
||||
"style" | "highlightStyle" | "onPressIn" | "onPressOut" | "onHighlightChange"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Paints `highlightStyle` while pressed and reports it, for the platforms that show a fill. A press
|
||||
* keeps the fill it started with until it ends, so the paint and the report always agree, even when
|
||||
* the caller adds or removes `highlightStyle` mid-press.
|
||||
*/
|
||||
export function usePressFill({
|
||||
style,
|
||||
highlightStyle,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
onHighlightChange,
|
||||
}: PressFillProps) {
|
||||
const [pressFill, setPressFill] = useState<StyleProp<ViewStyle> | null>(null);
|
||||
// Pressable can call the press-out handler from the render that started the press.
|
||||
const reportedRef = useRef(false);
|
||||
const fillStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
typeof style === "function" ? style(state) : style,
|
||||
pressFill,
|
||||
],
|
||||
[style, pressFill],
|
||||
);
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
if (highlightStyle != null) {
|
||||
setPressFill(highlightStyle);
|
||||
reportedRef.current = true;
|
||||
onHighlightChange?.(true);
|
||||
}
|
||||
onPressIn?.(event);
|
||||
},
|
||||
[highlightStyle, onHighlightChange, onPressIn],
|
||||
);
|
||||
const handlePressOut = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setPressFill(null);
|
||||
if (reportedRef.current) {
|
||||
reportedRef.current = false;
|
||||
onHighlightChange?.(false);
|
||||
}
|
||||
onPressOut?.(event);
|
||||
},
|
||||
[onHighlightChange, onPressOut],
|
||||
);
|
||||
return { style: fillStyle, onPressIn: handlePressIn, onPressOut: handlePressOut };
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
import { forwardRef, useCallback } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, type View } from "react-native";
|
||||
import { forwardRef } from "react";
|
||||
import { Pressable, type View } from "react-native";
|
||||
import { usePressFill } from "./press-highlight.fill";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
|
||||
// iOS. Android resolves `press-highlight.android.tsx`.
|
||||
|
||||
// Feedback waits this long so a scroll that claims the touch first cancels it.
|
||||
const PRESS_DELAY_MS = 130;
|
||||
|
||||
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
|
||||
{ highlightStyle, style, unstable_pressDelay = 130, ...props },
|
||||
{
|
||||
highlightStyle,
|
||||
onHighlightChange,
|
||||
style,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
unstable_pressDelay = PRESS_DELAY_MS,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// Give the scroll view time to claim a pan before Pressable activates its feedback.
|
||||
const pressStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
typeof style === "function" ? style(state) : style,
|
||||
state.pressed && highlightStyle,
|
||||
],
|
||||
[style, highlightStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable {...props} ref={ref} unstable_pressDelay={unstable_pressDelay} style={pressStyle} />
|
||||
);
|
||||
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
|
||||
return <Pressable {...props} {...fill} ref={ref} unstable_pressDelay={unstable_pressDelay} />;
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Pressable } from "react-native";
|
||||
import type { View } from "react-native";
|
||||
import { usePressFill } from "./press-highlight.fill";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
|
||||
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
|
||||
{ highlightStyle: _highlightStyle, ...props },
|
||||
{ highlightStyle, onHighlightChange, style, onPressIn, onPressOut, ...props },
|
||||
ref,
|
||||
) {
|
||||
return <Pressable ref={ref} {...props} />;
|
||||
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
|
||||
return <Pressable ref={ref} {...props} {...fill} />;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { PressableProps, StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export interface PressHighlightProps extends PressableProps {
|
||||
/** Native pressed style. Feedback and cancellation follow Pressable's press lifecycle. */
|
||||
/**
|
||||
* The pressed fill. iOS and web paint it while the pressable is pressed; Android draws the
|
||||
* platform ripple over the content instead. Callers do not paint their own pressed fill.
|
||||
*/
|
||||
highlightStyle?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Reports when `highlightStyle` starts and stops painting, so content that knocks out of the
|
||||
* surface (a badge ring, a text scrim) can match it. The Android ripple tints over the content,
|
||||
* so it never reports a highlight.
|
||||
*/
|
||||
onHighlightChange?: (highlighted: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { memo, type ReactElement, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -92,18 +87,17 @@ function useFileHeaderHover(enabled: boolean, onActiveChange?: (active: boolean)
|
||||
return { isHovered, handlePointerEnter, handlePointerLeave };
|
||||
}
|
||||
|
||||
// Press feedback is the pressable's `highlightStyle`, so it is not part of the active fill.
|
||||
function fileHeaderIsActive(input: {
|
||||
isHovered: boolean;
|
||||
pressed: boolean;
|
||||
showsBodyState: boolean;
|
||||
isSelected: boolean;
|
||||
}): boolean {
|
||||
return input.isHovered || input.pressed || (!input.showsBodyState && input.isSelected);
|
||||
return input.isHovered || (!input.showsBodyState && input.isSelected);
|
||||
}
|
||||
|
||||
function fileHeaderInteractionStyle(input: {
|
||||
isHovered: boolean;
|
||||
pressed: boolean;
|
||||
showsBodyState: boolean;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
@@ -217,8 +211,8 @@ export const FileHeader = memo(function FileHeader({
|
||||
[file.path, onHeaderHeightChange],
|
||||
),
|
||||
});
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
const pressableStyle = useMemo(
|
||||
() => [
|
||||
styles.header,
|
||||
!showsBodyState && workspaceTreeRowStyles.row,
|
||||
showsBodyState && styles.documentHeader,
|
||||
@@ -229,18 +223,14 @@ export const FileHeader = memo(function FileHeader({
|
||||
: null,
|
||||
fileHeaderInteractionStyle({
|
||||
isHovered: hover.isHovered,
|
||||
pressed,
|
||||
showsBodyState,
|
||||
isSelected,
|
||||
}),
|
||||
],
|
||||
[depth, hover.isHovered, isSelected, showsBodyState],
|
||||
);
|
||||
const canvasPressableStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
pressableStyle(state),
|
||||
canvasRendered && styles.canvasInteraction,
|
||||
],
|
||||
const canvasPressableStyle = useMemo(
|
||||
() => [pressableStyle, canvasRendered && styles.canvasInteraction],
|
||||
[canvasRendered, pressableStyle],
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
@@ -324,7 +314,7 @@ export const FileHeader = memo(function FileHeader({
|
||||
} else {
|
||||
trigger = (
|
||||
<View
|
||||
style={[pressableStyle({ pressed: false }), canvasRendered && styles.canvasInteraction]}
|
||||
style={[pressableStyle, canvasRendered && styles.canvasInteraction]}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
{renderedContent}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
|
||||
@@ -18,12 +18,6 @@ import { useCheckGroupState } from "./check-group-state";
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
|
||||
/**
|
||||
* Roughly eight rows. A repo with thirty checks would otherwise push the activity
|
||||
* timeline off the pane entirely, so past this height the list scrolls in place.
|
||||
*/
|
||||
const LIST_MAX_HEIGHT = 268;
|
||||
|
||||
/**
|
||||
* The three statuses the pane has always labelled for tests. Skipped has no id because
|
||||
* nothing asserts on it.
|
||||
@@ -112,11 +106,7 @@ export function ChecksSection({
|
||||
</Pressable>
|
||||
|
||||
{open && summary.groups.length > 0 ? (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
<View style={styles.list}>
|
||||
{summary.groups.map((group) => (
|
||||
<CheckGroup
|
||||
key={group.status}
|
||||
@@ -128,7 +118,7 @@ export function ChecksSection({
|
||||
onAddLogsToChat={onAddLogsToChat}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
@@ -263,9 +253,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
list: {
|
||||
maxHeight: LIST_MAX_HEIGHT,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
groupHeader: {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { ToastApiProvider } from "@/contexts/toast-context";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { PrPaneCheck, PrPaneData } from "./data";
|
||||
import { PullRequestPane } from "./pane";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const PANE_HEIGHT = 600;
|
||||
|
||||
const silentToasts: ToastApi = {
|
||||
show: () => () => {},
|
||||
copied: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
function successfulChecks(count: number): PrPaneCheck[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
provider: "github",
|
||||
name: `check-${index + 1}`,
|
||||
status: "success",
|
||||
url: `https://example.test/checks/${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function pullRequest(checks: PrPaneCheck[]): PrPaneData {
|
||||
return {
|
||||
provider: { id: "github", label: "GitHub" },
|
||||
forge: "github",
|
||||
number: 42,
|
||||
repoOwner: "acme",
|
||||
repoName: "widgets",
|
||||
title: "Ship the widget",
|
||||
state: "open",
|
||||
url: "https://example.test/pull/42",
|
||||
reviewDecision: "pending",
|
||||
awaitingReviewers: [],
|
||||
checks,
|
||||
activity: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A phone's home indicator: the pane clears it with bottom padding. */
|
||||
const HOME_INDICATOR_INSETS = { top: 0, right: 0, bottom: 34, left: 0 };
|
||||
|
||||
const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
function renderPane(data: PrPaneData, insets = NO_INSETS) {
|
||||
act(() =>
|
||||
root?.render(
|
||||
<SafeAreaInsetsContext.Provider value={insets}>
|
||||
<ToastApiProvider api={silentToasts}>
|
||||
<PullRequestPane serverId="server-1" cwd="/repo" data={data} activityLoading={false} />
|
||||
</ToastApiProvider>
|
||||
</SafeAreaInsetsContext.Provider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function mountPane(data: PrPaneData, insets = NO_INSETS) {
|
||||
container = document.createElement("div");
|
||||
container.style.height = `${PANE_HEIGHT}px`;
|
||||
container.style.width = "600px";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
renderPane(data, insets);
|
||||
const pane = container.querySelector<HTMLElement>('[data-testid="pr-pane"]');
|
||||
if (!pane) throw new Error("The pull request pane did not render");
|
||||
return pane;
|
||||
}
|
||||
|
||||
/** The pressable row holding the Activity title. */
|
||||
function activityHeader(pane: HTMLElement): HTMLElement {
|
||||
const title = within(pane).getByText(i18n.t("workspace.git.pr.sections.activity"));
|
||||
const header = title.closest<HTMLElement>("[tabindex]");
|
||||
if (!header) throw new Error("The Activity title is not inside a pressable header");
|
||||
return header;
|
||||
}
|
||||
|
||||
function checksRegionBottom(pane: HTMLElement): number {
|
||||
return within(pane).getByRole("separator").getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
/** Whether a user sees the element: no scroll container clips it out of view. */
|
||||
function isOnScreen(element: HTMLElement): boolean {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(rect.left + 8, rect.top + rect.height / 2);
|
||||
return hit !== null && element.contains(hit);
|
||||
}
|
||||
|
||||
/** React Native Web reports layout from a ResizeObserver, a frame or two after mounting. */
|
||||
async function settleLayout() {
|
||||
for (let frame = 0; frame < 2; frame++) {
|
||||
await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||
}
|
||||
}
|
||||
|
||||
/** Drags the separator through each offset from where it starts, releasing at the last one. */
|
||||
function dragSeparator(pane: HTMLElement, ...deltasY: number[]) {
|
||||
const separator = within(pane).getByRole("separator");
|
||||
const rect = separator.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const startY = rect.top + rect.height / 2;
|
||||
const pointer = { pointerId: 1, bubbles: true, cancelable: true, clientX: x, button: 0 };
|
||||
act(() => {
|
||||
separator.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientY: startY }));
|
||||
});
|
||||
for (const deltaY of deltasY) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new PointerEvent("pointermove", { ...pointer, clientY: startY + deltaY }),
|
||||
);
|
||||
});
|
||||
}
|
||||
const endY = startY + (deltasY.at(-1) ?? 0);
|
||||
act(() => {
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientY: endY }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("pull request pane layout", () => {
|
||||
it("docks a collapsed Activity section to the bottom and gives the checks the pane", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(10)));
|
||||
|
||||
act(() => activityHeader(pane).click());
|
||||
|
||||
await expect
|
||||
.poll(() => Math.round(activityHeader(pane).getBoundingClientRect().bottom))
|
||||
.toBe(Math.round(pane.getBoundingClientRect().bottom));
|
||||
const rows = within(pane).getAllByTestId("pr-pane-check-row");
|
||||
expect(rows).toHaveLength(10);
|
||||
expect(rows.every(isOnScreen)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps keyboard focus on the Activity header while it collapses and expands", () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(3)));
|
||||
const header = activityHeader(pane);
|
||||
act(() => header.focus());
|
||||
|
||||
act(() => header.click());
|
||||
expect(document.activeElement).toBe(header);
|
||||
act(() => header.click());
|
||||
expect(document.activeElement).toBe(header);
|
||||
});
|
||||
|
||||
it("exposes whether Activity is expanded to assistive technology", () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(3)));
|
||||
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
act(() => activityHeader(pane).click());
|
||||
|
||||
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps the Activity header inside the pane when a dragged pane gets short", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(30)));
|
||||
await settleLayout();
|
||||
dragSeparator(pane, PANE_HEIGHT);
|
||||
await settleLayout();
|
||||
|
||||
act(() => {
|
||||
if (container) container.style.height = "160px";
|
||||
});
|
||||
await settleLayout();
|
||||
|
||||
await expect
|
||||
.poll(() => activityHeader(pane).getBoundingClientRect().bottom)
|
||||
.toBeLessThanOrEqual(pane.getBoundingClientRect().bottom);
|
||||
expect(isOnScreen(activityHeader(pane))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["5px down", [5]],
|
||||
["400px down", [400]],
|
||||
["up past their end and back down", [-20, 100]],
|
||||
])("gives the checks their earlier share when they grow after a drag %s", async (_name, path) => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(2)));
|
||||
await settleLayout();
|
||||
const drawn = checksRegionBottom(pane);
|
||||
|
||||
dragSeparator(pane, ...path);
|
||||
await settleLayout();
|
||||
expect(checksRegionBottom(pane)).toBeCloseTo(drawn, 0);
|
||||
|
||||
renderPane(pullRequest(successfulChecks(30)));
|
||||
await settleLayout();
|
||||
const overview = within(pane).getByTestId("pr-pane-overview");
|
||||
expect(overview.getBoundingClientRect().height).toBeCloseTo(PANE_HEIGHT / 2, 0);
|
||||
});
|
||||
|
||||
it("resizes the checks against Activity by dragging the separator between them", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(30)));
|
||||
await settleLayout();
|
||||
const before = checksRegionBottom(pane);
|
||||
|
||||
dragSeparator(pane, 120);
|
||||
|
||||
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
|
||||
expect(activityHeader(pane).getBoundingClientRect().top).toBeGreaterThan(before + 100);
|
||||
});
|
||||
|
||||
it("keeps the separator under the pointer when the pane clears a bottom inset", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(30)), HOME_INDICATOR_INSETS);
|
||||
await settleLayout();
|
||||
const before = checksRegionBottom(pane);
|
||||
|
||||
dragSeparator(pane, 120);
|
||||
|
||||
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Image, Pressable, ScrollView, Text, View, type ViewStyle } from "react-native";
|
||||
import { Image, Pressable, Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
CircleCheck,
|
||||
@@ -74,13 +74,14 @@ import {
|
||||
type PrTimelineEntry,
|
||||
} from "./timeline";
|
||||
import {
|
||||
Section,
|
||||
SectionHeader,
|
||||
SummaryPill,
|
||||
dangerColorMapping,
|
||||
foregroundMutedColorMapping,
|
||||
sectionKitStyles,
|
||||
successColorMapping,
|
||||
} from "./section-kit";
|
||||
import { PullRequestPaneSplit } from "./split";
|
||||
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
@@ -478,8 +479,8 @@ export function PullRequestPane({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
<PullRequestPaneSplit activityOpen={activityOpen}>
|
||||
<>
|
||||
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
|
||||
<View style={styles.toolbarActions}>
|
||||
<Button
|
||||
@@ -499,7 +500,9 @@ export function PullRequestPane({
|
||||
accessibilityLabel={
|
||||
isRefreshing
|
||||
? t("workspace.git.diff.refreshing")
|
||||
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
|
||||
: t("workspace.git.diff.refreshState", {
|
||||
brand: forgePresentation.brandLabel,
|
||||
})
|
||||
}
|
||||
testID="pr-pane-refresh"
|
||||
style={toolbarRefreshButtonStyle}
|
||||
@@ -564,60 +567,58 @@ export function PullRequestPane({
|
||||
onAddLogsToChat={handleAddCheckLogsToChat}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title={t("workspace.git.pr.sections.activity")}
|
||||
open={activityOpen}
|
||||
onToggle={handleToggleActivity}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
icon={SUMMARY_CHANGES_REQUESTED_ICON}
|
||||
variant="danger"
|
||||
</>
|
||||
<SectionHeader
|
||||
title={t("workspace.git.pr.sections.activity")}
|
||||
open={activityOpen}
|
||||
onToggle={handleToggleActivity}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
icon={SUMMARY_CHANGES_REQUESTED_ICON}
|
||||
variant="danger"
|
||||
/>
|
||||
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<View style={sectionKitStyles.sectionBody}>
|
||||
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
|
||||
<View style={styles.activityToolbar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={MessageSquarePlus}
|
||||
onPress={handleAddAllToChat}
|
||||
disabled={activityLoading}
|
||||
>
|
||||
{t("workspace.git.pr.actions.addAllToChat")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||
{!activityLoading && visibleEntries.length === 0 ? (
|
||||
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
|
||||
) : null}
|
||||
{!activityLoading
|
||||
? visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
brandLabel={forgePresentation.brandLabel}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
|
||||
<View style={styles.activityToolbar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={MessageSquarePlus}
|
||||
onPress={handleAddAllToChat}
|
||||
disabled={activityLoading}
|
||||
>
|
||||
{t("workspace.git.pr.actions.addAllToChat")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||
{!activityLoading && visibleEntries.length === 0 ? (
|
||||
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
|
||||
) : null}
|
||||
{!activityLoading
|
||||
? visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
brandLabel={forgePresentation.brandLabel}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</Section>
|
||||
</ScrollView>
|
||||
</View>
|
||||
))
|
||||
: null}
|
||||
</View>
|
||||
</PullRequestPaneSplit>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1192,15 +1193,6 @@ function ThreadComment({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
invisible: {
|
||||
opacity: 0,
|
||||
},
|
||||
@@ -1261,10 +1253,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginLeft: theme.spacing[1],
|
||||
opacity: 0,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import React, { useMemo, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { CountedCheckPresentation } from "@/git/check-presentation";
|
||||
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
CheckPresentationIcon,
|
||||
getCheckPresentationTone,
|
||||
@@ -21,39 +22,57 @@ export const successColorMapping = (theme: Theme) => ({ color: theme.colors.stat
|
||||
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
||||
|
||||
interface SectionProps {
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
summary: ReactNode;
|
||||
children: ReactNode;
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
interface SectionProps extends SectionHeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** The disclosure row of a section, for layouts that place the section's body themselves. */
|
||||
export function SectionHeader({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
summary,
|
||||
children,
|
||||
accessibilityLabel,
|
||||
}: SectionProps) {
|
||||
}: SectionHeaderProps) {
|
||||
const accessibilityState = useMemo(() => ({ expanded: open }), [open]);
|
||||
// React Native Web does not map `accessibilityState.expanded` to `aria-expanded`.
|
||||
const webExpandedProps = useMemo(
|
||||
() => (isWeb ? ({ "aria-expanded": open } as const) : null),
|
||||
[open],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? title}
|
||||
accessibilityState={accessibilityState}
|
||||
{...webExpandedProps}
|
||||
style={sectionKitStyles.sectionHeader}
|
||||
onPress={onToggle}
|
||||
>
|
||||
{open ? (
|
||||
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({ children, ...header }: SectionProps) {
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={sectionKitStyles.sectionHeader}
|
||||
onPress={onToggle}
|
||||
>
|
||||
{open ? (
|
||||
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
{open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
|
||||
<SectionHeader {...header} />
|
||||
{header.open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Children, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { ScrollView, View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
|
||||
/** Most of the pane the overview may take before it scrolls, until the user drags the separator. */
|
||||
const DEFAULT_OVERVIEW_SHARE = 0.5;
|
||||
const SPLIT_GROUP_ID = "pr-pane";
|
||||
|
||||
interface PullRequestPaneSplitProps {
|
||||
/**
|
||||
* Exactly three children, in order:
|
||||
* 1. the overview (the PR's header and checks), which scrolls in its own region;
|
||||
* 2. the Activity disclosure row, docked to the bottom of the pane while collapsed;
|
||||
* 3. the Activity timeline, filling the space below the separator while open.
|
||||
*
|
||||
* Slots are children because `react-perf/jsx-no-jsx-as-prop` rejects JSX in a prop. Pass all
|
||||
* three unconditionally, the way `TreeRail` takes its slots.
|
||||
*/
|
||||
children: ReactNode;
|
||||
activityOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the pane out the way stacked sidebar sections behave: the overview and Activity share the
|
||||
* height, a separator between them resizes the share, and a collapsed Activity gives its space
|
||||
* back by docking its header to the bottom.
|
||||
*/
|
||||
export function PullRequestPaneSplit({ children, activityOpen }: PullRequestPaneSplitProps) {
|
||||
const [overview, activityHeader, activityTimeline] = Children.toArray(children);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [overviewShare, setOverviewShare] = useState(DEFAULT_OVERVIEW_SHARE);
|
||||
// The pointer's share while a drag is in progress. The saved share is decided once, when the
|
||||
// drag ends, so moves along the way cannot overwrite the share the overview had before it.
|
||||
const [dragShare, setDragShare] = useState<number | null>(null);
|
||||
const [splitHeight, setSplitHeight] = useState(0);
|
||||
const [overviewHeight, setOverviewHeight] = useState(0);
|
||||
const [overviewContentHeight, setOverviewContentHeight] = useState(0);
|
||||
|
||||
const handleSplitLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setSplitHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const handleOverviewLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setOverviewHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const handleOverviewContentSizeChange = useCallback((_width: number, height: number) => {
|
||||
setOverviewContentHeight(height);
|
||||
}, []);
|
||||
const handlePreviewResize = useCallback((_groupId: string, sizes: number[]) => {
|
||||
const [share] = sizes;
|
||||
if (share !== undefined) setDragShare(share);
|
||||
}, []);
|
||||
const handleResize = useCallback(
|
||||
(_groupId: string, sizes: number[]) => {
|
||||
setDragShare(null);
|
||||
const [share] = sizes;
|
||||
if (share === undefined || splitHeight <= 0) return;
|
||||
const contentShare = overviewContentHeight / splitHeight;
|
||||
setOverviewShare((current) => resolveDraggedShare(share, current, contentShare));
|
||||
},
|
||||
[overviewContentHeight, splitHeight],
|
||||
);
|
||||
|
||||
// The overview stops at its content height, so a drag starts from where the separator is
|
||||
// drawn rather than from the share the overview may grow to.
|
||||
const separatorSizes = useMemo(() => {
|
||||
const share = splitHeight > 0 ? overviewHeight / splitHeight : DEFAULT_OVERVIEW_SHARE;
|
||||
return [share, 1 - share];
|
||||
}, [overviewHeight, splitHeight]);
|
||||
// The Activity header docks to the bottom edge, so it clears the home indicator the way the
|
||||
// Changes pane's docked Commits section does. The split is measured inside that padding: the
|
||||
// overview's percentage height resolves against this box, so the drag must use the same one.
|
||||
const rootStyle = useMemo(() => [styles.root, { paddingBottom: insets.bottom }], [insets.bottom]);
|
||||
const drawnShare = dragShare ?? overviewShare;
|
||||
const overviewStyle = useMemo(
|
||||
() =>
|
||||
activityOpen
|
||||
? [styles.overviewShared, { maxHeight: `${drawnShare * 100}%` as const }]
|
||||
: styles.overviewFill,
|
||||
[activityOpen, drawnShare],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rootStyle} testID="pr-pane">
|
||||
<View style={styles.split} onLayout={handleSplitLayout}>
|
||||
<ScrollView
|
||||
style={overviewStyle}
|
||||
onLayout={handleOverviewLayout}
|
||||
onContentSizeChange={handleOverviewContentSizeChange}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="pr-pane-overview"
|
||||
>
|
||||
{overview}
|
||||
</ScrollView>
|
||||
{activityOpen ? (
|
||||
<ResizeHandle
|
||||
testID="pr-pane-split-handle"
|
||||
direction="vertical"
|
||||
groupId={SPLIT_GROUP_ID}
|
||||
index={0}
|
||||
sizes={separatorSizes}
|
||||
containerSize={splitHeight}
|
||||
onPreviewResizeSplit={handlePreviewResize}
|
||||
onResizeSplit={handleResize}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.divider} />
|
||||
)}
|
||||
{/* The header keeps one slot in both states, so toggling it does not remount the row
|
||||
and drop keyboard focus. Only the timeline mounts and unmounts. */}
|
||||
{activityHeader}
|
||||
{activityOpen ? (
|
||||
<ScrollView style={styles.activityScroll} showsVerticalScrollIndicator={false}>
|
||||
{activityTimeline}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The share a drag leaves the overview. Short content stops the separator at the content's
|
||||
* bottom, so a drag that ends there or below saves what was drawn and keeps any larger share
|
||||
* the overview already had. The separator then neither jumps to the pointer nor stays pinned
|
||||
* at the old content height when the content grows.
|
||||
*/
|
||||
function resolveDraggedShare(pointerShare: number, currentShare: number, contentShare: number) {
|
||||
if (pointerShare < contentShare) return pointerShare;
|
||||
return Math.max(currentShare, contentShare);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
split: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
overviewShared: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
overviewFill: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
// Same 1px line the separator draws, so collapsing Activity does not move the boundary's look.
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
activityScroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -487,10 +487,7 @@ function LocalModelRow({
|
||||
}
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ hovered, pressed }: HoverState) => [
|
||||
!selectionLocked && hovered && styles.rowHovered,
|
||||
!selectionLocked && pressed && styles.rowPressed,
|
||||
],
|
||||
({ hovered }: HoverState) => [!selectionLocked && hovered && styles.rowHovered],
|
||||
[selectionLocked],
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
|
||||
@@ -113,8 +113,8 @@ function pinnedUsageLabel(
|
||||
return `${label}: ${summary}${error}`;
|
||||
}
|
||||
|
||||
function triggerStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.trigger, hovered && styles.triggerHovered, pressed && styles.triggerPressed];
|
||||
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.trigger, hovered && styles.triggerHovered];
|
||||
}
|
||||
|
||||
function layoutWidth(event: LayoutChangeEvent): number {
|
||||
@@ -161,6 +161,7 @@ function PinnedUsageTrigger({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={pinnedUsageLabel(label, sources, errorLabel)}
|
||||
style={triggerStyle}
|
||||
highlightStyle={styles.triggerPressed}
|
||||
testID="sidebar-usage"
|
||||
>
|
||||
<View style={styles.line} onLayout={handleAvailableLayout}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createElement, type ElementType } from "react";
|
||||
import { lightHighlightColors } from "@getpaseo/highlight";
|
||||
import { lightTheme } from "../src/styles/theme";
|
||||
|
||||
const testTheme = {
|
||||
const pinnedTheme = {
|
||||
colorScheme: "light",
|
||||
colors: {
|
||||
syntax: lightHighlightColors,
|
||||
@@ -86,6 +87,14 @@ const testTheme = {
|
||||
},
|
||||
};
|
||||
|
||||
// Tokens a test does not pin come from the real light theme, so a style or `uniProps` mapping
|
||||
// that reads one (the markdown renderer reads `colors.terminal`) gets a value instead of crashing.
|
||||
const testTheme = {
|
||||
...lightTheme,
|
||||
...pinnedTheme,
|
||||
colors: { ...lightTheme.colors, ...pinnedTheme.colors },
|
||||
};
|
||||
|
||||
type StyleFactory<T> = (theme: typeof testTheme) => T;
|
||||
|
||||
function isStyleFactory<T>(styles: T | StyleFactory<T>): styles is StyleFactory<T> {
|
||||
@@ -142,7 +151,19 @@ export const StyleSheet = {
|
||||
resolveStyles(isStyleFactory(styles) ? styles(testTheme) : styles),
|
||||
};
|
||||
|
||||
export const withUnistyles = <T>(Component: T): T => Component;
|
||||
type UniPropsMapping = (theme: typeof testTheme, runtime: object) => Record<string, unknown>;
|
||||
|
||||
/** Applies `uniProps` with the test theme, the way Unistyles maps theme values onto props. */
|
||||
export const withUnistyles = <T>(Component: T): T => {
|
||||
function ThemedComponent({ uniProps, ...props }: { uniProps?: UniPropsMapping }) {
|
||||
// Explicit props win over mapped ones, as in Unistyles' `deepMergeObjects(mappings, uniProps, props)`.
|
||||
return createElement(Component as ElementType, {
|
||||
...(uniProps ? uniProps(testTheme, {}) : {}),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
return ThemedComponent as T;
|
||||
};
|
||||
|
||||
export const useUnistyles = () => ({
|
||||
theme: testTheme,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProviderInputSchema, type ProviderInput } from "./provider.js";
|
||||
|
||||
type ReviewMode = "uncommitted" | "base" | "all";
|
||||
type Delivery = "auto" | "steer";
|
||||
|
||||
const contextLine = {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 12,
|
||||
type: "add",
|
||||
content: "const value = 1;",
|
||||
} as const;
|
||||
|
||||
function reviewPrompt(mode: ReviewMode, delivery: Delivery): ProviderInput {
|
||||
return {
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "message-1",
|
||||
delivery,
|
||||
input: {
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/workspace",
|
||||
mode,
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 12,
|
||||
body: "Please review this change.",
|
||||
context: {
|
||||
hunkHeader: "@@ -10,2 +10,3 @@",
|
||||
targetLine: contextLine,
|
||||
lines: [{ ...contextLine, type: "context", oldLineNumber: 11 }, contextLine],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Provider review attachments", () => {
|
||||
it.each(["uncommitted", "base", "all"] as const)(
|
||||
"preserves mode %s on an ordinary prompt",
|
||||
(mode) => {
|
||||
const input = reviewPrompt(mode, "auto");
|
||||
expect(ProviderInputSchema.parse(input)).toEqual(input);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["uncommitted", "base", "all"] as const)(
|
||||
"preserves mode %s on a steering prompt",
|
||||
(mode) => {
|
||||
const input = reviewPrompt(mode, "steer");
|
||||
expect(ProviderInputSchema.parse(input)).toEqual(input);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -213,7 +213,7 @@ interface ProviderReviewAttachment {
|
||||
type: "review";
|
||||
mimeType: "application/paseo-review";
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
mode: "uncommitted" | "base" | "all";
|
||||
baseRef?: string | null;
|
||||
comments: Array<{
|
||||
filePath: string;
|
||||
@@ -881,7 +881,7 @@ const providerContentSchema: z.ZodType<ProviderContent> = z.union([
|
||||
type: z.literal("review"),
|
||||
mimeType: z.literal("application/paseo-review"),
|
||||
cwd: z.string(),
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
mode: z.enum(["uncommitted", "base", "all"]),
|
||||
baseRef: z.string().nullable().optional(),
|
||||
comments: z.array(
|
||||
z
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -210,6 +210,8 @@ export async function unarchiveAgentState(
|
||||
}
|
||||
|
||||
export interface SendPromptToAgentParams {
|
||||
/** Awaited after the agent is loaded and its mode is set, before the prompt is handed to a run. */
|
||||
beforeDispatch?: () => Promise<void>;
|
||||
onDispatch?: StartAgentRunOptions["onDispatch"];
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
@@ -357,6 +359,7 @@ export async function sendPreparedPromptToAgent(
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
}
|
||||
|
||||
await params.beforeDispatch?.();
|
||||
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
|
||||
replaceRunning: true,
|
||||
onDispatch: params.onDispatch,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -113,6 +113,111 @@ test.each([
|
||||
}
|
||||
});
|
||||
|
||||
test("re-probes a v1 runtime on a forced refresh after OpenCode is updated to v2", async () => {
|
||||
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
|
||||
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-version-upgrade-"));
|
||||
const script = join(root, "version.cjs");
|
||||
const client = new OpenCodeRuntimeClient(createTestLogger(), {
|
||||
command: { mode: "replace", argv: [process.execPath, script] },
|
||||
env: { XDG_STATE_HOME: root },
|
||||
});
|
||||
const controlLabel = async () =>
|
||||
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label;
|
||||
try {
|
||||
await writeFile(script, 'console.log("1.14.46")');
|
||||
expect(await controlLabel()).toBe("Auto Accept");
|
||||
await writeFile(script, 'console.log("opencode v2.0.10")');
|
||||
expect(await controlLabel()).toBe("Auto Accept");
|
||||
|
||||
// The fake binary cannot serve, so the refresh fails on the v2 startup path it switched to.
|
||||
await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
|
||||
"OpenCode v2 server exited during startup",
|
||||
);
|
||||
|
||||
expect(await controlLabel()).toBe("Auto-accept");
|
||||
} finally {
|
||||
await client.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createCountingVersionBinary(prefix: string) {
|
||||
const { mkdtemp, writeFile, readFile, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
|
||||
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
|
||||
const root = await mkdtemp(join(tmpdir(), prefix));
|
||||
const script = join(root, "version.cjs");
|
||||
const probes = join(root, "probes");
|
||||
await writeFile(probes, "");
|
||||
const client = new OpenCodeRuntimeClient(createTestLogger(), {
|
||||
command: { mode: "replace", argv: [process.execPath, script] },
|
||||
env: { XDG_STATE_HOME: root },
|
||||
});
|
||||
return {
|
||||
client,
|
||||
root,
|
||||
report: (version: string) =>
|
||||
writeFile(
|
||||
script,
|
||||
`if (process.argv.includes("--version")) require("node:fs").appendFileSync(${JSON.stringify(probes)}, "x"); console.log(${JSON.stringify(version)})`,
|
||||
),
|
||||
probeCount: async () => (await readFile(probes, "utf8")).length,
|
||||
controlLabel: async () =>
|
||||
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label,
|
||||
dispose: async () => {
|
||||
await client.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("keeps a v2 runtime on a forced refresh without probing the binary again", async () => {
|
||||
const binary = await createCountingVersionBinary("opencode-v2-refresh-");
|
||||
try {
|
||||
await binary.report("opencode v2.0.10");
|
||||
expect(await binary.controlLabel()).toBe("Auto-accept");
|
||||
expect(await binary.probeCount()).toBe(1);
|
||||
|
||||
await expect(binary.client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
|
||||
"OpenCode v2 server exited during startup",
|
||||
);
|
||||
|
||||
expect(await binary.probeCount()).toBe(1);
|
||||
expect(await binary.controlLabel()).toBe("Auto-accept");
|
||||
} finally {
|
||||
await binary.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([0, 1, 2, 3, 4, 5, 6, 8])(
|
||||
"probes once when a second forced refresh starts %i microtasks after the first",
|
||||
async (ticks) => {
|
||||
const binary = await createCountingVersionBinary("opencode-concurrent-refresh-");
|
||||
try {
|
||||
await binary.report("1.14.46");
|
||||
expect(await binary.controlLabel()).toBe("Auto Accept");
|
||||
await binary.report("opencode v2.0.10");
|
||||
|
||||
const first = binary.client.fetchCatalog({ scope: "global", force: true });
|
||||
for (let tick = 0; tick < ticks; tick++) await Promise.resolve();
|
||||
const second = binary.client.fetchCatalog({ scope: "global", force: true });
|
||||
const results = await Promise.allSettled([first, second]);
|
||||
|
||||
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]);
|
||||
expect(await binary.probeCount()).toBe(2);
|
||||
expect(await binary.controlLabel()).toBe("Auto-accept");
|
||||
} finally {
|
||||
await binary.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("appends a first notice after old history and records a changed major version", async () => {
|
||||
const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js");
|
||||
const { V2Harness } = await import("./test-utils/v2-harness.js");
|
||||
|
||||
@@ -155,8 +155,18 @@ export class OpenCodeRuntimeClient implements AgentClient {
|
||||
return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2, handle);
|
||||
}
|
||||
async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) {
|
||||
if (options.force) await this.reprobeLegacySelection();
|
||||
return (await this.client()).fetchCatalog(options, context);
|
||||
}
|
||||
// Updating the binary does not change provider config, so the client survives it. A forced
|
||||
// refresh re-probes a v1 pick so a 1.x to 2.x update takes effect without a daemon restart.
|
||||
// A v2 pick owns live sessions and stays.
|
||||
private async reprobeLegacySelection() {
|
||||
const current = this.selected;
|
||||
if (!current) return;
|
||||
const client = await current.catch(() => null);
|
||||
if (client === this.legacy && this.selected === current) this.selected = null;
|
||||
}
|
||||
async listCommands(config: AgentSessionConfig) {
|
||||
return (await this.client()).listCommands(config);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs";
|
||||
|
||||
// A refused fetch proves the previous bridge stopped listening. Runtimes report
|
||||
// it differently: Node puts the code on `cause`, Bun puts it on the error.
|
||||
export function isRefused(error) {
|
||||
const code = error?.code ?? error?.cause?.code;
|
||||
return code === "ECONNREFUSED" || code === "ConnectionRefused";
|
||||
}
|
||||
|
||||
// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only
|
||||
// live, per-session bindings over the authenticated OpenCode RPC channel.
|
||||
export async function registerSharedBridge(context) {
|
||||
@@ -130,12 +137,7 @@ export async function registerSharedBridge(context) {
|
||||
} catch (error) {
|
||||
// Refusal or rejected credentials mean this endpoint no longer
|
||||
// serves the previous binding. Other failures are inconclusive.
|
||||
if (
|
||||
error?.status !== 401 &&
|
||||
error?.cause?.code !== "ECONNREFUSED" &&
|
||||
error?.code !== "ConnectionRefused"
|
||||
)
|
||||
throw error;
|
||||
if (error?.status !== 401 && !isRefused(error)) throw error;
|
||||
}
|
||||
if (active)
|
||||
return { error: "OpenCode session is already bound to another Paseo bridge" };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -167,3 +169,88 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a shared plugin rebinds a session after the previous bridge stops listening", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-refused-"));
|
||||
const previous = createServer((_request, response) => {
|
||||
response.writeHead(200, { "Content-Type": "application/json", Connection: "close" });
|
||||
response.end(JSON.stringify({ tools: [] }));
|
||||
});
|
||||
await new Promise<void>((resolve) => previous.listen(0, "127.0.0.1", resolve));
|
||||
const previousPort = (previous.address() as AddressInfo).port;
|
||||
const current = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
|
||||
current.setManifestCatalog({ tools: new Map() });
|
||||
let rpc: Record<string, (input: Binding) => Promise<unknown>> = {};
|
||||
let dispose: (() => Promise<void>) | undefined;
|
||||
let releaseCurrent: (() => void) | undefined;
|
||||
try {
|
||||
const plugin = {
|
||||
package: await current.materializeV2Plugin(),
|
||||
options: { baseUrl: `http://127.0.0.1:${previousPort}`, token: "previous-bridge" },
|
||||
};
|
||||
const module = await import(
|
||||
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
|
||||
);
|
||||
dispose = await module.default.setup({
|
||||
options: {},
|
||||
location: { directory: home },
|
||||
provider: { reload: async () => {} },
|
||||
model: { reload: async () => {} },
|
||||
agent: { reload: async () => {} },
|
||||
command: { reload: async () => {} },
|
||||
skill: { reload: async () => {} },
|
||||
rpc: {
|
||||
register: async (_definition: unknown, handlers: typeof rpc) => {
|
||||
rpc = handlers;
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
},
|
||||
tool: { transform: async () => ({ dispose: async () => {} }) },
|
||||
session: {
|
||||
context: async () => [],
|
||||
get: async () => ({ parentID: "" }),
|
||||
hook: async () => ({ dispose: async () => {} }),
|
||||
},
|
||||
});
|
||||
await expect(rpc.bind({ sessionID: "paseo", ...plugin.options })).resolves.toEqual({});
|
||||
await current.start();
|
||||
// The restarted daemon owns the agent again, so its bridge serves this session.
|
||||
releaseCurrent = current.bindSession({ sessionId: "paseo", env: {} });
|
||||
await new Promise<void>((resolve) => {
|
||||
previous.close(() => resolve());
|
||||
previous.closeAllConnections();
|
||||
});
|
||||
await expect(
|
||||
rpc.bind({ sessionID: "paseo", ...current.sharedV2Connection() }),
|
||||
).resolves.toEqual({});
|
||||
} finally {
|
||||
await dispose?.();
|
||||
releaseCurrent?.();
|
||||
await current.close();
|
||||
await new Promise<void>((resolve) => previous.close(() => resolve()));
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused connection is the only probe failure that clears a binding", async () => {
|
||||
const source = new URL("./shared-bridge-plugin.mjs", import.meta.url).href;
|
||||
const { isRefused } = (await import(source)) as { isRefused: (error: unknown) => boolean };
|
||||
const nodeShape = Object.assign(new Error("fetch failed"), {
|
||||
cause: Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
|
||||
});
|
||||
const olderBunShape = Object.assign(new TypeError("Unable to connect"), {
|
||||
code: "ConnectionRefused",
|
||||
});
|
||||
// OpenCode 2.2.5 runs on Bun 1.4.4, which carries the code on the error and
|
||||
// leaves `cause` unset. Node puts the same failure on `cause` instead.
|
||||
const bunShape = Object.assign(
|
||||
new TypeError("ECONNREFUSED: Unable to connect. Is the computer able to access the url?"),
|
||||
{ code: "ECONNREFUSED" },
|
||||
);
|
||||
const inconclusive = Object.assign(new Error("request timed out"), { code: "ETIMEDOUT" });
|
||||
expect(isRefused(nodeShape)).toBe(true);
|
||||
expect(isRefused(olderBunShape)).toBe(true);
|
||||
expect(isRefused(bunShape)).toBe(true);
|
||||
expect(isRefused(inconclusive)).toBe(false);
|
||||
expect(isRefused(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, mkdir, writeFile, readdir, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import { AgentStorage, parseStoredAgentRecord } from "../agent-storage.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { readPersistedConfig } from "../../persisted-config.js";
|
||||
import { createRestartRecovery } from "./index.js";
|
||||
|
||||
test("recovery saves intent before normalization, clears before dispatch, and resumes each agent independently", async () => {
|
||||
@@ -103,3 +106,130 @@ test("opt-out consumes pending recovery and excludes archived, private, owned an
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const bashAvailable =
|
||||
process.platform !== "win32" &&
|
||||
spawnSync("bash", ["-c", "true"], { stdio: "ignore" }).status === 0;
|
||||
for (const seeder of ["setup", "shell", "setup-bom"] as const) {
|
||||
test.skipIf(seeder === "shell" && !bashAvailable)(
|
||||
`${seeder} seed never dispatches copied running or pending agents and excludes automation`,
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "inert-seed-"));
|
||||
try {
|
||||
const source = join(root, "source");
|
||||
const targetRoot = join(root, "target");
|
||||
const target = join(targetRoot, ".dev/paseo-home");
|
||||
const storage = new AgentStorage(join(root, "source-records"), createTestLogger());
|
||||
for (const [id, lastStatus, resumeOnStartup] of [
|
||||
["live", "running", false],
|
||||
["crashed", "running", true],
|
||||
["stopped", "closed", true],
|
||||
] as const) {
|
||||
await storage.upsert(
|
||||
parseStoredAgentRecord({
|
||||
id,
|
||||
lastStatus,
|
||||
resumeOnStartup,
|
||||
provider: "codex",
|
||||
cwd: root,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
await storage.flush();
|
||||
await mkdir(source, { recursive: true });
|
||||
await symlink(
|
||||
join(root, "source-records"),
|
||||
join(source, "agents"),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
await mkdir(join(source, "schedules"), { recursive: true });
|
||||
await writeFile(join(source, "schedules", "schedule.json"), '{"kind":"scheduled"}');
|
||||
await writeFile(join(source, "schedules", "heartbeat.json"), '{"kind":"heartbeat"}');
|
||||
await writeFile(
|
||||
join(source, "config.json"),
|
||||
(seeder === "setup-bom" ? "\uFEFF" : "") +
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
agents: { resumeOnStartup: true, resumePrompt: "Continue" },
|
||||
}),
|
||||
);
|
||||
const repo = fileURLToPath(new URL("../../../../../../", import.meta.url));
|
||||
const env = {
|
||||
...process.env,
|
||||
PASEO_SOURCE_CHECKOUT_PATH: join(root, "checkout"),
|
||||
PASEO_WORKTREE_PATH: targetRoot,
|
||||
PASEO_DEV_SEED_HOME: source,
|
||||
PASEO_HOME: target,
|
||||
};
|
||||
if (seeder !== "shell")
|
||||
execFileSync(process.execPath, [join(repo, "scripts/seed-worktree-dev-state.mjs")], {
|
||||
env,
|
||||
});
|
||||
else {
|
||||
const linkedRepo = join(root, "linked-checkout");
|
||||
await symlink(repo, linkedRepo, "dir");
|
||||
execFileSync("bash", [join(linkedRepo, "scripts/dev-home.sh")], { env });
|
||||
}
|
||||
expect((await readdir(target)).sort()).toEqual(["agents", "config.json"]);
|
||||
const config = readPersistedConfig(target);
|
||||
const copied = new AgentStorage(join(target, "agents"), createTestLogger());
|
||||
const dispatched: string[] = [];
|
||||
const recovery = createRestartRecovery({
|
||||
storage: copied,
|
||||
settings: () => ({
|
||||
enabled: config.agents?.resumeOnStartup === true,
|
||||
prompt: "Continue",
|
||||
}),
|
||||
eligible: async () => true,
|
||||
dispatch: async (id) => {
|
||||
dispatched.push(id);
|
||||
},
|
||||
report: () => {},
|
||||
});
|
||||
await recovery.prepareStartup();
|
||||
recovery.resumePending();
|
||||
for (const record of await copied.listAll())
|
||||
await recovery.waitForResume(record.id, new AbortController().signal);
|
||||
expect(dispatched).toEqual([]);
|
||||
expect((await copied.listAll()).map((r) => [r.lastStatus, r.resumeOnStartup])).toEqual([
|
||||
["closed", false],
|
||||
["closed", false],
|
||||
["closed", false],
|
||||
]);
|
||||
expect(readPersistedConfig(source).agents?.resumeOnStartup).toBe(true);
|
||||
const unchangedSource = new AgentStorage(join(root, "source-records"), createTestLogger());
|
||||
expect((await unchangedSource.get("live"))?.lastStatus).toBe("running");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("seeding skips an existing home without reading invalid source config", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "seed-skip-"));
|
||||
try {
|
||||
const source = join(root, "source");
|
||||
const targetRoot = join(root, "target");
|
||||
const target = join(targetRoot, ".dev/paseo-home");
|
||||
await mkdir(source, { recursive: true });
|
||||
await mkdir(target, { recursive: true });
|
||||
await writeFile(join(source, "config.json"), "invalid JSON");
|
||||
await writeFile(join(target, "keep.json"), "{}");
|
||||
const repo = fileURLToPath(new URL("../../../../../../", import.meta.url));
|
||||
execFileSync(process.execPath, [join(repo, "scripts/seed-worktree-dev-state.mjs")], {
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_SOURCE_CHECKOUT_PATH: join(root, "checkout"),
|
||||
PASEO_WORKTREE_PATH: targetRoot,
|
||||
PASEO_DEV_SEED_HOME: source,
|
||||
PASEO_DEV_RESET_HOME: "0",
|
||||
},
|
||||
});
|
||||
expect(await readdir(target)).toEqual(["keep.json"]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -182,6 +182,46 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts
|
||||
).toMatchObject({ payload: { accepted: false } });
|
||||
});
|
||||
|
||||
test("Hub retries a message whose send failed before reaching the provider", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const agentId = await hub.createUnrelatedLocalAgent();
|
||||
const message = {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: "mode-change-failed",
|
||||
text: "retry me",
|
||||
sessionMode: "full-access",
|
||||
};
|
||||
hub.failNextProviderModeChange();
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "first-attempt" })).toMatchObject({
|
||||
payload: { accepted: false, error: expect.stringContaining("mode change failure") },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(0);
|
||||
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
|
||||
payload: { accepted: true, error: null },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Hub never resubmits a message whose send failed after reaching the provider", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const agentId = await hub.createUnrelatedLocalAgent();
|
||||
const message = {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: "turn-start-failed",
|
||||
text: "do not repeat me",
|
||||
};
|
||||
hub.failProviderPromptStart("do not repeat me");
|
||||
await hub.requestOrdinary({ ...message, requestId: "first-attempt" });
|
||||
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
|
||||
payload: { accepted: false, error: "agent_request_outcome_unknown" },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "do not repeat me")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("ordinary Hub requests survive daemon restart and restore an archived workspace", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const create = {
|
||||
|
||||
@@ -458,6 +458,7 @@ export class HubRelationshipHarness {
|
||||
private readonly claimedCliSockets = new Set<WebSocket>();
|
||||
private readonly promptsToFail = new Set<string>();
|
||||
private failNextSessionClose = false;
|
||||
private failNextModeChange = false;
|
||||
private observedEnrollments = 0;
|
||||
private observedSockets = 0;
|
||||
private readonly codex: ControlledAgentClient;
|
||||
@@ -474,6 +475,11 @@ export class HubRelationshipHarness {
|
||||
this.failNextSessionClose = false;
|
||||
throw new Error("Requested provider session close failure");
|
||||
},
|
||||
beforeSetMode: async () => {
|
||||
if (!this.failNextModeChange) return;
|
||||
this.failNextModeChange = false;
|
||||
throw new Error("Requested provider mode change failure");
|
||||
},
|
||||
onStartTurn: (prompt) => {
|
||||
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
|
||||
if (this.promptsToFail.delete(promptText)) {
|
||||
@@ -674,6 +680,10 @@ export class HubRelationshipHarness {
|
||||
this.promptsToFail.add(prompt);
|
||||
}
|
||||
|
||||
failNextProviderModeChange(): void {
|
||||
this.failNextModeChange = true;
|
||||
}
|
||||
|
||||
failNextProviderSessionClose(): void {
|
||||
this.failNextSessionClose = true;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ test("message retries survive reconstruction without submitting twice", async ()
|
||||
agentId: "agent",
|
||||
messageId: "arrival",
|
||||
request: { text: "hello" },
|
||||
send: async () => {
|
||||
send: async (beforeDispatch: () => Promise<void>) => {
|
||||
await beforeDispatch();
|
||||
deliveries++;
|
||||
},
|
||||
};
|
||||
@@ -41,7 +42,8 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
|
||||
agentId: "agent",
|
||||
messageId: "arrival",
|
||||
request: {},
|
||||
send: async () => {
|
||||
send: async (beforeDispatch: () => Promise<void>) => {
|
||||
await beforeDispatch();
|
||||
deliveries++;
|
||||
throw new Error("connection lost");
|
||||
},
|
||||
@@ -53,7 +55,7 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
|
||||
expect(deliveries).toBe(1);
|
||||
});
|
||||
|
||||
test("failed local message preparation does not leave an ambiguous receipt", async () => {
|
||||
test("a send that fails before beforeDispatch can be retried with the same message", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let available = false;
|
||||
let sends = 0;
|
||||
@@ -61,14 +63,13 @@ test("failed local message preparation does not leave an ambiguous receipt", asy
|
||||
agentId: "agent",
|
||||
messageId: "message",
|
||||
request: {},
|
||||
prepare: async () => {
|
||||
if (!available) throw new Error("load failed");
|
||||
},
|
||||
send: async () => {
|
||||
send: async (beforeDispatch: () => Promise<void>) => {
|
||||
if (!available) throw new Error("mode change failed");
|
||||
await beforeDispatch();
|
||||
sends++;
|
||||
},
|
||||
};
|
||||
await expect(requests.send(input)).rejects.toThrow("load failed");
|
||||
await expect(requests.send(input)).rejects.toThrow("mode change failed");
|
||||
available = true;
|
||||
await new MessageReceipts(directory).send(input);
|
||||
available = false;
|
||||
|
||||
@@ -13,8 +13,11 @@ interface SendMessageInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
request: unknown;
|
||||
send: () => Promise<void>;
|
||||
prepare?: () => Promise<void>;
|
||||
/**
|
||||
* Delivers the message. It awaits `beforeDispatch` immediately before the provider can receive
|
||||
* the message; a failure before that call proves nothing was submitted and leaves no receipt.
|
||||
*/
|
||||
send: (beforeDispatch: () => Promise<void>) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Owns message delivery receipts; creation is owned by CreationService. */
|
||||
@@ -48,10 +51,12 @@ export class MessageReceipts {
|
||||
// A provider may have accepted the message before its receipt was committed.
|
||||
throw new Error("agent_request_outcome_unknown");
|
||||
}
|
||||
await input.prepare?.();
|
||||
const receipt = { fingerprint, agentId: input.agentId };
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "pending" });
|
||||
await input.send();
|
||||
let pending: Promise<void> | null = null;
|
||||
await input.send(() => {
|
||||
pending ??= writeJsonFileAtomic(file, { ...receipt, state: "pending" });
|
||||
return pending;
|
||||
});
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8918,8 +8918,9 @@ export class Session {
|
||||
queued = acceptAdmission;
|
||||
});
|
||||
let deliveryStatus: "queued" | undefined;
|
||||
const send = async () => {
|
||||
const send = async (beforeDispatch: () => Promise<void>) => {
|
||||
if (msg.sourceAgentId) {
|
||||
await beforeDispatch();
|
||||
const receipt = this.agentMessages.enqueue({
|
||||
recipientId: agentId,
|
||||
messageId: msg.messageId,
|
||||
@@ -8950,6 +8951,7 @@ export class Session {
|
||||
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
|
||||
sessionMode: msg.sessionMode,
|
||||
clearPendingPermissions: true,
|
||||
beforeDispatch,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
if (result.disposition === "turn_started") {
|
||||
@@ -8972,12 +8974,12 @@ export class Session {
|
||||
activeTurnBehavior:
|
||||
msg.activeTurnBehavior ?? (msg.sourceAgentId ? "steer" : "interrupt"),
|
||||
},
|
||||
prepare: async () => {
|
||||
send: async (beforeDispatch) => {
|
||||
if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text);
|
||||
await send(beforeDispatch);
|
||||
},
|
||||
send,
|
||||
})
|
||||
: send();
|
||||
: send(async () => {});
|
||||
void sent.catch((error) => {
|
||||
if (deliveryStatus === "queued") {
|
||||
this.handleAgentRunError(agentId, error, "Failed to dispatch queued agent message");
|
||||
|
||||
@@ -84,6 +84,7 @@ interface FakeAgentSessionOptions {
|
||||
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
|
||||
steerActiveTurn?: AgentSession["steerActiveTurn"];
|
||||
onInterrupt?: () => void;
|
||||
beforeSetMode?: (modeId: string) => Promise<void>;
|
||||
historyEvents?: readonly AgentStreamEvent[];
|
||||
}
|
||||
|
||||
@@ -95,6 +96,7 @@ export interface TestAgentClientOptions {
|
||||
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
|
||||
steerActiveTurn?: AgentSession["steerActiveTurn"];
|
||||
onInterrupt?: () => void;
|
||||
beforeSetMode?: (modeId: string) => Promise<void>;
|
||||
supportsMcpServers?: boolean;
|
||||
historyEvents?: readonly AgentStreamEvent[];
|
||||
}
|
||||
@@ -369,6 +371,7 @@ class FakeAgentSession implements AgentSession {
|
||||
|
||||
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
|
||||
private readonly onInterrupt: (() => void) | undefined;
|
||||
private readonly beforeSetMode: ((modeId: string) => Promise<void>) | undefined;
|
||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
|
||||
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
|
||||
@@ -385,6 +388,7 @@ class FakeAgentSession implements AgentSession {
|
||||
this.memoryMarker = options.memoryMarker ?? null;
|
||||
this.steerActiveTurn = options.steerActiveTurn;
|
||||
this.onInterrupt = options.onInterrupt;
|
||||
this.beforeSetMode = options.beforeSetMode;
|
||||
this.closeSession = options.closeSession;
|
||||
this.onStartTurn = options.onStartTurn;
|
||||
this.beforeTurnComplete = options.beforeTurnComplete;
|
||||
@@ -543,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 = {
|
||||
@@ -786,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",
|
||||
@@ -891,6 +942,7 @@ class FakeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
await this.beforeSetMode?.(modeId);
|
||||
this.config.modeId = modeId;
|
||||
}
|
||||
|
||||
@@ -1264,6 +1316,7 @@ class FakeAgentClient implements AgentClient {
|
||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||
steerActiveTurn: this.options.steerActiveTurn,
|
||||
onInterrupt: this.options.onInterrupt,
|
||||
beforeSetMode: this.options.beforeSetMode,
|
||||
historyEvents: this.options.historyEvents,
|
||||
});
|
||||
}
|
||||
@@ -1294,6 +1347,7 @@ class FakeAgentClient implements AgentClient {
|
||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||
steerActiveTurn: this.options.steerActiveTurn,
|
||||
onInterrupt: this.options.onInterrupt,
|
||||
beforeSetMode: this.options.beforeSetMode,
|
||||
historyEvents: this.options.historyEvents,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ export function createProviderSnapshotManagerStub(): {
|
||||
|
||||
export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] {
|
||||
return {
|
||||
send: (input) => input.send(),
|
||||
send: (input) => input.send(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1238,6 +1238,9 @@ test("a failed parent lookup is retried by the pull request status poll, keeping
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
now: () => new Date(nowMs),
|
||||
// Observing the workspace starts a background fetch of origin. A real one would run ssh
|
||||
// against the alias host in the checkout, and can outlive disposal and hold the folder.
|
||||
runGitFetch: async () => ({ changes: [], error: null }),
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: {
|
||||
// The adapter is real; only the poll is driven by the test instead of a timer.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,124 @@ index 2abb4a2..8ca967f 100644
|
||||
outerScrollOffset?: Animated.SharedValue<number>;
|
||||
onAnimValInit?: (animVals: ReturnType<typeof useAnimatedValues>) => void;
|
||||
itemEnteringAnimation?: AnimateProps<Animated.View>["entering"];
|
||||
diff --git a/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx b/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx
|
||||
index e4e861a..f485139 100644
|
||||
--- a/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx
|
||||
+++ b/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
runOnUI,
|
||||
+ SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
@@ -40,12 +41,12 @@ function CellRendererComponent<T>(props: Props<T>) {
|
||||
keyExtractor,
|
||||
horizontal,
|
||||
layoutAnimationDisabled,
|
||||
+ transformHostKeys,
|
||||
} = useDraggableFlatListContext<T>();
|
||||
|
||||
const key = keyExtractor(item, index);
|
||||
const offset = useSharedValue(-1);
|
||||
const size = useSharedValue(-1);
|
||||
- const heldTanslate = useSharedValue(0);
|
||||
|
||||
const translate = useCellTranslate({
|
||||
cellOffset: offset,
|
||||
@@ -55,20 +56,6 @@ function CellRendererComponent<T>(props: Props<T>) {
|
||||
|
||||
const isActive = activeKey === key;
|
||||
|
||||
- const animStyle = useAnimatedStyle(() => {
|
||||
- // When activeKey becomes null at the end of a drag and the list reorders,
|
||||
- // the animated style may apply before the next paint, causing a flicker.
|
||||
- // Solution is to hold over the last animated value until the next onLayout.
|
||||
- // (Not required in web)
|
||||
- if (translate.value && !isWeb) {
|
||||
- heldTanslate.value = translate.value;
|
||||
- }
|
||||
- const t = activeKey ? translate.value : heldTanslate.value;
|
||||
- return {
|
||||
- transform: [horizontalAnim.value ? { translateX: t } : { translateY: t }],
|
||||
- };
|
||||
- }, [translate, activeKey]);
|
||||
-
|
||||
const updateCellMeasurements = useStableCallback(() => {
|
||||
const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => {
|
||||
if (isWeb && horizontal) x += scrollOffset.value;
|
||||
@@ -99,7 +86,6 @@ function CellRendererComponent<T>(props: Props<T>) {
|
||||
});
|
||||
|
||||
const onCellLayout = useStableCallback((e?: LayoutChangeEvent) => {
|
||||
- heldTanslate.value = 0;
|
||||
updateCellMeasurements();
|
||||
if (onLayout && e) onLayout(e);
|
||||
});
|
||||
@@ -162,14 +148,58 @@ function CellRendererComponent<T>(props: Props<T>) {
|
||||
? itemLayoutAnimation
|
||||
: undefined
|
||||
}
|
||||
- style={[props.style, baseStyle, animStyle]}
|
||||
+ style={[props.style, baseStyle]}
|
||||
pointerEvents={activeKey ? "none" : "auto"}
|
||||
>
|
||||
- <CellProvider isActive={isActive}>{children}</CellProvider>
|
||||
+ <CellTransformHost
|
||||
+ key={transformHostKeys.get(key) ?? 0}
|
||||
+ translate={translate}
|
||||
+ dragging={activeKey !== null}
|
||||
+ horizontal={horizontal}
|
||||
+ >
|
||||
+ <CellProvider isActive={isActive}>{children}</CellProvider>
|
||||
+ </CellTransformHost>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
+// Owns the cell's drag translate. The list remounts it, with the cell's whole subtree, when a
|
||||
+// drop moves the cell: the reorder commit then paints the new slot with no translate, because a
|
||||
+// new view has no last animated transform for Reanimated to re-apply.
|
||||
+function CellTransformHost({
|
||||
+ translate,
|
||||
+ dragging,
|
||||
+ horizontal,
|
||||
+ children,
|
||||
+}: {
|
||||
+ translate: SharedValue<number>;
|
||||
+ dragging: boolean;
|
||||
+ horizontal: boolean;
|
||||
+ children: React.ReactNode;
|
||||
+}) {
|
||||
+ const { horizontalAnim } = useAnimatedValues();
|
||||
+ const animStyle = useAnimatedStyle(() => {
|
||||
+ const t = dragging ? translate.value : 0;
|
||||
+ return {
|
||||
+ transform: [horizontalAnim.value ? { translateX: t } : { translateY: t }],
|
||||
+ };
|
||||
+ }, [translate, dragging]);
|
||||
+
|
||||
+ return (
|
||||
+ <Animated.View
|
||||
+ collapsable={false}
|
||||
+ style={[horizontal ? styles.row : styles.column, animStyle]}
|
||||
+ >
|
||||
+ {children}
|
||||
+ </Animated.View>
|
||||
+ );
|
||||
+}
|
||||
+
|
||||
+const styles = {
|
||||
+ row: { flexDirection: "row" as const },
|
||||
+ column: { flexDirection: "column" as const },
|
||||
+};
|
||||
+
|
||||
export default typedMemo(CellRendererComponent);
|
||||
|
||||
declare global {
|
||||
diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
|
||||
index 7c88afc..a74140f 100644
|
||||
index 7c88afc..ad75714 100644
|
||||
--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
|
||||
+++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
@@ -88,13 +204,26 @@ index 7c88afc..a74140f 100644
|
||||
+ sessionId: number;
|
||||
+ index: number;
|
||||
+ } | null>(null);
|
||||
|
||||
|
||||
const {
|
||||
dragHitSlop = DEFAULT_PROPS.dragHitSlop,
|
||||
@@ -144,19 +157,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -100,6 +113,12 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
} = props;
|
||||
|
||||
let [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
+ // Reanimated re-applies a view's last animated transform to every React commit, so a moved
|
||||
+ // cell would paint once at its new slot plus its old drag translate. The drop gives each
|
||||
+ // moved cell a new transform host instead: a fresh view has no animated props to re-apply.
|
||||
+ const [transformHostKeys, setTransformHostKeys] = useState<
|
||||
+ ReadonlyMap<string, number>
|
||||
+ >(() => new Map());
|
||||
const [layoutAnimationDisabled, setLayoutAnimationDisabled] = useState(
|
||||
!propsRef.current.enableLayoutAnimationExperimental
|
||||
);
|
||||
@@ -144,19 +163,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
});
|
||||
}, [props.data, keyExtractor, keyToIndexRef]);
|
||||
|
||||
|
||||
+ const onDragCancel = useStableCallback((sessionId: number, index: number) => {
|
||||
+ if (activeDragRef.current?.sessionId !== sessionId) return;
|
||||
+ activeDragRef.current = null;
|
||||
@@ -168,7 +297,7 @@ index 7c88afc..a74140f 100644
|
||||
- activeCellOffset.value = cellData.measurements.offset;
|
||||
- activeCellSize.value = cellData.measurements.size;
|
||||
- }
|
||||
|
||||
|
||||
const { onDragBegin } = propsRef.current;
|
||||
if (index !== undefined) {
|
||||
- spacerIndexAnim.value = index;
|
||||
@@ -187,10 +316,10 @@ index 7c88afc..a74140f 100644
|
||||
setActiveKey(activeKey);
|
||||
onDragBegin?.(index);
|
||||
}
|
||||
@@ -218,8 +300,25 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -218,14 +306,46 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
props.onRelease?.(index);
|
||||
});
|
||||
|
||||
|
||||
+ const finalizeCancelledDrag = () => {
|
||||
+ "worklet";
|
||||
+ const activeIndex = activeIndexAnim.value;
|
||||
@@ -212,12 +341,33 @@ index 7c88afc..a74140f 100644
|
||||
+ if (activeDragRef.current?.sessionId !== sessionId) return;
|
||||
+ activeDragRef.current = null;
|
||||
const { onDragEnd, data } = props;
|
||||
|
||||
|
||||
const newData = [...data];
|
||||
@@ -238,26 +337,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
if (from !== to) {
|
||||
newData.splice(from, 1);
|
||||
newData.splice(to, 0, data[from]);
|
||||
+ const movedKeys = data
|
||||
+ .slice(Math.min(from, to), Math.max(from, to) + 1)
|
||||
+ .map((item, offset) => keyExtractor(item, Math.min(from, to) + offset));
|
||||
+ setTransformHostKeys((previous) => {
|
||||
+ const next = new Map<string, number>();
|
||||
+ data.forEach((item, index) => {
|
||||
+ const itemKey = keyExtractor(item, index);
|
||||
+ const hostKey = previous.get(itemKey);
|
||||
+ if (hostKey !== undefined) next.set(itemKey, hostKey);
|
||||
+ });
|
||||
+ for (const movedKey of movedKeys) {
|
||||
+ next.set(movedKey, (previous.get(movedKey) ?? 0) + 1);
|
||||
+ }
|
||||
+ return next;
|
||||
+ });
|
||||
}
|
||||
|
||||
onDragEnd?.({ from, to, data: newData });
|
||||
@@ -238,26 +358,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
props.onPlaceholderIndexChange?.(index);
|
||||
});
|
||||
|
||||
|
||||
- // Handle case where user ends drag without moving their finger.
|
||||
- useAnimatedReaction(
|
||||
- () => {
|
||||
@@ -241,15 +391,15 @@ index 7c88afc..a74140f 100644
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
return spacerIndexAnim.value;
|
||||
@@ -271,11 +350,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -271,11 +371,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
);
|
||||
|
||||
|
||||
const gestureDisabled = useSharedValue(false);
|
||||
+ const touchStartX = useSharedValue(0);
|
||||
+ const touchStartY = useSharedValue(0);
|
||||
+ const manualPanActivated = useSharedValue(false);
|
||||
+ const coordinatesDragWithExternalGesture = Boolean(props.simultaneousHandlers);
|
||||
|
||||
|
||||
- const panGesture = Gesture.Pan()
|
||||
+ let panGesture = Gesture.Pan()
|
||||
.onBegin((evt) => {
|
||||
@@ -267,7 +417,7 @@ index 7c88afc..a74140f 100644
|
||||
panGestureState.value = evt.state;
|
||||
})
|
||||
.onUpdate((evt) => {
|
||||
@@ -286,8 +378,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -286,8 +399,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
: evt.translationY;
|
||||
touchTranslate.value = translation;
|
||||
})
|
||||
@@ -284,15 +434,15 @@ index 7c88afc..a74140f 100644
|
||||
// Set touch val to current translate val
|
||||
isTouchActiveNative.value = false;
|
||||
const translation = horizontalAnim.value
|
||||
@@ -299,6 +398,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
|
||||
@@ -299,6 +419,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
|
||||
// Only call onDragEnd if actually dragging a cell
|
||||
if (activeIndexAnim.value === -1 || disabled.value) return;
|
||||
+ terminalHandled.value = true;
|
||||
disabled.value = true;
|
||||
runOnJS(onRelease)(activeIndexAnim.value);
|
||||
const springTo = placeholderOffset.value - activeCellOffset.value;
|
||||
@@ -309,22 +409,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -309,22 +430,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
runOnJS(onDragEnd)({
|
||||
from: activeIndexAnim.value,
|
||||
to: spacerIndexAnim.value,
|
||||
@@ -371,7 +521,7 @@ index 7c88afc..a74140f 100644
|
||||
+ stateManager.fail();
|
||||
+ }
|
||||
});
|
||||
|
||||
|
||||
if (dragHitSlop) panGesture.hitSlop(dragHitSlop);
|
||||
- if (activationDistanceProp) {
|
||||
+ if (coordinatesDragWithExternalGesture) {
|
||||
@@ -380,7 +530,7 @@ index 7c88afc..a74140f 100644
|
||||
const activeOffset = [-activationDistanceProp, activationDistanceProp];
|
||||
if (props.horizontal) {
|
||||
panGesture.activeOffsetX(activeOffset);
|
||||
@@ -332,6 +494,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -332,6 +515,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
panGesture.activeOffsetY(activeOffset);
|
||||
}
|
||||
}
|
||||
@@ -399,12 +549,14 @@ index 7c88afc..a74140f 100644
|
||||
+ props.dragGestureHostPresented === false
|
||||
+ ? Gesture.Simultaneous(panGesture, Gesture.Manual().enabled(false))
|
||||
+ : panGesture;
|
||||
|
||||
|
||||
const onScroll = useStableCallback((scrollOffset: number) => {
|
||||
props.onScrollOffsetChange?.(scrollOffset);
|
||||
@@ -373,8 +550,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -372,9 +570,11 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
keyExtractor={keyExtractor}
|
||||
horizontal={!!props.horizontal}
|
||||
layoutAnimationDisabled={layoutAnimationDisabled}
|
||||
+ transformHostKeys={transformHostKeys}
|
||||
>
|
||||
- <GestureDetector gesture={panGesture}>
|
||||
+ <GestureDetector gesture={detectorGesture}>
|
||||
@@ -426,17 +578,17 @@ index 1559352..33eb5de 100644
|
||||
useSharedValue,
|
||||
@@ -48,7 +48,7 @@ function NestableDraggableFlatListInner<T>(
|
||||
});
|
||||
|
||||
|
||||
const onListContainerLayout = useStableCallback(async ({ containerRef }) => {
|
||||
- const nodeHandle = findNodeHandle(scrollableRef.current);
|
||||
+ const nodeHandle = scrollableRef.current;
|
||||
|
||||
|
||||
const onSuccess = (_x: number, y: number) => {
|
||||
listVerticalOffset.value = y;
|
||||
@@ -74,6 +74,10 @@ function NestableDraggableFlatListInner<T>(
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+ const onDragTerminate = useStableCallback(() => {
|
||||
+ setOuterScrollEnabled(true);
|
||||
+ });
|
||||
@@ -452,6 +604,37 @@ index 1559352..33eb5de 100644
|
||||
onAnimValInit={onAnimValInit}
|
||||
/>
|
||||
);
|
||||
diff --git a/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx b/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx
|
||||
index 75f7db7..e99faf3 100644
|
||||
--- a/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx
|
||||
+++ b/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx
|
||||
@@ -5,6 +5,7 @@ type Props<T> = {
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
horizontal: boolean;
|
||||
layoutAnimationDisabled: boolean;
|
||||
+ transformHostKeys: ReadonlyMap<string, number>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -19,6 +20,7 @@ export default function DraggableFlatListProvider<T>({
|
||||
keyExtractor,
|
||||
horizontal,
|
||||
layoutAnimationDisabled,
|
||||
+ transformHostKeys,
|
||||
children,
|
||||
}: Props<T>) {
|
||||
const value = useMemo(
|
||||
@@ -27,8 +29,9 @@ export default function DraggableFlatListProvider<T>({
|
||||
keyExtractor,
|
||||
horizontal,
|
||||
layoutAnimationDisabled,
|
||||
+ transformHostKeys,
|
||||
}),
|
||||
- [activeKey, keyExtractor, horizontal, layoutAnimationDisabled]
|
||||
+ [activeKey, keyExtractor, horizontal, layoutAnimationDisabled, transformHostKeys]
|
||||
);
|
||||
|
||||
return (
|
||||
diff --git a/node_modules/react-native-draggable-flatlist/src/types.ts b/node_modules/react-native-draggable-flatlist/src/types.ts
|
||||
index d6755c8..1b244a6 100644
|
||||
--- a/node_modules/react-native-draggable-flatlist/src/types.ts
|
||||
|
||||
+3
-55
@@ -4,62 +4,10 @@ default_dev_paseo_root() {
|
||||
git rev-parse --show-toplevel 2>/dev/null || pwd
|
||||
}
|
||||
|
||||
copy_json_tree() {
|
||||
local source_dir="$1"
|
||||
local target_dir="$2"
|
||||
|
||||
if [ ! -d "$source_dir" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$target_dir"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --include='*/' --include='*.json' --exclude='*' "$source_dir/" "$target_dir/"
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r -d '' source_file; do
|
||||
local relative_path="${source_file#"$source_dir"/}"
|
||||
local target_file="$target_dir/$relative_path"
|
||||
mkdir -p "$(dirname "$target_file")"
|
||||
cp "$source_file" "$target_file"
|
||||
done < <(find "$source_dir" -type f -name '*.json' -print0)
|
||||
}
|
||||
|
||||
has_files() {
|
||||
[ -d "$1" ] && [ -n "$(find "$1" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]
|
||||
}
|
||||
|
||||
seed_worktree_paseo_home() {
|
||||
local source_home="${PASEO_DEV_SEED_HOME:-$HOME/.paseo}"
|
||||
local target_home="$1"
|
||||
|
||||
if [ ! -d "$source_home" ]; then
|
||||
echo " Seed: skipped (${source_home} missing)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$source_home" = "$target_home" ]; then
|
||||
echo " Seed: skipped (source is target)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "${PASEO_DEV_RESET_HOME:-0}" = "1" ]; then
|
||||
rm -rf "$target_home"
|
||||
elif has_files "$target_home"; then
|
||||
echo " Seed: skipped (${target_home} already has data)"
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$target_home"
|
||||
echo " Seed: copying metadata from ${source_home}"
|
||||
copy_json_tree "$source_home/agents" "$target_home/agents"
|
||||
copy_json_tree "$source_home/projects" "$target_home/projects"
|
||||
if [ -f "$source_home/config.json" ]; then
|
||||
cp "$source_home/config.json" "$target_home/config.json"
|
||||
fi
|
||||
|
||||
echo " Seed: copied metadata from ${source_home}"
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
node "$script_dir/seed-dev-home.mjs" "${PASEO_DEV_SEED_HOME:-$HOME/.paseo}" "$1"
|
||||
}
|
||||
|
||||
configure_dev_daemon_config() {
|
||||
|
||||
@@ -48,6 +48,10 @@ const patchedPackages = [
|
||||
nodeModulesPath: "node_modules/@legendapp/list",
|
||||
patchPrefix: "@legendapp+list+",
|
||||
},
|
||||
// Native sidebar lists. Besides gesture sessions and panel-gesture arbitration, the drop gives
|
||||
// each moved cell a fresh transform host. Reanimated re-applies a view's last animated
|
||||
// transform to every React commit, so a reused host painted one frame of new order plus old
|
||||
// drag translate (upstream computerjazz/react-native-draggable-flatlist#621, open).
|
||||
{
|
||||
nodeModulesPath: "node_modules/react-native-draggable-flatlist",
|
||||
patchPrefix: "react-native-draggable-flatlist+",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
cpSync,
|
||||
realpathSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export function seedPaseoHome(source, target) {
|
||||
if (!existsSync(source)) {
|
||||
console.log(` Seed: skipped (${source} missing)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === target) {
|
||||
console.log(" Seed: skipped (source is target)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.PASEO_DEV_RESET_HOME !== "1" && hasEntries(target)) {
|
||||
console.log(` Seed: skipped (${target} already has data)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = join(source, "config.json");
|
||||
const config = existsSync(configPath)
|
||||
? JSON.parse(readFileSync(configPath, "utf8").replace(/^\uFEFF/, ""))
|
||||
: { version: 1 };
|
||||
|
||||
if (process.env.PASEO_DEV_RESET_HOME === "1") {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
mkdirSync(target, { recursive: true });
|
||||
console.log(` Seed: copying metadata from ${source}`);
|
||||
copyJsonTree(join(source, "agents"), join(target, "agents"));
|
||||
copyJsonTree(join(source, "projects"), join(target, "projects"));
|
||||
|
||||
// A snapshot does not own the source daemon's pending work. Recovery normalizes
|
||||
// copied running records on startup without sending a prompt when disabled.
|
||||
config.agents = { ...config.agents, resumeOnStartup: false };
|
||||
writeFileSync(join(target, "config.json"), JSON.stringify(config, null, 2));
|
||||
|
||||
console.log(` Seed: copied metadata from ${source}`);
|
||||
}
|
||||
|
||||
// Durable JSON metadata only. Runtime files (pid files, sockets, logs) must not be copied.
|
||||
function copyJsonTree(source, target) {
|
||||
if (!existsSync(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cpSync(source, target, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (path) => isDirectory(path) || path.endsWith(".json"),
|
||||
});
|
||||
}
|
||||
|
||||
function hasEntries(path) {
|
||||
try {
|
||||
return readdirSync(path).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(path) {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
||||
seedPaseoHome(process.argv[2], process.argv[3]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Worktree setup runs through a stable script shell: bash on macOS/Linux, but PowerShell
|
||||
@@ -7,6 +7,8 @@ import { dirname, join } from "node:path";
|
||||
// `./scripts/*.sh`) cannot express this step portably, so the seeding lives in Node — the
|
||||
// one interpreter every Paseo checkout already depends on.
|
||||
|
||||
import { seedPaseoHome } from "./seed-dev-home.mjs";
|
||||
|
||||
const sourceRoot = process.env.PASEO_SOURCE_CHECKOUT_PATH;
|
||||
const targetRoot = process.env.PASEO_WORKTREE_PATH || process.cwd();
|
||||
|
||||
@@ -14,55 +16,12 @@ if (!sourceRoot || sourceRoot === targetRoot) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seedPaseoHome();
|
||||
seedPaseoHome(
|
||||
process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home"),
|
||||
join(targetRoot, ".dev/paseo-home"),
|
||||
);
|
||||
copyServerEnv();
|
||||
|
||||
function seedPaseoHome() {
|
||||
const source = process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home");
|
||||
const target = join(targetRoot, ".dev/paseo-home");
|
||||
|
||||
if (!existsSync(source)) {
|
||||
console.log(` Seed: skipped (${source} missing)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === target) {
|
||||
console.log(" Seed: skipped (source is target)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.PASEO_DEV_RESET_HOME === "1") {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
} else if (hasEntries(target)) {
|
||||
console.log(` Seed: skipped (${target} already has data)`);
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(target, { recursive: true });
|
||||
console.log(` Seed: copying metadata from ${source}`);
|
||||
copyJsonTree(join(source, "agents"), join(target, "agents"));
|
||||
copyJsonTree(join(source, "projects"), join(target, "projects"));
|
||||
|
||||
const config = join(source, "config.json");
|
||||
if (existsSync(config)) {
|
||||
cpSync(config, join(target, "config.json"));
|
||||
}
|
||||
|
||||
console.log(` Seed: copied metadata from ${source}`);
|
||||
}
|
||||
|
||||
// Durable JSON metadata only. Runtime files (pid files, sockets, logs) must not be copied.
|
||||
function copyJsonTree(source, target) {
|
||||
if (!existsSync(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cpSync(source, target, {
|
||||
recursive: true,
|
||||
filter: (path) => isDirectory(path) || path.endsWith(".json"),
|
||||
});
|
||||
}
|
||||
|
||||
function copyServerEnv() {
|
||||
const source = join(sourceRoot, "packages/server/.env");
|
||||
const target = join(targetRoot, "packages/server/.env");
|
||||
@@ -77,19 +36,3 @@ function copyServerEnv() {
|
||||
cpSync(source, target);
|
||||
console.log(` Env: copied ${source}`);
|
||||
}
|
||||
|
||||
function hasEntries(path) {
|
||||
try {
|
||||
return readdirSync(path).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(path) {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user