Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 31b22b4497 fix(app): keep the autocomplete list when the same results arrive again
The composer hands the popover a new list on every keystroke and refetch, often
holding the results it already showed. The list started over for each one: a
second mount before the filtered results, and a list the user had scrolled
returned to its rest. A result set is now its options in order.
2026-11-14 17:04:54 +01:00
Mohamed Boudra dafc9e470b fix(app): keep the selected autocomplete row in view when the popover gets shorter
The row the arrow keys selected was brought into view once. A popover that then
lost height, because the composer grew or the window shrank, cut that row off
while Enter still chose it. The list checks the selected row again whenever its
height changes.

The test that counted scroll calls through patched Element prototypes now reads
the list's resting scroll position instead.
2026-11-14 16:53:25 +01:00
Mohamed Boudra 5d5f88a5cf fix(app): rest the autocomplete list at the far end when the selection wraps there
Wrapping the arrow-key selection from the best match to the far end of a long
list scrolled there over several frames, because the rows in between were not
mounted. The list now gets a scroller that rests at the end the selection
wrapped to, so the jump is one frame and mounts only the rows it shows.
2026-11-14 16:49:53 +01:00
Mohamed Boudra 2235d3513f fix(app): open the autocomplete list where it rests, with only the rows it shows
The list mounted every result and then scrolled itself to the composer edge.
On Android the scroll command reaches the screen one or two frames after the
rows, so each open and each result change painted the far end of the list, or
the selection 8 px low, before the resting frame. Mounting 115 commands also
held the popover's first frame for 500 ms to 1 s.

The list is now an inverted, virtualized list: its resting scroll position is
the composer edge, and the first commit holds the rows that fill the popover.
Each result set gets its own list, so a result change starts at the composer
edge with the same bounded commit. Arrow-key selection is revealed through the
list's own viewability and scroll-to-index.
2026-11-14 16:37:26 +01:00
Mohamed Boudra f5499fd336 fix(app): close the autocomplete popover while its retained panel is hidden
A workspace stays mounted with display: none while another route shows, and the popover stayed open inside it. On native its frame then failed to measure the hidden composer, stopped tracking, and kept the stale placement after the user came back. The popover now closes with its panel and reopens with a fresh measurement.
2026-11-14 11:07:48 +01:00
Mohamed Boudra a9bc26bed2 fix(app): stop the native popover frame measuring a composer that left the tree
When the composer unmounts with the popover open, the portal removes the frame a commit later. A busy JS thread holds that commit for many frames, and every failed measure() logged a Reanimated warning: 31 to 111 per layout swap on Android. A gate written from the anchor's ref callback arrives on the UI thread only after that same JS task, so the frame now stops on its first failed measure and falls back to the rest measurement.
2026-11-14 10:51:28 +01:00
Mohamed Boudra 3503edc65b docs(app): state where the native popover's fixed-bottom tracking stops holding
The native frame follows only the composer's height so it never depends on whether iOS's native-driver keyboard lift has reached the shadow tree. The centered New workspace composer and width changes still wait for the rest measurement, as before.
2026-11-14 10:23:04 +01:00
Mohamed Boudra 9dedc14bbb fix(app): keep the command popover against the composer as it resizes
The popover lives in a portal and positioned itself from an asynchronous
copy of the composer's rect. Any composer layout change painted first: a
wrapped line overlapped the popover, cleared text left it floating above
the composer, and a window resize left it at the old width, for one frame
on web and up to seconds on Android under JS load.

