Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 40147c6805 test(chat): pass mountTimeline options as an object
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 10:49:30 +00:00
lukeandClaude Opus 5.5 974299450c fix(chat): preserve scroll offset when enabling virtualization
Reading older messages in a long web chat could jump to the first
message when older rows moved into the virtualized history section.
When the row virtualizer is enabled it scrolls to its remembered
offset, which falls back to initialOffset (0) the first time. Start
it from the scroll container's current position instead.

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