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
23 changed files with 578 additions and 350 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,21 +49,8 @@ afterEach(() => {
host.remove();
});
type OlderHistoryInput = Pick<
StreamRenderInput,
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
>;
interface MountTimelineOptions {
olderHistory?: OlderHistoryInput;
isMobileBreakpoint?: boolean;
}
function mountTimeline(
historyMounted: StreamItem[],
{ olderHistory, isMobileBreakpoint = true }: MountTimelineOptions = {},
) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint });
function mountTimeline(historyMounted: StreamItem[]) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const input: StreamRenderInput = {
agentId: "agent",
@@ -89,7 +76,6 @@ function mountTimeline(
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
...olderHistory,
};
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
@@ -114,20 +100,8 @@ function mountTimeline(
scrollTo: (top: number) => {
scrollContainer.scrollTop = top;
},
// A wheel listener runs before the browser applies the scroll the wheel causes.
wheelUpTo: (top: number) => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
scrollContainer.scrollTop = top;
},
distanceFromEnd: () =>
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
scrollTop: () => scrollContainer.scrollTop,
scrollUp: (distance: number) =>
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -distance }));
scrollContainer.scrollTop -= distance;
scrollContainer.dispatchEvent(new Event("scroll"));
}),
// Resize observers and the hold's settle run on animation frames.
settle: () =>
act(async () => {
@@ -139,53 +113,6 @@ function mountTimeline(
}
describe("web disclosure hold", () => {
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
"keeps the reading position when mounted history becomes virtualized (compact: $isMobileBreakpoint)",
async (options) => {
const history = Array.from({ length: 80 }, (_, index) => row(index));
const timeline = mountTimeline(history, options);
await timeline.settle();
timeline.scrollUp(600);
await timeline.settle();
const offset = timeline.scrollTop();
const readingTop = timeline.rowTop("message-64");
expect(offset).toBeGreaterThan(0);
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
});
await timeline.settle();
expect(timeline.scrollTop()).toBe(offset);
expect(timeline.rowTop("message-64")).toBe(readingTop);
},
);
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
"keeps following output when mounted history becomes virtualized at the end (compact: $isMobileBreakpoint)",
async (options) => {
const history = Array.from({ length: 80 }, (_, index) => row(index));
const timeline = mountTimeline(history, options);
await timeline.settle();
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
});
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
liveHead: [row(80)],
});
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
},
);
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
@@ -281,32 +208,3 @@ describe("web disclosure hold", () => {
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
});
});
describe("web history start", () => {
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
let olderHistoryLoads = 0;
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history, {
olderHistory: {
hasOlderHistory: true,
olderHistoryProgressKey: "epoch:1",
onNearHistoryStart: async () => {
olderHistoryLoads += 1;
return true;
},
},
});
await timeline.settle();
// Opening a row at the bottom stops following output without any scroll.
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
);
await timeline.settle();
expect(olderHistoryLoads).toBe(0);
timeline.wheelUpTo(0);
await timeline.settle();
expect(olderHistoryLoads).toBe(1);
});
});
+6 -19
View File
@@ -457,8 +457,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
// History can become virtualized after the reader has already scrolled the mounted rows.
initialOffset: () => scrollContainerRef.current?.scrollTop ?? 0,
initialRect: {
width: scrollContainerRef.current?.clientWidth ?? 0,
height: scrollContainerRef.current?.clientHeight ?? 0,
@@ -607,15 +605,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
}, []);
const hasUpwardInputEvidence = useCallback(() => {
const mouseGesture = mouseScrollGestureRef.current;
return (
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current
);
}, []);
useLayoutEffect(() => {
if (isActive) {
return;
@@ -802,16 +791,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
const mouseGesture = mouseScrollGestureRef.current;
const hasUpwardInputEvidence =
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current;
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
setFollowOutput(true);
} else if (followOutputRef.current && userScrolledUp) {
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
stopFollowingOutputFromUserIntent();
} else if (userScrolledUp) {
// Wheel listeners can run before or after the browser applies the scroll, so the
// upward scroll itself re-arms loading when it reaches the start.
rearmHistoryStartFromUserIntent();
}
lastKnownScrollTopRef.current = currentScrollTop;
@@ -822,10 +811,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}, [
evaluateHistoryStart,
hasUpwardInputEvidence,
isJumpSettling,
readingAnchor,
rearmHistoryStartFromUserIntent,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
@@ -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,
),
};
}
@@ -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;
+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,
@@ -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(),
};
}