The popover now follows the composer's live size between measurements.
Native reads the anchor's height on the UI thread every frame with a
Reanimated frame callback; web commits the placement from a
ResizeObserver with flushSync, before paint. The keyboard translate view
in the portal becomes host-sized and box-none, so the popover moves
inside it without leaving its parent's bounds (Android hit-testing).
2026-11-14 10:04:54 +01:00
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
80 changed files with 1667 additions and 2709 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:
+33 -15
View File
@@ -7,12 +7,12 @@ canonical files and are trying to add or change one.
## Canonical files
| File | Use case |
| ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
| File | Use case |
| ------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
| `components/ui/autocomplete-popover/` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
@@ -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
@@ -284,6 +280,22 @@ popover or leaves it behind the keyboard. `measureAtRest` measures through
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
settles; the composer's resting position changes as the draft grows.
A measured rect is an asynchronous copy, so it lands after the anchor's new
layout has painted. The composer resizes while the autocomplete is open: a
wrapped line, cleared text, a window resize. On web a `ResizeObserver` reads
the anchor's rect against the layer and commits the placement with `flushSync`,
which runs after layout and before paint. On native a Reanimated frame callback
reads only the anchor's height on the UI thread and holds its bottom edge at
the rest measurement. A height carries no transform. On iOS the absolute
position does not reliably include one: the keyboard lift runs on the native
driver and reaches the shadow tree only when the view re-renders. The
fixed-bottom model is wrong for the centered New workspace composer on tablets,
which grows around its middle, and for width or x changes. The popover is off
there by half the growth, or by the old width, until the next rest measurement.
Keep the portal's `KeyboardTranslateView` host-sized with
`pointerEvents="box-none"` and position the popover inside it, so the popover
moves without leaving its parent's bounds (Gotcha 1).
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
interactive move, but not at the terminal event, so JS contention can otherwise
@@ -426,11 +438,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
@@ -459,6 +469,14 @@ Before you write a new one, ask:
`top` paints over its anchor until the next placement lands, about 150 ms
on Android. Web hides this because React commits the resize and the
re-placement in one task.
7. **Does the panel hold a list that reads away from its anchor?** Make the
anchor edge the list's resting scroll position: an inverted list, as in
`components/ui/autocomplete.tsx`. On Android a scroll command issued after
the rows commit reaches the screen one or two frames after them, so a list
scrolled to its anchor edge paints its far end first. Give each result set
its own list with a `key`, so it starts at the anchor edge with the rows
that fill the panel. A list that mounts every result holds the panel's
first frame until the last row is ready.
Then copy the closest canonical file and trim.
-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,574 @@
import React, { act, type ReactNode } from "react";
import { flushSync } from "react-dom";
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 { RetainedPanel } from "@/components/retained-panel";
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 must paint its first frame at its place. On native, Reanimated computes a style's
* first value when its hook first runs and sends later values after the mounting commit, which
* native paints: a bound computed while the popover was closed hung the popover over the composer
* for a frame on every open. The frame now mounts only with a measured anchor. This suite runs the
* web frame; the native frame was checked frame by frame on Android.
*/
const SAFE_AREA = {
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
};
// The composer rests on the dock's bottom edge and grows upward, as in the app.
const COMPOSER_BOTTOM = 124;
const OFFSET_FROM_ANCHOR = 12;
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 styleCache = new Map<string, object>();
function cachedStyle<T extends object>(key: string, create: () => T): T {
const cached = styleCache.get(key);
if (cached) return cached as T;
const style = create();
styleCache.set(key, style);
return style;
}
function composerStyle(width: number): object {
return cachedStyle(`composer:${width}`, () => ({
position: "absolute" as const,
bottom: COMPOSER_BOTTOM,
left: 16,
width,
}));
}
function inputStyle(height: number): object {
return cachedStyle(`input:${height}`, () => ({ height }));
}
function ignoreSelection(): void {}
function Providers({ children }: { children: ReactNode }): ReactNode {
return (
<SafeAreaProvider initialMetrics={SAFE_AREA}>
<KeyboardProvider>
<KeyboardShiftProvider>
<PortalProvider>{children}</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
);
}
interface ScreenProps {
visible: boolean;
panelActive?: boolean;
inputHeight?: number;
composerWidth?: number;
options?: AutocompleteOption[];
/** Counted up from the best match, which is the last option. */
selectedFromBest?: number;
}
function Screen({
visible,
panelActive = true,
inputHeight = 120,
composerWidth = 358,
options = OPTIONS,
selectedFromBest = 0,
}: ScreenProps): ReactNode {
return (
<Providers>
<RetainedPanel active={panelActive}>
<View style={SCREEN_STYLE}>
<View style={composerStyle(composerWidth)}>
<AutocompletePopover
visible={visible}
options={options}
selectedIndex={options.length - 1 - selectedFromBest}
onSelect={ignoreSelection}
>
<View testID="composer-input" style={inputStyle(inputHeight)} />
</AutocompletePopover>
</View>
<FloatingPanelPortalHost />
</View>
</RetainedPanel>
</Providers>
);
}
function render(node: ReactNode): void {
if (!container) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
}
act(() => root?.render(node));
}
/** Commits a change to the screen synchronously and lets the browser paint it. */
async function changeAndPaint(node: ReactNode): Promise<void> {
flushSync(() => root?.render(node));
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
const channel = new MessageChannel();
channel.port1.addEventListener("message", () => resolve(), { once: true });
channel.port1.start();
channel.port2.postMessage(null);
});
});
}
function rectOf(testID: string): DOMRect | null {
return document.querySelector(`[data-testid="${testID}"]`)?.getBoundingClientRect() ?? null;
}
/** Where the popover sits against the composer, or null when it is not painted. */
function placementAgainstComposer(): { gap: number; x: number; width: number } | null {
const popover = rectOf("composer-autocomplete-popover");
const composer = rectOf("composer-input");
if (!popover || !composer) return null;
return {
gap: Math.round(composer.top - popover.bottom),
x: Math.round(popover.x - composer.x),
width: Math.round(popover.width - composer.width),
};
}
async function openSettled(props: Omit<ScreenProps, "visible"> = {}): Promise<void> {
render(<Screen visible={false} {...props} />);
render(<Screen visible {...props} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 200));
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
}
/** 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);
});
/**
* The popover lives in a portal and copies the composer's rect. A copy taken after the composer's
* layout paints the composer at its new size against the popover's old place: the popover overlapped
* a composer that grew a line, floated above one that shrank, and kept the old width after a resize.
*/
it("moves with the composer in the frame the composer grows", async () => {
await openSettled();
await changeAndPaint(<Screen visible inputHeight={160} />);
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
});
it("follows the composer down when its text clears before the popover closes", async () => {
// The input relayouts on the cleared text before the autocomplete hides the popover.
await openSettled({ inputHeight: 160 });
await changeAndPaint(<Screen visible inputHeight={120} />);
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
});
it("matches the composer's width in the frame the composer resizes", async () => {
await openSettled();
await changeAndPaint(<Screen visible composerWidth={240} />);
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
});
/**
* A retained workspace stays mounted while another route shows. A popover left open there tracked a
* composer it could not measure, and on native stopped following the composer after the user came
* back.
*/
it("closes while its retained panel is hidden and reopens in place when it shows again", async () => {
await openSettled();
render(<Screen visible panelActive={false} />);
expect(rectOf("composer-autocomplete-popover")).toBeNull();
render(<Screen visible />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 200));
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
});
/** As many commands as a workspace with skills installed lists; the best match is last. */
function commandOptions(prefix: string, count: number): AutocompleteOption[] {
return Array.from({ length: count }, (_, index) => ({
id: `${prefix}-${count - index}`,
label: `/${prefix}-${count - index}`,
icon: "skill" as const,
description: `Description of ${prefix} ${count - index}`,
tag: "Skill",
}));
}
const LIST_PADDING_AND_BORDER = 5;
const ROW_HEIGHT_AT_LEAST = 36;
/** How far the best match's row sits above the popover's bottom edge, or null when not painted. */
function bestMatchAgainstPopoverBottom(label: string): number | null {
const popover = rectOf("composer-autocomplete-popover");
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
if (!popover) return null;
if (!row) return Number.NaN;
return Math.round(popover.bottom - row.bottom);
}
/** Samples a reading after every paint until stopped. */
function recordPaintedFrames<T>(read: () => T): { frames: T[]; stop: () => void } {
const frames: T[] = [];
let running = true;
const channel = new MessageChannel();
channel.port1.addEventListener("message", () => {
if (running) frames.push(read());
});
channel.port1.start();
const tick = (): void => {
if (!running) return;
channel.port2.postMessage(null);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
return {
frames,
stop: () => {
running = false;
},
};
}
/**
* The list is longer than the popover and reads upward from its best match, which sits against the
* bottom edge. Scrolling there after the list mounted painted the list at its old offset first: a
* few pixels short on open, a different stretch of the list when the results changed.
*/
it("paints a long list with its best match against the bottom from the first frame", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
recording.stop();
const painted = recording.frames.filter((frame) => frame !== null);
expect(painted.length).toBeGreaterThan(0);
expect([...new Set(painted)]).toEqual([LIST_PADDING_AND_BORDER]);
});
it("keeps the best match against the bottom in the frame the results change", async () => {
const all = commandOptions("skill", 100);
const fewer = all.filter((_, index) => index % 3 === 0);
render(<Screen visible={false} options={all} />);
render(<Screen visible options={all} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
// Narrowing keeps `/skill-1` the best match; widening brings the rest back above it.
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
await changeAndPaint(<Screen visible options={fewer} />);
await new Promise((resolve) => setTimeout(resolve, 200));
await changeAndPaint(<Screen visible options={all} />);
await new Promise((resolve) => setTimeout(resolve, 300));
recording.stop();
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
});
function mountedRows(prefix = "skill"): Element[] {
return [...document.querySelectorAll(`[aria-label^="/${prefix}-"]`)];
}
/**
* Every result used to mount before the popover's first frame, so a workspace with a hundred
* commands waited on a hundred rows to see six.
*/
it("mounts the rows nearest the composer first, however many results there are", async () => {
const options = commandOptions("skill", 500);
render(<Screen visible={false} options={options} />);
const recording = recordPaintedFrames(() =>
rectOf("composer-autocomplete-popover") ? mountedRows().length : null,
);
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 100));
recording.stop();
const firstFrame = recording.frames.find((frame) => frame !== null);
expect(firstFrame).toBeGreaterThan(0);
expect(firstFrame).toBeLessThan(20);
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
});
/**
* A list that had mounted its whole window kept that window for the next results, so narrowing a
* long list to another long one waited on ten times the rows the popover shows.
*/
it("mounts the rows nearest the composer first when the results change", async () => {
const commands = commandOptions("skill", 500);
const matches = commandOptions("plugin", 300);
render(<Screen visible={false} options={commands} />);
render(<Screen visible options={commands} />);
await waitForPopover();
await expect.poll(() => mountedRows().length, { timeout: 3000 }).toBeGreaterThan(40);
const recording = recordPaintedFrames(() => mountedRows("plugin").length);
await changeAndPaint(<Screen visible options={matches} />);
recording.stop();
const firstFrame = recording.frames.find((count) => count > 0);
expect(firstFrame).toBeLessThan(20);
expect(bestMatchAgainstPopoverBottom("/plugin-1")).toBe(LIST_PADDING_AND_BORDER);
});
function scrollerOf(popover: HTMLElement): HTMLElement {
const scroller = [...popover.querySelectorAll<HTMLElement>("*")].find(
(element) =>
element.scrollHeight > element.clientHeight + 1 &&
["auto", "scroll"].includes(getComputedStyle(element).overflowY),
);
if (!scroller) throw new Error("The list does not scroll");
return scroller;
}
/**
* Native paints a scroll command a frame or two after the rows it was meant for. A list that rests
* against the composer only because script scrolled it there shows the rows somewhere else first,
* so the composer's edge is where the list's scroll starts.
*/
it("rests against the composer where its scroll starts", async () => {
const all = commandOptions("skill", 100);
const fewer = all.filter((_, index) => index % 3 === 0);
render(<Screen visible={false} options={all} />);
render(<Screen visible options={all} />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
expect(scrollerOf(popover).scrollTop).toBe(0);
await changeAndPaint(<Screen visible options={fewer} />);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(scrollerOf(popover).scrollTop).toBe(0);
await changeAndPaint(<Screen visible options={all} />);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(scrollerOf(popover).scrollTop).toBe(0);
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
});
it("returns to the best match in the frame results arrive while the list is scrolled away", async () => {
const all = commandOptions("skill", 100);
const fewer = all.filter((_, index) => index % 3 === 0);
render(<Screen visible={false} options={all} />);
render(<Screen visible options={all} />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
scrollerOf(popover).scrollTop = 400;
await new Promise((resolve) => setTimeout(resolve, 200));
expect(bestMatchAgainstPopoverBottom("/skill-1")).not.toBe(LIST_PADDING_AND_BORDER);
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
await changeAndPaint(<Screen visible options={fewer} />);
await new Promise((resolve) => setTimeout(resolve, 200));
recording.stop();
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
});
/**
* The composer hands over a new list on every keystroke and every refetch, often holding the results
* it already showed. Starting over for those returned a list the user had scrolled to its rest.
*/
it("keeps its place when the same results arrive again", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
scrollerOf(popover).scrollTop = 400;
await new Promise((resolve) => setTimeout(resolve, 200));
const scrolledAway = bestMatchAgainstPopoverBottom("/skill-1");
expect(scrolledAway).not.toBe(LIST_PADDING_AND_BORDER);
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
await changeAndPaint(<Screen visible options={commandOptions("skill", 100)} />);
await new Promise((resolve) => setTimeout(resolve, 200));
recording.stop();
expect([...new Set(recording.frames)]).toEqual([scrolledAway]);
});
/** Whether the row is wholly inside the popover's box. */
function isRowInView(label: string): boolean {
const popover = rectOf("composer-autocomplete-popover");
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
if (!popover || !row) return false;
return row.top >= popover.top - 0.5 && row.bottom <= popover.bottom + 0.5;
}
it("reveals the row the arrow keys select, including across the wrap to the far end", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
for (const selectedFromBest of [1, 5, 6, 7, 12, 6, 0, 99, 98, 93, 0]) {
render(<Screen visible options={options} selectedFromBest={selectedFromBest} />);
// Rows beyond the mounted ones come into view as they mount.
await expect
.poll(() => [selectedFromBest, isRowInView(`/skill-${selectedFromBest + 1}`)], {
timeout: 3000,
})
.toEqual([selectedFromBest, true]);
}
await expect.poll(() => bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
});
/** The composer grew until the space above it holds fewer rows than the popover was showing. */
it("keeps the selected row in view when the popover gets shorter", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
// Beyond the rows the popover opened with, so it came into view at the popover's top edge.
render(<Screen visible options={options} selectedFromBest={7} />);
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
const height = rectOf("composer-autocomplete-popover")!.height;
render(<Screen visible options={options} selectedFromBest={7} inputHeight={560} />);
await expect
.poll(() => rectOf("composer-autocomplete-popover")!.height, { timeout: 3000 })
.toBeLessThan(height - ROW_HEIGHT_AT_LEAST);
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
});
/** The label of the row at the top of the popover's box. */
function topRowInView(): string | null {
const popover = rectOf("composer-autocomplete-popover");
if (!popover) return null;
const rows = mountedRows()
.map((row) => ({ rect: row.getBoundingClientRect(), label: row.getAttribute("aria-label") }))
.filter(({ rect }) => rect.top >= popover.top - 0.5 && rect.bottom <= popover.bottom + 0.5)
.toSorted((a, b) => a.rect.top - b.rect.top);
return rows[0]?.label?.split(",")[0] ?? null;
}
/**
* The far end of a long list is not mounted while the list rests against the composer. Scrolling to
* it as rows mounted painted the list at several places on the way.
*/
it("jumps between the two ends in one frame when the arrow keys wrap", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
const restingAtComposer = topRowInView();
const toFarEnd = recordPaintedFrames(topRowInView);
await changeAndPaint(<Screen visible options={options} selectedFromBest={99} />);
await new Promise((resolve) => setTimeout(resolve, 400));
toFarEnd.stop();
expect([...new Set(toFarEnd.frames)]).toEqual(["/skill-100"]);
expect(isRowInView("/skill-100")).toBe(true);
const toComposer = recordPaintedFrames(topRowInView);
await changeAndPaint(<Screen visible options={options} selectedFromBest={0} />);
await new Promise((resolve) => setTimeout(resolve, 400));
toComposer.stop();
expect([...new Set(toComposer.frames)]).toEqual([restingAtComposer]);
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
});
it("scrolls towards rows further from the composer when the wheel turns up", async () => {
const options = commandOptions("skill", 100);
render(<Screen visible={false} options={options} />);
render(<Screen visible options={options} />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 300));
const row = document.querySelector('[aria-label^="/skill-1,"]');
row?.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 200));
// The best match moved down, out of the popover, by the distance the wheel turned.
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER - 120);
expect(isRowInView("/skill-7")).toBe(true);
expect(popover.getBoundingClientRect().bottom).toBe(rectOf("composer-input")!.top - 12);
});
@@ -1,15 +1,13 @@
import { useCallback, useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
StyleSheet,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Portal } from "@gorhom/portal";
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
import { useAnimatedReaction, useAnimatedRef } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
@@ -21,18 +19,10 @@ import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
const OFFSET_FROM_ANCHOR = SPACING[3];
interface RelativeAnchorRect {
x: number;
y: number;
width: number;
hostHeight: number;
}
import { PopoverFrame } from "./internal/frame";
import type { AnchorAtRest } from "./internal/placement";
interface AutocompletePopoverProps {
visible: boolean;
@@ -55,8 +45,16 @@ export function AutocompletePopover({
}: AutocompletePopoverProps): ReactElement | null {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const anchorRef = useRef<View>(null);
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
const anchorRef = useAnimatedRef<View>();
// The animated ref returns its native handle, which React 19 would call as a ref cleanup.
// The anchor stays a plain View: its style is the caller's Unistyles style.
const setAnchorElement = useCallback(
(element: View | null) => {
anchorRef(element);
},
[anchorRef],
);
const [anchorAtRest, setAnchorAtRest] = useState<AnchorAtRest | null>(null);
const windowDimensions = useWindowDimensions();
const safeAreaInsets = useSafeAreaInsets();
const portalHostName = useFloatingPanelPortalHostName();
@@ -64,7 +62,9 @@ export function AutocompletePopover({
const anchor = useKeyboardAnchor();
const { measureAtRest } = anchor;
const measurementGeneration = useRef(0);
const canMeasure = visible && (options.length === 0 || selectedIndex >= 0);
// A retained panel stays mounted while hidden; its anchor cannot be measured until it shows.
const isPanelActive = useRetainedPanelActive();
const canMeasure = isPanelActive && visible && (options.length === 0 || selectedIndex >= 0);
const remeasure = useCallback(() => {
if (!canMeasure) return;
@@ -78,10 +78,11 @@ export function AutocompletePopover({
if (generation !== measurementGeneration.current || !anchorRect || !hostRect) {
return undefined;
}
setRelativeAnchorRect({
setAnchorAtRest({
x: anchorRect.x - hostRect.x,
y: anchorRect.y - hostRect.y,
width: anchorRect.width,
height: anchorRect.height,
hostHeight: hostRect.height,
});
return undefined;
@@ -91,7 +92,7 @@ export function AutocompletePopover({
useEffect(() => {
measurementGeneration.current += 1;
if (!canMeasure) {
setRelativeAnchorRect(null);
setAnchorAtRest(null);
return;
}
@@ -114,47 +115,32 @@ export function AutocompletePopover({
[isMoving, remeasure],
);
// Lay out at the anchor's resting position; the KeyboardTranslateView below lifts
// it with the anchor. Height changes only at keyboard boundaries.
const baseStyle = useMemo(() => {
if (!relativeAnchorRect) return null;
return inlineUnistylesStyle({
position: "absolute" as const,
left: relativeAnchorRect.x,
width: relativeAnchorRect.width,
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
});
}, [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.
// The layer covers the host and rides the anchor's keyboard translation; the frame inside it
// places the popover. A layer that spans the host keeps the popover inside its parent's bounds,
// which Android hit-testing requires.
return (
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
<View ref={setAnchorElement} onLayout={remeasure} style={anchorStyle} collapsable={false}>
{children}
{showPopover ? (
{canMeasure && anchorAtRest ? (
<Portal hostName={portalHostName}>
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
<KeyboardTranslateView
{...anchor.translation}
style={StyleSheet.absoluteFill}
pointerEvents="box-none"
>
<PopoverFrame
anchorRef={anchorRef}
anchorAtRest={anchorAtRest}
reservedLift={anchor.reservedLift}
topInset={safeAreaInsets.top}
>
<Autocomplete
options={options}
selectedIndex={selectedIndex}
onSelect={onSelect}
status={status}
/>
</Animated.View>
</PopoverFrame>
</KeyboardTranslateView>
</Portal>
) : null}
@@ -0,0 +1,68 @@
import { useCallback, type ReactElement } from "react";
import Animated, {
measure,
useAnimatedStyle,
useFrameCallback,
useSharedValue,
} from "react-native-reanimated";
import { placeAboveAnchor, type PopoverFrameProps } from "./placement";
/**
* The composer grows upward from a fixed bottom edge as its text wraps. Its height is read on the
* UI thread every frame, so the popover moves in the frame the composer's new layout mounts; an
* asynchronous measurement through JS painted the composer over or away from the popover first.
*
* The frame 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 placement
* computed before the measurement painted the popover over the composer for a frame on every open.
*/
export function PopoverFrame({
anchorRef,
anchorAtRest,
reservedLift,
topInset,
children,
}: PopoverFrameProps): ReactElement {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const anchorHeight = useSharedValue(anchorAtRest.height);
const anchorLost = useSharedValue(false);
const readAnchorHeight = useCallback(() => {
"worklet";
if (anchorLost.value) return;
const measured = measure(anchorRef);
if (!measured) {
// The anchor left the shadow tree, usually as the composer unmounts; the portal removes the
// frame a commit later. Each failed measure() logs a warning, and a busy JS thread holds
// that commit for many frames, so stop measuring and follow the rest measurement.
anchorLost.value = true;
return;
}
if (measured.height !== anchorHeight.value) {
anchorHeight.value = measured.height;
}
}, [anchorHeight, anchorLost, anchorRef]);
useFrameCallback(readAnchorHeight);
const style = useAnimatedStyle(() => {
const anchorBottom = anchorAtRest.y + anchorAtRest.height;
const liveHeight = anchorLost.value ? anchorAtRest.height : anchorHeight.value;
return {
position: "absolute",
left: anchorAtRest.x,
width: anchorAtRest.width,
...placeAboveAnchor({
anchorTop: anchorBottom - liveHeight,
hostHeight: anchorAtRest.hostHeight,
reservedLift: reservedLift.value,
topInset,
}),
};
}, [anchorAtRest, anchorHeight, anchorLost, reservedLift, topInset]);
return (
<Animated.View testID="composer-autocomplete-popover" style={style}>
{children}
</Animated.View>
);
}
@@ -0,0 +1,68 @@
import { useLayoutEffect, useRef, useState, type ReactElement } from "react";
import { flushSync } from "react-dom";
import { View } from "react-native";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { placeAboveAnchor, type AnchorAtRest, type PopoverFrameProps } from "./placement";
type AnchorRect = Omit<AnchorAtRest, "height">;
function rectWithin(anchor: HTMLElement, host: HTMLElement): AnchorRect {
const anchorRect = anchor.getBoundingClientRect();
const hostRect = host.getBoundingClientRect();
return {
x: anchorRect.x - hostRect.x,
y: anchorRect.y - hostRect.y,
width: anchorRect.width,
hostHeight: hostRect.height,
};
}
/**
* The composer resizes as its text wraps and as the window resizes. ResizeObserver callbacks run
* after layout and before paint, so committing the new placement synchronously there paints the
* popover against the composer's new rect in the same frame.
*/
export function PopoverFrame({
anchorRef,
anchorAtRest,
reservedLift,
topInset,
children,
}: PopoverFrameProps): ReactElement {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const frameRef = useRef<View>(null);
const [anchor, setAnchor] = useState<AnchorRect>(anchorAtRest);
useLayoutEffect(() => {
const anchorElement = anchorRef.current as unknown as HTMLElement | null;
// The frame's parent is the portal layer, which covers the host.
const host = (frameRef.current as unknown as HTMLElement | null)?.parentElement;
if (!anchorElement || !host) return undefined;
setAnchor(rectWithin(anchorElement, host));
const observer = new ResizeObserver(() => {
flushSync(() => setAnchor(rectWithin(anchorElement, host)));
});
observer.observe(anchorElement);
observer.observe(host);
return () => observer.disconnect();
}, [anchorAtRest, anchorRef]);
const style = inlineUnistylesStyle({
position: "absolute" as const,
left: anchor.x,
width: anchor.width,
...placeAboveAnchor({
anchorTop: anchor.y,
hostHeight: anchor.hostHeight,
reservedLift: reservedLift.value,
topInset,
}),
});
return (
<View ref={frameRef} testID="composer-autocomplete-popover" style={style}>
{children}
</View>
);
}
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import type { View } from "react-native";
import type { AnimatedRef, DerivedValue } from "react-native-reanimated";
import { SPACING } from "@/styles/theme";
const OFFSET_FROM_ANCHOR = SPACING[3];
/** The anchor's resting rect, relative to the portal host the popover renders in. */
export interface AnchorAtRest {
x: number;
y: number;
width: number;
height: number;
hostHeight: number;
}
export interface PopoverFrameProps {
anchorRef: AnimatedRef<View>;
/** Taken asynchronously; the frame follows the anchor's live size between measurements. */
anchorAtRest: AnchorAtRest;
reservedLift: DerivedValue<number>;
topInset: number;
children: ReactNode;
}
/** Places the popover above an anchor whose top edge is `anchorTop` within the host. */
export function placeAboveAnchor(input: {
anchorTop: number;
hostHeight: number;
reservedLift: number;
topInset: number;
}): { bottom: number; maxHeight: number } {
"worklet";
return {
bottom: input.hostHeight - input.anchorTop + OFFSET_FROM_ANCHOR,
maxHeight: Math.max(
0,
input.anchorTop - input.reservedLift - input.topInset - OFFSET_FROM_ANCHOR * 2,
),
};
}
+217 -122
View File
@@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ScrollView,
FlatList,
Text,
View,
Pressable,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ListRenderItemInfo,
type PressableStateCallbackType,
type ViewToken,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -29,7 +28,6 @@ import { Button } from "./button";
import { HighlightedText } from "./highlighted-text";
import { mutedIconColorMapping } from "./icon-color";
import { LoadingSpinner } from "./loading-spinner";
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
import { StatusBadge } from "./status-badge";
export type AutocompleteOptionIcon =
@@ -80,6 +78,8 @@ const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping);
const ROW_ICON_SIZE = 14;
const ROW_MIN_HEIGHT = 36;
const DEFAULT_MAX_HEIGHT = 220;
const BOLT_GLYPH_PATTERN = /⚡|️/gu;
@@ -92,21 +92,17 @@ function removeBoltGlyphs(value?: string): string | undefined {
}
interface AutocompleteRowProps {
index: number;
option: AutocompleteOption;
isSelected: boolean;
showArgumentHint: boolean;
onSelect: (option: AutocompleteOption) => void;
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
}
function AutocompleteRow({
index,
const AutocompleteRow = memo(function AutocompleteRow({
option,
isSelected,
showArgumentHint,
onSelect,
onRowLayout,
}: AutocompleteRowProps) {
const label = removeBoltGlyphs(option.label) ?? option.label;
const description = removeBoltGlyphs(option.description);
@@ -114,10 +110,6 @@ function AutocompleteRow({
const Icon = ROW_ICONS[option.icon];
const labelMatches = label === option.label ? option.labelMatches : undefined;
const handleLayout = useCallback(
(event: LayoutChangeEvent) => onRowLayout(index, event),
[index, onRowLayout],
);
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
const pressableStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -129,7 +121,6 @@ function AutocompleteRow({
return (
<Pressable
onLayout={handleLayout}
onPress={handlePress}
style={pressableStyle}
// Name and description only; the hint and tag repeat what the name implies.
@@ -155,7 +146,7 @@ function AutocompleteRow({
{option.tag ? <StatusBadge label={option.tag} size="xs" /> : null}
</Pressable>
);
}
});
function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
const { t } = useTranslation();
@@ -202,94 +193,212 @@ function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
);
}
type ListEnd = "composer" | "far";
/**
* The scroller a list is shown in: one for each result set, resting at one end of it. A scroller
* starts at its resting end with only the rows nearest it, so the list opens against the composer
* and reaches the far end when the selection wraps there without mounting the rows in between.
* A scroller that outlived its results would keep its scroll position and its mounted rows: on
* native a scroll command reaches the screen after the rows it was meant for.
*/
function useScroller(
options: readonly AutocompleteOption[],
selectedIndex: number,
): { key: string; restsAt: ListEnd } {
const [scroller, setScroller] = useState<{
options: readonly AutocompleteOption[];
resultSet: number;
restsAt: ListEnd;
}>({ options, resultSet: 0, restsAt: "composer" });
let next = scroller;
if (next.options !== options) {
next = isSameResultSet(next.options, options)
? { ...next, options }
: { options, resultSet: next.resultSet + 1, restsAt: "composer" };
}
const restsAt = endSelected(options, selectedIndex) ?? next.restsAt;
if (restsAt !== next.restsAt) {
next = { ...next, restsAt };
}
if (next !== scroller) {
setScroller(next);
}
return { key: `${next.resultSet}:${next.restsAt}`, restsAt: next.restsAt };
}
/** The composer builds a new list on every keystroke and refetch, often of the results it had. */
function isSameResultSet(
shown: readonly AutocompleteOption[],
arrived: readonly AutocompleteOption[],
): boolean {
return (
shown.length === arrived.length && shown.every((option, row) => option.id === arrived[row].id)
);
}
/** The end of the list the selection sits at, if it sits at one. The best match is last. */
function endSelected(
options: readonly AutocompleteOption[],
selectedIndex: number,
): ListEnd | null {
if (selectedIndex === options.length - 1) return "composer";
if (selectedIndex === 0) return "far";
return null;
}
const FULLY_VISIBLE = { itemVisiblePercentThreshold: 100 };
function ListPadding() {
return <View style={styles.listPadding} />;
}
function keyOfOption(option: AutocompleteOption): string {
return option.id;
}
interface AutocompleteRowsProps {
/** Nearest the resting end first. */
rows: readonly AutocompleteOption[];
selectedRow: number;
restsAt: ListEnd;
maxHeight: number;
onSelect: (option: AutocompleteOption) => void;
}
/**
* The rows, read from the end the list rests at. Against the composer the list is inverted. Either
* way its resting scroll position is that end and the rows nearest it mount first, so a list of any
* length opens where it rests.
*/
function AutocompleteRows({
rows,
selectedRow,
restsAt,
maxHeight,
onSelect,
}: AutocompleteRowsProps) {
const isCompact = useIsCompactFormFactor();
const listRef = useRef<FlatList<AutocompleteOption>>(null);
const fullyVisibleRows = useRef<{ first: number; last: number } | null>(null);
// The row the keyboard selected, until it has been brought into the viewport. The row at the
// other end is never revealed here: selecting it gives the list a scroller that rests there.
const pendingReveal = useRef<number | null>(null);
const isPendingRowMeasured = useRef(true);
const revealPendingRow = useCallback(() => {
const list = listRef.current;
const row = pendingReveal.current;
const visible = fullyVisibleRows.current;
if (!list || row === null || !visible) return;
const span = visible.last - visible.first;
if (row >= visible.first && row <= visible.last) {
pendingReveal.current = null;
} else if (row === 0) {
list.scrollToOffset({ offset: 0, animated: false });
pendingReveal.current = null;
fullyVisibleRows.current = { first: 0, last: span };
} else {
// The row comes in at the edge it was beyond.
const isNearer = row < visible.first;
isPendingRowMeasured.current = true;
list.scrollToIndex({ index: row, viewPosition: isNearer ? 0 : 1, animated: false });
// A row that has not mounted has no position yet: the list was sent towards it instead, and
// is asked again as rows mount.
if (!isPendingRowMeasured.current) return;
pendingReveal.current = null;
// The list reports what it shows after it has scrolled; the next key press may come first.
fullyVisibleRows.current = isNearer
? { first: row, last: row + span }
: { first: row - span, last: row };
}
}, []);
const handleScrollToIndexFailed = useCallback(
(info: { index: number; averageItemLength: number }) => {
isPendingRowMeasured.current = false;
listRef.current?.scrollToOffset({
offset: info.index * info.averageItemLength,
animated: false,
});
},
[],
);
useEffect(() => {
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
revealPendingRow();
}, [revealPendingRow, selectedRow]);
const handleViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken<AutocompleteOption>[] }) => {
const indexes = viewableItems.flatMap((token) => (token.index === null ? [] : [token.index]));
if (indexes.length === 0) return;
fullyVisibleRows.current = { first: Math.min(...indexes), last: Math.max(...indexes) };
revealPendingRow();
},
[revealPendingRow],
);
// A viewport that got shorter can leave the selected row outside it.
const handleLayout = useCallback(() => {
// The list reports the rows its new height shows before the row is checked against them.
listRef.current?.recordInteraction();
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
revealPendingRow();
}, [revealPendingRow, selectedRow]);
const renderRow = useCallback(
({ item, index }: ListRenderItemInfo<AutocompleteOption>) => (
<AutocompleteRow
option={item}
isSelected={index === selectedRow}
showArgumentHint={!isCompact}
onSelect={onSelect}
/>
),
[isCompact, onSelect, selectedRow],
);
return (
<FlatList
ref={listRef}
inverted={restsAt === "composer"}
data={rows}
renderItem={renderRow}
keyExtractor={keyOfOption}
// The first commit fills the tallest popover, so the popover never opens short and grows.
initialNumToRender={Math.ceil(maxHeight / ROW_MIN_HEIGHT) + 1}
removeClippedSubviews={false}
viewabilityConfig={FULLY_VISIBLE}
onViewableItemsChanged={handleViewableItemsChanged}
onLayout={handleLayout}
onContentSizeChange={revealPendingRow}
onScrollToIndexFailed={handleScrollToIndexFailed}
// The list's padding. As a header and footer it counts in the list's own scroll positions.
ListHeaderComponent={ListPadding}
ListFooterComponent={ListPadding}
style={styles.scrollView}
keyboardShouldPersistTaps="always"
/>
);
}
export function Autocomplete({
options,
selectedIndex,
onSelect,
status,
maxHeight = 220,
maxHeight = DEFAULT_MAX_HEIGHT,
}: AutocompleteProps) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const scrollRef = useRef<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
const viewportHeightRef = useRef(0);
const scrollOffsetRef = useRef(0);
const ensureActiveItemVisible = useCallback(() => {
if (selectedIndex < 0) {
return;
}
const layout = rowLayoutsRef.current.get(selectedIndex);
if (!layout) {
return;
}
const nextOffset = getScrollOffsetToRevealItem({
currentOffset: scrollOffsetRef.current,
viewportHeight: viewportHeightRef.current,
itemTop: layout.top,
itemHeight: layout.height,
});
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
return;
}
scrollOffsetRef.current = nextOffset;
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}, [selectedIndex]);
const pinToBottom = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false });
requestAnimationFrame(() => {
scrollRef.current?.scrollToEnd({ animated: false });
});
}, []);
useEffect(() => {
rowLayoutsRef.current.clear();
scrollOffsetRef.current = 0;
}, [options]);
useEffect(() => {
if (options.length === 0) {
return;
}
pinToBottom();
}, [options, pinToBottom]);
useEffect(() => {
const raf = requestAnimationFrame(ensureActiveItemVisible);
return () => {
cancelAnimationFrame(raf);
};
}, [ensureActiveItemVisible, options.length]);
const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height;
ensureActiveItemVisible();
},
[ensureActiveItemVisible],
const scroller = useScroller(options, selectedIndex);
const rows = useMemo(
() => (scroller.restsAt === "composer" ? options.toReversed() : options),
[options, scroller.restsAt],
);
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}, []);
const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, {
top: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
ensureActiveItemVisible();
},
[ensureActiveItemVisible],
);
const selectedRow =
scroller.restsAt === "composer" && selectedIndex >= 0
? options.length - 1 - selectedIndex
: selectedIndex;
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
const noResultsMessage = t("common.empty.noResults");
@@ -308,28 +417,14 @@ export function Autocomplete({
return (
<View style={containerStyle}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => (
<AutocompleteRow
key={option.id}
index={index}
option={option}
isSelected={index === selectedIndex}
showArgumentHint={!isCompact}
onSelect={onSelect}
onRowLayout={handleRowLayout}
/>
))}
</ScrollView>
<AutocompleteRows
key={scroller.key}
rows={rows}
selectedRow={selectedRow}
restsAt={scroller.restsAt}
maxHeight={maxHeight}
onSelect={onSelect}
/>
</View>
);
}
@@ -350,14 +445,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexGrow: 0,
flexShrink: 1,
},
scrollContent: {
paddingVertical: theme.spacing[1],
listPadding: {
height: theme.spacing[1],
},
item: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 36,
minHeight: ROW_MIN_HEIGHT,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
@@ -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;
}
@@ -45,7 +45,6 @@ export interface RevealActiveItemHandlers {
* content. Measuring on selection/list changes also covers retained rows that
* move or keep their size without emitting another onLayout event on web.
* A list replacement resets to the top only when selection restarts there.
* Bottom-anchored lists share only getScrollOffsetToRevealItem.
*/
export function useRevealActiveItem(options: {
activeIndex: number;
@@ -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;
}
}