mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 12:25:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40147c6805 | ||
|
|
974299450c | ||
|
|
2fa0e4b169 | ||
|
|
d11239fd95 | ||
|
|
d30aa16beb |
+6
-22
@@ -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/` | 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.tsx` | 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,22 +280,6 @@ 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
|
||||
|
||||
@@ -61,6 +61,10 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
|
||||
example because the agent could not be loaded or the requested mode could not be set, leaves no
|
||||
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
|
||||
as an unfinished delivery.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface OlderTimelinePagesGate {
|
||||
getRepeatedEntryCount(): number;
|
||||
getOwnedEntryCountContaining(text: string): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaemonHydrationGate {
|
||||
@@ -371,15 +370,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
const ownedEntries = new Map<string, string>();
|
||||
const releasedPages = new Set<number>();
|
||||
const delayedForwards = new Map<number, Array<() => void>>();
|
||||
const requestWaiters = new Map<number, Array<() => void>>();
|
||||
|
||||
const resolveRequestWaiters = () => {
|
||||
for (const [count, resolvers] of requestWaiters) {
|
||||
if (requestCount < count) continue;
|
||||
requestWaiters.delete(count);
|
||||
for (const resolve of resolvers) resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const routePattern =
|
||||
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
|
||||
@@ -393,7 +383,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
sessionMessage.direction === "before"
|
||||
) {
|
||||
requestCount += 1;
|
||||
resolveRequestWaiters();
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
@@ -439,14 +428,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||
delayedForwards.delete(pageNumber);
|
||||
},
|
||||
waitForRequestCount(count) {
|
||||
if (requestCount >= count) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const resolvers = requestWaiters.get(count) ?? [];
|
||||
resolvers.push(resolve);
|
||||
requestWaiters.set(count, resolvers);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -427,7 +427,11 @@ export async function holdOlderHistoryPages(
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
await expect
|
||||
.poll(() => gate.getRequestCount(), {
|
||||
message: `older history requests ${count} page(s)`,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(count);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
|
||||
@@ -49,8 +49,21 @@ afterEach(() => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
function mountTimeline(historyMounted: StreamItem[]) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
type OlderHistoryInput = Pick<
|
||||
StreamRenderInput,
|
||||
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
|
||||
>;
|
||||
|
||||
interface MountTimelineOptions {
|
||||
olderHistory?: OlderHistoryInput;
|
||||
isMobileBreakpoint?: boolean;
|
||||
}
|
||||
|
||||
function mountTimeline(
|
||||
historyMounted: StreamItem[],
|
||||
{ olderHistory, isMobileBreakpoint = true }: MountTimelineOptions = {},
|
||||
) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const input: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
@@ -76,6 +89,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
...olderHistory,
|
||||
};
|
||||
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
|
||||
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
|
||||
@@ -100,8 +114,20 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
scrollTo: (top: number) => {
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
// A wheel listener runs before the browser applies the scroll the wheel causes.
|
||||
wheelUpTo: (top: number) => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
distanceFromEnd: () =>
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
|
||||
scrollTop: () => scrollContainer.scrollTop,
|
||||
scrollUp: (distance: number) =>
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -distance }));
|
||||
scrollContainer.scrollTop -= distance;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
}),
|
||||
// Resize observers and the hold's settle run on animation frames.
|
||||
settle: () =>
|
||||
act(async () => {
|
||||
@@ -113,6 +139,53 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
}
|
||||
|
||||
describe("web disclosure hold", () => {
|
||||
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
|
||||
"keeps the reading position when mounted history becomes virtualized (compact: $isMobileBreakpoint)",
|
||||
async (options) => {
|
||||
const history = Array.from({ length: 80 }, (_, index) => row(index));
|
||||
const timeline = mountTimeline(history, options);
|
||||
await timeline.settle();
|
||||
timeline.scrollUp(600);
|
||||
await timeline.settle();
|
||||
const offset = timeline.scrollTop();
|
||||
const readingTop = timeline.rowTop("message-64");
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
});
|
||||
await timeline.settle();
|
||||
|
||||
expect(timeline.scrollTop()).toBe(offset);
|
||||
expect(timeline.rowTop("message-64")).toBe(readingTop);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
|
||||
"keeps following output when mounted history becomes virtualized at the end (compact: $isMobileBreakpoint)",
|
||||
async (options) => {
|
||||
const history = Array.from({ length: 80 }, (_, index) => row(index));
|
||||
const timeline = mountTimeline(history, options);
|
||||
await timeline.settle();
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
});
|
||||
await timeline.settle();
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
liveHead: [row(80)],
|
||||
});
|
||||
await timeline.settle();
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
@@ -208,3 +281,32 @@ describe("web disclosure hold", () => {
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web history start", () => {
|
||||
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
|
||||
let olderHistoryLoads = 0;
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history, {
|
||||
olderHistory: {
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch:1",
|
||||
onNearHistoryStart: async () => {
|
||||
olderHistoryLoads += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
await timeline.settle();
|
||||
// Opening a row at the bottom stops following output without any scroll.
|
||||
timeline.hold("message-5", () =>
|
||||
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(olderHistoryLoads).toBe(0);
|
||||
|
||||
timeline.wheelUpTo(0);
|
||||
await timeline.settle();
|
||||
|
||||
expect(olderHistoryLoads).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,6 +457,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
// History can become virtualized after the reader has already scrolled the mounted rows.
|
||||
initialOffset: () => scrollContainerRef.current?.scrollTop ?? 0,
|
||||
initialRect: {
|
||||
width: scrollContainerRef.current?.clientWidth ?? 0,
|
||||
height: scrollContainerRef.current?.clientHeight ?? 0,
|
||||
@@ -605,6 +607,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
|
||||
}, []);
|
||||
|
||||
const hasUpwardInputEvidence = useCallback(() => {
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
return (
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current
|
||||
);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) {
|
||||
return;
|
||||
@@ -791,16 +802,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
const hasUpwardInputEvidence =
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current;
|
||||
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
|
||||
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
setFollowOutput(true);
|
||||
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
|
||||
} else if (followOutputRef.current && userScrolledUp) {
|
||||
stopFollowingOutputFromUserIntent();
|
||||
} else if (userScrolledUp) {
|
||||
// Wheel listeners can run before or after the browser applies the scroll, so the
|
||||
// upward scroll itself re-arms loading when it reaches the start.
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
@@ -811,8 +822,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasUpwardInputEvidence,
|
||||
isJumpSettling,
|
||||
readingAnchor,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
+58
-44
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useAnimatedReaction, useAnimatedRef } from "react-native-reanimated";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
@@ -19,10 +21,18 @@ import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift";
|
||||
import { PopoverFrame } from "./internal/frame";
|
||||
import type { AnchorAtRest } from "./internal/placement";
|
||||
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;
|
||||
}
|
||||
|
||||
interface AutocompletePopoverProps {
|
||||
visible: boolean;
|
||||
@@ -45,16 +55,8 @@ export function AutocompletePopover({
|
||||
}: AutocompletePopoverProps): ReactElement | null {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
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 anchorRef = useRef<View>(null);
|
||||
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
@@ -62,9 +64,7 @@ export function AutocompletePopover({
|
||||
const anchor = useKeyboardAnchor();
|
||||
const { measureAtRest } = anchor;
|
||||
const measurementGeneration = useRef(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 canMeasure = visible && (options.length === 0 || selectedIndex >= 0);
|
||||
|
||||
const remeasure = useCallback(() => {
|
||||
if (!canMeasure) return;
|
||||
@@ -78,11 +78,10 @@ export function AutocompletePopover({
|
||||
if (generation !== measurementGeneration.current || !anchorRect || !hostRect) {
|
||||
return undefined;
|
||||
}
|
||||
setAnchorAtRest({
|
||||
setRelativeAnchorRect({
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
height: anchorRect.height,
|
||||
hostHeight: hostRect.height,
|
||||
});
|
||||
return undefined;
|
||||
@@ -92,7 +91,7 @@ export function AutocompletePopover({
|
||||
useEffect(() => {
|
||||
measurementGeneration.current += 1;
|
||||
if (!canMeasure) {
|
||||
setAnchorAtRest(null);
|
||||
setRelativeAnchorRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,32 +114,47 @@ export function AutocompletePopover({
|
||||
[isMoving, remeasure],
|
||||
);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
return (
|
||||
<View ref={setAnchorElement} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
{children}
|
||||
{canMeasure && anchorAtRest ? (
|
||||
{showPopover ? (
|
||||
<Portal hostName={portalHostName}>
|
||||
<KeyboardTranslateView
|
||||
{...anchor.translation}
|
||||
style={StyleSheet.absoluteFill}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<PopoverFrame
|
||||
anchorRef={anchorRef}
|
||||
anchorAtRest={anchorAtRest}
|
||||
reservedLift={anchor.reservedLift}
|
||||
topInset={safeAreaInsets.top}
|
||||
>
|
||||
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
status={status}
|
||||
/>
|
||||
</PopoverFrame>
|
||||
</Animated.View>
|
||||
</KeyboardTranslateView>
|
||||
</Portal>
|
||||
) : null}
|
||||
@@ -1,68 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -83,7 +83,6 @@ 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;
|
||||
@@ -101,7 +100,6 @@ 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;
|
||||
|
||||
@@ -35,26 +35,6 @@ 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",
|
||||
@@ -207,9 +187,6 @@ 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",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProviderInputSchema, type ProviderInput } from "./provider.js";
|
||||
|
||||
type ReviewMode = "uncommitted" | "base" | "all";
|
||||
type Delivery = "auto" | "steer";
|
||||
|
||||
const contextLine = {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 12,
|
||||
type: "add",
|
||||
content: "const value = 1;",
|
||||
} as const;
|
||||
|
||||
function reviewPrompt(mode: ReviewMode, delivery: Delivery): ProviderInput {
|
||||
return {
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "message-1",
|
||||
delivery,
|
||||
input: {
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/workspace",
|
||||
mode,
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 12,
|
||||
body: "Please review this change.",
|
||||
context: {
|
||||
hunkHeader: "@@ -10,2 +10,3 @@",
|
||||
targetLine: contextLine,
|
||||
lines: [{ ...contextLine, type: "context", oldLineNumber: 11 }, contextLine],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Provider review attachments", () => {
|
||||
it.each(["uncommitted", "base", "all"] as const)(
|
||||
"preserves mode %s on an ordinary prompt",
|
||||
(mode) => {
|
||||
const input = reviewPrompt(mode, "auto");
|
||||
expect(ProviderInputSchema.parse(input)).toEqual(input);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["uncommitted", "base", "all"] as const)(
|
||||
"preserves mode %s on a steering prompt",
|
||||
(mode) => {
|
||||
const input = reviewPrompt(mode, "steer");
|
||||
expect(ProviderInputSchema.parse(input)).toEqual(input);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -213,7 +213,7 @@ interface ProviderReviewAttachment {
|
||||
type: "review";
|
||||
mimeType: "application/paseo-review";
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
mode: "uncommitted" | "base" | "all";
|
||||
baseRef?: string | null;
|
||||
comments: Array<{
|
||||
filePath: string;
|
||||
@@ -881,7 +881,7 @@ const providerContentSchema: z.ZodType<ProviderContent> = z.union([
|
||||
type: z.literal("review"),
|
||||
mimeType: z.literal("application/paseo-review"),
|
||||
cwd: z.string(),
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
mode: z.enum(["uncommitted", "base", "all"]),
|
||||
baseRef: z.string().nullable().optional(),
|
||||
comments: z.array(
|
||||
z
|
||||
|
||||
@@ -210,6 +210,8 @@ export async function unarchiveAgentState(
|
||||
}
|
||||
|
||||
export interface SendPromptToAgentParams {
|
||||
/** Awaited after the agent is loaded and its mode is set, before the prompt is handed to a run. */
|
||||
beforeDispatch?: () => Promise<void>;
|
||||
onDispatch?: StartAgentRunOptions["onDispatch"];
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
@@ -357,6 +359,7 @@ export async function sendPreparedPromptToAgent(
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
}
|
||||
|
||||
await params.beforeDispatch?.();
|
||||
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
|
||||
replaceRunning: true,
|
||||
onDispatch: params.onDispatch,
|
||||
|
||||
@@ -182,6 +182,46 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts
|
||||
).toMatchObject({ payload: { accepted: false } });
|
||||
});
|
||||
|
||||
test("Hub retries a message whose send failed before reaching the provider", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const agentId = await hub.createUnrelatedLocalAgent();
|
||||
const message = {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: "mode-change-failed",
|
||||
text: "retry me",
|
||||
sessionMode: "full-access",
|
||||
};
|
||||
hub.failNextProviderModeChange();
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "first-attempt" })).toMatchObject({
|
||||
payload: { accepted: false, error: expect.stringContaining("mode change failure") },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(0);
|
||||
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
|
||||
payload: { accepted: true, error: null },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Hub never resubmits a message whose send failed after reaching the provider", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const agentId = await hub.createUnrelatedLocalAgent();
|
||||
const message = {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: "turn-start-failed",
|
||||
text: "do not repeat me",
|
||||
};
|
||||
hub.failProviderPromptStart("do not repeat me");
|
||||
await hub.requestOrdinary({ ...message, requestId: "first-attempt" });
|
||||
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
|
||||
payload: { accepted: false, error: "agent_request_outcome_unknown" },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "do not repeat me")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("ordinary Hub requests survive daemon restart and restore an archived workspace", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const create = {
|
||||
|
||||
@@ -458,6 +458,7 @@ export class HubRelationshipHarness {
|
||||
private readonly claimedCliSockets = new Set<WebSocket>();
|
||||
private readonly promptsToFail = new Set<string>();
|
||||
private failNextSessionClose = false;
|
||||
private failNextModeChange = false;
|
||||
private observedEnrollments = 0;
|
||||
private observedSockets = 0;
|
||||
private readonly codex: ControlledAgentClient;
|
||||
@@ -474,6 +475,11 @@ export class HubRelationshipHarness {
|
||||
this.failNextSessionClose = false;
|
||||
throw new Error("Requested provider session close failure");
|
||||
},
|
||||
beforeSetMode: async () => {
|
||||
if (!this.failNextModeChange) return;
|
||||
this.failNextModeChange = false;
|
||||
throw new Error("Requested provider mode change failure");
|
||||
},
|
||||
onStartTurn: (prompt) => {
|
||||
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
|
||||
if (this.promptsToFail.delete(promptText)) {
|
||||
@@ -674,6 +680,10 @@ export class HubRelationshipHarness {
|
||||
this.promptsToFail.add(prompt);
|
||||
}
|
||||
|
||||
failNextProviderModeChange(): void {
|
||||
this.failNextModeChange = true;
|
||||
}
|
||||
|
||||
failNextProviderSessionClose(): void {
|
||||
this.failNextSessionClose = true;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ test("message retries survive reconstruction without submitting twice", async ()
|
||||
agentId: "agent",
|
||||
messageId: "arrival",
|
||||
request: { text: "hello" },
|
||||
send: async () => {
|
||||
send: async (beforeDispatch: () => Promise<void>) => {
|
||||
await beforeDispatch();
|
||||
deliveries++;
|
||||
},
|
||||
};
|
||||
@@ -41,7 +42,8 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
|
||||
agentId: "agent",
|
||||
messageId: "arrival",
|
||||
request: {},
|
||||
send: async () => {
|
||||
send: async (beforeDispatch: () => Promise<void>) => {
|
||||
await beforeDispatch();
|
||||
deliveries++;
|
||||
throw new Error("connection lost");
|
||||
},
|
||||
@@ -53,7 +55,7 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
|
||||
expect(deliveries).toBe(1);
|
||||
});
|
||||
|
||||
test("failed local message preparation does not leave an ambiguous receipt", async () => {
|
||||
test("a send that fails before beforeDispatch can be retried with the same message", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let available = false;
|
||||
let sends = 0;
|
||||
@@ -61,14 +63,13 @@ test("failed local message preparation does not leave an ambiguous receipt", asy
|
||||
agentId: "agent",
|
||||
messageId: "message",
|
||||
request: {},
|
||||
prepare: async () => {
|
||||
if (!available) throw new Error("load failed");
|
||||
},
|
||||
send: async () => {
|
||||
send: async (beforeDispatch: () => Promise<void>) => {
|
||||
if (!available) throw new Error("mode change failed");
|
||||
await beforeDispatch();
|
||||
sends++;
|
||||
},
|
||||
};
|
||||
await expect(requests.send(input)).rejects.toThrow("load failed");
|
||||
await expect(requests.send(input)).rejects.toThrow("mode change failed");
|
||||
available = true;
|
||||
await new MessageReceipts(directory).send(input);
|
||||
available = false;
|
||||
|
||||
@@ -13,8 +13,11 @@ interface SendMessageInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
request: unknown;
|
||||
send: () => Promise<void>;
|
||||
prepare?: () => Promise<void>;
|
||||
/**
|
||||
* Delivers the message. It awaits `beforeDispatch` immediately before the provider can receive
|
||||
* the message; a failure before that call proves nothing was submitted and leaves no receipt.
|
||||
*/
|
||||
send: (beforeDispatch: () => Promise<void>) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Owns message delivery receipts; creation is owned by CreationService. */
|
||||
@@ -48,10 +51,12 @@ export class MessageReceipts {
|
||||
// A provider may have accepted the message before its receipt was committed.
|
||||
throw new Error("agent_request_outcome_unknown");
|
||||
}
|
||||
await input.prepare?.();
|
||||
const receipt = { fingerprint, agentId: input.agentId };
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "pending" });
|
||||
await input.send();
|
||||
let pending: Promise<void> | null = null;
|
||||
await input.send(() => {
|
||||
pending ??= writeJsonFileAtomic(file, { ...receipt, state: "pending" });
|
||||
return pending;
|
||||
});
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8918,8 +8918,9 @@ export class Session {
|
||||
queued = acceptAdmission;
|
||||
});
|
||||
let deliveryStatus: "queued" | undefined;
|
||||
const send = async () => {
|
||||
const send = async (beforeDispatch: () => Promise<void>) => {
|
||||
if (msg.sourceAgentId) {
|
||||
await beforeDispatch();
|
||||
const receipt = this.agentMessages.enqueue({
|
||||
recipientId: agentId,
|
||||
messageId: msg.messageId,
|
||||
@@ -8950,6 +8951,7 @@ export class Session {
|
||||
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
|
||||
sessionMode: msg.sessionMode,
|
||||
clearPendingPermissions: true,
|
||||
beforeDispatch,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
if (result.disposition === "turn_started") {
|
||||
@@ -8972,12 +8974,12 @@ export class Session {
|
||||
activeTurnBehavior:
|
||||
msg.activeTurnBehavior ?? (msg.sourceAgentId ? "steer" : "interrupt"),
|
||||
},
|
||||
prepare: async () => {
|
||||
send: async (beforeDispatch) => {
|
||||
if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text);
|
||||
await send(beforeDispatch);
|
||||
},
|
||||
send,
|
||||
})
|
||||
: send();
|
||||
: send(async () => {});
|
||||
void sent.catch((error) => {
|
||||
if (deliveryStatus === "queued") {
|
||||
this.handleAgentRunError(agentId, error, "Failed to dispatch queued agent message");
|
||||
|
||||
@@ -84,6 +84,7 @@ interface FakeAgentSessionOptions {
|
||||
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
|
||||
steerActiveTurn?: AgentSession["steerActiveTurn"];
|
||||
onInterrupt?: () => void;
|
||||
beforeSetMode?: (modeId: string) => Promise<void>;
|
||||
historyEvents?: readonly AgentStreamEvent[];
|
||||
}
|
||||
|
||||
@@ -95,6 +96,7 @@ export interface TestAgentClientOptions {
|
||||
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
|
||||
steerActiveTurn?: AgentSession["steerActiveTurn"];
|
||||
onInterrupt?: () => void;
|
||||
beforeSetMode?: (modeId: string) => Promise<void>;
|
||||
supportsMcpServers?: boolean;
|
||||
historyEvents?: readonly AgentStreamEvent[];
|
||||
}
|
||||
@@ -369,6 +371,7 @@ class FakeAgentSession implements AgentSession {
|
||||
|
||||
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
|
||||
private readonly onInterrupt: (() => void) | undefined;
|
||||
private readonly beforeSetMode: ((modeId: string) => Promise<void>) | undefined;
|
||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
|
||||
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
|
||||
@@ -385,6 +388,7 @@ class FakeAgentSession implements AgentSession {
|
||||
this.memoryMarker = options.memoryMarker ?? null;
|
||||
this.steerActiveTurn = options.steerActiveTurn;
|
||||
this.onInterrupt = options.onInterrupt;
|
||||
this.beforeSetMode = options.beforeSetMode;
|
||||
this.closeSession = options.closeSession;
|
||||
this.onStartTurn = options.onStartTurn;
|
||||
this.beforeTurnComplete = options.beforeTurnComplete;
|
||||
@@ -891,6 +895,7 @@ class FakeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
await this.beforeSetMode?.(modeId);
|
||||
this.config.modeId = modeId;
|
||||
}
|
||||
|
||||
@@ -1264,6 +1269,7 @@ class FakeAgentClient implements AgentClient {
|
||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||
steerActiveTurn: this.options.steerActiveTurn,
|
||||
onInterrupt: this.options.onInterrupt,
|
||||
beforeSetMode: this.options.beforeSetMode,
|
||||
historyEvents: this.options.historyEvents,
|
||||
});
|
||||
}
|
||||
@@ -1294,6 +1300,7 @@ class FakeAgentClient implements AgentClient {
|
||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||
steerActiveTurn: this.options.steerActiveTurn,
|
||||
onInterrupt: this.options.onInterrupt,
|
||||
beforeSetMode: this.options.beforeSetMode,
|
||||
historyEvents: this.options.historyEvents,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ export function createProviderSnapshotManagerStub(): {
|
||||
|
||||
export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] {
|
||||
return {
|
||||
send: (input) => input.send(),
|
||||
send: (input) => input.send(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user