Compare commits

..
Author SHA1 Message Date
Mohamed Boudra eb9e3645ed fix(app): dismiss file link tooltips when leaving their panel
Retained chats suspend while their tooltip portals remain mounted. Remove the surface before suspension and clear open state and pending hover timers so returning to the chat does not revive it.
2026-10-25 02:58:09 +01:00
17 changed files with 200 additions and 840 deletions
@@ -0,0 +1,69 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function readFileLinkTooltip(page: Page): Promise<void> {
const link = page.getByRole("link", { name: "the guide", exact: true });
await link.hover();
await expect(page.getByRole("tooltip")).toContainText("guide.md");
}
async function leaveFileLink(page: Page): Promise<void> {
await page.mouse.move(5, 5);
await expect(page.getByRole("tooltip")).toHaveCount(0);
}
async function openLinkedFile(page: Page): Promise<void> {
await page.getByRole("link", { name: "the guide", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
async function returnToAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Tooltip regression", exact: true }).click();
await expect(page.getByRole("link", { name: "the guide", exact: true })).toBeVisible();
await leaveFileLink(page);
}
async function switchToFileTab(page: Page): Promise<void> {
await page.getByRole("button", { name: "guide.md", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
test("opening an assistant file link dismisses its tooltip over the file tab", async ({
page,
}, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "tooltip-5811-",
title: "Tooltip regression",
featureValues: {
mockStreamingAssistantResponse: "Read [the guide](./guide.md).",
mockStreamingAssistantIntervalMs: 5,
},
});
try {
await writeFile(
path.join(agent.cwd, "guide.md"),
"# Example guide\n\n" + "Plain paragraph.\n\n".repeat(150),
);
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Show the file link.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await readFileLinkTooltip(page);
await leaveFileLink(page);
await readFileLinkTooltip(page);
await openLinkedFile(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await switchToFileTab(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await leaveFileLink(page);
} finally {
await agent.cleanup();
}
});
@@ -229,143 +229,6 @@ test("captures the desktop import sheet and command-center entry", async ({ page
});
});
test("large import lists keep mounted rows bounded and remain scrollable", async ({
page,
}, testInfo) => {
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
await mkdir(fixtureDirectory, { recursive: true });
try {
const newest = Date.now();
for (let index = 0; index < 200; index += 1) {
const id = `virtualized-${String(index).padStart(3, "0")}`;
const file = path.join(fixtureDirectory, `${id}.jsonl`);
await writeFile(
file,
`${JSON.stringify({
type: "user",
sessionId: id,
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
message: {
role: "user",
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
},
})}\n`,
);
const timestamp = new Date(newest - index * 10);
await utimes(file, timestamp, timestamp);
}
for (const width of [1280, 390]) {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
await flow.openGlobally();
const rows = page.locator('[data-testid^="import-session-session-"]');
const scrollToEnd = async (end = true) => {
// Scroll the body's real overflow element. Virtual rows need not exist yet.
await rows.first().evaluate((row, atEnd) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
element = element.parentElement;
}
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = atEnd ? element.scrollHeight : 0;
element.dispatchEvent(new Event("scroll"));
}, end);
};
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
const loadMore = page.getByTestId("import-session-load-more");
await expect(async () => {
await scrollToEnd();
await expect(loadMore).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await loadMore.click();
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
}
await expect(async () => {
await scrollToEnd();
await expect(
page.getByTestId("import-session-session-claude-virtualized-199"),
).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await expect.poll(() => rows.count()).toBeLessThan(100);
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
if (width === 1280) {
const row = (index: number) =>
page.getByTestId(
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
);
await expect(async () => {
await scrollToEnd(false);
await expect(row(0)).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await row(0).focus();
for (let index = 1; index <= 30; index += 1) {
await page.keyboard.press("Tab");
await expect(row(index)).toBeFocused();
}
for (let index = 29; index >= 15; index -= 1) {
await page.keyboard.press("Shift+Tab");
await expect(row(index)).toBeFocused();
}
const list = page.getByTestId("import-session-list");
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
const listener = await list.evaluateHandle((node, eventName) => {
const promise = new Promise<void>((resolve) => {
node.addEventListener(eventName, () => resolve(), { once: true });
});
return { promise };
}, event);
try {
await page.keyboard.press(key);
await listener.evaluate((value) => value.promise);
} finally {
await listener.dispose();
}
};
const beforePageDown = await list.evaluate((node) => node.scrollTop);
await scrollWithKey("scroll", "PageDown");
await expect
.poll(() => list.evaluate((node) => node.scrollTop))
.toBeGreaterThan(beforePageDown);
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(15)).toBeFocused();
await expect(row(199)).toBeInViewport();
// Home must remain at the first row after the native page scroll ends.
await scrollWithKey("scrollend", "Home");
await expect(row(0)).toBeInViewport();
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(199)).toBeInViewport();
await row(199).focus();
await scrollWithKey("scroll", "PageUp");
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
await scrollWithKey("scrollend", "End");
await expect(row(199)).toBeInViewport();
await expect(row(199)).toBeFocused();
await testInfo.attach("keyboard-scroll-owner", {
body: JSON.stringify(
await list.evaluate((node) => ({
focusedRow: document.activeElement?.getAttribute("data-testid"),
scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
})),
),
contentType: "application/json",
});
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
}
await flow.close();
}
} finally {
await rm(fixtureDirectory, { recursive: true, force: true });
}
});
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const outputPath = testInfo.outputPath(name);
await page.screenshot({ path: outputPath, fullPage: true });
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
await expectMobileAgentSidebarVisible(this.page);
}
await openGlobalNewWorkspaceComposer(this.page);
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
const button = this.page.getByTestId("new-workspace-import-session");
await expect(button).toHaveAccessibleName("Import session");
await expect(button).toBeInViewport();
}
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
await this.openFromNewWorkspace();
}
async openFromNewWorkspace() {
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
await this.page.getByTestId("new-workspace-import-session").click();
await this.expectSheetReady();
}
async openFromWorkspaceHeader() {
@@ -86,29 +86,7 @@ export class ImportSessionFlow {
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
}
async revealSession(id: string) {
const target = this.page.getByTestId(rowTestId(id));
if ((await target.count()) === 0) {
await this.scrollList(false);
await expect(async () => {
if ((await target.count()) === 0) await this.scrollList(true);
await expect(target).toHaveCount(1, { timeout: 100 });
}).toPass({ timeout: 10_000 });
}
await target.scrollIntoViewIfNeeded();
await expect(target).toBeInViewport();
}
private async scrollList(forward: boolean) {
await this.page
.locator('[data-testid^="import-session-session-"]')
.first()
.evaluate((row, advance) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY))
element = element.parentElement;
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
element.dispatchEvent(new Event("scroll"));
}, forward);
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
}
async search(query: string) {
await this.page.getByTestId("import-session-search").fill(query);
@@ -122,8 +100,7 @@ export class ImportSessionFlow {
}
async loadMore() {
await this.page.getByTestId("import-session-load-more").click();
await this.revealSession("fixture-root-20");
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
}
async retryProvider(provider: string, label: string) {
@@ -134,7 +111,6 @@ export class ImportSessionFlow {
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
}
async importSession(id: string) {
await this.revealSession(id);
await this.page.getByTestId(rowTestId(id)).click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
timeout: 30_000,
@@ -162,7 +138,7 @@ export class ImportSessionFlow {
);
}
async close() {
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
await this.page.keyboard.press("Escape");
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
}
async expectCommandCenterMatch() {
@@ -1,4 +0,0 @@
import type { ImportSessionListComponent } from "./import-session-list.types";
// Native keeps the sheet-owned ScrollView until its gestures and keyboard are verified.
export const ImportSessionList: ImportSessionListComponent | null = null;
@@ -1,13 +0,0 @@
import type { ReactElement, ReactNode } from "react";
export interface ImportSessionListProps<Item> {
data: ReadonlyArray<Item>;
keyExtractor: (item: Item) => string;
renderItem: (item: Item) => ReactNode;
header: ReactElement | null;
footer: ReactElement | null;
}
export type ImportSessionListComponent = <Item>(
props: ImportSessionListProps<Item>,
) => ReactElement;
@@ -1,141 +0,0 @@
import React, {
useCallback,
useMemo,
useRef,
useState,
type CSSProperties,
type FocusEvent,
type KeyboardEvent,
} from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual";
import type { ImportSessionListProps } from "./import-session-list.types";
const SCROLLER_STYLE: CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
overscrollBehavior: "contain",
};
const CONTENT_STYLE: CSSProperties = { width: "100%", position: "relative" };
export function ImportSessionList<Item>({
data,
keyExtractor,
renderItem,
header,
footer,
}: ImportSessionListProps<Item>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [headerHeight, setHeaderHeight] = useState(0);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const indexByKey = useMemo(
() => new Map(data.map((item, index) => [keyExtractor(item), index])),
[data, keyExtractor],
);
const focusedIndex = focusedKey === null ? undefined : indexByKey.get(focusedKey);
const rangeExtractor = useCallback(
(range: Range) => {
const visible = defaultRangeExtractor(range);
if (focusedIndex === undefined) return visible;
// Keep focus and its Tab neighbours mounted when PageDown/End moves the window.
const focused = [focusedIndex - 1, focusedIndex, focusedIndex + 1].filter(
(index) => index >= 0 && index < data.length,
);
return [...new Set([...visible, ...focused])].sort((left, right) => left - right);
},
[data.length, focusedIndex],
);
const handleFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
const row = event.target.closest<HTMLElement>("[data-session-key]");
setFocusedKey(row?.dataset.sessionKey ?? null);
}, []);
const handleBlur = useCallback((event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null);
}, []);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(event.nativeEvent.layout.height);
}, []);
const getScrollElement = useCallback(() => scrollRef.current, []);
const getItemKey = useCallback(
(index: number) => keyExtractor(data[index]!),
[data, keyExtractor],
);
const estimateSize = useCallback(() => 88, []);
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement,
getItemKey,
estimateSize,
scrollMargin: headerHeight,
overscan: 5,
rangeExtractor,
});
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const target = event.target;
if (
event.defaultPrevented ||
!(target instanceof HTMLElement) ||
!event.currentTarget.contains(target) ||
target.isContentEditable ||
target.closest("input, textarea, select")
)
return;
if (event.key === "End") {
event.preventDefault();
// Reconcile the landing position as variable-height rows are measured.
virtualizer.scrollToIndex(data.length - 1, { align: "end" });
} else if (event.key === "Home") {
event.preventDefault();
virtualizer.scrollToOffset(0);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
// Native page-scroll animations can keep moving after Home or End.
const direction = event.key === "PageDown" ? 1 : -1;
virtualizer.scrollBy(direction * event.currentTarget.clientHeight);
}
},
[data.length, virtualizer],
);
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const contentStyle = useMemo(() => ({ ...CONTENT_STYLE, height: totalSize }), [totalSize]);
const rowStyle = useCallback(
(offset: number): CSSProperties => ({
...CONTENT_STYLE,
position: "absolute",
top: 0,
transform: `translateY(${offset}px)`,
}),
[],
);
return (
<div
ref={scrollRef}
style={SCROLLER_STYLE}
data-testid="import-session-list"
onFocusCapture={handleFocus}
onBlurCapture={handleBlur}
onKeyDown={handleKeyDown}
>
<View onLayout={handleHeaderLayout}>{header}</View>
<div style={contentStyle}>
{virtualRows.map((row) => (
<div
key={row.key}
data-index={row.index}
data-session-key={row.key}
ref={virtualizer.measureElement}
style={rowStyle(row.start - headerHeight)}
>
{renderItem(data[row.index]!)}
</div>
))}
</div>
<div>{footer}</div>
</div>
);
}
@@ -1,4 +1,4 @@
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@@ -11,7 +11,6 @@ import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { ImportSessionList } from "@/components/import-session-list";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
@@ -372,33 +371,7 @@ function ImportSessionActivityTime({ date }: { date: string }) {
return <Text style={styles.rowMeta}>{label}</Text>;
}
function getSessionRowKey(entry: FetchRecentProviderSessionEntry): string {
return `${entry.providerId}:${entry.providerHandleId}`;
}
function hasListHeader(
input: SheetStatusMessagesProps & {
showFilter: boolean;
hasProviderErrors: boolean;
},
): boolean {
return (
input.showFilter ||
!input.isClientReady ||
input.isSnapshotUnsupported ||
input.hasNoImportableProviders ||
(input.isLoadingSessions && !input.hasRows) ||
input.importErrored ||
input.hasProviderErrors
);
}
function SessionRows({
visible,
onClose,
header,
listHeader,
listFooter,
serverId,
entries,
disabled,
@@ -406,11 +379,6 @@ function SessionRows({
resolveFolder,
onImportSession,
}: {
visible: boolean;
onClose: () => void;
header: SheetHeader;
listHeader: ReactNode;
listFooter: ReactNode;
serverId: string | null;
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
disabled: boolean;
@@ -418,69 +386,20 @@ function SessionRows({
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
// Keep short results content-sized; only long lists need a bounded viewport.
const virtualized = ImportSessionList !== null && entries.length > 8;
const renderSessionRow = useCallback(
(entry: FetchRecentProviderSessionEntry) => (
<ImportSessionSheetRow
key={getSessionRowKey(entry)}
serverId={serverId}
entry={entry}
disabled={disabled}
importing={importingSessionKey === getSessionRowKey(entry)}
folder={resolveFolder(entry)}
onImportSession={onImportSession}
/>
),
[serverId, disabled, importingSessionKey, resolveFolder, onImportSession],
);
const renderItem = useCallback(
(item: FetchRecentProviderSessionEntry) => (
<View style={styles.listRow}>{renderSessionRow(item)}</View>
),
[renderSessionRow],
);
const virtualHeader = useMemo(
() => (listHeader ? <View style={styles.listHeader}>{listHeader}</View> : null),
[listHeader],
);
const virtualFooter = useMemo(
() => <View style={styles.listFooter}>{listFooter}</View>,
[listFooter],
);
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={header}
testID="import-session-sheet"
desktopMaxWidth={560}
desktopHeight={virtualized ? "85%" : undefined}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
scrollable={!virtualized}
contentStyle={virtualized ? styles.listBody : undefined}
>
{ImportSessionList && virtualized ? (
<View style={styles.list}>
<ImportSessionList
data={entries}
keyExtractor={getSessionRowKey}
renderItem={renderItem}
header={virtualHeader}
footer={virtualFooter}
/>
</View>
) : (
<>
{listHeader}
{entries.length > 0 ? (
<View style={styles.smallList}>{entries.map(renderSessionRow)}</View>
) : null}
{listFooter}
</>
)}
</AdaptiveModalSheet>
<View style={styles.list}>
{entries.map((entry) => (
<ImportSessionSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
serverId={serverId}
entry={entry}
disabled={disabled}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
folder={resolveFolder(entry)}
onImportSession={onImportSession}
/>
))}
</View>
);
}
@@ -807,124 +726,85 @@ export function ImportSessionSheet({
});
const showFilter = filterProviders.length > 1;
const showLoadMore = hasMoreSessions(queries, pageLimit);
const showListHeader = hasListHeader({
showFilter,
isClientReady: Boolean(client),
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
hasRows: visibleEntries.length > 0,
importErrored: importMutation.isError,
hasProviderErrors: providerErrorRows.length > 0,
});
const listHeader = useMemo(
() =>
showListHeader ? (
<View style={styles.listHeaderContent}>
{showFilter ? (
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
importErrored={importMutation.isError}
/>
{providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null}
</View>
) : null,
[
showListHeader,
showFilter,
filterTriggerStyle,
handleFilterOpen,
selectedProvider,
theme.colors.foregroundMuted,
SelectedProviderIcon,
selectedProviderLabel,
filterComboboxOptions,
handleFilterSelect,
renderFilterOption,
isFilterOpen,
client,
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
visibleEntries.length,
importMutation.isError,
providerErrorRows,
handleRetryProvider,
],
);
const listFooter = useMemo(
() => (
<>
{showLoadMore ? (
<View style={styles.footer}>
<Button
variant="ghost"
onPress={handleLoadMore}
disabled={isRefreshing}
testID="import-session-load-more"
>
{t("importSession.actions.loadMore")}
</Button>
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</>
),
[showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle],
);
return (
<SessionRows
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={header}
listHeader={listHeader}
listFooter={listFooter}
serverId={serverId}
entries={visibleEntries}
disabled={importMutation.isPending}
importingSessionKey={importingSessionKey}
resolveFolder={resolveFolder}
onImportSession={handleImportSession}
/>
testID="import-session-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
{showFilter ? (
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
importErrored={importMutation.isError}
/>
{providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null}
{visibleEntries.length > 0 ? (
<SessionRows
serverId={serverId}
entries={visibleEntries}
disabled={importMutation.isPending}
importingSessionKey={importingSessionKey}
resolveFolder={resolveFolder}
onImportSession={handleImportSession}
/>
) : null}
{showLoadMore ? (
<View style={styles.footer}>
<Button
variant="ghost"
onPress={handleLoadMore}
disabled={isRefreshing}
testID="import-session-load-more"
>
{t("importSession.actions.loadMore")}
</Button>
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</AdaptiveModalSheet>
);
}
@@ -973,31 +853,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
listBody: {
gap: 0,
},
list: {
flex: 1,
minHeight: 0,
marginHorizontal: -theme.spacing[2],
},
listHeader: {
marginHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
listHeaderContent: {
gap: theme.spacing[4],
},
smallList: {
gap: theme.spacing[1],
},
listFooter: {
marginHorizontal: theme.spacing[2],
},
listRow: {
marginHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
footer: {
paddingTop: theme.spacing[2],
alignItems: "center",
@@ -249,35 +249,6 @@ describe("bare URL sentence punctuation", () => {
]);
});
it.each([
["http://127.0.0.1:8059/?token=t-1", ""],
["http://example.com:8080/a", "."],
["http://localhost:8059/?token=t-1", "."],
["http://localhost:8059/?token=t-1", ")"],
["http://user@127.0.0.1:8059/?token=t-1", '"'],
["http://user:pass@127.0.0.1:8059/?token=t-1", ""],
["http://localhost", ":"],
["http://[::1]:8059/?token=t-1", "'"],
["https://github.com/o/r/compare/main...user:repo:fix/branch", ""],
["https://example.com/a:b/c", ":"],
])("links %s whole and leaves %j outside", async (url, punctuation) => {
const prepared = prepareMarkdownSource(`See ${url}${punctuation} now`);
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
expect(tree.children?.[0]?.children).toEqual([
{ type: "Text", content: "See " },
{
type: "Link",
attributes: expect.objectContaining({ url }),
children: [{ type: "Text", content: url }],
},
{ type: "Text", content: `${punctuation} now` },
]);
});
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
"preserves punctuation in explicit destinations: %s",
async (source) => {
@@ -27,17 +27,11 @@ const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PAT
function createFilePattern(path: string): RegExp {
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp(
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:https?://[^\s<>]+|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const FILE_PATTERN = createFilePattern(PROSE_PATH);
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
// The engine accepts regexes on both platforms. Classify complete code spans
+13 -1
View File
@@ -27,6 +27,7 @@ import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { isNative, isWeb } from "@/constants/platform";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
@@ -256,8 +257,15 @@ export function Tooltip({
});
const isCompact = useIsCompactFormFactor();
const active = useRetainedPanelActive();
const opensOnPress = isNative || isCompact;
const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop;
const enabled = active && (opensOnPress ? enabledOnMobile : enabledOnDesktop);
// Remove the portal in the inactive commit, before the retained chat freezes.
// Closing also prevents the tooltip from returning when its panel is shown again.
useEffect(() => {
if (!active && isOpen) setIsOpen(false);
}, [active, isOpen, setIsOpen]);
const value = useMemo<TooltipContextValue>(
() => ({
@@ -329,6 +337,10 @@ export function TooltipTrigger({
};
}, [clearOpenTimer]);
useEffect(() => {
if (!ctx.enabled) clearOpenTimer();
}, [ctx.enabled, clearOpenTimer]);
const handleHoverIn = useCallback(
(e?: unknown) => {
if (isCallable(onHoverIn)) onHoverIn(e);
@@ -1,55 +0,0 @@
import { execFile, spawnSync } from "node:child_process";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const run = promisify(execFile);
const hasSsh = !spawnSync("ssh", ["-V"]).error;
test.skipIf(!hasSsh)(
"an SSH peer abort during a pending write closes the tunnel without crashing its caller",
async () => {
// Isolate the caller so an unhandled stream error fails this assertion instead
// of crashing the test worker. Both the TCP peer and OpenSSH are real.
const { stdout } = await run(
process.execPath,
[
"--import",
"tsx",
"--input-type=module",
"-e",
`
import { createServer, createConnection } from 'node:net';
import { once } from 'node:events';
import { createSshTunnel } from ${JSON.stringify(new URL("./ssh-tunnel.ts", import.meta.url).href)};
const remote = createServer(socket => socket.destroy());
remote.listen(0, '127.0.0.1');
await once(remote, 'listening');
const tunnel = await createSshTunnel({
host: '127.0.0.1', sshPort: remote.address().port, daemonPort: 6767,
});
const [host, port] = tunnel.endpoint.split(':');
const client = createConnection({ host, port: Number(port) });
client.on('error', () => undefined);
try {
const closed = new Promise(resolve => client.once('close', resolve));
await once(client, 'connect');
client.write(Buffer.alloc(8 * 1024 * 1024, 65));
await closed;
console.log(JSON.stringify({ closed: client.destroyed, failure: tunnel.failureDetail() }));
} finally {
client.destroy();
tunnel.close();
await new Promise(resolve => remote.close(resolve));
}
`,
],
{ timeout: 10_000 },
);
expect(JSON.parse(stdout)).toEqual({
closed: true,
failure: expect.any(String),
});
expect(JSON.parse(stdout).failure).not.toBe("");
},
);
+2 -4
View File
@@ -54,12 +54,10 @@ export function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel>
child.stderr.on("data", (chunk: Buffer | string) => {
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
});
const fail = (error: Error) => {
child.on("error", (error) => {
failure = error.message;
acceptedSocket.destroy(error);
};
child.on("error", fail);
child.stdin.on("error", fail);
});
child.on("exit", (code, signal) => {
if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal);
acceptedSocket.destroy(failure ? new Error(failure) : undefined);
@@ -16,6 +16,7 @@ interface FakeSpawnBehavior {
interface FakeSpawnController {
activeCount: number;
nextPid: number;
peakActiveCount: number;
processes: FakeChildProcess[];
queue: FakeSpawnBehavior[];
@@ -24,6 +25,7 @@ interface FakeSpawnController {
const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
activeCount: 0,
nextPid: 1000,
peakActiveCount: 0,
processes: [],
queue: [],
@@ -33,6 +35,7 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
}
this.activeCount = 0;
this.nextPid = 1000;
this.peakActiveCount = 0;
this.processes = [];
this.queue = [];
@@ -40,8 +43,7 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
}));
class FakeChildProcess extends EventEmitter {
// This is not an OS process; tree cleanup must use the direct-child fallback.
public readonly pid = undefined;
public readonly pid: number;
public readonly stderr = new EventEmitter();
public readonly stdout = new EventEmitter();
public killed = false;
@@ -55,6 +57,8 @@ class FakeChildProcess extends EventEmitter {
public constructor(behavior: FakeSpawnBehavior) {
super();
this.behavior = behavior;
this.pid = fakeSpawnController.nextPid;
fakeSpawnController.nextPid += 1;
fakeSpawnController.activeCount += 1;
fakeSpawnController.peakActiveCount = Math.max(
+1 -2
View File
@@ -13,7 +13,6 @@ import {
submitGitCommandTrace,
} from "./git-command-trace.js";
import { spawnProcess } from "./spawn.js";
import { signalProcessTree } from "./tree-kill.js";
import {
GitProcessScheduler,
type GitProcessPriority,
@@ -423,7 +422,7 @@ function executeGitCommand<Output>(
timer = setTimeout(() => {
timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`);
void signalProcessTree(child, "SIGKILL");
child.kill("SIGKILL");
settle(() => reject(timeoutError));
if (processExit) {
settleTimeoutTrace(processExit.exitCode, processExit.signal);
@@ -1,17 +1,12 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createServer, type Socket } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { runGitCommand } from "./run-git-command.js";
const tempDirs: string[] = [];
function allowConnectionReset(error: NodeJS.ErrnoException): void {
if (error.code !== "ECONNRESET") throw error;
}
function makeTempRepo(): string {
const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-"));
tempDirs.push(repo);
@@ -25,49 +20,6 @@ afterEach(() => {
});
describe("runGitCommand shell behavior", () => {
it.each(["http", "ssh"])(
"closes a stalled %s fetch connection on timeout and admits the next command",
async (protocol) => {
const repo = makeTempRepo();
await runGitCommand(["init"], { cwd: repo });
const sockets: Socket[] = [];
const server = createServer((socket) => {
sockets.push(socket);
// Force-killing a Windows peer resets the connection instead of sending EOF.
socket.on("error", allowConnectionReset);
// Consume the request but never answer, keeping the real transport helper busy.
socket.resume();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing TCP address");
const remote = `${protocol}://127.0.0.1:${address.port}/repo.git`;
try {
await expect(
runGitCommand(["fetch", remote, "main"], {
cwd: repo,
timeout: 2000,
envOverlay: {
NO_PROXY: "127.0.0.1",
no_proxy: "127.0.0.1",
GIT_SSH_COMMAND: "ssh -F none -o BatchMode=yes",
},
}),
).rejects.toThrow("Git command timed out after 2000ms");
expect(sockets).toHaveLength(1);
await expect.poll(() => sockets[0]?.destroyed, { timeout: 3000 }).toBe(true);
await expect(runGitCommand(["--version"], { cwd: repo })).resolves.toMatchObject({
exitCode: 0,
truncated: false,
});
} finally {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
},
);
it("passes git arguments directly instead of through the platform shell", async () => {
const repo = makeTempRepo();
const literalName = "%PASEO_GIT_SHELL_SENTINEL%";
+1 -9
View File
@@ -55,18 +55,10 @@ function createLegacyWorktreeForTest(
});
}
function allowConnectionReset(error: NodeJS.ErrnoException): void {
if (error.code !== "ECONNRESET") throw error;
}
// A remote that accepts connections and never answers, like a VPN-only host while off the VPN.
async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> {
const connections = new Set<Socket>();
const server = createServer((socket) => {
connections.add(socket);
// Timeout cleanup force-kills the peer, which can reset its connection on Windows.
socket.on("error", allowConnectionReset);
});
const server = createServer((socket) => connections.add(socket));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return {
File diff suppressed because one or more lines are too long