Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 11c28a48ea fix(app): open the command popover in place on native
Reanimated computes a style's first value when its hook first runs and sends
later values from an effect after the mounting commit, which native paints.
The popover's height bound lived in the component that renders while the
popover is closed, so it started at 0. Every open painted one frame with the
popover at its minimum height, hanging down over the composer, before the
measured bound arrived. The bound now lives in a frame that mounts only with
a measured anchor.

Browser tests could not apply animated style updates at all: Reanimated loads
its react-native-web style helpers with require, which Vite's ESM serving
lacks, so the first update after mount crashed the component.
2026-11-14 09:19:32 +01:00
75 changed files with 823 additions and 2540 deletions
+1 -1
View File
@@ -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 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.
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.
Before adding a new component, read `components/ui/`. The primitive usually exists.
+1 -1
View File
@@ -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 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.
- **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.
- **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:
+3 -9
View File
@@ -65,10 +65,6 @@ Two escape hatches in the codebase:
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
Use `@/components/ui/modal`, not React Native's `Modal`. On Android, React Native lays out a
modal's first frames at the screen size minus the status and navigation bars, then grows it to
the full edge-to-edge window, so a full-screen backdrop stops short of the bottom and snaps
down. The wrapper sizes the content to the window from the first frame.
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is
@@ -426,11 +422,9 @@ gets that 0 and draws over the navigation bar.
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
- An RN `Modal` passes the call site's context straight through, so wrap its content in
`WindowSafeArea` from `@/window-layout` and read the inset below that. It provides the insets of
the whole window, published by `WindowLayoutProvider`. Do not put a `SafeAreaProvider` inside the
modal: on Android it measures the dialog window, whose bottom inset reads 24, then 0, then 24
again over the first frames, and everything padded by it jumps. See `attachment-lightbox.tsx`.
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
inside it and read the inset below that. The modal is its own window, and the provider measures
it. See `attachment-lightbox.tsx`.
## Recipe for a new anchored panel
-4
View File
@@ -61,10 +61,6 @@ 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
@@ -14,7 +14,6 @@ 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,
@@ -26,7 +25,6 @@ import {
resizeSidebarToMinimum,
rowArchiveButton,
rowKebab,
scrollSidebarListToEnd,
undoButton,
workspaceRow,
workspaceTitleWidth,
@@ -121,41 +119,6 @@ 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,
}) => {
@@ -21,6 +21,7 @@ export interface OlderTimelinePagesGate {
getRepeatedEntryCount(): number;
getOwnedEntryCountContaining(text: string): number;
releasePage(pageNumber: number): void;
waitForRequestCount(count: number): Promise<void>;
}
export interface DaemonHydrationGate {
@@ -370,6 +371,15 @@ 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));
@@ -383,6 +393,7 @@ export async function holdAgentOlderTimelinePages(
sessionMessage.direction === "before"
) {
requestCount += 1;
resolveRequestWaiters();
}
server.send(message);
});
@@ -428,6 +439,14 @@ 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,11 +427,7 @@ export async function holdOlderHistoryPages(
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await expect
.poll(() => gate.getRequestCount(), {
message: `older history requests ${count} page(s)`,
})
.toBeGreaterThanOrEqual(count);
await gate.waitForRequestCount(count);
expect(gate.getRequestCount()).toBe(count);
},
async expectSettledWithRequestedPages(count) {
@@ -162,38 +162,3 @@ 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)),
};
});
}
@@ -49,21 +49,8 @@ afterEach(() => {
host.remove();
});
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 });
function mountTimeline(historyMounted: StreamItem[]) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const input: StreamRenderInput = {
agentId: "agent",
@@ -89,7 +76,6 @@ function mountTimeline(
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 } }));
@@ -114,20 +100,8 @@ function mountTimeline(
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 () => {
@@ -139,53 +113,6 @@ function mountTimeline(
}
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);
@@ -281,32 +208,3 @@ 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);
});
});
+6 -19
View File
@@ -457,8 +457,6 @@ 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,
@@ -607,15 +605,6 @@ 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;
@@ -802,16 +791,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 userScrolledUp = scrolledUp && hasUpwardInputEvidence();
const mouseGesture = mouseScrollGestureRef.current;
const hasUpwardInputEvidence =
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current;
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
setFollowOutput(true);
} else if (followOutputRef.current && userScrolledUp) {
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
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;
@@ -822,10 +811,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}, [
evaluateHistoryStart,
hasUpwardInputEvidence,
isJumpSettling,
readingAnchor,
rearmHistoryStartFromUserIntent,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
@@ -39,21 +39,6 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
/**
* Resolves a pending lookup and waits until React Query has delivered the result. It delivers on a
* timer, which would otherwise fire after the test, when jsdom is gone.
*/
async function resolveLookupAndDeliver(
lookup: ReturnType<typeof createDeferred<DirectorySuggestionResult>>,
result: DirectorySuggestionResult,
): Promise<void> {
await act(async () => {
lookup.resolve(result);
await lookup.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
@@ -337,12 +322,11 @@ describe("useFileLink", () => {
act(() => {
result.current.setWorkspaceRoot("/Users/test/other");
});
await resolveLookupAndDeliver(
deferred,
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
);
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
expect(openedFiles).toEqual([]);
});
});
@@ -1,71 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import type { AttachmentMetadata } from "@/attachments/types";
import { type AttachmentPreviewUrl, useAttachmentPreviewUrl } from "./use-attachment-preview-url";
let root: Root;
let container: HTMLDivElement;
let latest: AttachmentPreviewUrl | null = null;
function PreviewProbe({ attachment }: { attachment: AttachmentMetadata | null }) {
latest = useAttachmentPreviewUrl(attachment);
return null;
}
async function render(attachment: AttachmentMetadata | null) {
await act(async () => root.render(<PreviewProbe attachment={attachment} />));
}
async function waitForReady(): Promise<string> {
await vi.waitFor(() => expect(latest?.status).toBe("ready"));
if (latest?.status !== "ready") throw new Error("preview never resolved");
return latest.url;
}
beforeEach(() => {
vi.stubGlobal("React", React);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
latest = null;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("never hands back a URL it already released when the same attachment returns", async () => {
const attachment = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
await render(attachment);
const released = await waitForReady();
await render(null);
await render(attachment);
expect(latest).not.toEqual({ status: "ready", url: released });
expect(await waitForReady()).not.toBe(released);
});
it("retries a failed attachment from loading when it returns", async () => {
const stored = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
const missing = { ...stored, id: "missing-attachment", storageKey: "missing-attachment" };
await render(missing);
await vi.waitFor(() => expect(latest?.status).toBe("failed"));
await render(null);
await render(missing);
expect(latest).toEqual({ status: "loading" });
});
@@ -2,75 +2,62 @@ import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
export type AttachmentPreviewUrl =
| { status: "loading" }
| { status: "ready"; url: string }
| { status: "failed" };
const LOADING: AttachmentPreviewUrl = { status: "loading" };
const FAILED: AttachmentPreviewUrl = { status: "failed" };
function attachmentKey(attachment: AttachmentMetadata | null | undefined): string | null {
if (!attachment) return null;
return [attachment.id, attachment.storageType, attachment.storageKey, attachment.mimeType].join(
"\u0000",
);
}
/**
* Resolves a displayable URL for a stored attachment. Stays `loading` until the URL for the
* current attachment resolves, so a caller never shows a failure, or the previous attachment's
* URL, while the next one is still resolving. A missing attachment stays `loading`.
*/
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined,
): AttachmentPreviewUrl {
const [resolved, setResolved] = useState<{
key: string;
preview: AttachmentPreviewUrl;
} | null>(null);
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
const attachmentRef = useRef(attachment);
attachmentRef.current = attachment;
const key = attachmentKey(attachment);
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
useEffect(() => {
const current = attachmentRef.current;
if (!current || key === null) {
return;
}
let disposed = false;
let currentUrl: string | null = null;
const current = attachmentRef.current;
activeAttachmentRef.current = current ?? null;
if (!current) {
setUrl(null);
return;
}
void (async () => {
try {
const url = await resolveAttachmentPreviewUrl(current);
const resolved = await resolveAttachmentPreviewUrl(current);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment: current, url });
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
return;
}
currentUrl = url;
setResolved({ key, preview: { status: "ready", url } });
currentUrl = resolved;
setUrl(resolved);
} catch (error) {
console.error("[attachments] Failed to resolve preview URL", {
attachmentId: current.id,
error,
});
if (!disposed) {
setResolved({ key, preview: FAILED });
setUrl(null);
}
}
})();
return () => {
disposed = true;
if (currentUrl) {
void releaseAttachmentPreviewUrl({ attachment: current, url: currentUrl });
const activeAttachment = activeAttachmentRef.current;
if (!currentUrl || !activeAttachment) {
return;
}
// A result lives only as long as its lookup. Kept, the same attachment coming back would
// read a released URL, or an old failure, until the next lookup lands.
setResolved((previous) => (previous?.key === key ? null : previous));
void releaseAttachmentPreviewUrl({
attachment: activeAttachment,
url: currentUrl,
});
};
}, [key]);
}, [id, storageType, storageKey, mimeType]);
return resolved !== null && resolved.key === key ? resolved.preview : LOADING;
return url;
}
@@ -1,149 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { i18n } from "@/i18n/i18next";
import { WindowLayoutProvider } from "@/window-layout";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
interface FrameSample {
image: { x: number; y: number; width: number; height: number } | null;
error: boolean;
}
function nextFrame(): Promise<number> {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
function createPng(width: number, height: number): Promise<Blob> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D context unavailable");
context.fillStyle = "#3a7";
context.fillRect(0, 0, width, height);
return canvas.convertToBlob({ type: "image/png" });
}
function noop() {}
function isPainted(element: Element): boolean {
for (let node: Element | null = element; node; node = node.parentElement) {
if (Number(getComputedStyle(node).opacity) === 0) return false;
}
return true;
}
/** What the next paint will show: the visible lightbox image box, and whether the error is up. */
function sampleFrame(): FrameSample {
const image = document.querySelector('[data-testid="attachment-lightbox-image"]');
const rect = image?.getBoundingClientRect();
const visible = image && rect && rect.width > 0 && rect.height > 0 && isPainted(image);
return {
image: visible
? {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
}
: null,
error: (document.body.textContent ?? "").includes("Couldn't load image"),
};
}
/** Samples every frame from the open until the image has held one box for 20 frames. */
async function recordOpening(open: () => void): Promise<FrameSample[]> {
const samples: FrameSample[] = [];
open();
let stableFrames = 0;
for (let frame = 0; frame < 300 && stableFrames < 20; frame += 1) {
await nextFrame();
const sample = sampleFrame();
const previous = samples.at(-1);
stableFrames =
sample.image && JSON.stringify(sample.image) === JSON.stringify(previous?.image)
? stableFrames + 1
: 0;
samples.push(sample);
}
return samples;
}
let root: Root;
let container: HTMLDivElement;
beforeEach(async () => {
vi.stubGlobal("React", React);
await i18n.changeLanguage("en");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
document.body.replaceChildren();
vi.unstubAllGlobals();
});
function renderLightbox(source: ImageLightboxSource | null) {
root.render(
<SafeAreaProvider>
<WindowLayoutProvider>
<AttachmentLightbox source={source} onClose={noop} />
</WindowLayoutProvider>
</SafeAreaProvider>,
);
}
async function expectOpeningPaintsOnlyTheFittedImage(source: ImageLightboxSource) {
const samples = await recordOpening(() => renderLightbox(source));
const fitted = samples.at(-1)?.image;
expect(fitted, "the image never settled").toBeTruthy();
expect(samples.filter((sample) => sample.error)).toEqual([]);
expect(
samples.filter(
(sample) => sample.image && JSON.stringify(sample.image) !== JSON.stringify(fitted),
),
).toEqual([]);
}
describe("AttachmentLightbox opening in the browser", () => {
it("paints the fitted attachment image as its first image frame", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
await expectOpeningPaintsOnlyTheFittedImage({ type: "attachment", metadata });
});
it("paints only the fitted image when the same attachment opens again", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
const source: ImageLightboxSource = { type: "attachment", metadata };
await expectOpeningPaintsOnlyTheFittedImage(source);
await act(async () => renderLightbox(null));
await expectOpeningPaintsOnlyTheFittedImage(source);
});
it("shows an image the browser reports no natural size for", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>';
const uri = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
it("paints the fitted timeline image as its first image frame", async () => {
const uri = URL.createObjectURL(await createPng(2400, 1600));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
});
@@ -4,7 +4,6 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import type { AttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
@@ -39,9 +38,9 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
return {
theme: hoistedTheme,
imageMetadata: hoistedImageMetadata,
useAttachmentPreviewUrlMock: vi.fn<
(metadata: AttachmentMetadata | null) => AttachmentPreviewUrl
>(() => ({ status: "ready", url: "blob:preview" })),
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
() => "blob:preview",
),
};
});
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
@@ -74,13 +73,10 @@ vi.mock("react-i18next", () => ({
}));
vi.mock("react-native-safe-area-context", () => ({
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/window-layout", () => ({
WindowSafeArea: ({ children }: { children?: React.ReactNode }) => children,
}));
vi.mock("react-native-gesture-handler", () => ({
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
@@ -155,7 +151,7 @@ beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useAttachmentPreviewUrlMock.mockReturnValue({ status: "ready", url: "blob:preview" });
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
});
afterEach(() => {
@@ -233,22 +229,14 @@ describe("AttachmentLightbox", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows error text when the preview URL fails to resolve", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "failed" });
it("shows error text when the preview URL resolves to null", () => {
useAttachmentPreviewUrlMock.mockReturnValue(null);
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").toContain("Couldn't load image");
});
it("shows neither the image nor an error while the preview URL resolves", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "loading" });
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").not.toContain("Couldn't load image");
});
it("closes on Escape key on web", () => {
const onClose = vi.fn();
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
@@ -4,21 +4,17 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
import { Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types";
import {
type AttachmentPreviewUrl,
useAttachmentPreviewUrl,
} from "@/attachments/use-attachment-preview-url";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { isNative } from "@/constants/platform";
import { SPACING } from "@/styles/theme";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
import { WindowSafeArea } from "@/window-layout";
export type ImageLightboxSource =
| { type: "attachment"; metadata: AttachmentMetadata }
@@ -35,13 +31,10 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
const { t } = useTranslation();
const metadata = source?.type === "attachment" ? source.metadata : null;
const attachmentPreview = useAttachmentPreviewUrl(metadata);
const image: AttachmentPreviewUrl =
source?.type === "uri" ? { status: "ready", url: source.uri } : attachmentPreview;
const url = image.status === "ready" ? image.url : null;
const attachmentUrl = useAttachmentPreviewUrl(metadata);
const url = source?.type === "uri" ? source.uri : attachmentUrl;
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
const [errored, setErrored] = useState(false);
const failed = errored || image.status === "failed";
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
useEffect(() => {
@@ -83,12 +76,14 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
return null;
}
const hasError = errored || !url;
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<ModalRoot style={styles.root}>
{/* The modal draws over the whole window: its insets are the window's, not the ones left
where the image was opened (inside a sheet tab, the bottom one is already spent). */}
<WindowSafeArea>
{/* The modal is its own window: its insets are the window's, not the ones left where the
image was opened (inside a sheet tab, the bottom one is already spent). */}
<SafeAreaProvider>
<WindowChromeRootRegion corners="both">
<View ref={setOverlayScope} style={styles.root}>
<Pressable
@@ -99,10 +94,9 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
style={styles.backdrop}
/>
<LightboxImageArea>
{failed ? (
{hasError ? (
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
) : null}
{!failed && url ? (
) : (
<ZoomableImage
accessibilityLabel={t("composer.attachments.openImage")}
actions={actions}
@@ -114,11 +108,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
testID="attachment-lightbox"
uri={url}
/>
) : null}
)}
</LightboxImageArea>
</View>
</WindowChromeRootRegion>
</WindowSafeArea>
</SafeAreaProvider>
</ModalRoot>
</Modal>
);
@@ -133,10 +133,9 @@ export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps)
/** Square image preview pill body. */
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
const preview = useAttachmentPreviewUrl(metadata);
const uri = preview.status === "ready" ? preview.url : null;
const source = useMemo(() => (uri ? { uri } : null), [uri]);
if (!source) {
const uri = useAttachmentPreviewUrl(metadata);
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
if (!uri) {
return <View style={styles.thumbnailPlaceholder} />;
}
return <Image source={source} style={styles.thumbnail} />;
@@ -11,10 +11,6 @@ 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,
@@ -121,10 +117,6 @@ 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,7 +54,11 @@ export function SettingsRowHeader({
}: SettingsRowHeaderProps) {
const layout = useSettingsRowLayout();
const rowStyle = useCallback(
({ hovered }: HoverState) => [layout.row, !disabled && hovered && styles.hovered],
({ hovered, pressed }: HoverState) => [
layout.row,
!disabled && hovered && styles.hovered,
!disabled && pressed && styles.pressed,
],
[disabled, layout],
);
const chevronStyle = useMemo(
@@ -462,9 +462,7 @@ 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, expanded: false, policy }),
);
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
expect(result.current.canToggle).toBe(count > 7);
});
@@ -474,7 +472,7 @@ describe("sidebar group visibility", () => {
(status) => {
const items = entries([...Array(11).fill("done"), status]);
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
useLimitedSidebarGroup({ items, selection: null, policy }),
);
expect(names(result.current.visibleItems)).toEqual([
"row-1",
@@ -499,7 +497,6 @@ describe("sidebar group visibility", () => {
useLimitedSidebarGroup({
items,
selection: null,
expanded: false,
policy: createWorkspaceVisibilityPolicy(entriesByKey),
}),
);
@@ -521,9 +518,7 @@ describe("sidebar group visibility", () => {
"needs_input",
"failed",
]);
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
);
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(result.current.visibleItems).toEqual(items);
expect(result.current.canToggle).toBe(false);
});
@@ -537,10 +532,9 @@ describe("sidebar group visibility", () => {
"done",
"attention",
]);
const { result, rerender } = renderHook(
({ rows, expanded }) =>
useLimitedSidebarGroup({ items: rows, selection: null, expanded, policy }),
{ initialProps: { rows: items, expanded: false } },
const { result, rerender, unmount } = renderHook(
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
{ initialProps: { rows: items } },
);
expect(names(result.current.visibleItems)).toEqual([
"row-1",
@@ -554,15 +548,16 @@ describe("sidebar group visibility", () => {
"row-10",
"row-12",
]);
rerender({ rows: items, expanded: true });
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toEqual(items);
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })), expanded: true });
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
expect(result.current.canToggle).toBe(false);
rerender({ rows: items.slice(0, 3), expanded: true });
expect(result.current.expanded).toBe(true);
rerender({ rows: items.slice(0, 3) });
expect(result.current.canToggle).toBe(false);
rerender({ rows: items.toReversed(), expanded: true });
rerender({ rows: items.toReversed() });
expect(result.current.visibleItems).toEqual(items.toReversed());
rerender({ rows: items.toReversed(), expanded: false });
act(() => result.current.toggleExpanded());
expect(names(result.current.visibleItems)).toEqual([
"row-12",
"row-11",
@@ -573,25 +568,15 @@ describe("sidebar group visibility", () => {
"row-6",
]);
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
rerender({ rows: [newest, ...items], expanded: false });
rerender({ rows: [newest, ...items] });
expect(result.current.visibleItems[0]).toBe(newest);
expect(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);
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("follows effective pending creation, recovery and agent activity without changing Created order", () => {
@@ -617,7 +602,6 @@ describe("sidebar group visibility", () => {
useLimitedSidebarGroup({
items: [...items.slice(0, 11), last],
selection: null,
expanded: false,
policy,
}),
{ initialProps: { last: items[11] } },
@@ -657,15 +641,12 @@ describe("sidebar group visibility", () => {
it("preserves the twenty-row pinned policy regardless of status", () => {
const items = entries(Array(21).fill("running"));
const { result, rerender } = renderHook(
({ expanded }) => useLimitedSidebarGroup({ items, selection: null, expanded }),
{ initialProps: { expanded: false } },
);
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
expect(result.current.canToggle).toBe(true);
rerender({ expanded: true });
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toEqual(items);
rerender({ expanded: false });
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toHaveLength(20);
});
});
@@ -368,10 +368,12 @@ function projectKebabStyle({
function getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
@@ -380,6 +382,7 @@ function getProjectWorkspaceRowStyle({
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -817,6 +820,7 @@ function NewWorkspaceGhostRow({
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.newWorkspaceGhostRow,
hovered && !pressed && styles.newWorkspaceGhostRowHovered,
pressed && styles.newWorkspaceGhostRowPressed,
],
[],
);
@@ -829,7 +833,6 @@ function NewWorkspaceGhostRow({
})}
onPress={handlePress}
style={rowStyle}
highlightStyle={styles.newWorkspaceGhostRowPressed}
testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
>
{({ hovered, pressed }) => (
@@ -880,7 +883,7 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const [isHighlighted, setIsHighlighted] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const localDaemonServerId = useLocalDaemonServerId();
@@ -927,13 +930,25 @@ 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 = useMemo(
() => [
const projectRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
],
[isDragging, selected, isHovered],
);
@@ -946,7 +961,7 @@ function ProjectHeaderRow({
iconDataUri={iconDataUri}
statusBucket={statusBucket}
projectViewKey={project.viewKey}
backdrop={getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -992,10 +1007,9 @@ function ProjectHeaderRow({
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={interaction.handlePressIn}
onPressIn={handleProjectPressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleProjectPressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1019,10 +1033,9 @@ function ProjectHeaderRow({
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={interaction.handlePressIn}
onPressIn={handleProjectPressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleProjectPressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1078,7 +1091,7 @@ function WorkspaceRowInner({
reserveIdleStatusIndicatorSpace = true,
}: WorkspaceRowInnerProps) {
const isCompact = useIsCompactFormFactor();
const [isHighlighted, setIsHighlighted] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isTouchPlatform = platformIsNative || isCompact;
const interaction = useLongPressDragInteraction({
drag,
@@ -1098,6 +1111,17 @@ 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;
@@ -1111,8 +1135,13 @@ function WorkspaceRowInner({
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, selected, isHovered });
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -1147,11 +1176,10 @@ function WorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={isEditingTitle ? undefined : interaction.handlePressIn}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
onPressOut={isEditingTitle ? undefined : interaction.handlePressOut}
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1529,8 +1557,6 @@ function ProjectBlock({
showShortcutBadges,
shortcutIndexByWorkspaceKey,
onToggleCollapsed,
workspacesExpanded,
onToggleWorkspacesExpanded,
onWorkspacePress,
parentGestureRef,
onWorkspaceReorder,
@@ -1557,9 +1583,6 @@ 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;
@@ -1584,11 +1607,12 @@ 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;
@@ -1746,10 +1770,6 @@ 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) {
@@ -1786,7 +1806,7 @@ function ProjectBlock({
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
onPress={handleToggleWorkspacesExpanded}
onPress={toggleWorkspacesExpanded}
testID={`sidebar-project-show-more-${project.viewKey}`}
/>
) : null}
@@ -1856,8 +1876,6 @@ 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 &&
@@ -2186,29 +2204,15 @@ 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(
() =>
@@ -2364,8 +2368,6 @@ function ProjectModeList({
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onToggleCollapsed={onToggleProjectCollapsed}
workspacesExpanded={expandedProjectKeys.has(item.viewKey)}
onToggleWorkspacesExpanded={toggleProjectWorkspacesExpanded}
onWorkspacePress={onWorkspacePress}
manualSort={manualSort}
parentGestureRef={parentGestureRef}
@@ -2387,8 +2389,6 @@ 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,
// 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],
// 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],
},
projectListContainer: {
width: "100%",
@@ -39,6 +39,7 @@ export function SidebarGroupToggleRow({
styles.row,
indented && sidebarWorkspaceRowStyles.rowIndented,
hovered && !pressed && styles.rowHovered,
pressed && styles.rowPressed,
],
[indented],
);
@@ -49,7 +50,6 @@ export function SidebarGroupToggleRow({
accessibilityLabel={label}
onPress={onPress}
style={rowStyle}
highlightStyle={styles.rowPressed}
testID={testID}
>
{({ hovered, pressed }) => (
@@ -12,17 +12,16 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
*/
export function getSidebarRowBackdrop({
isDragging = false,
isHighlighted = false,
isPressed = false,
selected = false,
isHovered = false,
}: {
isDragging?: boolean;
/** The press highlight is painting the row's pressed fill. */
isHighlighted?: boolean;
isPressed?: boolean;
selected?: boolean;
isHovered?: boolean;
}): SidebarSurfaceBackdrop {
if (isDragging || isHighlighted) return "surface2";
if (isDragging || isPressed) return "surface2";
if (selected) return "surfaceSidebarSelected";
if (isHovered) return "surfaceSidebarHover";
return "surfaceSidebar";
@@ -9,7 +9,13 @@ import {
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import { View, Text, ScrollView } from "react-native";
import {
View,
Text,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
} 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";
@@ -149,20 +155,13 @@ 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,
canToggle: canTogglePinnedWorkspaces,
} = useLimitedSidebarGroup({
items: pinnedWorkspaces,
selection: activeWorkspaceSelection,
expanded: pinnedWorkspacesExpanded,
});
canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
@@ -367,19 +366,15 @@ 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,
});
@@ -465,8 +460,12 @@ function StatusGroupHeader({
}, [group.key, toggleWorkspaceGroupCollapsed]);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const rowStyle = useMemo(
() => [styles.statusGroupRow, isHovered && styles.statusGroupRowHovered],
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.statusGroupRow,
isHovered && styles.statusGroupRowHovered,
pressed && styles.statusGroupRowPressed,
],
[isHovered],
);
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
@@ -478,7 +477,6 @@ function StatusGroupHeader({
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
accessibilityState={accessibilityState}
style={rowStyle}
highlightStyle={styles.statusGroupRowPressed}
onPress={handlePress}
testID={`sidebar-status-group-${group.key}`}
>
@@ -828,7 +826,7 @@ function StatusWorkspaceRowInnerContent({
}) {
const isCompact = useIsCompactFormFactor();
const isTouchPlatform = platformIsNative || isCompact;
const [isHighlighted, setIsHighlighted] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const {
role: _dragRole,
@@ -853,6 +851,17 @@ function StatusWorkspaceRowInnerContent({
}
onPress();
}, [didLongPressRef, onPress]);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
startDragPress?.(event);
},
[startDragPress],
);
const handlePressOut = useCallback(() => {
setIsPressed(false);
endDragPress?.();
}, [endDragPress]);
return (
<SidebarWorkspaceRowFrame
@@ -878,12 +887,13 @@ function StatusWorkspaceRowInnerContent({
showShortcut,
});
const workspaceRowStyle = getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
isDragging,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -918,11 +928,10 @@ function StatusWorkspaceRowInnerContent({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={isEditingTitle ? undefined : startDragPress}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handlePressIn}
onTouchMove={isEditingTitle ? undefined : moveDragPress}
onPressOut={isEditingTitle ? undefined : endDragPress}
onPressOut={isEditingTitle ? undefined : handlePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1059,11 +1068,13 @@ function StatusWorkspaceActionSlot({
}
function getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
isDragging,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
@@ -1075,6 +1086,7 @@ function getStatusWorkspaceRowStyle({
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -1089,9 +1101,8 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
paddingTop: 2,
// 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],
// Match project grouping: permanent room to scroll the final row above sidebar toasts.
paddingBottom: theme.spacing[24],
},
pinnedSection: {
marginBottom: theme.spacing[1],
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
@@ -12,22 +12,23 @@ 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> {
return useMemo(
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
expanded: boolean;
toggleExpanded: () => void;
} {
const [expanded, setExpanded] = useState(false);
const visibility = useMemo(
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
[expanded, items, policy, selection],
);
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
return { ...visibility, expanded, toggleExpanded };
}
@@ -0,0 +1,152 @@
import React, { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { PortalProvider } from "@gorhom/portal";
import { View } from "react-native";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import type { AutocompleteOption } from "./autocomplete";
import { AutocompletePopover } from "./autocomplete-popover";
import { FloatingPanelPortalHost } from "./floating-panel-portal";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* The popover's height bound is a Reanimated style. Reanimated computes a style's first value
* when its hook first runs and sends later values from an effect, after the commit that mounts
* the view. Native paints that commit: a bound computed while the popover was closed painted the
* popover at its minimum height, hanging down over the composer, for a frame on every open.
* Web runs the effect before paint, so the wrong value only shows as the first style the DOM
* receives.
*/
const SAFE_AREA = {
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
};
const COMPOSER_TOP = 600;
const OPTIONS: AutocompleteOption[] = ["help", "hello", "history"].map((name) => ({
id: name,
label: `/${name}`,
icon: "command",
}));
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
const SCREEN_STYLE = { width: 390, height: 844 };
const COMPOSER_STYLE = { position: "absolute" as const, top: COMPOSER_TOP, left: 16, width: 358 };
const INPUT_STYLE = { height: 120 };
function ignoreSelection(): void {}
function Providers({ children }: { children: ReactNode }): ReactNode {
return (
<SafeAreaProvider initialMetrics={SAFE_AREA}>
<KeyboardProvider>
<KeyboardShiftProvider>
<PortalProvider>{children}</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
);
}
function Screen({ visible }: { visible: boolean }): ReactNode {
return (
<Providers>
<View style={SCREEN_STYLE}>
<View style={COMPOSER_STYLE}>
<AutocompletePopover
visible={visible}
options={OPTIONS}
selectedIndex={OPTIONS.length - 1}
onSelect={ignoreSelection}
>
<View style={INPUT_STYLE} />
</AutocompletePopover>
</View>
<FloatingPanelPortalHost />
</View>
</Providers>
);
}
function render(node: ReactNode): void {
if (!container) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
}
act(() => root?.render(node));
}
/** Every max-height the popover's frame has held, starting with the one it mounted with. */
function recordPopoverMaxHeights(): { values: string[]; stop: () => void } {
const values: string[] = [];
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "childList") {
for (const node of record.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const popover = node.matches('[data-testid="composer-autocomplete-popover"]')
? node
: node.querySelector<HTMLElement>('[data-testid="composer-autocomplete-popover"]');
// The attribute records below carry what the style was before each change; this
// carries the style at insertion when nothing changed it afterwards.
if (popover && values.length === 0) values.push(popover.style.maxHeight);
}
}
if (
record.type === "attributes" &&
record.target instanceof HTMLElement &&
record.target.dataset.testid === "composer-autocomplete-popover"
) {
const before = /max-height:\s*([^;]+)/.exec(record.oldValue ?? "")?.[1]?.trim() ?? "";
if (values.length <= 1) values.splice(0, values.length, before);
values.push(record.target.style.maxHeight);
}
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["style"],
attributeOldValue: true,
});
return { values, stop: () => observer.disconnect() };
}
async function waitForPopover(): Promise<HTMLElement> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const popover = document.querySelector<HTMLElement>(
'[data-testid="composer-autocomplete-popover"]',
);
if (popover) return popover;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("The popover did not open");
}
it("opens with its height bound instead of the bound it had while closed", async () => {
// The composer mounts first and opens the popover later, as typing `/` does.
render(<Screen visible={false} />);
const recording = recordPopoverMaxHeights();
render(<Screen visible />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 200));
recording.stop();
const settled = popover.style.maxHeight;
expect(Number.parseFloat(settled)).toBeGreaterThan(0);
expect(recording.values[0]).toBe(settled);
});
@@ -9,7 +9,11 @@ import {
} from "react";
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
import { Portal } from "@gorhom/portal";
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
import Animated, {
useAnimatedReaction,
useAnimatedStyle,
type DerivedValue,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
@@ -126,38 +130,62 @@ export function AutocompletePopover({
});
}, [relativeAnchorRect]);
const anchorY = relativeAnchorRect?.y ?? 0;
const reservedLift = anchor.reservedLift;
const heightStyle = useAnimatedStyle(
() => ({
maxHeight: Math.max(
0,
anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2,
),
}),
[anchorY, reservedLift, safeAreaInsets.top],
);
const showPopover = canMeasure && relativeAnchorRect && baseStyle;
// The translate view is the popover's own frame, so it lifts only the popover.
return (
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
{children}
{showPopover ? (
{canMeasure && relativeAnchorRect && baseStyle ? (
<Portal hostName={portalHostName}>
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
<PopoverFrame
anchorY={relativeAnchorRect.y}
reservedLift={anchor.reservedLift}
topInset={safeAreaInsets.top}
>
<Autocomplete
options={options}
selectedIndex={selectedIndex}
onSelect={onSelect}
status={status}
/>
</Animated.View>
</PopoverFrame>
</KeyboardTranslateView>
</Portal>
) : null}
</View>
);
}
interface PopoverFrameProps {
anchorY: number;
reservedLift: DerivedValue<number>;
topInset: number;
children: ReactNode;
}
/**
* Bounds the popover's height to the space above the anchor. It mounts only with a measured
* anchor: Reanimated computes the first style when the hook first runs and sends later values
* after the mounting commit, which native paints. A bound computed before the measurement painted
* the popover at its minimum height, hanging over the composer, for a frame on every open.
*/
function PopoverFrame({
anchorY,
reservedLift,
topInset,
children,
}: PopoverFrameProps): ReactElement {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const heightStyle = useAnimatedStyle(
() => ({
maxHeight: Math.max(0, anchorY - reservedLift.value - topInset - OFFSET_FROM_ANCHOR * 2),
}),
[anchorY, reservedLift, topInset],
);
return (
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
{children}
</Animated.View>
);
}
@@ -8,6 +8,7 @@ import {
import {
View,
type GestureResponderEvent,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type ViewProps,
@@ -26,7 +27,6 @@ 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,7 +109,8 @@ export function ContextMenuTrigger({
triggerRef,
...props
}: PropsWithChildren<
Omit<PressHighlightProps, "style"> & {
Omit<PressableProps, "style"> & {
highlightStyle?: StyleProp<ViewStyle>;
style?: TriggerStyleProp;
enabled?: boolean;
enabledOnMobile?: boolean;
+3 -25
View File
@@ -1,10 +1,4 @@
import { useMemo } from "react";
import {
Modal as NativeModal,
Platform,
useWindowDimensions,
type ModalProps as NativeModalProps,
} from "react-native";
import { Modal as NativeModal, type ModalProps as NativeModalProps } from "react-native";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
@@ -23,23 +17,7 @@ type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
function noClose() {}
export function Modal(props: ModalProps) {
const { visible = true, onRequestClose, style } = props;
const { visible = true, onRequestClose } = props;
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
const windowFrame = useAndroidWindowFrame();
const containerStyle = useMemo(() => [windowFrame, style], [windowFrame, style]);
return <NativeModal supportedOrientations={orientations} {...props} style={containerStyle} />;
}
/**
* On Android, React Native lays out a modal's first frames at the screen size minus the status and
* navigation bars, then grows it to the full window once the dialog reports its size. The app draws
* edge-to-edge, so the dialog always covers the whole window: size the content to the window from
* the first frame, or a full-screen backdrop stops short of the bottom and then snaps down.
*/
function useAndroidWindowFrame() {
const { width, height } = useWindowDimensions();
return useMemo(
() => (Platform.OS === "android" ? { flex: 0, width, height } : null),
[width, height],
);
return <NativeModal supportedOrientations={orientations} {...props} />;
}
@@ -1,87 +0,0 @@
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,8 +4,6 @@ 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 };
@@ -15,22 +13,6 @@ 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} />,
@@ -49,147 +31,30 @@ it("keeps a floating action opaque while showing its held-press feedback", async
await waitFor(() => expect(getComputedStyle(button).backgroundColor).toBe(restingFill));
});
it("paints the native row fill while pressed and keeps its existing corners", async () => {
const { getByTestId } = render(
it("uses the pressable's pressed state for the native row fill and its existing corners", () => {
const { getByTestId, rerender } = render(
<GestureHandlerRootView>
<PressHighlight testID="row" style={rowStyle} highlightStyle={highlightStyle} />
<PressHighlight
testID="row"
testOnly_pressed
style={rowStyle}
highlightStyle={highlightStyle}
/>
</GestureHandlerRootView>,
);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)"));
expect(getComputedStyle(row).borderRadius).toBe("8px");
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(20, 30, 40)");
expect(getComputedStyle(getByTestId("row")).borderRadius).toBe("8px");
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}
/>,
rerender(
<GestureHandlerRootView>
<PressHighlight
testID="row"
testOnly_pressed={false}
style={rowStyle}
highlightStyle={highlightStyle}
/>
</GestureHandlerRootView>,
);
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]);
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(255, 255, 255)");
});
@@ -1,60 +0,0 @@
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,25 +1,21 @@
import { forwardRef } from "react";
import { Pressable, type View } from "react-native";
import { usePressFill } from "./press-highlight.fill";
import { forwardRef, useCallback } from "react";
import { Pressable, type PressableStateCallbackType, type View } from "react-native";
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,
onHighlightChange,
style,
onPressIn,
onPressOut,
unstable_pressDelay = PRESS_DELAY_MS,
...props
},
{ highlightStyle, style, unstable_pressDelay = 130, ...props },
ref,
) {
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
return <Pressable {...props} {...fill} ref={ref} unstable_pressDelay={unstable_pressDelay} />;
// 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} />
);
});
@@ -1,13 +1,11 @@
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, onHighlightChange, style, onPressIn, onPressOut, ...props },
{ highlightStyle: _highlightStyle, ...props },
ref,
) {
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
return <Pressable ref={ref} {...props} {...fill} />;
return <Pressable ref={ref} {...props} />;
});
@@ -1,15 +1,6 @@
import type { PressableProps, StyleProp, ViewStyle } from "react-native";
export interface PressHighlightProps extends PressableProps {
/**
* 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.
*/
/** Native pressed style. Feedback and cancellation follow Pressable's press lifecycle. */
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,16 +0,0 @@
import { Image } from "react-native";
import type { ViewportSize } from "./geometry";
/** Resolves the image's natural size; rejects when the image cannot load or has no size. */
export function measureImage(uri: string): Promise<ViewportSize> {
return new Promise((resolve, reject) => {
Image.getSize(
uri,
(width, height) => {
if (width > 0 && height > 0) resolve({ width, height });
else reject(new Error("Image has no size"));
},
reject,
);
});
}
@@ -1,24 +0,0 @@
import type { ViewportSize } from "./geometry";
// The size a browser gives a replaced element that has no intrinsic size of its own.
const NO_INTRINSIC_SIZE: ViewportSize = { width: 300, height: 150 };
/**
* Resolves the image's natural size; rejects when the image cannot load. React Native Web's
* `Image.getSize` never answers for an image that loads with no natural size (an SVG without
* width and height, in some browsers), so the image would never show and never fail.
*/
export function measureImage(uri: string): Promise<ViewportSize> {
return new Promise((resolve, reject) => {
const image = new window.Image();
image.addEventListener("load", () =>
resolve(
image.naturalWidth > 0 && image.naturalHeight > 0
? { width: image.naturalWidth, height: image.naturalHeight }
: NO_INTRINSIC_SIZE,
),
);
image.addEventListener("error", () => reject(new Error("Image failed to load")));
image.src = uri;
});
}
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ViewportFitOptions, ViewportSize } from "./geometry";
import { measureImage } from "./image-size";
import { ZoomableViewport } from "./index";
import type { ZoomableViewportAction } from "./types";
@@ -35,33 +34,42 @@ export function ZoomableImage({
testID = "zoomable-image",
wheelActivation = "always",
}: ZoomableImageProps) {
const [loaded, setLoaded] = useState<{ uri: string; size: ViewportSize } | null>(null);
const resolvedSize = contentSize ?? (loaded?.uri === uri ? loaded.size : null);
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
const resolvedSize = contentSize ?? loadedSize;
const source = useMemo(() => ({ uri }), [uri]);
const [paintedUri, setPaintedUri] = useState<string | null>(null);
const handleLoad = useCallback(() => setPaintedUri(uri), [uri]);
useEffect(() => {
if (contentSize) return;
let active = true;
void (async () => {
try {
const size = await measureImage(uri);
if (active) setLoaded({ uri, size });
} catch {
setLoadedSize(null);
Image.getSize(
uri,
(width, height) => {
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
},
() => {
if (active) onError?.();
}
})();
},
);
return () => {
active = false;
};
}, [contentSize, onError, uri]);
// Paint nothing until the image can be fitted: an unfitted image fills the whole area for the
// frames before the size arrives, then jumps to its fitted box. Then keep the viewport, toolbar
// included, hidden until the image itself has loaded, so they appear together.
if (!resolvedSize) {
return <View style={[styles.root, style]} testID={testID} />;
return (
<View style={[styles.root, style]} testID={testID}>
<Image
accessibilityLabel={accessibilityLabel}
accessibilityRole="image"
onError={onError}
resizeMode="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}
/>
</View>
);
}
return (
@@ -73,15 +81,12 @@ export function ZoomableImage({
maxScale={maxScale}
minScale={minScale}
onPressOutsideContent={onPressOutsideContent}
style={[style, paintedUri !== uri && styles.loading]}
style={style}
testID={testID}
wheelActivation={wheelActivation}
>
<Image
// Android fades a freshly decoded image in over 300ms; it appears with its toolbar instead.
fadeDuration={0}
onError={onError}
onLoad={handleLoad}
resizeMode="contain"
source={source}
style={styles.image}
@@ -94,5 +99,4 @@ export function ZoomableImage({
const styles = StyleSheet.create(() => ({
root: { flex: 1, minHeight: 0 },
image: { width: "100%", height: "100%" },
loading: { opacity: 0 },
}));
+18 -8
View File
@@ -1,5 +1,10 @@
import { memo, type ReactElement, useCallback, useMemo, useState } from "react";
import { Text, View, type GestureResponderEvent } from "react-native";
import {
Text,
View,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
import { DiffStat } from "@/components/diff-stat";
@@ -87,17 +92,18 @@ 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.showsBodyState && input.isSelected);
return input.isHovered || input.pressed || (!input.showsBodyState && input.isSelected);
}
function fileHeaderInteractionStyle(input: {
isHovered: boolean;
pressed: boolean;
showsBodyState: boolean;
isSelected: boolean;
}) {
@@ -211,8 +217,8 @@ export const FileHeader = memo(function FileHeader({
[file.path, onHeaderHeightChange],
),
});
const pressableStyle = useMemo(
() => [
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.header,
!showsBodyState && workspaceTreeRowStyles.row,
showsBodyState && styles.documentHeader,
@@ -223,14 +229,18 @@ export const FileHeader = memo(function FileHeader({
: null,
fileHeaderInteractionStyle({
isHovered: hover.isHovered,
pressed,
showsBodyState,
isSelected,
}),
],
[depth, hover.isHovered, isSelected, showsBodyState],
);
const canvasPressableStyle = useMemo(
() => [pressableStyle, canvasRendered && styles.canvasInteraction],
const canvasPressableStyle = useCallback(
(state: PressableStateCallbackType) => [
pressableStyle(state),
canvasRendered && styles.canvasInteraction,
],
[canvasRendered, pressableStyle],
);
const accessibilityState = useMemo(
@@ -314,7 +324,7 @@ export const FileHeader = memo(function FileHeader({
} else {
trigger = (
<View
style={[pressableStyle, canvasRendered && styles.canvasInteraction]}
style={[pressableStyle({ pressed: false }), canvasRendered && styles.canvasInteraction]}
accessibilityLabel={accessibilityLabel}
>
{renderedContent}
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { Pressable, ScrollView, 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,6 +18,12 @@ 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.
@@ -106,7 +112,11 @@ export function ChecksSection({
</Pressable>
{open && summary.groups.length > 0 ? (
<View style={styles.list}>
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
nestedScrollEnabled
>
{summary.groups.map((group) => (
<CheckGroup
key={group.status}
@@ -118,7 +128,7 @@ export function ChecksSection({
onAddLogsToChat={onAddLogsToChat}
/>
))}
</View>
</ScrollView>
) : null}
</View>
);
@@ -253,6 +263,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
list: {
maxHeight: LIST_MAX_HEIGHT,
},
listContent: {
paddingBottom: theme.spacing[2],
},
groupHeader: {
@@ -1,231 +0,0 @@
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, Text, View, type ViewStyle } from "react-native";
import { Image, Pressable, ScrollView, Text, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleCheck,
@@ -74,14 +74,13 @@ import {
type PrTimelineEntry,
} from "./timeline";
import {
SectionHeader,
Section,
SummaryPill,
dangerColorMapping,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "./section-kit";
import { PullRequestPaneSplit } from "./split";
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleX = withUnistyles(CircleX);
@@ -479,8 +478,8 @@ export function PullRequestPane({
: null;
return (
<PullRequestPaneSplit activityOpen={activityOpen}>
<>
<View style={styles.root} testID="pr-pane">
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
<View style={styles.toolbarActions}>
<Button
@@ -500,9 +499,7 @@ 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}
@@ -567,58 +564,60 @@ export function PullRequestPane({
onAddLogsToChat={handleAddCheckLogsToChat}
/>
)}
</>
<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}
<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"
/>
))
: null}
</View>
</PullRequestPaneSplit>
<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>
);
}
@@ -1193,6 +1192,15 @@ function ThreadComment({
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
scroll: {
flex: 1,
minHeight: 0,
},
invisible: {
opacity: 0,
},
@@ -1253,6 +1261,10 @@ const styles = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[1],
opacity: 0,
},
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
toolbar: {
flexDirection: "row",
alignItems: "center",
@@ -1,9 +1,8 @@
import React, { useMemo, type ReactNode } from "react";
import React, { 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,
@@ -22,57 +21,39 @@ 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 SectionHeaderProps {
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: ReactNode;
children: ReactNode;
accessibilityLabel?: string;
}
interface SectionProps extends SectionHeaderProps {
children: ReactNode;
}
/** The disclosure row of a section, for layouts that place the section's body themselves. */
export function SectionHeader({
export function Section({
title,
open,
onToggle,
summary,
children,
accessibilityLabel,
}: 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) {
}: SectionProps) {
return (
<View>
<SectionHeader {...header} />
{header.open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
<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}
</View>
);
}
@@ -1,162 +0,0 @@
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,
},
}));
@@ -487,7 +487,10 @@ function LocalModelRow({
}
const rowStyle = useCallback(
({ hovered }: HoverState) => [!selectionLocked && hovered && styles.rowHovered],
({ hovered, pressed }: HoverState) => [
!selectionLocked && hovered && styles.rowHovered,
!selectionLocked && pressed && styles.rowPressed,
],
[selectionLocked],
);
const accessibilityState = useMemo(
+2 -3
View File
@@ -113,8 +113,8 @@ function pinnedUsageLabel(
return `${label}: ${summary}${error}`;
}
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.trigger, hovered && styles.triggerHovered];
function triggerStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.trigger, hovered && styles.triggerHovered, pressed && styles.triggerPressed];
}
function layoutWidth(event: LayoutChangeEvent): number {
@@ -161,7 +161,6 @@ function PinnedUsageTrigger({
accessibilityRole="button"
accessibilityLabel={pinnedUsageLabel(label, sources, errorLabel)}
style={triggerStyle}
highlightStyle={styles.triggerPressed}
testID="sidebar-usage"
>
<View style={styles.line} onLayout={handleAvailableLayout}>
@@ -1,6 +1,5 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { EdgeInsets } from "react-native-safe-area-context";
import type { TopBand, WindowLayout } from "./model";
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
@@ -8,12 +7,6 @@ export const WindowLayoutContext = createContext<WindowLayout | null>(null);
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
export const TopBandContext = createContext<TopBand | null>(null);
/**
* The insets of the whole window, published by `WindowLayoutProvider` before any surface (a
* sheet tab, for one) narrows the insets its content reads.
*/
export const WindowInsetsContext = createContext<EdgeInsets | null>(null);
export function useWindowLayout(): WindowLayout | null {
const layout = useContext(WindowLayoutContext);
const { width, height } = useWindowDimensions();
+2 -10
View File
@@ -2,19 +2,13 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import {
TopBandContext,
WindowInsetsContext,
WindowLayoutContext,
useWindowLayout,
} from "./context";
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export { WindowSafeArea } from "./window-safe-area";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
@@ -62,9 +56,7 @@ function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
);
return (
<TopBandContext.Provider value={topBand}>
<WindowInsetsContext.Provider value={insets}>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
</WindowInsetsContext.Provider>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
</TopBandContext.Provider>
);
}
+1 -5
View File
@@ -1,12 +1,9 @@
import type { ReactNode } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { WindowInsetsContext } from "./context";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export { WindowSafeArea } from "./window-safe-area";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
@@ -15,6 +12,5 @@ export {
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets();
return <WindowInsetsContext.Provider value={insets}>{children}</WindowInsetsContext.Provider>;
return children;
}
@@ -1,43 +0,0 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { Text } from "react-native";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { afterEach, expect, it, vi } from "vitest";
import { WindowLayoutProvider, WindowSafeArea } from "@/window-layout";
vi.stubGlobal("React", React);
const windowInsets = { top: 52, right: 0, bottom: 24, left: 0 };
const sheetTabInsets = { ...windowInsets, bottom: 0 };
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
afterEach(() => {
act(() => root.render(null));
});
function InsetsProbe({ testID }: { testID: string }) {
const insets = useSafeAreaInsets();
return <Text testID={testID}>{JSON.stringify(insets)}</Text>;
}
it("gives an overlay opened inside a sheet tab the window's insets", () => {
act(() =>
root.render(
<SafeAreaInsetsContext.Provider value={windowInsets}>
<WindowLayoutProvider>
<SafeAreaInsetsContext.Provider value={sheetTabInsets}>
<InsetsProbe testID="sheet-tab" />
<WindowSafeArea>
<InsetsProbe testID="overlay" />
</WindowSafeArea>
</SafeAreaInsetsContext.Provider>
</WindowLayoutProvider>
</SafeAreaInsetsContext.Provider>,
),
);
const insetsOf = (testID: string) =>
JSON.parse(container.querySelector(`[data-testid="${testID}"]`)?.textContent ?? "null");
expect(insetsOf("sheet-tab")).toEqual(sheetTabInsets);
expect(insetsOf("overlay")).toEqual(windowInsets);
});
@@ -1,15 +0,0 @@
import { useContext, type ReactNode } from "react";
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
import { WindowInsetsContext } from "./context";
/**
* Gives an overlay that draws over the whole window, such as an RN `Modal`, the window's insets
* instead of the ones its call site was given. Read them below it with `useSafeAreaInsets()`.
*/
export function WindowSafeArea({ children }: { children: ReactNode }) {
const insets = useContext(WindowInsetsContext);
if (!insets) {
throw new Error("WindowSafeArea must render inside WindowLayoutProvider");
}
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
}
@@ -83,6 +83,7 @@ export const PinOff = StubIcon;
export const Play = StubIcon;
export const Plug = StubIcon;
export const Plus = StubIcon;
export const Puzzle = StubIcon;
export const QrCode = StubIcon;
export const RefreshCcw = StubIcon;
export const RefreshCw = StubIcon;
@@ -100,6 +101,7 @@ export const Smartphone = StubIcon;
export const Split = StubIcon;
export const Square = StubIcon;
export const SquarePen = StubIcon;
export const SquareSlash = StubIcon;
export const SquareTerminal = StubIcon;
export const Star = StubIcon;
export const Sun = StubIcon;
@@ -1,8 +1,7 @@
import { createElement, type ElementType } from "react";
import { lightHighlightColors } from "@getpaseo/highlight";
import { lightTheme } from "../src/styles/theme";
const pinnedTheme = {
const testTheme = {
colorScheme: "light",
colors: {
syntax: lightHighlightColors,
@@ -87,14 +86,6 @@ const pinnedTheme = {
},
};
// 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> {
@@ -151,19 +142,7 @@ export const StyleSheet = {
resolveStyles(isStyleFactory(styles) ? styles(testTheme) : styles),
};
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 withUnistyles = <T>(Component: T): T => Component;
export const useUnistyles = () => ({
theme: testTheme,
+23
View File
@@ -35,6 +35,26 @@ export default defineConfig({
};
},
},
{
name: "reanimated-web-style-helpers",
transform(_source, id) {
// Reanimated writes animated styles to the DOM through react-native-web helpers it loads
// with `require` inside try/catch. Vite serves ESM, so the require throws, the helpers stay
// undefined, and the first animated style update after mount crashes the component.
if (
!id.split("?")[0].endsWith("/lib/module/ReanimatedModule/js-reanimated/webUtils.web.js")
) {
return;
}
return {
code: [
'export { default as createReactDOMStyle } from "react-native-web/dist/exports/StyleSheet/compiler/createReactDOMStyle";',
'export { createTransformValue, createTextShadowValue } from "react-native-web/dist/exports/StyleSheet/preprocess";',
].join("\n"),
map: null,
};
},
},
{
name: "compile-react-components",
enforce: "pre",
@@ -187,6 +207,9 @@ export default defineConfig({
"react/jsx-dev-runtime",
"react-native-gesture-handler > hoist-non-react-statics",
"react-native-gesture-handler > invariant",
// Loaded by the reanimated-web-style-helpers plugin, which the dependency scan does not see.
"react-native-web/dist/exports/StyleSheet/compiler/createReactDOMStyle",
"react-native-web/dist/exports/StyleSheet/preprocess",
],
exclude: [
"react-native-enriched-markdown",
@@ -1,66 +0,0 @@
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);
},
);
});
+2 -2
View File
@@ -213,7 +213,7 @@ interface ProviderReviewAttachment {
type: "review";
mimeType: "application/paseo-review";
cwd: string;
mode: "uncommitted" | "base" | "all";
mode: "uncommitted" | "base";
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", "all"]),
mode: z.enum(["uncommitted", "base"]),
baseRef: z.string().nullable().optional(),
comments: z.array(
z
@@ -210,8 +210,6 @@ 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;
@@ -359,7 +357,6 @@ 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,
@@ -113,111 +113,6 @@ 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,18 +155,8 @@ 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,12 +1,5 @@
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) {
@@ -137,7 +130,12 @@ 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 && !isRefused(error)) throw error;
if (
error?.status !== 401 &&
error?.cause?.code !== "ECONNREFUSED" &&
error?.code !== "ConnectionRefused"
)
throw error;
}
if (active)
return { error: "OpenCode session is already bound to another Paseo bridge" };
@@ -1,6 +1,4 @@
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";
@@ -169,88 +167,3 @@ 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,12 +1,9 @@
import { expect, test } from "vitest";
import { mkdtemp, rm, mkdir, writeFile, readdir, symlink } from "node:fs/promises";
import { mkdtemp, rm } 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 () => {
@@ -106,130 +103,3 @@ 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 });
}
});
@@ -182,46 +182,6 @@ 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,7 +458,6 @@ 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;
@@ -475,11 +474,6 @@ 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)) {
@@ -680,10 +674,6 @@ export class HubRelationshipHarness {
this.promptsToFail.add(prompt);
}
failNextProviderModeChange(): void {
this.failNextModeChange = true;
}
failNextProviderSessionClose(): void {
this.failNextSessionClose = true;
}
@@ -23,8 +23,7 @@ test("message retries survive reconstruction without submitting twice", async ()
agentId: "agent",
messageId: "arrival",
request: { text: "hello" },
send: async (beforeDispatch: () => Promise<void>) => {
await beforeDispatch();
send: async () => {
deliveries++;
},
};
@@ -42,8 +41,7 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
agentId: "agent",
messageId: "arrival",
request: {},
send: async (beforeDispatch: () => Promise<void>) => {
await beforeDispatch();
send: async () => {
deliveries++;
throw new Error("connection lost");
},
@@ -55,7 +53,7 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
expect(deliveries).toBe(1);
});
test("a send that fails before beforeDispatch can be retried with the same message", async () => {
test("failed local message preparation does not leave an ambiguous receipt", async () => {
const { requests, directory } = await fixture();
let available = false;
let sends = 0;
@@ -63,13 +61,14 @@ test("a send that fails before beforeDispatch can be retried with the same messa
agentId: "agent",
messageId: "message",
request: {},
send: async (beforeDispatch: () => Promise<void>) => {
if (!available) throw new Error("mode change failed");
await beforeDispatch();
prepare: async () => {
if (!available) throw new Error("load failed");
},
send: async () => {
sends++;
},
};
await expect(requests.send(input)).rejects.toThrow("mode change failed");
await expect(requests.send(input)).rejects.toThrow("load failed");
available = true;
await new MessageReceipts(directory).send(input);
available = false;
@@ -13,11 +13,8 @@ interface SendMessageInput {
agentId: string;
messageId: string;
request: unknown;
/**
* 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>;
send: () => Promise<void>;
prepare?: () => Promise<void>;
}
/** Owns message delivery receipts; creation is owned by CreationService. */
@@ -51,12 +48,10 @@ 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 };
let pending: Promise<void> | null = null;
await input.send(() => {
pending ??= writeJsonFileAtomic(file, { ...receipt, state: "pending" });
return pending;
});
await writeJsonFileAtomic(file, { ...receipt, state: "pending" });
await input.send();
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
}
}
+4 -6
View File
@@ -8918,9 +8918,8 @@ export class Session {
queued = acceptAdmission;
});
let deliveryStatus: "queued" | undefined;
const send = async (beforeDispatch: () => Promise<void>) => {
const send = async () => {
if (msg.sourceAgentId) {
await beforeDispatch();
const receipt = this.agentMessages.enqueue({
recipientId: agentId,
messageId: msg.messageId,
@@ -8951,7 +8950,6 @@ export class Session {
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
sessionMode: msg.sessionMode,
clearPendingPermissions: true,
beforeDispatch,
logger: this.sessionLogger,
});
if (result.disposition === "turn_started") {
@@ -8974,12 +8972,12 @@ export class Session {
activeTurnBehavior:
msg.activeTurnBehavior ?? (msg.sourceAgentId ? "steer" : "interrupt"),
},
send: async (beforeDispatch) => {
prepare: async () => {
if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text);
await send(beforeDispatch);
},
send,
})
: send(async () => {});
: send();
void sent.catch((error) => {
if (deliveryStatus === "queued") {
this.handleAgentRunError(agentId, error, "Failed to dispatch queued agent message");
@@ -84,7 +84,6 @@ interface FakeAgentSessionOptions {
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
beforeSetMode?: (modeId: string) => Promise<void>;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -96,7 +95,6 @@ export interface TestAgentClientOptions {
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
beforeSetMode?: (modeId: string) => Promise<void>;
supportsMcpServers?: boolean;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -371,7 +369,6 @@ 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;
@@ -388,7 +385,6 @@ 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;
@@ -895,7 +891,6 @@ class FakeAgentSession implements AgentSession {
}
async setMode(modeId: string): Promise<void> {
await this.beforeSetMode?.(modeId);
this.config.modeId = modeId;
}
@@ -1269,7 +1264,6 @@ class FakeAgentClient implements AgentClient {
beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
beforeSetMode: this.options.beforeSetMode,
historyEvents: this.options.historyEvents,
});
}
@@ -1300,7 +1294,6 @@ 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(async () => {}),
send: (input) => input.send(),
};
}
@@ -1238,9 +1238,6 @@ 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.
@@ -57,124 +57,8 @@ 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..ad75714 100644
index 7c88afc..a74140f 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 {
@@ -204,26 +88,13 @@ index 7c88afc..ad75714 100644
+ sessionId: number;
+ index: number;
+ } | null>(null);
const {
dragHitSlop = DEFAULT_PROPS.dragHitSlop,
@@ -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>) {
@@ -144,19 +157,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;
@@ -297,7 +168,7 @@ index 7c88afc..ad75714 100644
- activeCellOffset.value = cellData.measurements.offset;
- activeCellSize.value = cellData.measurements.size;
- }
const { onDragBegin } = propsRef.current;
if (index !== undefined) {
- spacerIndexAnim.value = index;
@@ -316,10 +187,10 @@ index 7c88afc..ad75714 100644
setActiveKey(activeKey);
onDragBegin?.(index);
}
@@ -218,14 +306,46 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -218,8 +300,25 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
props.onRelease?.(index);
});
+ const finalizeCancelledDrag = () => {
+ "worklet";
+ const activeIndex = activeIndexAnim.value;
@@ -341,33 +212,12 @@ index 7c88afc..ad75714 100644
+ if (activeDragRef.current?.sessionId !== sessionId) return;
+ activeDragRef.current = null;
const { onDragEnd, data } = props;
const newData = [...data];
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>) {
@@ -238,26 +337,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
props.onPlaceholderIndexChange?.(index);
});
- // Handle case where user ends drag without moving their finger.
- useAnimatedReaction(
- () => {
@@ -391,15 +241,15 @@ index 7c88afc..ad75714 100644
useAnimatedReaction(
() => {
return spacerIndexAnim.value;
@@ -271,11 +371,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -271,11 +350,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) => {
@@ -417,7 +267,7 @@ index 7c88afc..ad75714 100644
panGestureState.value = evt.state;
})
.onUpdate((evt) => {
@@ -286,8 +399,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -286,8 +378,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
: evt.translationY;
touchTranslate.value = translation;
})
@@ -434,15 +284,15 @@ index 7c88afc..ad75714 100644
// Set touch val to current translate val
isTouchActiveNative.value = false;
const translation = horizontalAnim.value
@@ -299,6 +419,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -299,6 +398,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 +430,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -309,22 +409,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
runOnJS(onDragEnd)({
from: activeIndexAnim.value,
to: spacerIndexAnim.value,
@@ -521,7 +371,7 @@ index 7c88afc..ad75714 100644
+ stateManager.fail();
+ }
});
if (dragHitSlop) panGesture.hitSlop(dragHitSlop);
- if (activationDistanceProp) {
+ if (coordinatesDragWithExternalGesture) {
@@ -530,7 +380,7 @@ index 7c88afc..ad75714 100644
const activeOffset = [-activationDistanceProp, activationDistanceProp];
if (props.horizontal) {
panGesture.activeOffsetX(activeOffset);
@@ -332,6 +515,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -332,6 +494,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
panGesture.activeOffsetY(activeOffset);
}
}
@@ -549,14 +399,12 @@ index 7c88afc..ad75714 100644
+ props.dragGestureHostPresented === false
+ ? Gesture.Simultaneous(panGesture, Gesture.Manual().enabled(false))
+ : panGesture;
const onScroll = useStableCallback((scrollOffset: number) => {
props.onScrollOffsetChange?.(scrollOffset);
@@ -372,9 +570,11 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
keyExtractor={keyExtractor}
@@ -373,8 +550,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
horizontal={!!props.horizontal}
layoutAnimationDisabled={layoutAnimationDisabled}
+ transformHostKeys={transformHostKeys}
>
- <GestureDetector gesture={panGesture}>
+ <GestureDetector gesture={detectorGesture}>
@@ -578,17 +426,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);
+ });
@@ -604,37 +452,6 @@ 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
+55 -3
View File
@@ -4,10 +4,62 @@ 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 script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
node "$script_dir/seed-dev-home.mjs" "${PASEO_DEV_SEED_HOME:-$HOME/.paseo}" "$1"
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}"
}
configure_dev_daemon_config() {
-4
View File
@@ -48,10 +48,6 @@ 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+",
-84
View File
@@ -1,84 +0,0 @@
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]);
}
+64 -7
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
// Worktree setup runs through a stable script shell: bash on macOS/Linux, but PowerShell
@@ -7,8 +7,6 @@ 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();
@@ -16,12 +14,55 @@ if (!sourceRoot || sourceRoot === targetRoot) {
process.exit(0);
}
seedPaseoHome(
process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home"),
join(targetRoot, ".dev/paseo-home"),
);
seedPaseoHome();
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");
@@ -36,3 +77,19 @@ 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;
}
}