Compare commits

..
Author SHA1 Message Date
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
34 changed files with 692 additions and 1013 deletions
+22 -6
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
@@ -280,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
-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
@@ -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) {
@@ -49,12 +49,7 @@ afterEach(() => {
host.remove();
});
type OlderHistoryInput = Pick<
StreamRenderInput,
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
>;
function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistoryInput) {
function mountTimeline(historyMounted: StreamItem[]) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const input: StreamRenderInput = {
@@ -81,7 +76,6 @@ function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistory
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 } }));
@@ -106,11 +100,6 @@ function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistory
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,
// Resize observers and the hold's settle run on animation frames.
@@ -219,30 +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, {
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 -17
View File
@@ -605,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;
@@ -800,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;
@@ -820,10 +811,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}, [
evaluateHistoryStart,
hasUpwardInputEvidence,
isJumpSettling,
readingAnchor,
rearmHistoryStartFromUserIntent,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
@@ -0,0 +1,262 @@
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;
}
function Screen({
visible,
panelActive = true,
inputHeight = 120,
composerWidth = 358,
}: ScreenProps): ReactNode {
return (
<Providers>
<RetainedPanel active={panelActive}>
<View style={SCREEN_STYLE}>
<View style={composerStyle(composerWidth)}>
<AutocompletePopover
visible={visible}
options={OPTIONS}
selectedIndex={OPTIONS.length - 1}
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 });
});
@@ -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,
),
};
}
@@ -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,
},
}));
@@ -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);
});
@@ -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.