Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 7f3967d288 test(server): keep the forge parent retry test from fetching over the network (#9658)
Observing the workspace starts a background git fetch of origin. The test's
origin is an SSH alias, so a real fetch runs ssh in the checkout and can still
be running after the service is disposed. On Windows that holds the folder
open and cleanup fails with EBUSY. The test now supplies a fetch that returns
no ref changes; the parent lookup and status poll it checks are unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-14 12:23:33 +00:00
paseo-bot[bot]andquickwind ade902f195 Existing OpenCode sessions rebind after a daemon restart when OpenCode runs on Bun (#9653)
* fix(opencode): rebind a session when the previous bridge stops listening

A refused fetch proves the previous bridge is gone, but the check only
matched the Node shape (`cause.code`) and Bun's older spelling
(`code === "ConnectionRefused"`). OpenCode 2.2.5 runs on Bun 1.4.4,
which throws a TypeError with `code === "ECONNREFUSED"` and no
`cause`, so the error was rethrown, the stale binding was never
deleted, and every later bind for that session answered HTTP 500
(#8780).

Accept the code from either location. The narrow refusal check stays:
other connection failures remain inconclusive so two daemons cannot
take over the same session.

The probe is now classified by an exported predicate with the runtime
shapes as explicit cases, and the stale-binding test closes a real
listener instead of stubbing the network, so neither test patches a
global.

* test(opencode): bind the session on the restarted bridge before rebinding

The bridge now serves tools only for sessions it has bound, so the stale-binding
test registers the session on the restarted bridge, as a restarted daemon does
when it takes the agent back.

Co-authored-by: quickwind <wallacewei@live.cn>

---------

Co-authored-by: quickwind <wallacewei@live.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-14 11:59:29 +00:00
Mohamed Boudra 1bd94ac2b0 Collapsing Activity in the PR pane gives the checks the space, and the separator above it resizes (#9656)
* fix(app): let the PR pane's Activity section collapse and resize

The PR pane was one ScrollView. Checks sat in a nested list capped at
268px, and the line above Activity was a plain 1px View. Collapsing
Activity only hid its body, so the checks stayed boxed with empty space
below, and nothing could be dragged.

The pane now splits like the Changes pane's docked Commits section. The
overview (header and checks) scrolls in its own region. While Activity is
open, a ResizeHandle between the two sets the overview's share. While it
is collapsed, the Activity row docks to the bottom and the overview fills
the rest.

* fix(app): keep the PR pane separator under the finger above the home indicator

The split measured the pane including its bottom safe-area padding, but
the overview's percentage height resolves against the box inside that
padding. On a phone with a home indicator the two heights differ, so a
drag jumped at the start and then lagged the finger (a 250px drag moved
the separator 212px on Android). The split is now measured inside the
padding, so the drag and the layout use the same height.

* fix(app): keep focus on the PR pane's Activity header when it toggles

The header moved between two parents as Activity opened and closed, so
React remounted the row and keyboard focus fell to the body. It now keeps
one slot in the split and only the timeline mounts. That also stops the
header shrinking into the space left by a large drag when the pane gets
short, since the header no longer sits in a min-height-0 wrapper.

The header exposes aria-expanded on web, which React Native Web does not
derive from accessibilityState. The Unistyles test stub now lets explicit
props override mapped ones, matching the library's merge order.

* test(app): fill unpinned test-theme tokens from the light theme

The Unistyles stub now runs uniProps mappings, and the markdown renderer's
mapping reads colors.terminal, which the hand-written test theme lacked.
Tokens a test does not pin now come from the real light theme.

* fix(app): save the PR pane separator where it is drawn

Dragging past the end of short checks left the separator at the content's
bottom but saved the pointer's share, so the separator jumped down when
more checks arrived. The saved share is now capped at the overview's
content height.

* fix(app): keep the PR pane's earlier share after a drag past short checks

Capping the saved share at the content height made any downward drag over
short checks pin the overview at that height, even a drag that visibly did
nothing. A drag that ends at or past the content's bottom now keeps the
larger of the current share and the content's, so growing checks return to
the share the overview had, and never to the pointer's position.

* fix(app): decide the PR pane's saved share when a drag ends

Drag previews wrote straight into the saved share, so a move above the end
of short checks during a drag replaced the share the overview had before
it, and a drag that ended back at the content's bottom left the overview
pinned there. Previews now live in their own drag state, and the saved
share is resolved once, on commit, against the share from before the drag.
2026-11-14 18:03:46 +07:00
hankyoandClaude Opus 5.5 7deb338d4c fix(opencode): re-probe a v1 runtime when the provider is refreshed (#9593)
Updating OpenCode from 1.x to 2.x while the daemon runs left it on the v1
client: the version probe result was cached for the client's lifetime and the
binary update does not change provider config, so the client was never rebuilt.
Once the old v1 server exited, every request failed with "Request is not
supported by this version of OpenCode Server (Server responded with
text/html)". Refresh and Reload did not recover; only a daemon restart did.

A forced catalog refresh now drops a cached v1 selection so the next call
re-probes the binary. A v2 selection owns live sessions and stays. A guard keeps
an overlapping forced refresh from discarding the selection the first one made.

Fixes #9583

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 11:00:45 +00:00
paseo-bot[bot]andClaude Opus 5.5 2fa0e4b169 fix(app): load older history when an upward wheel lands at the start (#9570)
A wheel listener can run before or after the browser applies the scroll it
causes. When the reader had already stopped following output and the last
notch landed exactly at the history start, the listener saw the pre-scroll
position, did not re-arm history-start pagination, and no older page was
requested. The upward scroll now re-arms loading itself.

The turn-collapse journey hung on this race until its 120s timeout; the
older-history page wait now fails with a message after the expect timeout.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 09:32:44 +00:00
ese111 d11239fd95 fix(plugin): accept all review attachment mode (#9590) 2026-11-14 09:04:58 +00:00
d30aa16beb Retrying a message that failed before reaching the provider delivers it (#9580)
* fix(server): let a message rejected before dispatch be retried

A send_agent_message_request with a messageId wrote its pending receipt
before loading the agent, setting its mode, and starting the run. When one
of those steps failed, nothing reached the provider, yet every retry with
the same messageId reported agent_request_outcome_unknown.

The pending receipt is now written at the dispatch boundary: the send
awaits beforeDispatch after the agent is loaded and its mode is set, just
before the prompt is handed to a run. A failure before that point leaves
no receipt, so the retry submits the message once. Failures after it stay
ambiguous, as before.

Closes #9297

Co-authored-by: tmad4000 <1430007+tmad4000@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(server): lock that a send failing after dispatch is never resubmitted

Scope the docs/hub.md retry sentence to direct sends: agent-sourced sends
still write their receipt before queueing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(hub): scope the retry promise to failures before the run starts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(hub): name run startup as the ambiguous boundary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: tmad4000 <1430007+tmad4000@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 08:10:41 +00:00
30 changed files with 967 additions and 385 deletions
+4
View File
@@ -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,7 +49,12 @@ afterEach(() => {
host.remove();
});
function mountTimeline(historyMounted: StreamItem[]) {
type OlderHistoryInput = Pick<
StreamRenderInput,
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
>;
function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistoryInput) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const input: StreamRenderInput = {
@@ -76,6 +81,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,6 +106,11 @@ 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,
// Resize observers and the hold's settle run on animation frames.
@@ -208,3 +219,30 @@ describe("web disclosure hold", () => {
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
});
});
describe("web history start", () => {
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
let olderHistoryLoads = 0;
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history, {
hasOlderHistory: true,
olderHistoryProgressKey: "epoch:1",
onNearHistoryStart: async () => {
olderHistoryLoads += 1;
return true;
},
});
await timeline.settle();
// Opening a row at the bottom stops following output without any scroll.
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
);
await timeline.settle();
expect(olderHistoryLoads).toBe(0);
timeline.wheelUpTo(0);
await timeline.settle();
expect(olderHistoryLoads).toBe(1);
});
});
+17 -6
View File
@@ -605,6 +605,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 +800,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 +820,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}, [
evaluateHistoryStart,
hasUpwardInputEvidence,
isJumpSettling,
readingAnchor,
rearmHistoryStartFromUserIntent,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
@@ -1,152 +0,0 @@
import React, { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { PortalProvider } from "@gorhom/portal";
import { View } from "react-native";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import type { AutocompleteOption } from "./autocomplete";
import { AutocompletePopover } from "./autocomplete-popover";
import { FloatingPanelPortalHost } from "./floating-panel-portal";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* The popover's height bound is a Reanimated style. Reanimated computes a style's first value
* when its hook first runs and sends later values from an effect, after the commit that mounts
* the view. Native paints that commit: a bound computed while the popover was closed painted the
* popover at its minimum height, hanging down over the composer, for a frame on every open.
* Web runs the effect before paint, so the wrong value only shows as the first style the DOM
* receives.
*/
const SAFE_AREA = {
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
};
const COMPOSER_TOP = 600;
const OPTIONS: AutocompleteOption[] = ["help", "hello", "history"].map((name) => ({
id: name,
label: `/${name}`,
icon: "command",
}));
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
const SCREEN_STYLE = { width: 390, height: 844 };
const COMPOSER_STYLE = { position: "absolute" as const, top: COMPOSER_TOP, left: 16, width: 358 };
const INPUT_STYLE = { height: 120 };
function ignoreSelection(): void {}
function Providers({ children }: { children: ReactNode }): ReactNode {
return (
<SafeAreaProvider initialMetrics={SAFE_AREA}>
<KeyboardProvider>
<KeyboardShiftProvider>
<PortalProvider>{children}</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
);
}
function Screen({ visible }: { visible: boolean }): ReactNode {
return (
<Providers>
<View style={SCREEN_STYLE}>
<View style={COMPOSER_STYLE}>
<AutocompletePopover
visible={visible}
options={OPTIONS}
selectedIndex={OPTIONS.length - 1}
onSelect={ignoreSelection}
>
<View style={INPUT_STYLE} />
</AutocompletePopover>
</View>
<FloatingPanelPortalHost />
</View>
</Providers>
);
}
function render(node: ReactNode): void {
if (!container) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
}
act(() => root?.render(node));
}
/** Every max-height the popover's frame has held, starting with the one it mounted with. */
function recordPopoverMaxHeights(): { values: string[]; stop: () => void } {
const values: string[] = [];
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "childList") {
for (const node of record.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const popover = node.matches('[data-testid="composer-autocomplete-popover"]')
? node
: node.querySelector<HTMLElement>('[data-testid="composer-autocomplete-popover"]');
// The attribute records below carry what the style was before each change; this
// carries the style at insertion when nothing changed it afterwards.
if (popover && values.length === 0) values.push(popover.style.maxHeight);
}
}
if (
record.type === "attributes" &&
record.target instanceof HTMLElement &&
record.target.dataset.testid === "composer-autocomplete-popover"
) {
const before = /max-height:\s*([^;]+)/.exec(record.oldValue ?? "")?.[1]?.trim() ?? "";
if (values.length <= 1) values.splice(0, values.length, before);
values.push(record.target.style.maxHeight);
}
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["style"],
attributeOldValue: true,
});
return { values, stop: () => observer.disconnect() };
}
async function waitForPopover(): Promise<HTMLElement> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const popover = document.querySelector<HTMLElement>(
'[data-testid="composer-autocomplete-popover"]',
);
if (popover) return popover;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("The popover did not open");
}
it("opens with its height bound instead of the bound it had while closed", async () => {
// The composer mounts first and opens the popover later, as typing `/` does.
render(<Screen visible={false} />);
const recording = recordPopoverMaxHeights();
render(<Screen visible />);
const popover = await waitForPopover();
await new Promise((resolve) => setTimeout(resolve, 200));
recording.stop();
const settled = popover.style.maxHeight;
expect(Number.parseFloat(settled)).toBeGreaterThan(0);
expect(recording.values[0]).toBe(settled);
});
@@ -9,11 +9,7 @@ import {
} from "react";
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
import { Portal } from "@gorhom/portal";
import Animated, {
useAnimatedReaction,
useAnimatedStyle,
type DerivedValue,
} 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 {
@@ -130,62 +126,38 @@ export function AutocompletePopover({
});
}, [relativeAnchorRect]);
const anchorY = relativeAnchorRect?.y ?? 0;
const reservedLift = anchor.reservedLift;
const heightStyle = useAnimatedStyle(
() => ({
maxHeight: Math.max(
0,
anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2,
),
}),
[anchorY, reservedLift, safeAreaInsets.top],
);
const showPopover = canMeasure && relativeAnchorRect && baseStyle;
// The translate view is the popover's own frame, so it lifts only the popover.
return (
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
{children}
{canMeasure && relativeAnchorRect && baseStyle ? (
{showPopover ? (
<Portal hostName={portalHostName}>
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
<PopoverFrame
anchorY={relativeAnchorRect.y}
reservedLift={anchor.reservedLift}
topInset={safeAreaInsets.top}
>
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
<Autocomplete
options={options}
selectedIndex={selectedIndex}
onSelect={onSelect}
status={status}
/>
</PopoverFrame>
</Animated.View>
</KeyboardTranslateView>
</Portal>
) : null}
</View>
);
}
interface PopoverFrameProps {
anchorY: number;
reservedLift: DerivedValue<number>;
topInset: number;
children: ReactNode;
}
/**
* Bounds the popover's height to the space above the anchor. It mounts only with a measured
* anchor: Reanimated computes the first style when the hook first runs and sends later values
* after the mounting commit, which native paints. A bound computed before the measurement painted
* the popover at its minimum height, hanging over the composer, for a frame on every open.
*/
function PopoverFrame({
anchorY,
reservedLift,
topInset,
children,
}: PopoverFrameProps): ReactElement {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const heightStyle = useAnimatedStyle(
() => ({
maxHeight: Math.max(0, anchorY - reservedLift.value - topInset - OFFSET_FROM_ANCHOR * 2),
}),
[anchorY, reservedLift, topInset],
);
return (
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
{children}
</Animated.View>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
@@ -18,12 +18,6 @@ import { useCheckGroupState } from "./check-group-state";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
/**
* Roughly eight rows. A repo with thirty checks would otherwise push the activity
* timeline off the pane entirely, so past this height the list scrolls in place.
*/
const LIST_MAX_HEIGHT = 268;
/**
* The three statuses the pane has always labelled for tests. Skipped has no id because
* nothing asserts on it.
@@ -112,11 +106,7 @@ export function ChecksSection({
</Pressable>
{open && summary.groups.length > 0 ? (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
nestedScrollEnabled
>
<View style={styles.list}>
{summary.groups.map((group) => (
<CheckGroup
key={group.status}
@@ -128,7 +118,7 @@ export function ChecksSection({
onAddLogsToChat={onAddLogsToChat}
/>
))}
</ScrollView>
</View>
) : null}
</View>
);
@@ -263,9 +253,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
list: {
maxHeight: LIST_MAX_HEIGHT,
},
listContent: {
paddingBottom: theme.spacing[2],
},
groupHeader: {
@@ -0,0 +1,231 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/i18n/i18next";
import { ToastApiProvider } from "@/contexts/toast-context";
import type { ToastApi } from "@/components/toast-host";
import type { PrPaneCheck, PrPaneData } from "./data";
import { PullRequestPane } from "./pane";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
const PANE_HEIGHT = 600;
const silentToasts: ToastApi = {
show: () => () => {},
copied: () => {},
error: () => {},
};
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function successfulChecks(count: number): PrPaneCheck[] {
return Array.from({ length: count }, (_, index) => ({
provider: "github",
name: `check-${index + 1}`,
status: "success",
url: `https://example.test/checks/${index + 1}`,
}));
}
function pullRequest(checks: PrPaneCheck[]): PrPaneData {
return {
provider: { id: "github", label: "GitHub" },
forge: "github",
number: 42,
repoOwner: "acme",
repoName: "widgets",
title: "Ship the widget",
state: "open",
url: "https://example.test/pull/42",
reviewDecision: "pending",
awaitingReviewers: [],
checks,
activity: [],
};
}
/** A phone's home indicator: the pane clears it with bottom padding. */
const HOME_INDICATOR_INSETS = { top: 0, right: 0, bottom: 34, left: 0 };
const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
function renderPane(data: PrPaneData, insets = NO_INSETS) {
act(() =>
root?.render(
<SafeAreaInsetsContext.Provider value={insets}>
<ToastApiProvider api={silentToasts}>
<PullRequestPane serverId="server-1" cwd="/repo" data={data} activityLoading={false} />
</ToastApiProvider>
</SafeAreaInsetsContext.Provider>,
),
);
}
function mountPane(data: PrPaneData, insets = NO_INSETS) {
container = document.createElement("div");
container.style.height = `${PANE_HEIGHT}px`;
container.style.width = "600px";
container.style.display = "flex";
container.style.flexDirection = "column";
document.body.appendChild(container);
root = createRoot(container);
renderPane(data, insets);
const pane = container.querySelector<HTMLElement>('[data-testid="pr-pane"]');
if (!pane) throw new Error("The pull request pane did not render");
return pane;
}
/** The pressable row holding the Activity title. */
function activityHeader(pane: HTMLElement): HTMLElement {
const title = within(pane).getByText(i18n.t("workspace.git.pr.sections.activity"));
const header = title.closest<HTMLElement>("[tabindex]");
if (!header) throw new Error("The Activity title is not inside a pressable header");
return header;
}
function checksRegionBottom(pane: HTMLElement): number {
return within(pane).getByRole("separator").getBoundingClientRect().top;
}
/** Whether a user sees the element: no scroll container clips it out of view. */
function isOnScreen(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + 8, rect.top + rect.height / 2);
return hit !== null && element.contains(hit);
}
/** React Native Web reports layout from a ResizeObserver, a frame or two after mounting. */
async function settleLayout() {
for (let frame = 0; frame < 2; frame++) {
await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
}
}
/** Drags the separator through each offset from where it starts, releasing at the last one. */
function dragSeparator(pane: HTMLElement, ...deltasY: number[]) {
const separator = within(pane).getByRole("separator");
const rect = separator.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const startY = rect.top + rect.height / 2;
const pointer = { pointerId: 1, bubbles: true, cancelable: true, clientX: x, button: 0 };
act(() => {
separator.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientY: startY }));
});
for (const deltaY of deltasY) {
act(() => {
window.dispatchEvent(
new PointerEvent("pointermove", { ...pointer, clientY: startY + deltaY }),
);
});
}
const endY = startY + (deltasY.at(-1) ?? 0);
act(() => {
window.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientY: endY }));
});
}
describe("pull request pane layout", () => {
it("docks a collapsed Activity section to the bottom and gives the checks the pane", async () => {
const pane = mountPane(pullRequest(successfulChecks(10)));
act(() => activityHeader(pane).click());
await expect
.poll(() => Math.round(activityHeader(pane).getBoundingClientRect().bottom))
.toBe(Math.round(pane.getBoundingClientRect().bottom));
const rows = within(pane).getAllByTestId("pr-pane-check-row");
expect(rows).toHaveLength(10);
expect(rows.every(isOnScreen)).toBe(true);
});
it("keeps keyboard focus on the Activity header while it collapses and expands", () => {
const pane = mountPane(pullRequest(successfulChecks(3)));
const header = activityHeader(pane);
act(() => header.focus());
act(() => header.click());
expect(document.activeElement).toBe(header);
act(() => header.click());
expect(document.activeElement).toBe(header);
});
it("exposes whether Activity is expanded to assistive technology", () => {
const pane = mountPane(pullRequest(successfulChecks(3)));
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("true");
act(() => activityHeader(pane).click());
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("false");
});
it("keeps the Activity header inside the pane when a dragged pane gets short", async () => {
const pane = mountPane(pullRequest(successfulChecks(30)));
await settleLayout();
dragSeparator(pane, PANE_HEIGHT);
await settleLayout();
act(() => {
if (container) container.style.height = "160px";
});
await settleLayout();
await expect
.poll(() => activityHeader(pane).getBoundingClientRect().bottom)
.toBeLessThanOrEqual(pane.getBoundingClientRect().bottom);
expect(isOnScreen(activityHeader(pane))).toBe(true);
});
it.each([
["5px down", [5]],
["400px down", [400]],
["up past their end and back down", [-20, 100]],
])("gives the checks their earlier share when they grow after a drag %s", async (_name, path) => {
const pane = mountPane(pullRequest(successfulChecks(2)));
await settleLayout();
const drawn = checksRegionBottom(pane);
dragSeparator(pane, ...path);
await settleLayout();
expect(checksRegionBottom(pane)).toBeCloseTo(drawn, 0);
renderPane(pullRequest(successfulChecks(30)));
await settleLayout();
const overview = within(pane).getByTestId("pr-pane-overview");
expect(overview.getBoundingClientRect().height).toBeCloseTo(PANE_HEIGHT / 2, 0);
});
it("resizes the checks against Activity by dragging the separator between them", async () => {
const pane = mountPane(pullRequest(successfulChecks(30)));
await settleLayout();
const before = checksRegionBottom(pane);
dragSeparator(pane, 120);
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
expect(activityHeader(pane).getBoundingClientRect().top).toBeGreaterThan(before + 100);
});
it("keeps the separator under the pointer when the pane clears a bottom inset", async () => {
const pane = mountPane(pullRequest(successfulChecks(30)), HOME_INDICATOR_INSETS);
await settleLayout();
const before = checksRegionBottom(pane);
dragSeparator(pane, 120);
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
});
});
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { Image, Pressable, ScrollView, Text, View, type ViewStyle } from "react-native";
import { Image, Pressable, Text, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleCheck,
@@ -74,13 +74,14 @@ import {
type PrTimelineEntry,
} from "./timeline";
import {
Section,
SectionHeader,
SummaryPill,
dangerColorMapping,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "./section-kit";
import { PullRequestPaneSplit } from "./split";
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleX = withUnistyles(CircleX);
@@ -478,8 +479,8 @@ export function PullRequestPane({
: null;
return (
<View style={styles.root} testID="pr-pane">
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<PullRequestPaneSplit activityOpen={activityOpen}>
<>
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
<View style={styles.toolbarActions}>
<Button
@@ -499,7 +500,9 @@ export function PullRequestPane({
accessibilityLabel={
isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
: t("workspace.git.diff.refreshState", {
brand: forgePresentation.brandLabel,
})
}
testID="pr-pane-refresh"
style={toolbarRefreshButtonStyle}
@@ -564,60 +567,58 @@ export function PullRequestPane({
onAddLogsToChat={handleAddCheckLogsToChat}
/>
)}
<View style={styles.divider} />
<Section
title={t("workspace.git.pr.sections.activity")}
open={activityOpen}
onToggle={handleToggleActivity}
summary={
<>
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
<SummaryPill
count={changesRequested}
icon={SUMMARY_CHANGES_REQUESTED_ICON}
variant="danger"
</>
<SectionHeader
title={t("workspace.git.pr.sections.activity")}
open={activityOpen}
onToggle={handleToggleActivity}
summary={
<>
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
<SummaryPill
count={changesRequested}
icon={SUMMARY_CHANGES_REQUESTED_ICON}
variant="danger"
/>
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
</>
}
/>
<View style={sectionKitStyles.sectionBody}>
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
<View style={styles.activityToolbar}>
<Button
variant="ghost"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat}
disabled={activityLoading}
>
{t("workspace.git.pr.actions.addAllToChat")}
</Button>
</View>
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
<TimelineEntryCard
key={entry.id}
entry={entry}
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
brandLabel={forgePresentation.brandLabel}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
/>
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
</>
}
>
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
<View style={styles.activityToolbar}>
<Button
variant="ghost"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat}
disabled={activityLoading}
>
{t("workspace.git.pr.actions.addAllToChat")}
</Button>
</View>
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
<TimelineEntryCard
key={entry.id}
entry={entry}
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
brandLabel={forgePresentation.brandLabel}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
/>
))
: null}
</Section>
</ScrollView>
</View>
))
: null}
</View>
</PullRequestPaneSplit>
);
}
@@ -1192,15 +1193,6 @@ function ThreadComment({
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
scroll: {
flex: 1,
minHeight: 0,
},
invisible: {
opacity: 0,
},
@@ -1261,10 +1253,6 @@ const styles = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[1],
opacity: 0,
},
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
toolbar: {
flexDirection: "row",
alignItems: "center",
@@ -1,8 +1,9 @@
import React, { type ReactNode } from "react";
import React, { useMemo, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { CountedCheckPresentation } from "@/git/check-presentation";
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
import { isWeb } from "@/constants/platform";
import {
CheckPresentationIcon,
getCheckPresentationTone,
@@ -21,39 +22,57 @@ export const successColorMapping = (theme: Theme) => ({ color: theme.colors.stat
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
interface SectionProps {
interface SectionHeaderProps {
title: string;
open: boolean;
onToggle: () => void;
summary: ReactNode;
children: ReactNode;
accessibilityLabel?: string;
}
export function Section({
interface SectionProps extends SectionHeaderProps {
children: ReactNode;
}
/** The disclosure row of a section, for layouts that place the section's body themselves. */
export function SectionHeader({
title,
open,
onToggle,
summary,
children,
accessibilityLabel,
}: SectionProps) {
}: SectionHeaderProps) {
const accessibilityState = useMemo(() => ({ expanded: open }), [open]);
// React Native Web does not map `accessibilityState.expanded` to `aria-expanded`.
const webExpandedProps = useMemo(
() => (isWeb ? ({ "aria-expanded": open } as const) : null),
[open],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? title}
accessibilityState={accessibilityState}
{...webExpandedProps}
style={sectionKitStyles.sectionHeader}
onPress={onToggle}
>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
</Pressable>
);
}
export function Section({ children, ...header }: SectionProps) {
return (
<View>
<Pressable
accessibilityLabel={accessibilityLabel}
style={sectionKitStyles.sectionHeader}
onPress={onToggle}
>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
<SectionHeader {...header} />
{header.open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
</View>
);
}
@@ -0,0 +1,162 @@
import { Children, useCallback, useMemo, useState, type ReactNode } from "react";
import { ScrollView, View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ResizeHandle } from "@/components/resize-handle";
/** Most of the pane the overview may take before it scrolls, until the user drags the separator. */
const DEFAULT_OVERVIEW_SHARE = 0.5;
const SPLIT_GROUP_ID = "pr-pane";
interface PullRequestPaneSplitProps {
/**
* Exactly three children, in order:
* 1. the overview (the PR's header and checks), which scrolls in its own region;
* 2. the Activity disclosure row, docked to the bottom of the pane while collapsed;
* 3. the Activity timeline, filling the space below the separator while open.
*
* Slots are children because `react-perf/jsx-no-jsx-as-prop` rejects JSX in a prop. Pass all
* three unconditionally, the way `TreeRail` takes its slots.
*/
children: ReactNode;
activityOpen: boolean;
}
/**
* Lays the pane out the way stacked sidebar sections behave: the overview and Activity share the
* height, a separator between them resizes the share, and a collapsed Activity gives its space
* back by docking its header to the bottom.
*/
export function PullRequestPaneSplit({ children, activityOpen }: PullRequestPaneSplitProps) {
const [overview, activityHeader, activityTimeline] = Children.toArray(children);
const insets = useSafeAreaInsets();
const [overviewShare, setOverviewShare] = useState(DEFAULT_OVERVIEW_SHARE);
// The pointer's share while a drag is in progress. The saved share is decided once, when the
// drag ends, so moves along the way cannot overwrite the share the overview had before it.
const [dragShare, setDragShare] = useState<number | null>(null);
const [splitHeight, setSplitHeight] = useState(0);
const [overviewHeight, setOverviewHeight] = useState(0);
const [overviewContentHeight, setOverviewContentHeight] = useState(0);
const handleSplitLayout = useCallback((event: LayoutChangeEvent) => {
setSplitHeight(event.nativeEvent.layout.height);
}, []);
const handleOverviewLayout = useCallback((event: LayoutChangeEvent) => {
setOverviewHeight(event.nativeEvent.layout.height);
}, []);
const handleOverviewContentSizeChange = useCallback((_width: number, height: number) => {
setOverviewContentHeight(height);
}, []);
const handlePreviewResize = useCallback((_groupId: string, sizes: number[]) => {
const [share] = sizes;
if (share !== undefined) setDragShare(share);
}, []);
const handleResize = useCallback(
(_groupId: string, sizes: number[]) => {
setDragShare(null);
const [share] = sizes;
if (share === undefined || splitHeight <= 0) return;
const contentShare = overviewContentHeight / splitHeight;
setOverviewShare((current) => resolveDraggedShare(share, current, contentShare));
},
[overviewContentHeight, splitHeight],
);
// The overview stops at its content height, so a drag starts from where the separator is
// drawn rather than from the share the overview may grow to.
const separatorSizes = useMemo(() => {
const share = splitHeight > 0 ? overviewHeight / splitHeight : DEFAULT_OVERVIEW_SHARE;
return [share, 1 - share];
}, [overviewHeight, splitHeight]);
// The Activity header docks to the bottom edge, so it clears the home indicator the way the
// Changes pane's docked Commits section does. The split is measured inside that padding: the
// overview's percentage height resolves against this box, so the drag must use the same one.
const rootStyle = useMemo(() => [styles.root, { paddingBottom: insets.bottom }], [insets.bottom]);
const drawnShare = dragShare ?? overviewShare;
const overviewStyle = useMemo(
() =>
activityOpen
? [styles.overviewShared, { maxHeight: `${drawnShare * 100}%` as const }]
: styles.overviewFill,
[activityOpen, drawnShare],
);
return (
<View style={rootStyle} testID="pr-pane">
<View style={styles.split} onLayout={handleSplitLayout}>
<ScrollView
style={overviewStyle}
onLayout={handleOverviewLayout}
onContentSizeChange={handleOverviewContentSizeChange}
showsVerticalScrollIndicator={false}
testID="pr-pane-overview"
>
{overview}
</ScrollView>
{activityOpen ? (
<ResizeHandle
testID="pr-pane-split-handle"
direction="vertical"
groupId={SPLIT_GROUP_ID}
index={0}
sizes={separatorSizes}
containerSize={splitHeight}
onPreviewResizeSplit={handlePreviewResize}
onResizeSplit={handleResize}
/>
) : (
<View style={styles.divider} />
)}
{/* The header keeps one slot in both states, so toggling it does not remount the row
and drop keyboard focus. Only the timeline mounts and unmounts. */}
{activityHeader}
{activityOpen ? (
<ScrollView style={styles.activityScroll} showsVerticalScrollIndicator={false}>
{activityTimeline}
</ScrollView>
) : null}
</View>
</View>
);
}
/**
* The share a drag leaves the overview. Short content stops the separator at the content's
* bottom, so a drag that ends there or below saves what was drawn and keeps any larger share
* the overview already had. The separator then neither jumps to the pointer nor stays pinned
* at the old content height when the content grows.
*/
function resolveDraggedShare(pointerShare: number, currentShare: number, contentShare: number) {
if (pointerShare < contentShare) return pointerShare;
return Math.max(currentShare, contentShare);
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
split: {
flex: 1,
minHeight: 0,
},
overviewShared: {
flexGrow: 0,
flexShrink: 1,
minHeight: 0,
},
overviewFill: {
flex: 1,
minHeight: 0,
},
// Same 1px line the separator draws, so collapsing Activity does not move the boundary's look.
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
activityScroll: {
flex: 1,
minHeight: 0,
},
}));
@@ -83,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;
@@ -1,7 +1,8 @@
import { createElement, type ElementType } from "react";
import { lightHighlightColors } from "@getpaseo/highlight";
import { lightTheme } from "../src/styles/theme";
const testTheme = {
const pinnedTheme = {
colorScheme: "light",
colors: {
syntax: lightHighlightColors,
@@ -86,6 +87,14 @@ const testTheme = {
},
};
// Tokens a test does not pin come from the real light theme, so a style or `uniProps` mapping
// that reads one (the markdown renderer reads `colors.terminal`) gets a value instead of crashing.
const testTheme = {
...lightTheme,
...pinnedTheme,
colors: { ...lightTheme.colors, ...pinnedTheme.colors },
};
type StyleFactory<T> = (theme: typeof testTheme) => T;
function isStyleFactory<T>(styles: T | StyleFactory<T>): styles is StyleFactory<T> {
@@ -142,7 +151,19 @@ export const StyleSheet = {
resolveStyles(isStyleFactory(styles) ? styles(testTheme) : styles),
};
export const withUnistyles = <T>(Component: T): T => Component;
type UniPropsMapping = (theme: typeof testTheme, runtime: object) => Record<string, unknown>;
/** Applies `uniProps` with the test theme, the way Unistyles maps theme values onto props. */
export const withUnistyles = <T>(Component: T): T => {
function ThemedComponent({ uniProps, ...props }: { uniProps?: UniPropsMapping }) {
// Explicit props win over mapped ones, as in Unistyles' `deepMergeObjects(mappings, uniProps, props)`.
return createElement(Component as ElementType, {
...(uniProps ? uniProps(testTheme, {}) : {}),
...props,
});
}
return ThemedComponent as T;
};
export const useUnistyles = () => ({
theme: testTheme,
-23
View File
@@ -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);
},
);
});
+2 -2
View File
@@ -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,
@@ -113,6 +113,111 @@ test.each([
}
});
test("re-probes a v1 runtime on a forced refresh after OpenCode is updated to v2", async () => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const root = await mkdtemp(join(tmpdir(), "opencode-version-upgrade-"));
const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
});
const controlLabel = async () =>
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label;
try {
await writeFile(script, 'console.log("1.14.46")');
expect(await controlLabel()).toBe("Auto Accept");
await writeFile(script, 'console.log("opencode v2.0.10")');
expect(await controlLabel()).toBe("Auto Accept");
// The fake binary cannot serve, so the refresh fails on the v2 startup path it switched to.
await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
"OpenCode v2 server exited during startup",
);
expect(await controlLabel()).toBe("Auto-accept");
} finally {
await client.shutdown();
await rm(root, { recursive: true, force: true });
}
});
async function createCountingVersionBinary(prefix: string) {
const { mkdtemp, writeFile, readFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const root = await mkdtemp(join(tmpdir(), prefix));
const script = join(root, "version.cjs");
const probes = join(root, "probes");
await writeFile(probes, "");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
});
return {
client,
root,
report: (version: string) =>
writeFile(
script,
`if (process.argv.includes("--version")) require("node:fs").appendFileSync(${JSON.stringify(probes)}, "x"); console.log(${JSON.stringify(version)})`,
),
probeCount: async () => (await readFile(probes, "utf8")).length,
controlLabel: async () =>
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label,
dispose: async () => {
await client.shutdown();
await rm(root, { recursive: true, force: true });
},
};
}
test("keeps a v2 runtime on a forced refresh without probing the binary again", async () => {
const binary = await createCountingVersionBinary("opencode-v2-refresh-");
try {
await binary.report("opencode v2.0.10");
expect(await binary.controlLabel()).toBe("Auto-accept");
expect(await binary.probeCount()).toBe(1);
await expect(binary.client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
"OpenCode v2 server exited during startup",
);
expect(await binary.probeCount()).toBe(1);
expect(await binary.controlLabel()).toBe("Auto-accept");
} finally {
await binary.dispose();
}
});
test.each([0, 1, 2, 3, 4, 5, 6, 8])(
"probes once when a second forced refresh starts %i microtasks after the first",
async (ticks) => {
const binary = await createCountingVersionBinary("opencode-concurrent-refresh-");
try {
await binary.report("1.14.46");
expect(await binary.controlLabel()).toBe("Auto Accept");
await binary.report("opencode v2.0.10");
const first = binary.client.fetchCatalog({ scope: "global", force: true });
for (let tick = 0; tick < ticks; tick++) await Promise.resolve();
const second = binary.client.fetchCatalog({ scope: "global", force: true });
const results = await Promise.allSettled([first, second]);
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]);
expect(await binary.probeCount()).toBe(2);
expect(await binary.controlLabel()).toBe("Auto-accept");
} finally {
await binary.dispose();
}
},
);
test("appends a first notice after old history and records a changed major version", async () => {
const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js");
const { V2Harness } = await import("./test-utils/v2-harness.js");
@@ -155,8 +155,18 @@ export class OpenCodeRuntimeClient implements AgentClient {
return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2, handle);
}
async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) {
if (options.force) await this.reprobeLegacySelection();
return (await this.client()).fetchCatalog(options, context);
}
// Updating the binary does not change provider config, so the client survives it. A forced
// refresh re-probes a v1 pick so a 1.x to 2.x update takes effect without a daemon restart.
// A v2 pick owns live sessions and stays.
private async reprobeLegacySelection() {
const current = this.selected;
if (!current) return;
const client = await current.catch(() => null);
if (client === this.legacy && this.selected === current) this.selected = null;
}
async listCommands(config: AgentSessionConfig) {
return (await this.client()).listCommands(config);
}
@@ -1,5 +1,12 @@
import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs";
// A refused fetch proves the previous bridge stopped listening. Runtimes report
// it differently: Node puts the code on `cause`, Bun puts it on the error.
export function isRefused(error) {
const code = error?.code ?? error?.cause?.code;
return code === "ECONNREFUSED" || code === "ConnectionRefused";
}
// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only
// live, per-session bindings over the authenticated OpenCode RPC channel.
export async function registerSharedBridge(context) {
@@ -130,12 +137,7 @@ export async function registerSharedBridge(context) {
} catch (error) {
// Refusal or rejected credentials mean this endpoint no longer
// serves the previous binding. Other failures are inconclusive.
if (
error?.status !== 401 &&
error?.cause?.code !== "ECONNREFUSED" &&
error?.code !== "ConnectionRefused"
)
throw error;
if (error?.status !== 401 && !isRefused(error)) throw error;
}
if (active)
return { error: "OpenCode session is already bound to another Paseo bridge" };
@@ -1,4 +1,6 @@
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -167,3 +169,88 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
await rm(home, { recursive: true, force: true });
}
});
test("a shared plugin rebinds a session after the previous bridge stops listening", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-refused-"));
const previous = createServer((_request, response) => {
response.writeHead(200, { "Content-Type": "application/json", Connection: "close" });
response.end(JSON.stringify({ tools: [] }));
});
await new Promise<void>((resolve) => previous.listen(0, "127.0.0.1", resolve));
const previousPort = (previous.address() as AddressInfo).port;
const current = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
current.setManifestCatalog({ tools: new Map() });
let rpc: Record<string, (input: Binding) => Promise<unknown>> = {};
let dispose: (() => Promise<void>) | undefined;
let releaseCurrent: (() => void) | undefined;
try {
const plugin = {
package: await current.materializeV2Plugin(),
options: { baseUrl: `http://127.0.0.1:${previousPort}`, token: "previous-bridge" },
};
const module = await import(
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
);
dispose = await module.default.setup({
options: {},
location: { directory: home },
provider: { reload: async () => {} },
model: { reload: async () => {} },
agent: { reload: async () => {} },
command: { reload: async () => {} },
skill: { reload: async () => {} },
rpc: {
register: async (_definition: unknown, handlers: typeof rpc) => {
rpc = handlers;
return { dispose: async () => {} };
},
},
tool: { transform: async () => ({ dispose: async () => {} }) },
session: {
context: async () => [],
get: async () => ({ parentID: "" }),
hook: async () => ({ dispose: async () => {} }),
},
});
await expect(rpc.bind({ sessionID: "paseo", ...plugin.options })).resolves.toEqual({});
await current.start();
// The restarted daemon owns the agent again, so its bridge serves this session.
releaseCurrent = current.bindSession({ sessionId: "paseo", env: {} });
await new Promise<void>((resolve) => {
previous.close(() => resolve());
previous.closeAllConnections();
});
await expect(
rpc.bind({ sessionID: "paseo", ...current.sharedV2Connection() }),
).resolves.toEqual({});
} finally {
await dispose?.();
releaseCurrent?.();
await current.close();
await new Promise<void>((resolve) => previous.close(() => resolve()));
await rm(home, { recursive: true, force: true });
}
});
test("a refused connection is the only probe failure that clears a binding", async () => {
const source = new URL("./shared-bridge-plugin.mjs", import.meta.url).href;
const { isRefused } = (await import(source)) as { isRefused: (error: unknown) => boolean };
const nodeShape = Object.assign(new Error("fetch failed"), {
cause: Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
});
const olderBunShape = Object.assign(new TypeError("Unable to connect"), {
code: "ConnectionRefused",
});
// OpenCode 2.2.5 runs on Bun 1.4.4, which carries the code on the error and
// leaves `cause` unset. Node puts the same failure on `cause` instead.
const bunShape = Object.assign(
new TypeError("ECONNREFUSED: Unable to connect. Is the computer able to access the url?"),
{ code: "ECONNREFUSED" },
);
const inconclusive = Object.assign(new Error("request timed out"), { code: "ETIMEDOUT" });
expect(isRefused(nodeShape)).toBe(true);
expect(isRefused(olderBunShape)).toBe(true);
expect(isRefused(bunShape)).toBe(true);
expect(isRefused(inconclusive)).toBe(false);
expect(isRefused(undefined)).toBe(false);
});
@@ -182,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" });
}
}
+6 -4
View File
@@ -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 () => {}),
};
}
@@ -1238,6 +1238,9 @@ test("a failed parent lookup is retried by the pull request status poll, keeping
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
now: () => new Date(nowMs),
// Observing the workspace starts a background fetch of origin. A real one would run ssh
// against the alias host in the checkout, and can outlive disposal and hold the folder.
runGitFetch: async () => ({ changes: [], error: null }),
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
forgeOverrides: {
// The adapter is real; only the poll is driven by the test instead of a timer.