Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 52f72fcbb0 feat(app): host picker trigger and Usage host placement
HostFilter renders the shared ComboboxTrigger, as in Settings, and
truncates long host names. Sessions, Schedules, plugin surfaces, the
labels manager and Usage pick this up together.

The Usage modal puts the host picker at the top of its body on every
form factor. The header is title, Refresh all, Settings, X, so the
title stays whole on a phone.
2026-11-15 05:01:10 +01:00
Mohamed Boudra 89e2b84a96 feat(app): one icon button for every sheet header
The sheet header's back arrow, close X and actions now render through
SheetHeaderIconButton, which takes the header island chrome CompactHeader
uses: 26pt frame and 16pt glyph on pointer, 44pt and 20pt on touch, muted
colour, hover fill and tooltip. Edge buttons hang into the row padding so
their glyphs sit on the content rail; the row keeps its 64pt height and the
trailing actions shrink before the title collapses.

Migrates the diagnostic, import session, model browser, changelog, plugin
logs and sheet pane actions, and moves the tool-call and tool-call overview
sheets onto SheetHeaderView. The Usage refresh and options buttons size
their glyph from the header scope. adaptive-modal-sheet and the migrated
sheets leave the raw-pressable burn-down list.
2026-11-15 05:01:10 +01:00
Mohamed Boudra cf377c8de0 feat(app): reorder and turn off usage cards
Usage cards default to alphabetical order. Drag a card by its handle to
reorder it; the handle shows on hover on web and is always visible on
native and compact layouts. A card's switch turns it off: the card
collapses to its header and its windows leave the sidebar summary. Order
and off state are device-local usage preferences.

Inside a native bottom sheet, a drag gesture can now keep the touch:
the sheet's content pan waits for the list's drag pan
(react-native-draggable-flatlist gains `dragGestureRef`), and the sheet
scroll stops while a drag is active.
2026-11-15 05:01:09 +01:00
63 changed files with 1831 additions and 664 deletions
+1 -6
View File
@@ -530,7 +530,7 @@
}
},
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
// 94 files. They may only leave this list.
// 85 files. They may only leave this list.
{
"files": [
"packages/app/src/components/ui/**/*.{ts,tsx}",
@@ -539,12 +539,9 @@
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/attachment-pill.tsx",
"packages/app/src/components/combined-model-selector.tsx",
@@ -583,7 +580,6 @@
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/components/workspace-hover-card.tsx",
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
@@ -619,7 +615,6 @@
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
"packages/app/src/usage/sidebar-item.tsx",
"packages/app/src/usage/window-bar.tsx",
"packages/app/src/workspace-labels/manager-modal.tsx",
+2
View File
@@ -114,6 +114,8 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
The sheet header (`SheetHeaderView`) is the only sheet title row. A sheet that brings its own Gorhom modal still renders it. Back, Close and every action passed through `header.actions` are `SheetHeaderIconButton`, so all of them get the header island's chrome, as `CompactHeader` does: one frame, glyph, colour and hover. The first and last buttons hang into the row's padding, so their glyphs sit on the content rail.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
+61 -4
View File
@@ -33,7 +33,10 @@ import {
pinRow,
openUsageOptions,
closeUsageOptions,
dragUsageCardAbove,
expectUsageCardOrder,
expectUsageOptionsFitContent,
setUsageCardOn,
showUsageAs,
refreshAllUsage,
togglePin,
@@ -108,6 +111,14 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
};
}
function accountReport(sourceId: string, account: string, usedPct: number): UsageReportEntry {
return {
...weeklyReport(sourceId, usedPct),
id: `${sourceId}:${account}`,
account: { label: account },
};
}
function installReversedUsageRefresh(page: Page) {
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
@@ -120,15 +131,15 @@ async function openUsageSummaryAtWidth(page: Page, width: number) {
await page.setViewportSize({ width, height: 900 });
await gotoAppShell(page);
if (width === 390) await openCompactSidebar(page);
await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
await expectPinnedUsage(page, ["12% Weekly", "31% Weekly"]);
await openUsageFromItem(page);
}
async function expectUsageProviderOrder(page: Page, summary: string[]) {
const group = page.getByTestId(`usage-host-${getServerId()}`);
await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
"opencode plan",
"openai plan",
"opencode plan",
]);
await expectPinnedUsage(page, summary);
}
@@ -200,15 +211,61 @@ test.describe("usage modal", () => {
}) => {
const usage = await installReversedUsageRefresh(page);
await openUsageSummaryAtWidth(page, width);
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
// Reports arrive opencode first; cards and the summary stay alphabetical.
await expectUsageProviderOrder(page, ["12% Weekly", "31% Weekly"]);
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
await refreshUsageAndWaitForReports(page, usage);
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
await expectUsageProviderOrder(page, ["22% Weekly", "41% Weekly"]);
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
});
}
test("keeps a card turned off and a dragged order after a reload", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, {
lists: [
[
accountReport("beta", "work", 12),
weeklyReport("alpha", 31),
accountReport("beta", "home", 20),
],
],
});
await gotoAppShell(page);
// Three windows leave no room for labels in the summary, so it reads percents.
await expectPinnedUsage(page, ["31%", "20%", "12%"]);
await openUsageFromItem(page);
await expectUsageCardOrder(page, ["alpha:a", "beta:home", "beta:work"]);
// Without the pointer over the card, keyboard focus on its handle shows the handle.
await page.mouse.move(0, 0);
const alphaHandle = page
.getByTestId("usage-report-alpha:a")
.getByRole("button", { name: "Reorder alpha plan", exact: true });
await expect(alphaHandle).toHaveCSS("opacity", "0");
await alphaHandle.focus();
await expect(alphaHandle).toHaveCSS("opacity", "1");
await alphaHandle.blur();
// The off card collapses to its header, which still names the account.
await setUsageCardOn(page, "beta plan (work)", false);
const work = page.getByTestId("usage-report-beta:work");
await expect(work.getByText("12% used")).toHaveCount(0);
await expect(work.getByText("work", { exact: true })).toBeVisible();
await expectPinnedUsage(page, ["31% Weekly", "20% Weekly"]);
await dragUsageCardAbove(page, "beta:home", "alpha:a");
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
await page.reload();
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
await openUsageFromItem(page);
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
await expect(page.getByTestId("usage-report-beta:work").getByText("12% used")).toHaveCount(0);
});
test("refreshes one report from its card", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
@@ -147,6 +147,46 @@ export function usageModal(page: Page): Locator {
return visible(page, "usage-modal-body");
}
/** The Usage modal's cards, top to bottom, by report id. */
export async function expectUsageCardOrder(page: Page, reportIds: string[]): Promise<void> {
const cards = usageModal(page).locator('[data-testid^="usage-report-"]');
await expect(cards).toHaveCount(reportIds.length);
await expect
.poll(() =>
cards.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-testid")?.slice("usage-report-".length)),
),
)
.toEqual(reportIds);
}
/** Turns a card on or off from its header switch, named by its source and account. */
export async function setUsageCardOn(page: Page, name: string, on: boolean): Promise<void> {
const cardSwitch = usageModal(page).getByRole("switch", { name: `Show ${name}`, exact: true });
await cardSwitch.click();
await expect(cardSwitch).toBeChecked({ checked: on });
}
/** Drags a card by its handle until it sits above another card, with the mouse. */
export async function dragUsageCardAbove(
page: Page,
reportId: string,
targetReportId: string,
): Promise<void> {
const card = usageModal(page).getByTestId(`usage-report-${reportId}`);
const target = usageModal(page).getByTestId(`usage-report-${targetReportId}`);
await card.hover();
const handle = await card.getByTestId("usage-card-drag-handle").boundingBox();
const targetBox = await target.boundingBox();
if (!handle || !targetBox) throw new Error("Both cards must be on screen to drag.");
const x = handle.x + handle.width / 2;
await page.mouse.move(x, handle.y + handle.height / 2);
await page.mouse.down();
await page.mouse.move(x, handle.y + handle.height / 2 - 10, { steps: 3 });
await page.mouse.move(x, targetBox.y + 4, { steps: 12 });
await page.mouse.up();
}
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
@@ -167,14 +207,18 @@ export function pinRow(scope: Locator, source: string, window: string): Locator
/**
* A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable
* but buttons, so no row presses or highlights on hover.
* but the header's buttons and on/off switch, so no row presses or highlights on hover.
*/
export async function expectUnpinnableRows(card: Locator): Promise<void> {
await expect(card.getByRole("checkbox")).toHaveCount(0);
await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0);
const focusable = await card
.locator("[tabindex]")
.evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length);
.evaluateAll(
(nodes) =>
nodes.filter((node) => !["button", "switch"].includes(node.getAttribute("role") ?? ""))
.length,
);
expect(focusable).toBe(0);
}
@@ -1,18 +1,18 @@
import { memo, useCallback, useMemo, useState } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Text, View } from "react-native";
import { ExternalLink, Gift } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import {
AdaptiveModalSheet,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
import { StatusBadge } from "@/components/ui/status-badge";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolveAppVersion } from "@/utils/app-version";
@@ -28,12 +28,9 @@ import {
const WEBSITE_CHANGELOG_URL = "https://paseo.sh/changelog";
const ThemedGift = withUnistyles(Gift);
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const sheetLeadingIcon = <ThemedGift size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
const websiteButtonStyle = (state: PressableStateCallbackType & { hovered?: boolean }) =>
iconButtonChromeStyle({ size: "large", state });
interface ChangelogSheetProps {
visible: boolean;
@@ -54,19 +51,12 @@ export function ChangelogSheet({ visible, onClose }: ChangelogSheetProps) {
title: t("changelog.title"),
leading: sheetLeadingIcon,
actions: (
<Pressable
<SheetHeaderIconButton
icon={ExternalLink}
label={t("changelog.openWebsite")}
onPress={handleOpenWebsite}
hitSlop={8}
style={websiteButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("changelog.openWebsite")}
testID="changelog-open-website"
>
<ThemedExternalLink
size={iconButtonChromeGlyphSize("large")}
uniProps={mutedColorMapping}
/>
</Pressable>
/>
),
}),
[handleOpenWebsite, t],
@@ -0,0 +1,76 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { RotateCw, Settings } from "lucide-react-native";
import { afterEach, describe, expect, it } from "vitest";
import {
InlineHeaderView,
SheetHeaderIconButton,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function noop() {}
const LONG_TITLE =
"A sheet title long enough to fill the whole header row on a phone and then some more";
const header: SheetHeader = {
title: LONG_TITLE,
actions: (
<>
<SheetHeaderIconButton icon={RotateCw} label="Refresh" onPress={noop} testID="refresh" />
<SheetHeaderIconButton icon={Settings} label="Settings" onPress={noop} testID="settings" />
</>
),
};
function mount(node: React.ReactNode) {
container = document.createElement("div");
container.style.width = "390px";
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(node));
}
function rectOf(testID: string): DOMRect {
const found = document.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`);
return found.getBoundingClientRect();
}
/** Each button keeps its own width, follows the previous one and stays inside the row. */
function expectButtonsInRow(testIDs: string[]) {
const row = container?.getBoundingClientRect();
if (!row) throw new Error("Nothing mounted");
const rects = testIDs.map(rectOf);
for (const [index, rect] of rects.entries()) {
expect(rect.width).toBeGreaterThan(0);
expect(rect.right).toBeLessThanOrEqual(row.right + 1);
const previous = rects[index - 1];
if (previous) expect(rect.left).toBeGreaterThanOrEqual(previous.right);
}
}
describe("sheet header with a long title", () => {
it("truncates the title and keeps the actions and Close in place", () => {
mount(<SheetHeaderView header={header} onClose={noop} closeTestID="close" />);
expectButtonsInRow(["refresh", "settings", "close"]);
});
it("does the same in the inline header", () => {
mount(<InlineHeaderView header={header} />);
expectButtonsInRow(["refresh", "settings"]);
});
});
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Keyboard, Pressable, Text, View } from "react-native";
import { Keyboard, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -29,6 +29,15 @@ import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import { DismissBackdrop } from "@/components/ui/dismiss-backdrop";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
iconButtonChromeHang,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
// Horizontal indent token shared by the sheet header (title, back arrow,
@@ -37,11 +46,6 @@ export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/ada
// match this padding.
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
// The header's close button grows outward from its glyph, so the glyph's
// trailing rail is the content inset plus this padding. Rows whose trailing
// glyph should line up with the X must reach the same rail.
export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2;
/** The one height a sheet takes when it shows a whole pane rather than sizing to its content. */
export const FULL_SHEET_SNAP_POINTS = ["92%"];
@@ -63,6 +67,7 @@ export interface SheetHeaderBack {
export interface SheetHeader {
title: string;
titleTestID?: string;
subtitle?: ReactNode;
back?: SheetHeaderBack;
leading?: ReactNode;
@@ -71,7 +76,9 @@ export interface SheetHeader {
}
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFill };
// Header buttons hang by how far their frame reaches past the glyph, so the edge glyphs sit on the
// content rail and a 44pt touch frame does not grow the row.
const HEADER_BUTTON_HANG = iconButtonChromeHang("large");
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
const styles = StyleSheet.create((theme) => ({
@@ -105,20 +112,24 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
},
// One height for every sheet header whatever its buttons; a hung 44pt touch frame fits inside it.
headerRow: {
minHeight: 64,
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerBackButton: {
borderRadius: theme.borderRadius.lg,
headerBack: {
marginVertical: HEADER_BUTTON_HANG,
marginLeft: HEADER_BUTTON_HANG,
},
headerLeadingSlot: {
alignItems: "center",
justifyContent: "center",
},
// The title takes the room the buttons leave and truncates; the buttons never shrink.
headerTitleGroup: {
flex: 1,
gap: theme.spacing[1],
@@ -128,14 +139,13 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
headerActions: {
headerTrailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
borderRadius: theme.borderRadius.lg,
flexShrink: 0,
marginVertical: HEADER_BUTTON_HANG,
marginRight: HEADER_BUTTON_HANG,
},
// Without a header the close button floats over the card's top-right corner.
floatingClose: {
@@ -176,6 +186,7 @@ const styles = StyleSheet.create((theme) => ({
},
inlineTitle: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
@@ -343,47 +354,83 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
return <View style={[styles.sheetContent, style]}>{children}</View>;
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedHeaderGlyph = withUnistyles(function HeaderGlyph({
icon: Icon,
size,
color,
}: {
icon: LucideIcon;
size: number;
color?: string;
}) {
return <Icon size={size} color={color} />;
});
/**
* An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
* actions line up with it at one size. Pass it through `SheetHeader.actions`.
* The one icon button of a sheet header: the back arrow, the close X, and every action passed
* through `SheetHeader.actions`. Inside a header it takes the header island's chrome, so all of them
* share one frame, glyph, colour and hover. `busy` swaps the glyph for a spinner and holds presses.
*/
export function SheetHeaderIconButton({
icon: Icon,
accessibilityLabel,
icon,
label,
onPress,
busy = false,
disabled = false,
testID,
}: {
icon: LucideIcon;
accessibilityLabel: string;
/** The accessible name, also shown as the desktop tooltip. */
label: string;
onPress: () => void;
busy?: boolean;
disabled?: boolean;
testID?: string;
}) {
const { theme } = useUnistyles();
const compact = useIsCompactFormFactor();
const glyphSize = iconButtonChromeGlyphSize("large", compact);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.closeButton}
<ToolbarButton
label={label}
compact={compact}
disabled={disabled || busy}
onPress={onPress}
testID={testID}
>
{({ pressed }) => (
<Icon size={16} color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted} />
{busy ? (
<ThemedLoadingSpinner size={glyphSize} uniProps={mutedIconColorMapping} />
) : (
<ThemedHeaderGlyph icon={icon} size={glyphSize} uniProps={mutedIconColorMapping} />
)}
</Pressable>
</ToolbarButton>
);
}
function SheetHeaderBackButton({ back }: { back: SheetHeaderBack }) {
const { t } = useTranslation();
return (
<View style={styles.headerBack}>
<SheetHeaderIconButton
icon={ArrowLeft}
label={back.accessibilityLabel ?? back.label ?? t("common.actions.back")}
onPress={back.onPress}
testID="sheet-header-back"
/>
</View>
);
}
export function SheetHeaderView({
header,
onClose,
showCloseButton = true,
testID,
closeTestID,
}: {
header: SheetHeader;
onClose: () => void;
showCloseButton?: boolean;
testID?: string;
closeTestID?: string;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -391,8 +438,6 @@ export function SheetHeaderView({
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const back = header.back;
const handleBackPress = back?.onPress;
const search = header.search;
const handleSearchChange = useCallback(
(value: string) => {
@@ -403,40 +448,27 @@ export function SheetHeaderView({
return (
<View style={styles.headerContainer} testID={testID}>
<View style={styles.headerRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={18}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<View style={styles.headerTitleGroup}>
<Text style={titleStyle} numberOfLines={1}>
{header.title}
</Text>
{header.subtitle}
<IconButtonChromeProvider value="header-island">
<View style={styles.headerRow}>
{header.back ? <SheetHeaderBackButton back={header.back} /> : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<View style={styles.headerTitleGroup}>
<Text style={titleStyle} numberOfLines={1} testID={header.titleTestID}>
{header.title}
</Text>
{header.subtitle}
</View>
<View style={styles.headerTrailing}>
{header.actions}
<SheetHeaderIconButton
icon={X}
label={t("common.actions.close")}
onPress={onClose}
testID={closeTestID}
/>
</View>
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<SheetHeaderIconButton
icon={X}
accessibilityLabel={t("common.actions.close")}
onPress={onClose}
/>
) : null}
</View>
</IconButtonChromeProvider>
{search ? (
<View style={styles.searchRow}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -462,39 +494,21 @@ export function SheetHeaderView({
export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const back = header.back;
const handleBackPress = back?.onPress;
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
const hasInlineRow = Boolean(header.back || header.leading || header.actions);
if (!hasInlineRow && !header.search) return null;
return (
<View>
{hasInlineRow ? (
<View style={styles.inlineHeaderRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={
back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")
}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<Text style={styles.inlineTitle} numberOfLines={1}>
{header.title}
</Text>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
</View>
<IconButtonChromeProvider value="header-island">
<View style={styles.inlineHeaderRow}>
{header.back ? <SheetHeaderBackButton back={header.back} /> : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<Text style={styles.inlineTitle} numberOfLines={1} testID={header.titleTestID}>
{header.title}
</Text>
{header.actions ? <View style={styles.headerTrailing}>{header.actions}</View> : null}
</View>
</IconButtonChromeProvider>
) : null}
{header.search ? (
<View style={styles.inlineSearchRow}>
@@ -798,24 +812,18 @@ export function AdaptiveModalSheet({
</View>
{footerView}
{header ? null : (
<View style={styles.floatingClose}>
<SheetHeaderIconButton
icon={X}
accessibilityLabel={t("common.actions.close")}
onPress={onClose}
/>
</View>
<IconButtonChromeProvider value="header-island">
<View style={styles.floatingClose}>
<SheetHeaderIconButton icon={X} label={t("common.actions.close")} onPress={onClose} />
</View>
</IconButtonChromeProvider>
)}
</OverlayLayerProvider>
);
const desktopContent = (
<View style={desktopOverlayStyle} testID={testID}>
<Pressable
accessibilityLabel={t("common.actions.dismiss")}
style={ABSOLUTE_FILL_STYLE}
onPress={onClose}
/>
<DismissBackdrop label={t("common.actions.dismiss")} onPress={onClose} />
<View ref={setOverlayScope} style={desktopCardStyle} role="dialog" aria-modal tabIndex={-1}>
{cardInner}
</View>
@@ -1,11 +1,15 @@
import * as Clipboard from "expo-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Platform, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Platform, Text, View } from "react-native";
import { Copy, RotateCw } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import {
AdaptiveModalSheet,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useToast } from "@/contexts/toast-context";
@@ -42,8 +46,6 @@ interface DiagnosticCollectionResult {
}
const SNAP_POINTS = ["55%", "88%"];
const ThemedCopy = withUnistyles(Copy);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
@@ -150,68 +152,31 @@ export function AppDiagnosticSheet({
.catch(() => toast.error(t("settings.diagnostics.app.copyFailed")));
}, [diagnostic, t, toast]);
const iconButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.iconButton,
(Boolean(hovered) || pressed) && styles.iconButtonHovered,
],
[],
);
const disabledIconButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && styles.iconButtonHovered,
diagnostic ? null : styles.disabled,
],
[diagnostic],
);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.diagnostics.app.title"),
actions: (
<View style={styles.headerActions}>
<Pressable
<>
<SheetHeaderIconButton
icon={Copy}
label={t("settings.diagnostics.app.copyAccessibility")}
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={disabledIconButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.diagnostics.app.copyAccessibility")}
>
<ThemedCopy size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={
/>
<SheetHeaderIconButton
icon={RotateCw}
label={
loading
? t("settings.diagnostics.app.refreshingAccessibility")
: t("settings.diagnostics.app.refreshAccessibility")
}
>
{loading ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
)}
</Pressable>
</View>
onPress={handleRefreshPress}
busy={loading}
/>
</>
),
}),
[
diagnostic,
disabledIconButtonStyle,
handleCopyPress,
handleRefreshPress,
iconButtonStyle,
loading,
t,
],
[diagnostic, handleCopyPress, handleRefreshPress, loading, t],
);
return (
@@ -342,24 +307,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
iconButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
disabled: {
opacity: 0.5,
},
statusDot: {
width: 8,
height: 8,
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDragHold } from "./drag-hold";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
function hold() {
const onArm = vi.fn();
return { onArm, hold: createDragHold({ delayMs: 180, slopPx: 4, onArm }) };
}
describe("createDragHold", () => {
it("arms once the finger has stayed within the slop for the delay", () => {
const { onArm, hold: dragHold } = hold();
dragHold.begin({ x: 10, y: 10 });
dragHold.move({ x: 12, y: 13 });
vi.advanceTimersByTime(180);
expect(onArm).toHaveBeenCalledTimes(1);
});
it("never arms when the finger moves past the slop first, as when a scroll starts", () => {
const { onArm, hold: dragHold } = hold();
dragHold.begin({ x: 10, y: 10 });
dragHold.move({ x: 10, y: 20 });
vi.advanceTimersByTime(500);
expect(onArm).not.toHaveBeenCalled();
});
it("never arms when the finger lifts before the delay", () => {
const { onArm, hold: dragHold } = hold();
dragHold.begin({ x: 10, y: 10 });
vi.advanceTimersByTime(100);
dragHold.cancel();
vi.advanceTimersByTime(500);
expect(onArm).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,51 @@
export interface TouchPoint {
x: number;
y: number;
}
export interface DragHold {
begin: (point: TouchPoint) => void;
move: (point: TouchPoint) => void;
cancel: () => void;
}
/**
* A hold that arms a drag once the finger has stayed put for `delayMs`. Moving further than
* `slopPx` from where the touch began cancels it, so a scroll that starts on a handle stays a
* scroll. The sidebar rows arm on the same delay and slop.
*/
export function createDragHold({
delayMs,
slopPx,
onArm,
}: {
delayMs: number;
slopPx: number;
onArm: () => void;
}): DragHold {
let timer: ReturnType<typeof setTimeout> | null = null;
let start: TouchPoint | null = null;
function cancel() {
if (timer) clearTimeout(timer);
timer = null;
start = null;
}
return {
begin(point) {
cancel();
start = point;
timer = setTimeout(() => {
timer = null;
start = null;
onArm();
}, delayMs);
},
move(point) {
if (!start) return;
if (Math.hypot(point.x - start.x, point.y - start.y) > slopPx) cancel();
},
cancel,
};
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { movedItemKey } from "./native-drop";
const byValue = (item: string): string => item;
describe("movedItemKey", () => {
it("names the item at its new slot", () => {
expect(movedItemKey({ data: ["b", "c", "a"], from: 0, to: 2 }, byValue)).toBe("a");
});
it("names nothing when the item is released in the slot it started in", () => {
expect(movedItemKey({ data: ["a", "b", "c"], from: 1, to: 1 }, byValue)).toBeNull();
});
});
@@ -0,0 +1,17 @@
export interface NativeDrop<T> {
data: T[];
from: number;
to: number;
}
/**
* The key of the item a native drop moved, or null when the item was released in the slot it
* started in. The native list reports every release; web reports only drops that moved an item.
*/
export function movedItemKey<T>(
{ data, from, to }: NativeDrop<T>,
keyExtractor: (item: T, index: number) => string,
): string | null {
if (from === to) return null;
return keyExtractor(data[to], to);
}
@@ -24,7 +24,7 @@ export function useDragReorderState<T>({
}: {
data: T[];
keyExtractor: (item: T, index: number) => string;
onDragEnd?: (items: T[]) => void;
onDragEnd?: (items: T[], movedKey: string) => void;
onDragBegin?: () => void;
disabled?: boolean;
}): DragReorderState<T> {
@@ -50,13 +50,14 @@ export function useDragReorderState<T>({
dispatch({ type: "clear" });
if (disabled) return;
const activeId = String(active.id);
const reordered = reorderItemsOnDragEnd({
items,
activeId: String(active.id),
activeId,
overId: over ? String(over.id) : null,
keyExtractor,
});
if (reordered) onDragEnd?.(reordered);
if (reordered) onDragEnd?.(reordered, activeId);
},
[data, disabled, keyExtractor, onDragEnd, state.dragItems],
);
@@ -1,13 +1,87 @@
import { RefreshControl } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { RefreshControl, View, type GestureResponderEvent } from "react-native";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import * as Haptics from "expo-haptics";
import DraggableFlatList, {
NestableDraggableFlatList,
type RenderItemParams,
} from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { useUnistyles } from "react-native-unistyles";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { useContentDrag } from "@/components/ui/scroll-view";
import { createDragHold } from "./drag-reorder/drag-hold";
import { movedItemKey } from "./drag-reorder/native-drop";
import type {
DraggableListHandleProps,
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
export type { DraggableListProps, DraggableRenderItemInfo };
export type { DraggableListHandleProps, DraggableListProps, DraggableRenderItemInfo };
/** The row's `drag()`, for its handle. */
const RowDragContext = createContext<(() => void) | null>(null);
// The list only accepts drag() once its pan gesture has begun for this touch, and that begin
// reaches JS after the touch does. Same hold and slop as the sidebar rows' drag arm.
const HANDLE_HOLD_MS = 180;
const HANDLE_HOLD_SLOP_PX = 4;
function touchPoint(event: GestureResponderEvent) {
return { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
}
/**
* On native the handle has no accessibility role or label: a screen reader cannot hold and drag,
* and announcing a control whose activation does nothing misleads. The label is for web, where
* the keyboard reorders through it.
*/
export function DraggableListHandle({ testID, style, children }: DraggableListHandleProps) {
const drag = useContext(RowDragContext);
const hold = useMemo(
() =>
drag
? createDragHold({
delayMs: HANDLE_HOLD_MS,
slopPx: HANDLE_HOLD_SLOP_PX,
onArm: () => {
void Haptics.selectionAsync().catch(() => {});
drag();
},
})
: null,
[drag],
);
useEffect(() => () => hold?.cancel(), [hold]);
const startHold = useCallback(
(event: GestureResponderEvent) => hold?.begin(touchPoint(event)),
[hold],
);
const moveHold = useCallback(
(event: GestureResponderEvent) => hold?.move(touchPoint(event)),
[hold],
);
const cancelHold = useCallback(() => hold?.cancel(), [hold]);
return (
<View
onTouchStart={startHold}
onTouchMove={moveHold}
onTouchEnd={cancelHold}
onTouchCancel={cancelHold}
style={style}
testID={testID}
>
{children}
</View>
);
}
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
@@ -41,6 +115,12 @@ export function DraggableList<T>({
}: DraggableListProps<T>) {
const { theme } = useUnistyles();
const [isDragging, setIsDragging] = useState(false);
// Inside a sheet the sheet would otherwise take the vertical move from an armed row.
const contentDrag = useContentDrag();
const { attach: attachContentDrag } = contentDrag;
const dragGestureRef = useRef<GestureType | undefined>(undefined);
// Passive effects run after the list's gesture detector has attached and filled the ref.
useEffect(() => attachContentDrag?.(dragGestureRef), [attachContentDrag]);
// Pass the ref directly to DraggableFlatList - it handles gesture
// coordination internally for nestable lists.
@@ -63,27 +143,31 @@ export function DraggableList<T>({
drag,
isActive,
};
return renderItem(info);
return <RowDragContext.Provider value={drag}>{renderItem(info)}</RowDragContext.Provider>;
},
[renderItem],
);
const handleDragEnd = useCallback(
({ data: newData }: { data: T[] }) => {
(drop: { data: T[]; from: number; to: number }) => {
setIsDragging(false);
onDragEnd(newData);
contentDrag.setActive(false);
const moved = movedItemKey(drop, keyExtractor);
if (moved !== null) onDragEnd(drop.data, moved);
},
[onDragEnd],
[contentDrag, keyExtractor, onDragEnd],
);
const handleDragBegin = useCallback(() => {
setIsDragging(true);
contentDrag.setActive(true);
onDragBeginProp?.();
}, [onDragBeginProp]);
}, [contentDrag, onDragBeginProp]);
const handleRelease = useCallback(() => {
setIsDragging(false);
}, []);
contentDrag.setActive(false);
}, [contentDrag]);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
@@ -128,6 +212,7 @@ export function DraggableList<T>({
extraData={extraData}
simultaneousHandlers={simultaneousHandlers}
dragGestureHostPresented={gestureHostPresented}
dragGestureRef={attachContentDrag ? dragGestureRef : undefined}
// Higher activation distance reduces accidental drag capture while nested
// lists are inside a scroll container.
activationDistance={20}
@@ -1,4 +1,4 @@
import type { ReactElement, MutableRefObject } from "react";
import type { ReactElement, ReactNode, MutableRefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { GestureType } from "react-native-gesture-handler";
@@ -12,6 +12,18 @@ export interface DraggableListDragHandleProps {
setActivatorNodeRef?: (node: unknown) => void;
}
/**
* The grip a row is dragged by, rendered anywhere inside `renderItem`. Pointer drags start from
* it on web (with `useDragHandle`); touch drags start after a short hold on native.
*/
export interface DraggableListHandleProps {
/** Names the keyboard-reorderable handle on web. Native leaves the handle out of accessibility. */
accessibilityLabel: string;
testID?: string;
style?: StyleProp<ViewStyle>;
children: ReactNode;
}
export interface DraggableRenderItemInfo<T> {
item: T;
index: number;
@@ -24,7 +36,8 @@ export interface DraggableListProps<T> {
data: T[];
keyExtractor: (item: T, index: number) => string;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
onDragEnd: (data: T[]) => void;
/** The new order, and the key of the item the user moved to reach it. */
onDragEnd: (data: T[], movedKey: string) => void;
style?: StyleProp<ViewStyle>;
/** Outer container style (useful for nested, non-scrolling lists). */
containerStyle?: StyleProp<ViewStyle>;
@@ -0,0 +1,110 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DraggableList,
DraggableListHandle,
type DraggableRenderItemInfo,
} from "./draggable-list.web";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
interface Item {
id: string;
}
function keyExtractor(item: Item): string {
return item.id;
}
function noop() {}
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function renderRowWithHandle(info: DraggableRenderItemInfo<Item>) {
return (
<div>
<DraggableListHandle accessibilityLabel={`Move ${info.item.id}`} testID="row-handle">
<span>grip</span>
</DraggableListHandle>
<span data-testid="row-body">{info.item.id}</span>
</div>
);
}
function renderList(onDragBegin: () => void) {
act(() => {
root?.render(
<DraggableList
data={[{ id: "one" }, { id: "two" }]}
keyExtractor={keyExtractor}
renderItem={renderRowWithHandle}
onDragEnd={noop}
onDragBegin={onDragBegin}
useDragHandle
/>,
);
});
}
function byTestId(testID: string): HTMLElement {
const found = container?.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`);
return found;
}
/** Press on the element's centre, move the mouse down by `distance`, and release. */
function pressAndMove(element: HTMLElement, distance: number) {
const { left, top, width, height } = element.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
act(() => {
element.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: x, clientY: y }),
);
});
act(() => {
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y + distance }),
);
});
act(() => {
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
}
describe("DraggableListHandle on web", () => {
it("starts the row's drag from the handle, and only from the handle", () => {
const onDragBegin = vi.fn();
renderList(onDragBegin);
pressAndMove(byTestId("row-body"), 20);
expect(onDragBegin).not.toHaveBeenCalled();
pressAndMove(byTestId("row-handle"), 20);
expect(onDragBegin).toHaveBeenCalledTimes(1);
});
it("is a labelled button rather than dnd-kit's sortable", () => {
renderList(noop);
const handle = byTestId("row-handle");
expect(handle.getAttribute("role")).toBe("button");
expect(handle.getAttribute("aria-label")).toBe("Move one");
expect(handle.getAttribute("aria-roledescription")).toBeNull();
});
});
@@ -1,4 +1,13 @@
import { memo, useCallback, useMemo, useRef, type ReactElement } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useMemo,
useRef,
type ReactElement,
type Ref,
} from "react";
import { ScrollView, View } from "react-native";
import {
DndContext,
@@ -17,10 +26,52 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import type {
DraggableListDragHandleProps,
DraggableListHandleProps,
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import { getDragActivationConstraints, useDragReorderState } from "./drag-reorder";
export type { DraggableListProps, DraggableRenderItemInfo };
export type { DraggableListHandleProps, DraggableListProps, DraggableRenderItemInfo };
/** The row's dnd-kit activator, for its handle. Absent unless the list uses drag handles. */
const RowHandleContext = createContext<DraggableListDragHandleProps | undefined>(undefined);
// React Native's style types have no `cursor: "grab"`; react-native-web passes it through.
const GRAB_CURSOR = { cursor: "grab" } as object;
export function DraggableListHandle({
accessibilityLabel,
testID,
style,
children,
}: DraggableListHandleProps) {
const handle = useContext(RowHandleContext);
// The handle is a labelled button; dnd-kit's own role, focus stop and "sortable" description
// would replace that.
const {
role: _role,
tabIndex: _tabIndex,
"aria-roledescription": _roleDescription,
...attributes
} = handle?.attributes ?? {};
const handleStyle = useMemo(() => [GRAB_CURSOR, style], [style]);
return (
<View
{...attributes}
{...handle?.listeners}
ref={handle?.setActivatorNodeRef as unknown as Ref<View>}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={handleStyle}
testID={testID}
>
{children}
</View>
);
}
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
...transform,
@@ -177,7 +228,9 @@ function SortableItemInner<T>({
return (
<div {...wrapperProps} style={style}>
{renderItem(info)}
<RowHandleContext.Provider value={dragHandleProps}>
{renderItem(info)}
</RowHandleContext.Provider>
</div>
);
}
@@ -5,6 +5,8 @@ import {
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
export const HOST_STATUS_DOT_SIZE = 8;
export function HostStatusDot({ serverId }: { serverId: string }) {
const status = useHostRuntimeConnectionStatus(serverId);
@@ -19,8 +21,8 @@ function statusStyle(status: HostRuntimeConnectionStatus) {
const styles = StyleSheet.create((theme) => ({
dot: {
width: 8,
height: 8,
width: HOST_STATUS_DOT_SIZE,
height: HOST_STATUS_DOT_SIZE,
borderRadius: theme.borderRadius.full,
},
dotOnline: {
@@ -1,8 +1,12 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, Server } from "lucide-react-native";
import { Text, View } from "react-native";
import { Server } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { HOST_STATUS_DOT_SIZE } from "@/components/host-status-dot";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { mapLayout } from "@/components/ui/control-sizing";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
@@ -12,7 +16,8 @@ import {
} from "@/components/hosts/host-picker";
const ThemedServer = withUnistyles(Server);
const ThemedChevronDown = withUnistyles(ChevronDown);
// The height a hung trigger keeps: one line of its base-size label.
const LABEL_LINE = 20;
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface HostFilterProps {
@@ -24,20 +29,28 @@ export interface HostFilterProps {
* manager edits a single host's catalog, so "all" is not an answer it could carry out.
*/
includeAllHost?: boolean;
/**
* Hang the trigger into the surrounding padding, for a trigger that leads a column such as a
* modal body: its leading glyph's ink sits on the content rail and it adds only a label line to
* the column, so the column's own gap sets the rhythm. The frame still grows outward on hover.
*/
hang?: boolean;
triggerTestID?: string;
hostOptionTestID?: (serverId: string) => string;
}
/**
* The "All hosts / <host>" host pill shared by History, Schedules, Usage, the label manager and
* The "All hosts / <host>" host picker shared by History, Schedules, Usage, the label manager and
* plugin screens: an anchored HostPicker, hidden by the caller when only one host exists. Without
* `includeAllHost` it picks the one host a surface shows.
* `includeAllHost` it picks the one host a surface shows. Its trigger is the Settings host
* trigger; a long host name truncates instead of pushing the row.
*/
export function HostFilter({
hosts,
selectedHost,
onSelectHost,
includeAllHost = true,
hang = false,
triggerTestID,
hostOptionTestID,
}: HostFilterProps): ReactElement {
@@ -50,12 +63,21 @@ export function HostFilter({
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const showsAllHosts = selectedHost === ALL_HOSTS_OPTION_ID;
const slotStyle = useMemo(
() => [
styles.triggerSlot,
hang && styles.triggerSlotHang,
hang && (showsAllHosts ? styles.glyphHang : styles.dotHang),
],
[hang, showsAllHosts],
);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
const triggerStyle = useCallback(
({ pressed, hovered }: { pressed: boolean; hovered: boolean }) => [
styles.trigger,
hovered && styles.triggerHovered,
pressed && styles.triggerPressed,
],
[],
);
@@ -74,54 +96,73 @@ export function HostFilter({
desktopPlacement="bottom-start"
hostOptionTestID={hostOptionTestID}
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
<View style={slotStyle}>
<ComboboxTrigger
ref={filterAnchorRef}
onPress={handleFilterOpen}
style={filterTriggerStyle}
style={triggerStyle}
testID={triggerTestID}
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<ThemedServer size={14} uniProps={mutedColorMapping} />
{showsAllHosts ? (
<ThemedServer size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
<Text style={styles.label} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</ComboboxTrigger>
</View>
</HostPicker>
);
}
const styles = StyleSheet.create((theme) => ({
filterTriggerWrap: {
alignSelf: "flex-start",
// A row around the trigger keeps it as wide as its label in a column, and centred like any other
// item in a row.
triggerSlot: {
flexDirection: "row",
flexShrink: 1,
minWidth: 0,
maxWidth: "100%",
},
filterTrigger: {
// Hangs the frame's height down to a label line.
triggerSlotHang: {
marginVertical: mapLayout(density.tightRowHeight, (height) => (LABEL_LINE - height) / 2),
},
// Hangs the padding, so the server glyph's box sits on the rail.
glyphHang: {
marginLeft: -theme.spacing[2],
},
// Hangs the padding and the dot's inset in its icon slot, so the dot's ink sits on the rail.
dotHang: {
marginLeft: -(theme.spacing[2] + (ICON_SIZE.sm - HOST_STATUS_DOT_SIZE) / 2),
},
// The Settings host trigger: a borderless row that fills on hover and press.
trigger: {
flexShrink: 1,
minWidth: 0,
maxWidth: "100%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
minHeight: density.tightRowHeight,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
filterTriggerHovered: {
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
triggerPressed: {
backgroundColor: theme.colors.surface2,
},
filterTriggerText: {
label: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -164,6 +164,25 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({
{children}
</section>
) : null,
SheetHeaderIconButton: ({
label,
onPress,
busy,
testID,
}: {
label: string;
onPress: () => void;
busy?: boolean;
testID?: string;
}) => (
<button
type="button"
aria-label={label}
disabled={busy}
onClick={onPress}
data-testid={testID}
/>
),
}));
vi.mock("react-native", async () => {
@@ -9,7 +9,11 @@ import type {
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
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 {
AdaptiveModalSheet,
SheetHeaderIconButton,
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";
@@ -216,36 +220,6 @@ function ProviderErrorBannerRow({
);
}
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.refreshButton,
pressed && styles.refreshButtonPressed,
],
[],
);
return (
<Pressable
onPress={onPress}
disabled={isRefreshing}
accessibilityLabel={t("importSession.actions.refresh")}
accessibilityRole="button"
testID="import-session-refresh"
style={pressableStyle}
>
<View style={styles.refreshIconSlot}>
{isRefreshing ? (
<LoadingSpinner color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={16} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
);
}
function ScopeSubtitle({
hostLabel,
isScoped,
@@ -775,7 +749,15 @@ export function ImportSessionSheet({
},
}
: {}),
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
actions: (
<SheetHeaderIconButton
icon={RotateCw}
label={t("importSession.actions.refresh")}
onPress={handleRefresh}
busy={isRefreshing}
testID="import-session-refresh"
/>
),
}),
[handleRefresh, handleShowAll, hostLabel, isRefreshing, scopeCwd, supportsSearch, t, visible],
);
@@ -1099,18 +1081,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
textAlign: "center",
},
refreshButton: {
padding: theme.spacing[2],
marginRight: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
},
refreshButtonPressed: {
backgroundColor: theme.colors.surface2,
},
refreshIconSlot: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
}));
+12 -35
View File
@@ -35,7 +35,7 @@ import {
type AgentProfilePickerRow as AgentProfilePickerRowModel,
type AgentProfileSeed,
} from "@/agent-profiles";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { SheetHeaderIconButton, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -74,14 +74,13 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedPencil = withUnistyles(Pencil);
const ThemedPlus = withUnistyles(Plus);
const ThemedSearch = withUnistyles(Search);
const ThemedSettings = withUnistyles(Settings);
function ProviderSettingsAction({
accessibilityLabel,
label,
provider,
serverId,
}: {
accessibilityLabel: string;
label: string;
provider: string;
serverId: string | null;
}) {
@@ -92,17 +91,13 @@ function ProviderSettingsAction({
}, [overlayParentLayer, provider, serverId]);
return (
<Pressable
<SheetHeaderIconButton
icon={Settings}
label={label}
onPress={handlePress}
disabled={!serverId}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={`selector-header-settings-${provider}`}
>
<HeaderSettingsIcon disabled={!serverId} />
</Pressable>
/>
);
}
@@ -188,10 +183,6 @@ const foregroundExtraMutedMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
});
interface ModelBrowserInput {
providers: ProviderSelectorProvider[];
selectedProvider: string;
@@ -278,11 +269,6 @@ export function ModelProviderGlyph({
return <Icon size={size} strokeWidth={strokeWidth} color={color} />;
}
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
return <ThemedSettings size={ICON_SIZE.sm} uniProps={uniProps} />;
}
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.rowIconButton,
@@ -395,15 +381,11 @@ export function useModelBrowser({
),
back: singleProviderView ? undefined : { onPress: showAll },
actions: (
<View style={styles.headerActionRow}>
<ProviderSettingsAction
serverId={serverId}
provider={view.providerId}
accessibilityLabel={t("modelSelector.openProviderSettings", {
provider: view.providerLabel,
})}
/>
</View>
<ProviderSettingsAction
serverId={serverId}
provider={view.providerId}
label={t("modelSelector.openProviderSettings", { provider: view.providerLabel })}
/>
),
search: {
onChange: handleSearchQueryChange,
@@ -1586,11 +1568,6 @@ export function ModelBrowser({
}
const styles = StyleSheet.create((theme) => ({
headerActionRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
profilesContainer: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
@@ -8,6 +8,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -275,28 +276,10 @@ function DiagnosticSubSheet({
}
}, [visible, fetchDiagnostic]);
const refreshButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
loading ? sheetStyles.disabled : null,
],
[loading],
);
const handleRefreshPress = useCallback(() => {
void fetchDiagnostic();
}, [fetchDiagnostic]);
const copyButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
diagnostic ? null : sheetStyles.disabled,
],
[diagnostic],
);
const handleCopyPress = useCallback(() => {
if (!diagnostic) return;
void Clipboard.setStringAsync(diagnostic)
@@ -308,49 +291,27 @@ function DiagnosticSubSheet({
() => ({
title: t("settings.providers.diagnostic.title"),
actions: (
<View style={sheetStyles.headerActions}>
<Pressable
<>
<SheetHeaderIconButton
icon={Copy}
label={t("settings.providers.diagnostic.copyAccessibility")}
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={copyButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
>
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
/>
<SheetHeaderIconButton
icon={RotateCw}
label={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
onPress={handleRefreshPress}
busy={loading}
/>
</>
),
}),
[
copyButtonStyle,
diagnostic,
handleCopyPress,
handleRefreshPress,
loading,
refreshButtonStyle,
t,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
[diagnostic, handleCopyPress, handleRefreshPress, loading, t],
);
let body: React.ReactNode;
@@ -762,11 +723,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
disabled: {
opacity: 0.5,
},
@@ -127,7 +127,7 @@ export function SortableInlineList<T>({
data: T[];
keyExtractor: (item: T, index: number) => string;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
onDragEnd?: (data: T[]) => void;
onDragEnd?: (data: T[], movedKey: string) => void;
useDragHandle?: boolean;
disabled?: boolean;
activationDistance?: number;
+11 -49
View File
@@ -1,16 +1,16 @@
import React, { createContext, useContext, useCallback, useMemo } from "react";
import type { ReactNode } from "react";
import { View, Text, Pressable } from "react-native";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import {
IsolatedBottomSheetModal,
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { SheetHeaderView, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ICON_SIZE } from "@/styles/theme";
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
import { ToolCallDetailsContent } from "./tool-call-details";
@@ -66,9 +66,6 @@ function ToolCallHeaderIcon({ icon: Icon, size, color }: ToolCallHeaderIconProps
const ThemedToolCallHeaderIcon = withUnistyles(ToolCallHeaderIcon, (theme) => ({
color: theme.colors.foreground,
}));
const ThemedCloseIcon = withUnistyles(X, (theme) => ({
color: theme.colors.foregroundMuted,
}));
export const ToolCallSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
backgroundStyle: {
@@ -148,7 +145,6 @@ interface ToolCallSheetContentProps {
}
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { t } = useTranslation();
const {
toolName,
displayName,
@@ -157,27 +153,17 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
icon: IconComponent,
showLoadingSkeleton,
} = data;
const header = useMemo<SheetHeader>(
() => ({
title: displayName,
leading: <ThemedToolCallHeaderIcon icon={IconComponent} size={ICON_SIZE.md} />,
}),
[IconComponent, displayName],
);
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<ThemedToolCallHeaderIcon icon={IconComponent} size={20} />
<Text style={styles.headerTitle} numberOfLines={1}>
{displayName}
</Text>
</View>
<Pressable
onPress={onClose}
style={styles.closeButton}
accessibilityRole="button"
accessibilityLabel={t("common.actions.close")}
testID="tool-call-sheet-close"
>
<ThemedCloseIcon size={20} />
</Pressable>
</View>
<SheetHeaderView header={header} onClose={onClose} closeTestID="tool-call-sheet-close" />
{/* Content */}
<BottomSheetScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
@@ -200,30 +186,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface2,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
},
headerTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
flex: 1,
},
closeButton: {
padding: theme.spacing[2],
},
content: {
flex: 1,
minHeight: 0,
@@ -1,11 +1,47 @@
import React, { createContext, useContext, type ReactNode } from "react";
import React, {
createContext,
useContext,
useMemo,
type MutableRefObject,
type ReactNode,
} from "react";
import type { GestureType } from "react-native-gesture-handler";
const BottomSheetScopeContext = createContext(false);
/**
* Ref for a drag gesture inside the sheet's content, set through the gesture's `withRef`. Keep it
* out of worklet closures: Reanimated freezes captured objects in dev, and RNGH can then no longer
* set `.current`.
*/
export type SheetContentDragGesture = MutableRefObject<GestureType | undefined>;
export function BottomSheetScope({ children }: { children: ReactNode }) {
return <BottomSheetScopeContext.Provider value>{children}</BottomSheetScopeContext.Provider>;
/**
* Registers a child's drag with the sheet, so the sheet's content pan waits for it to fail and the
* child keeps the touch instead of moving the sheet. Returns the unregister. Call it after the
* gesture has attached: RNGH resolves the ref to a handler tag when the sheet builds its pan, and
* drops a ref that is still empty.
*/
export type AttachSheetContentDrag = (gesture: SheetContentDragGesture) => () => void;
interface BottomSheetScopeValue {
attachContentDrag: AttachSheetContentDrag;
}
const BottomSheetScopeContext = createContext<BottomSheetScopeValue | null>(null);
export function BottomSheetScope({
attachContentDrag,
children,
}: BottomSheetScopeValue & { children: ReactNode }) {
const value = useMemo(() => ({ attachContentDrag }), [attachContentDrag]);
return (
<BottomSheetScopeContext.Provider value={value}>{children}</BottomSheetScopeContext.Provider>
);
}
export function useIsInsideBottomSheet(): boolean {
return useContext(BottomSheetScopeContext);
return useContext(BottomSheetScopeContext) !== null;
}
export function useAttachSheetContentDrag(): AttachSheetContentDrag | undefined {
return useContext(BottomSheetScopeContext)?.attachContentDrag;
}
@@ -0,0 +1,6 @@
import { Pressable, StyleSheet } from "react-native";
/** The invisible layer behind a dialog that closes it when pressed outside the dialog. */
export function DismissBackdrop({ label, onPress }: { label: string; onPress: () => void }) {
return <Pressable accessibilityLabel={label} style={StyleSheet.absoluteFill} onPress={onPress} />;
}
@@ -8,6 +8,7 @@ import {
TOUCH_ICON_SIZE,
type SizingValue,
} from "@/components/ui/control-geometry";
import type { LayoutValue } from "@/components/ui/control-sizing";
import { ICON_SIZE } from "@/styles/theme";
export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color";
@@ -102,6 +103,25 @@ export function iconButtonChromeGlyphSize(size: IconButtonChromeSize, compact =
return GLYPH_SIZES[size][controlSizingForLayout(compact)];
}
/** The glyph for an icon button, sized by its container's chrome the way its frame is. */
export function useIconButtonChromeGlyphSize(size: IconButtonChromeSize, compact = false): number {
const scope = useContext(IconButtonChromeContext);
return iconButtonChromeGlyphSize(scope === "header-island" ? "large" : size, compact);
}
/**
* The margin that pulls a button out by how far its frame reaches past its glyph. A row that hangs
* its edge buttons by it keeps their glyphs, not their frames, on the row's content rail.
*/
export function iconButtonChromeHang(size: IconButtonChromeSize): LayoutValue {
const frame = ICON_BUTTON_SIZES[size];
const glyph = GLYPH_SIZES[size];
return bySizing({
touch: (glyph.touch - frame.touch) / 2,
pointer: (glyph.pointer - frame.pointer) / 2,
});
}
export function smallIconButtonChromeFrameSize(compact = false): number {
return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)];
}
@@ -41,25 +41,41 @@ vi.mock("@gorhom/portal", () => ({
PortalHost: () => React.createElement("div", { "data-host": true }),
}));
/** Every `waitFor` Gorhom received, in render order. Gorhom rebuilds its content pan per value. */
const sheetWaitFor = vi.hoisted(() => [] as unknown[]);
vi.mock("@gorhom/bottom-sheet", async () => {
const { Portal } = await import("@gorhom/portal");
return {
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
BottomSheetModal: React.forwardRef(
({ children, stackBehavior }: { children?: React.ReactNode; stackBehavior?: string }, _ref) =>
React.createElement(
(
{
children,
stackBehavior,
waitFor,
}: { children?: React.ReactNode; stackBehavior?: string; waitFor?: unknown },
_ref,
) => {
sheetWaitFor.push(waitFor);
return React.createElement(
Portal,
null,
React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
),
);
},
),
};
});
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout";
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
import {
useAttachSheetContentDrag,
useIsInsideBottomSheet,
type SheetContentDragGesture,
} from "@/components/ui/bottom-sheet-scope";
/** Renders whatever the portal is currently holding, from its own place in the tree. */
function PortalHostProbe() {
@@ -233,3 +249,51 @@ describe("bottom sheet layout subscription", () => {
}
});
});
describe("IsolatedBottomSheetModal content drag", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
sheetWaitFor.length = 0;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
portalStore.reset();
});
const dragGesture: SheetContentDragGesture = { current: undefined };
function ContentDrag() {
const attach = useAttachSheetContentDrag();
React.useEffect(() => attach?.(dragGesture), [attach]);
return null;
}
function renderSheet(content: React.ReactNode) {
act(() => {
root.render(
<>
<IsolatedBottomSheetModal contextBridge={null}>{content}</IsolatedBottomSheetModal>
<PortalHostProbe />
</>,
);
});
}
it("makes the sheet wait for a content drag that mounts after it, until it unmounts", () => {
renderSheet(null);
expect(sheetWaitFor.at(-1)).toBeUndefined();
renderSheet(<ContentDrag />);
expect(sheetWaitFor.at(-1)).toBe(dragGesture);
renderSheet(null);
expect(sheetWaitFor.at(-1)).toBeUndefined();
});
});
@@ -4,7 +4,15 @@ import {
type BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import React from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import type { ElementRef, ReactNode } from "react";
import { systemBackPress } from "./back-press";
@@ -12,7 +20,11 @@ import {
type BottomSheetController,
createBottomSheetVisibilityTracker,
} from "./visibility-tracker";
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
import {
BottomSheetScope,
type AttachSheetContentDrag,
type SheetContentDragGesture,
} from "@/components/ui/bottom-sheet-scope";
import { SheetBackdrop } from "./sheet-backdrop";
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
@@ -95,6 +107,14 @@ export const IsolatedBottomSheetModal = forwardRef<
enableDismissOnClose = true,
...bottomSheetProps
} = props;
// State, not a ref the child fills in: Gorhom builds its pan once per `waitFor`, and RNGH reads
// the ref's handler tag only then. Registering hands Gorhom a new `waitFor`, so a drag that
// mounts after the sheet (content still loading) still gets the relation.
const [contentDragGesture, setContentDragGesture] = useState<SheetContentDragGesture>();
const attachContentDrag = useCallback<AttachSheetContentDrag>((gesture) => {
setContentDragGesture(gesture);
return () => setContentDragGesture((current) => (current === gesture ? undefined : current));
}, []);
const renderBackdrop = useCallback(
(backdropProps: BottomSheetBackdropProps) => (
<SheetBackdrop
@@ -112,8 +132,11 @@ export const IsolatedBottomSheetModal = forwardRef<
enableDismissOnClose={enableDismissOnClose}
stackBehavior={presentation}
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
waitFor={contentDragGesture}
>
<BottomSheetScope>{contextBridge ? contextBridge(children) : children}</BottomSheetScope>
<BottomSheetScope attachContentDrag={attachContentDrag}>
{contextBridge ? contextBridge(children) : children}
</BottomSheetScope>
</GorhomBottomSheetModal>
);
+42 -4
View File
@@ -1,4 +1,4 @@
import { forwardRef, type Ref } from "react";
import { createContext, forwardRef, useContext, useMemo, useState, type Ref } from "react";
import {
ScrollView as NativeScrollView,
FlatList as NativeFlatList,
@@ -12,7 +12,11 @@ import {
type NativeViewGestureHandlerProps,
} from "react-native-gesture-handler";
import { isNative } from "@/constants/platform";
import { useIsInsideBottomSheet } from "./bottom-sheet-scope";
import {
useAttachSheetContentDrag,
useIsInsideBottomSheet,
type AttachSheetContentDrag,
} from "./bottom-sheet-scope";
// Both libraries forward to the RN FlatList instance. Their published ref types
// disagree about getScrollResponder (Gorhom) and include the component function
@@ -34,9 +38,43 @@ export const ScrollView = forwardRef<NativeScrollView, ScrollViewProps>(
<NativeScrollView {...props} ref={ref} />
);
}
return <SheetScrollView {...props} ref={ref} />;
},
);
/**
* What a child that drags inside a sheet needs to keep the touch: it registers its drag gesture
* through `attach` so the sheet's pan waits for it, and `setActive(true)` stops the sheet's
* scroll until the drag ends. Outside a sheet scroll there is no `attach` and `setActive` does
* nothing.
*/
export interface ContentDrag {
attach: AttachSheetContentDrag | undefined;
setActive: (active: boolean) => void;
}
const ContentDragContext = createContext<ContentDrag | null>(null);
const NO_CONTENT_DRAG: ContentDrag = { attach: undefined, setActive: () => {} };
export function useContentDrag(): ContentDrag {
return useContext(ContentDragContext) ?? NO_CONTENT_DRAG;
}
const SheetScrollView = forwardRef<NativeScrollView, ScrollViewProps>(
function SheetScrollView(props, ref) {
const attach = useAttachSheetContentDrag();
const [dragging, setDragging] = useState(false);
const contentDrag = useMemo(() => ({ attach, setActive: setDragging }), [attach]);
return (
<BottomSheetScrollView {...props} ref={ref}>
{props.children}
<BottomSheetScrollView
{...props}
scrollEnabled={!dragging && props.scrollEnabled !== false}
ref={ref}
>
<ContentDragContext.Provider value={contentDrag}>
{props.children}
</ContentDragContext.Provider>
</BottomSheetScrollView>
);
},
@@ -66,13 +66,15 @@ function createHandleRecorder() {
return { handleRef, received, assignHandle };
}
const attachNoContentDrag = () => () => {};
describe("EditingTextInputNative", () => {
it("uses the bottom-sheet input only inside a bottom sheet", () => {
act(() => {
root?.render(
<>
<EditingTextInput testID="outside" />
<BottomSheetScope>
<BottomSheetScope attachContentDrag={attachNoContentDrag}>
<EditingTextInput testID="inside" />
</BottomSheetScope>
</>,
+4 -8
View File
@@ -6,7 +6,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { FolderTree } from "lucide-react-native";
import {
AdaptiveModalSheet,
SHEET_HEADER_CLOSE_PADDING_SCALE,
SHEET_HORIZONTAL_PADDING_SCALE,
} from "@/components/adaptive-modal-sheet";
import {
@@ -27,14 +26,11 @@ import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
// difference so a depth-0 file icon lands on the header glyph's rail.
const TREE_LEADING_INSET = SPACING[SHEET_HORIZONTAL_PADDING_SCALE] - treeRowPaddingLeft(0);
// The header's close glyph sits at the content inset plus the button's own
// padding, while a tree row ends its trailing glyph at
// `WORKSPACE_PANE_TRAILING_GLYPH_RAIL`. The body carries the difference so row
// change icons land on the header X's rail.
// The header's close glyph ends on the sheet's content inset, while a tree row
// ends its trailing glyph at `WORKSPACE_PANE_TRAILING_GLYPH_RAIL`. The body
// carries the difference so row change icons land on the header X's rail.
const TREE_TRAILING_INSET =
SPACING[SHEET_HORIZONTAL_PADDING_SCALE] +
SPACING[SHEET_HEADER_CLOSE_PADDING_SCALE] -
WORKSPACE_PANE_TRAILING_GLYPH_RAIL;
SPACING[SHEET_HORIZONTAL_PADDING_SCALE] - WORKSPACE_PANE_TRAILING_GLYPH_RAIL;
const ThemedFolderTree = withUnistyles(FolderTree);
@@ -330,6 +330,8 @@ describe("loadAppSettingsFromStorage", () => {
displayAs: "used",
pins: null,
serverId: null,
cardOrder: [],
cardsOff: [],
});
const deps = makeDeps({
@@ -354,6 +356,8 @@ describe("loadAppSettingsFromStorage", () => {
{ sourceId: "claude", windowId: "five-hour" },
],
serverId: "server",
cardOrder: [],
cardsOff: [],
});
});
@@ -369,6 +373,8 @@ describe("loadAppSettingsFromStorage", () => {
displayAs: "remaining",
pins: [],
serverId: "server",
cardOrder: [],
cardsOff: [],
});
});
@@ -389,6 +395,8 @@ describe("loadAppSettingsFromStorage", () => {
displayAs: "used",
pins: [{ sourceId: "claude", windowId: "weekly" }],
serverId: null,
cardOrder: [],
cardsOff: [],
});
});
+2
View File
@@ -2226,6 +2226,8 @@ export const ar: TranslationResources = {
loginExpired: "انتهت صلاحية تسجيل الدخول {{relativeTime}}. {{remedy}}",
title: "استخدام الخطة",
refresh: "تحديث",
reorderCard: "إعادة ترتيب {{source}}",
showCard: "إظهار {{source}}",
refreshAll: "تحديث الكل",
refreshing: "جارٍ التحديث...",
loading: "جارٍ تحميل بيانات الاستخدام...",
+2
View File
@@ -2252,6 +2252,8 @@ export const en = {
loginExpired: "Login expired {{relativeTime}}. {{remedy}}",
title: "Plan usage",
refresh: "Refresh",
reorderCard: "Reorder {{source}}",
showCard: "Show {{source}}",
refreshAll: "Refresh all",
refreshing: "Refreshing...",
loading: "Loading usage...",
+2
View File
@@ -2275,6 +2275,8 @@ export const es: TranslationResources = {
loginExpired: "La sesión caducó {{relativeTime}}. {{remedy}}",
title: "Uso del plan",
refresh: "Actualizar",
reorderCard: "Reordenar {{source}}",
showCard: "Mostrar {{source}}",
refreshAll: "Actualizar todo",
refreshing: "Actualizando...",
loading: "Cargando uso...",
+2
View File
@@ -2276,6 +2276,8 @@ export const fr: TranslationResources = {
loginExpired: "Connexion expirée {{relativeTime}}. {{remedy}}",
title: "Utilisation du forfait",
refresh: "Actualiser",
reorderCard: "Réorganiser {{source}}",
showCard: "Afficher {{source}}",
refreshAll: "Tout actualiser",
refreshing: "Actualisation...",
loading: "Chargement de l’utilisation...",
+2
View File
@@ -2249,6 +2249,8 @@ export const ja: TranslationResources = {
loginExpired: "ログインは{{relativeTime}}に期限切れになりました。{{remedy}}",
title: "プラン使用量",
refresh: "更新",
reorderCard: "{{source}} を並べ替え",
showCard: "{{source}} を表示",
refreshAll: "すべて更新",
refreshing: "更新中...",
loading: "使用状況を読み込み中...",
+2
View File
@@ -2237,6 +2237,8 @@ export const ko: TranslationResources = {
loginExpired: "로그인이 {{relativeTime}}에 만료되었습니다. {{remedy}}",
title: "플랜 사용량",
refresh: "새로 고침",
reorderCard: "{{source}} 순서 변경",
showCard: "{{source}} 표시",
refreshAll: "모두 새로 고침",
refreshing: "새로 고치는 중...",
loading: "사용량 불러오는 중...",
+2
View File
@@ -2260,6 +2260,8 @@ export const ptBR: TranslationResources = {
loginExpired: "O login expirou {{relativeTime}}. {{remedy}}",
title: "Uso do plano",
refresh: "Atualizar",
reorderCard: "Reordenar {{source}}",
showCard: "Mostrar {{source}}",
refreshAll: "Atualizar tudo",
refreshing: "Atualizando...",
loading: "Carregando uso...",
+2
View File
@@ -2261,6 +2261,8 @@ export const ru: TranslationResources = {
loginExpired: "Срок входа истёк {{relativeTime}}. {{remedy}}",
title: "Использование плана",
refresh: "Обновить",
reorderCard: "Переместить {{source}}",
showCard: "Показывать {{source}}",
refreshAll: "Обновить всё",
refreshing: "Обновление...",
loading: "Загрузка данных об использовании...",
+2
View File
@@ -2203,6 +2203,8 @@ export const zhCN: TranslationResources = {
loginExpired: "登录已于{{relativeTime}}过期。{{remedy}}",
title: "套餐用量",
refresh: "刷新",
reorderCard: "重新排序 {{source}}",
showCard: "显示 {{source}}",
refreshAll: "全部刷新",
refreshing: "刷新中...",
loading: "正在加载用量...",
@@ -6,8 +6,12 @@ import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import { MoreHorizontal, Trash2 } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { MoreHorizontal, RotateCw, Trash2 } from "lucide-react-native";
import {
AdaptiveModalSheet,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { SettingsCard, SettingsRow } from "@/components/settings";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -218,11 +222,16 @@ function PluginLogsSheet({
() => ({
title: t("settings.plugins.logs.title", { name: pluginDisplayName(plugin) }),
actions: (
<Button variant="outline" onPress={refresh} disabled={logs.isFetching}>
{logs.isFetching
? t("settings.plugins.logs.refreshing")
: t("settings.plugins.logs.refresh")}
</Button>
<SheetHeaderIconButton
icon={RotateCw}
label={
logs.isFetching
? t("settings.plugins.logs.refreshing")
: t("settings.plugins.logs.refresh")
}
onPress={refresh}
busy={logs.isFetching}
/>
),
}),
[logs.isFetching, plugin, refresh, t],
@@ -94,7 +94,7 @@ export function SheetPane({ sheet, onClose, onBack, opener = null, isTop }: Shee
actions: sheet.policy.openAsTab ? (
<SheetHeaderIconButton
icon={Maximize2}
accessibilityLabel={t("workspace.sheet.openAsTab")}
label={t("workspace.sheet.openAsTab")}
onPress={handleOpenAsTab}
testID="sheet-tab-open-as-tab"
/>
@@ -1,16 +1,16 @@
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { View } from "react-native";
import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorhom/bottom-sheet";
import { Wrench, X } from "lucide-react-native";
import { Wrench } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import {
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { SheetHeaderView, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ToolCallSheetModal, useToolCallSheetContextBridge } from "@/components/tool-call-sheet";
import { useToolCallExpansionContextBridge } from "@/tool-calls/expansion-context";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
interface OverviewToolCallGroupSheetProps {
visible: boolean;
@@ -21,9 +21,8 @@ interface OverviewToolCallGroupSheetProps {
const SNAP_POINTS = ["60%", "95%"];
const ThemedWrench = withUnistyles(Wrench);
const ThemedX = withUnistyles(X);
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const headerIcon = <ThemedWrench size={ICON_SIZE.md} uniProps={foregroundMapping} />;
export function OverviewToolCallGroupSheet({
visible,
@@ -31,7 +30,6 @@ export function OverviewToolCallGroupSheet({
children,
onClose,
}: OverviewToolCallGroupSheetProps) {
const { t } = useTranslation();
const scrollRef = useRef<BottomSheetScrollViewMethods>(null);
const sheetContextBridge = useToolCallSheetContextBridge();
const expansionContextBridge = useToolCallExpansionContextBridge();
@@ -39,6 +37,10 @@ export function OverviewToolCallGroupSheet({
(content) => sheetContextBridge(expansionContextBridge(content)),
[expansionContextBridge, sheetContextBridge],
);
const header = useMemo<SheetHeader>(
() => ({ title: summary, titleTestID: "tool-call-group-sheet-summary", leading: headerIcon }),
[summary],
);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
onClose,
@@ -68,27 +70,11 @@ export function OverviewToolCallGroupSheet({
enablePanDownToClose
>
<View style={styles.container} testID="tool-call-group-sheet">
<View style={styles.header}>
<View style={styles.headerLeft}>
<ThemedWrench size={20} uniProps={foregroundMapping} />
<Text
style={styles.headerTitle}
numberOfLines={2}
testID="tool-call-group-sheet-summary"
>
{summary}
</Text>
</View>
<Pressable
onPress={onClose}
style={styles.closeButton}
accessibilityRole="button"
accessibilityLabel={t("common.actions.close")}
testID="tool-call-group-sheet-close"
>
<ThemedX size={20} uniProps={foregroundMutedMapping} />
</Pressable>
</View>
<SheetHeaderView
header={header}
onClose={onClose}
closeTestID="tool-call-group-sheet-close"
/>
<BottomSheetScrollView ref={scrollRef} style={styles.content}>
<View style={styles.contentContainer}>{children}</View>
</BottomSheetScrollView>
@@ -102,30 +88,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface2,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
},
headerTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
flex: 1,
},
closeButton: {
padding: theme.spacing[2],
},
content: {
flex: 1,
minHeight: 0,
+4 -2
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -8,7 +9,7 @@ import type { UsageReportEntry } from "./types";
/**
* The usage of the account an agent runs under, for its context window details: one card per
* report as it streams in, without pins. Nothing on hosts that cannot report usage, or for an
* report in the user's card order, without pins. Nothing on hosts that cannot report usage, or for an
* agent with no account to report.
*/
export function AgentUsage({
@@ -55,7 +56,8 @@ function AgentUsageCards({
refreshable: boolean;
}) {
const { display } = useUsageDisplay(reports);
return reports.map((entry) => (
const arranged = useMemo(() => display.arrange(reports), [display, reports]);
return arranged.map((entry) => (
<UsageCard
key={entry.id}
serverId={serverId}
+141 -10
View File
@@ -1,4 +1,4 @@
import { RotateCw } from "lucide-react-native";
import { GripVertical, RotateCw } from "lucide-react-native";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type TextStyle } from "react-native";
@@ -8,15 +8,18 @@ import {
iconButtonChromeGlyphSize,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { DraggableListHandle } from "@/components/draggable-list";
import { ControlSizeProvider } from "@/components/ui/control-size";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { StatusBadge } from "@/components/ui/status-badge";
import { Switch } from "@/components/ui/switch";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import type { TFunction } from "i18next";
import { UsageBalanceBar } from "./balance-bar";
import type { UsageDisplay } from "./display";
import { formatAgo, formatProviderUsageLabel } from "./format";
import { formatAgo, formatProviderUsageLabel, formatUsageAccountName } from "./format";
import { resolveUsageReport, type UsageRefresh } from "./model";
import { useReportRefresh } from "./queries";
import { UsageSourceIcon } from "./source-icon";
@@ -32,6 +35,15 @@ function statusText(report: UsageReport, t: TFunction): string | null {
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedGripVertical = withUnistyles(GripVertical);
/** A card in the user's arrangement: dragged by its handle, turned off from its switch. */
export interface UsageCardArrangement {
on: boolean;
onSetOn: (on: boolean) => void;
/** The handle is always there; it shows while the card is hovered or where there is no hover. */
handleVisible: boolean;
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
export function UsageCard({
@@ -41,6 +53,7 @@ export function UsageCard({
display,
pinnable,
refreshable,
arrangement,
compact = false,
}: {
serverId: string;
@@ -51,6 +64,8 @@ export function UsageCard({
pinnable: boolean;
/** Whether the header has a Refresh button. Without one the freshness shows on the card. */
refreshable: boolean;
/** Present where the user arranges cards. An off card collapses to its header. */
arrangement?: UsageCardArrangement;
compact?: boolean;
}) {
const { t } = useTranslation();
@@ -60,9 +75,9 @@ export function UsageCard({
const showsFreshnessInline = isNative || isCompact || !refreshable;
const usage = entry.report;
const status = statusText(usage, t);
const footer = entry.account.label ?? null;
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
resolveUsageReport(entry, t);
const collapsed = arrangement?.on === false;
const report = resolveUsageReport(entry, t);
const { planLabel, successfulAt, retained } = report;
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
@@ -79,20 +94,34 @@ export function UsageCard({
return (
<View style={containerStyle} testID={`usage-report-${entry.id}`}>
<View style={styles.header}>
<View style={arrangement ? styles.headerWithHandle : styles.header}>
{arrangement ? (
<UsageCardDragHandle
name={formatUsageAccountName(entry)}
visible={arrangement.handleVisible}
/>
) : null}
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{planLabel ? <StatusBadge label={planLabel} variant="muted" size="xs" /> : null}
{collapsed && entry.account.label ? (
// The body that names the account is gone, and two off cards of one source must differ.
<Text style={styles.collapsedAccount} numberOfLines={1}>
{entry.account.label}
</Text>
) : null}
{planLabel && !collapsed ? (
<StatusBadge label={planLabel} variant="muted" size="xs" />
) : null}
<View style={styles.headerSpacer} />
{status ? (
{status && !collapsed ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
{refreshable ? (
{refreshable && !collapsed ? (
<UsageRefreshButton
sourceLabel={entry.sourceLabel}
successfulAt={successfulAt}
@@ -102,8 +131,55 @@ export function UsageCard({
compact={isCompact}
/>
) : null}
{arrangement ? (
<ControlSizeProvider size="sm">
<Switch
value={arrangement.on}
onValueChange={arrangement.onSetOn}
accessibilityLabel={t("providerUsage.showCard", {
source: formatUsageAccountName(entry),
})}
style={styles.cardSwitch}
testID={`usage-card-switch-${entry.id}`}
/>
</ControlSizeProvider>
) : null}
</View>
{collapsed ? null : (
<UsageCardBody
entry={entry}
display={display}
pinnable={pinnable}
report={report}
showsFreshnessInline={showsFreshnessInline}
refreshState={refreshState}
/>
)}
</View>
);
}
function UsageCardBody({
entry,
display,
pinnable,
report,
showsFreshnessInline,
refreshState,
}: {
entry: UsageReportEntry;
display: UsageDisplay;
pinnable: boolean;
report: ReturnType<typeof resolveUsageReport>;
showsFreshnessInline: boolean;
refreshState: UsageRefresh;
}) {
const { t } = useTranslation();
const { windows, balances, details, retained, successfulAt, messages } = report;
const footer = entry.account.label ?? null;
return (
<>
{messages.length > 0 ? (
<Text style={styles.error} testID="usage-login-error">
{messages.join("\n")}
@@ -164,7 +240,24 @@ export function UsageCard({
{t("providerUsage.refreshFailed")}
</Text>
) : null}
</View>
</>
);
}
/**
* Drags the card. It hangs in the card's left padding, so the logo, name and body share one rail
* and revealing it on hover moves nothing.
*/
function UsageCardDragHandle({ name, visible }: { name: string; visible: boolean }) {
const { t } = useTranslation();
return (
<DraggableListHandle
accessibilityLabel={t("providerUsage.reorderCard", { source: name })}
style={visible ? styles.dragHandle : styles.dragHandleHidden}
testID="usage-card-drag-handle"
>
<ThemedGripVertical size={DRAG_GRIP_SIZE} uniProps={extraMutedIconColorMapping} />
</DraggableListHandle>
);
}
@@ -279,6 +372,21 @@ function UsageFreshness({
);
}
const DRAG_GRIP_SIZE = 14;
// The handle fills the card's left padding beside the header.
function dragHandleSlot(spacing: { 4: number }) {
return {
position: "absolute",
left: 0,
width: spacing[4],
top: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
} as const;
}
// The Refresh glyph lands on the card's right rail and the header keeps its text height;
// the button's larger hitbox overhangs both instead of pushing them.
function iconHitboxOverhang(compact: boolean) {
@@ -305,11 +413,28 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
// The row reaches into the card's left padding and above and below its text, so the handle
// there is inside it: Android delivers no touches to a child where it overflows its parent.
headerWithHandle: {
position: "relative",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginLeft: -theme.spacing[4],
paddingLeft: theme.spacing[4],
marginVertical: -theme.spacing[2],
paddingVertical: theme.spacing[2],
},
name: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
collapsedAccount: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
headerSpacer: {
flex: 1,
},
@@ -379,6 +504,12 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm,
},
dragHandle: dragHandleSlot(theme.spacing),
dragHandleHidden: { ...dragHandleSlot(theme.spacing), opacity: 0 },
// The switch's reserved control height overhangs the header like the Refresh button's hitbox.
cardSwitch: {
marginVertical: -theme.spacing[2],
},
refreshButton: iconHitboxOverhang(false),
refreshButtonCompact: iconHitboxOverhang(true),
}));
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import {
arrangeUsageCards,
arrangeUsageReports,
DEFAULT_USAGE_PREFERENCES,
setUsageCardOn,
UsagePreferencesSchema,
usageReportsOn,
type UsagePreferences,
} from "./preferences";
import { resolvePinnedUsage } from "./pinned";
import type { UsageReportEntry } from "./types";
function report(sourceId: string, sourceLabel: string, account?: string): UsageReportEntry {
return {
id: `${sourceId}:${account ?? "default"}`,
sourceId,
sourceLabel,
account: account ? { label: account } : {},
fetchedAt: "2026-01-01T00:00:00.000Z",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", summary: true, usedPct: 40 }],
},
};
}
const codex = report("codex", "Codex");
const claudeWork = report("claude", "Claude", "work");
const claudePersonal = report("claude", "Claude", "personal");
const copilot = report("copilot", "Copilot");
const gemini = report("gemini", "Gemini");
function ids(reports: readonly UsageReportEntry[]): string[] {
return reports.map((entry) => entry.id);
}
function arranged(cardOrder: string[]): UsagePreferences {
return { ...DEFAULT_USAGE_PREFERENCES, cardOrder };
}
describe("usage card order", () => {
it("shows cards by provider, then account, whatever order they arrive in", () => {
const expected = ["claude:personal", "claude:work", "codex:default", "copilot:default"];
for (const arrival of [
[codex, claudeWork, copilot, claudePersonal],
[copilot, claudePersonal, codex, claudeWork],
]) {
expect(ids(arrangeUsageReports(arrival, DEFAULT_USAGE_PREFERENCES))).toEqual(expected);
}
});
it("puts the cards the user arranged first, in their order", () => {
const preferences = arranged(["copilot:default", "claude:work"]);
expect(
ids(arrangeUsageReports([claudeWork, codex, claudePersonal, copilot], preferences)),
).toEqual(["copilot:default", "claude:work", "claude:personal", "codex:default"]);
});
it("lands a report from a new login in its default slot after the arranged cards", () => {
const preferences = arrangeUsageCards(DEFAULT_USAGE_PREFERENCES, {
moved: "codex:default",
after: ["codex:default", "claude:work"],
});
const newLogin = report("claude", "Claude", "agent");
expect(ids(arrangeUsageReports([newLogin, claudeWork, codex], preferences))).toEqual([
"codex:default",
"claude:work",
"claude:agent",
]);
});
it("keeps another host's arrangement when a drop on this host moves a card both show", () => {
// Host A shows Codex, Claude and Gemini; host B shows Copilot and the same Claude account.
const hostA = [codex, claudeWork, gemini];
const hostB = [claudeWork, copilot];
const arrangedOnA = arrangeUsageCards(DEFAULT_USAGE_PREFERENCES, {
moved: "codex:default",
after: ["codex:default", "claude:work", "gemini:default"],
});
const after = ["copilot:default", "claude:work"];
const arrangedOnB = arrangeUsageCards(arrangedOnA, { moved: "copilot:default", after });
expect(ids(arrangeUsageReports(hostA, arrangedOnB))).toEqual([
"codex:default",
"claude:work",
"gemini:default",
]);
expect(ids(arrangeUsageReports(hostB, arrangedOnB))).toEqual(after);
});
it("moves only the dragged card when it swaps with its neighbour", () => {
// Gemini sits between Codex and Copilot, but only another host shows it.
const preferences = arranged(["codex:default", "gemini:default", "copilot:default"]);
const swapped = arrangeUsageCards(preferences, {
moved: "codex:default",
after: ["copilot:default", "codex:default"],
});
expect(ids(arrangeUsageReports([codex, copilot], swapped))).toEqual([
"copilot:default",
"codex:default",
]);
expect(ids(arrangeUsageReports([gemini, copilot], swapped))).toEqual([
"gemini:default",
"copilot:default",
]);
});
});
describe("usage cards turned off", () => {
it("turns a card off and back on", () => {
const off = setUsageCardOn(DEFAULT_USAGE_PREFERENCES, "codex:default", false);
expect(off.cardsOff).toEqual(["codex:default"]);
expect(setUsageCardOn(off, "codex:default", true).cardsOff).toEqual([]);
});
it("leaves off cards out of the sidebar summary", () => {
const preferences = setUsageCardOn(DEFAULT_USAGE_PREFERENCES, "codex:default", false);
expect(ids(usageReportsOn([codex, claudeWork], preferences))).toEqual(["claude:work"]);
expect(
resolvePinnedUsage([codex, claudeWork], preferences).map((source) => source.key),
).toEqual(["claude:work"]);
});
it("orders the sidebar summary like the cards", () => {
const preferences = arranged(["codex:default"]);
expect(
resolvePinnedUsage([claudeWork, codex], preferences).map((source) => source.key),
).toEqual(["codex:default", "claude:work"]);
});
});
describe("stored card preferences", () => {
it("reads settings saved before cards could be arranged as no arrangement", () => {
expect(UsagePreferencesSchema.parse({ displayAs: "used", serverId: null })).toEqual(
DEFAULT_USAGE_PREFERENCES,
);
});
it("survives a save and reload, and ignores a malformed arrangement", () => {
const saved = setUsageCardOn(arranged(["codex:default"]), "claude:work", false);
expect(UsagePreferencesSchema.parse(JSON.parse(JSON.stringify(saved)))).toEqual(saved);
expect(
UsagePreferencesSchema.parse({ displayAs: "used", cardOrder: "bad", cardsOff: [1] }),
).toEqual(DEFAULT_USAGE_PREFERENCES);
});
});
+32 -37
View File
@@ -16,47 +16,41 @@ export interface UsageHostSelection {
}
/**
* The controls on the right of every usage title row: the host filter when there is more than one
* host, Refresh all, and the Settings cog. A host that cannot report usage keeps only the host filter.
* The controls on the right of every usage title row: Refresh all and the Settings cog. A host
* that cannot report usage has none.
*/
export function UsageControls({
view,
serverId,
onRefresh,
hostSelection,
}: {
view: UsageView;
serverId: string;
onRefresh: () => void;
hostSelection?: UsageHostSelection;
}) {
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
if (view.kind === "unavailable") return null;
return (
<View style={styles.controls}>
{hasHostChoice(hostSelection) ? (
<HostFilter
hosts={hostSelection.hosts}
selectedHost={hostSelection.serverId}
onSelectHost={hostSelection.onSelect}
includeAllHost={false}
triggerTestID="usage-host-filter-trigger"
hostOptionTestID={usageHostOptionTestID}
/>
) : null}
{view.kind === "unavailable" ? null : (
<>
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
<UsageOptions serverId={serverId} />
</>
)}
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
<UsageOptions serverId={serverId} />
</View>
);
}
function hasHostChoice(
hostSelection: UsageHostSelection | undefined,
): hostSelection is UsageHostSelection {
return hostSelection !== undefined && hostSelection.hosts.length > 1;
/** The host whose usage shows, when there is more than one to choose from. */
function UsageHostFilter({ hostSelection }: { hostSelection: UsageHostSelection }) {
return (
<HostFilter
hosts={hostSelection.hosts}
selectedHost={hostSelection.serverId}
onSelectHost={hostSelection.onSelect}
includeAllHost={false}
hang
triggerTestID="usage-host-filter-trigger"
hostOptionTestID={usageHostOptionTestID}
/>
);
}
function usageHostOptionTestID(serverId: string): string {
@@ -73,27 +67,28 @@ const styles = StyleSheet.create((theme) => ({
}));
/**
* One host’s usage and title-row controls for the Usage modal.
* Returns null when there is nothing to control, so headers leave no empty island.
* One host’s usage for the Usage modal: its title-row controls, null when there is nothing to
* control so the header leaves no empty island, and the host filter for the top of the body, null
* with a single host.
*/
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
view: UsageView;
refresh: () => void;
controls: ReactElement | null;
hostFilter: ReactElement | null;
} {
const { view, refresh } = useHostUsage(hostSelection.serverId);
const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable";
const controls = useMemo(
() =>
hasControls ? (
<UsageControls
serverId={hostSelection.serverId}
view={view}
onRefresh={refresh}
hostSelection={hostSelection}
/>
) : null,
[hasControls, hostSelection, refresh, view],
view.kind === "unavailable" ? null : (
<UsageControls serverId={hostSelection.serverId} view={view} onRefresh={refresh} />
),
[hostSelection.serverId, refresh, view],
);
return { view, refresh, controls };
const hostFilter = useMemo(
() =>
hostSelection.hosts.length > 1 ? <UsageHostFilter hostSelection={hostSelection} /> : null,
[hostSelection],
);
return { view, refresh, controls, hostFilter };
}
+29 -2
View File
@@ -1,21 +1,32 @@
import { useCallback, useMemo } from "react";
import { useAppSettings } from "@/hooks/use-settings";
import {
arrangeUsageCards,
arrangeUsageReports,
isUsageCardOff,
isUsagePinned,
setUsageCardOn,
setUsageDisplayAs,
toggleUsagePin,
type UsageCardDrop,
type UsageDisplayAs,
type UsagePin,
type UsagePreferences,
} from "./preferences";
import type { UsageReportEntry } from "./types";
/** What usage views need from the preferences: how to read percents, and the pins. */
/** What usage views need from the preferences: how to read percents, the pins, and the cards. */
export interface UsageDisplay {
displayAs: UsageDisplayAs;
setDisplayAs: (displayAs: UsageDisplayAs) => void;
isPinned: (pin: UsagePin) => boolean;
togglePin: (pin: UsagePin) => void;
/** Reports in the order their cards show. */
arrange: (reports: readonly UsageReportEntry[]) => UsageReportEntry[];
/** Saves the order of the shown cards after the user drops one. */
arrangeCards: (drop: UsageCardDrop) => void;
isCardOn: (reportId: string) => boolean;
setCardOn: (reportId: string, on: boolean) => void;
}
interface UsagePreferencesState {
@@ -41,14 +52,30 @@ export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePref
},
[updateSettings, reports],
);
const arrangeCards = useCallback(
(drop: UsageCardDrop) => {
void updateSettings((current) => ({ usage: arrangeUsageCards(current.usage, drop) }));
},
[updateSettings],
);
const setCardOn = useCallback(
(reportId: string, on: boolean) => {
void updateSettings((current) => ({ usage: setUsageCardOn(current.usage, reportId, on) }));
},
[updateSettings],
);
const display = useMemo<UsageDisplay>(
() => ({
displayAs: preferences.displayAs,
setDisplayAs,
isPinned: (pin) => isUsagePinned(preferences, pin, reports),
togglePin,
arrange: (shown) => arrangeUsageReports(shown, preferences),
arrangeCards,
isCardOn: (reportId) => !isUsageCardOff(preferences, reportId),
setCardOn,
}),
[preferences, reports, setDisplayAs, togglePin],
[arrangeCards, preferences, reports, setCardOn, setDisplayAs, togglePin],
);
return { preferences, display };
}
+6 -1
View File
@@ -2,7 +2,7 @@ import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatTimeOfDay } from "@/utils/time";
import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types";
import type { UsageBalanceUnit, UsageReportEntry } from "./types";
const providerUsageLabels = {
session: { label: "Session", key: "providerUsage.labels.session" },
@@ -13,6 +13,11 @@ const providerUsageLabels = {
monthly_credits: { label: "Monthly credits", key: "providerUsage.labels.monthlyCredits" },
} as const;
/** The source, with the account when the report names one: "Claude (work)". */
export function formatUsageAccountName(entry: UsageReportEntry): string {
return entry.account.label ? `${entry.sourceLabel} (${entry.account.label})` : entry.sourceLabel;
}
export function formatProviderUsageLabel(id: string, label: string, t: TFunction = i18n.t): string {
const reviewPrefix = "Code review · ";
if (id.startsWith("code_review:") && label.startsWith(reviewPrefix)) {
+114 -14
View File
@@ -1,12 +1,24 @@
import { useCallback, useMemo, useState } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DraggableList } from "@/components/draggable-list";
import type { DraggableRenderItemInfo } from "@/components/draggable-list.types";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings";
import { UsageCard } from "./card";
import type { UsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageReportEntry } from "./types";
/** One card per report (source + account). Window rows pin while the sidebar summary is on. */
function reportKey(entry: UsageReportEntry): string {
return entry.id;
}
/**
* One card per report (source + account), in the user's order. Each card drags by its handle and
* turns off from its switch. Window rows pin while the sidebar summary is on.
*/
export function UsageList({
serverId,
reports,
@@ -17,25 +29,113 @@ export function UsageList({
display: UsageDisplay;
}) {
const { inSidebar } = useUsageInSidebar();
const { arrange, arrangeCards } = display;
const arranged = useMemo(() => arrange(reports), [arrange, reports]);
const handleDragEnd = useCallback(
(next: UsageReportEntry[], moved: string) =>
arrangeCards({ moved, after: next.map(reportKey) }),
[arrangeCards],
);
const renderItem = useCallback(
({ item, isActive }: DraggableRenderItemInfo<UsageReportEntry>) => (
<View style={styles.item}>
<UsageListCard
serverId={serverId}
entry={item}
display={display}
pinnable={inSidebar}
dragging={isActive}
/>
</View>
),
[display, inSidebar, serverId],
);
return (
<View style={styles.list}>
{reports.map((entry) => (
<View key={entry.id} style={settingsStyles.card}>
<UsageCard
serverId={serverId}
entry={entry}
display={display}
pinnable={inSidebar}
refreshable
/>
</View>
))}
<DraggableList
data={arranged}
keyExtractor={reportKey}
renderItem={renderItem}
onDragEnd={handleDragEnd}
extraData={display}
scrollEnabled={false}
useDragHandle
containerStyle={styles.list}
testID="usage-list"
/>
);
}
/**
* The card inside its hover target: the drag handle shows while the pointer is over the card or
* keyboard focus is inside it, and always where there is no hover.
*/
function UsageListCard({
serverId,
entry,
display,
pinnable,
dragging,
}: {
serverId: string;
entry: UsageReportEntry;
display: UsageDisplay;
pinnable: boolean;
dragging: boolean;
}) {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
// Focus events bubble from the card's controls, so tabbing onto the handle reveals it.
const [hasFocus, setHasFocus] = useState(false);
const handleFocus = useCallback(() => setHasFocus(true), []);
const handleBlur = useCallback(() => setHasFocus(false), []);
const { isCardOn, setCardOn } = display;
const on = isCardOn(entry.id);
const handleSetOn = useCallback(
(next: boolean) => setCardOn(entry.id, next),
[entry.id, setCardOn],
);
const arrangement = useMemo(
() => ({
on,
onSetOn: handleSetOn,
handleVisible: isHovered || hasFocus || dragging || isNative || isCompact,
}),
[dragging, handleSetOn, hasFocus, isCompact, isHovered, on],
);
return (
<View
style={styles.hoverTarget}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onFocus={handleFocus}
onBlur={handleBlur}
>
<View style={settingsStyles.card}>
<UsageCard
serverId={serverId}
entry={entry}
display={display}
pinnable={pinnable}
refreshable
arrangement={arrangement}
/>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
// Each card carries the gap below it, so a native drag moves it with its slot; the list
// takes the last one back.
list: {
gap: theme.spacing[3],
marginBottom: -theme.spacing[3],
},
item: {
paddingBottom: theme.spacing[3],
},
hoverTarget: {
position: "relative",
},
}));
+7 -6
View File
@@ -3,8 +3,11 @@ import { View } from "react-native";
import { Settings } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import {
mutedIconColorMapping,
useIconButtonChromeGlyphSize,
} from "@/components/ui/icon-button-chrome";
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
@@ -32,6 +35,7 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
},
];
const compact = useIsCompactFormFactor();
const glyphSize = useIconButtonChromeGlyphSize("small", compact);
const { display } = useUsageDisplay([]);
const { inSidebar, setInSidebar } = useUsageInSidebar();
return (
@@ -42,10 +46,7 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
compact={compact}
testID="usage-options-menu"
>
<ThemedSettings
size={paneContentToolbarIconSize(compact)}
uniProps={extraMutedIconColorMapping}
/>
<ThemedSettings size={glyphSize} uniProps={mutedIconColorMapping} />
</ToolbarButton>
<DropdownMenuContent
align="end"
+25 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { UsagePreferences } from "./preferences";
import { DEFAULT_USAGE_PREFERENCES, type UsagePreferences } from "./preferences";
import {
choosePinnedUsageLayout,
resolvePinnedUsage,
@@ -49,7 +49,7 @@ function preferences(
pinned: UsagePreferences["pins"],
displayAs: UsagePreferences["displayAs"] = "used",
): UsagePreferences {
return { displayAs, pins: pinned, serverId: null };
return { ...DEFAULT_USAGE_PREFERENCES, displayAs, pins: pinned };
}
describe("choosePinnedUsageLayout", () => {
@@ -114,7 +114,7 @@ describe("resolvePinnedUsage", () => {
]);
});
it("groups pinned windows by source in report order, not pin order", () => {
it("lists pinned windows in card order, not pin order", () => {
const work = report({
sourceId: "claude",
sourceLabel: "Claude",
@@ -161,6 +161,28 @@ describe("resolvePinnedUsage", () => {
});
});
it("follows the arranged card order, even when it separates a source's accounts", () => {
const work = report({
sourceId: "claude",
sourceLabel: "Claude",
account: "work",
windows: [{ id: "weekly", label: "Weekly", usedPct: 90 }],
});
const sources = resolvePinnedUsage([claude, codex, work], {
...preferences([
{ sourceId: "claude", windowId: "weekly" },
{ sourceId: "codex", windowId: "weekly" },
]),
cardOrder: ["claude:default", "codex:default", "claude:work"],
});
expect(sources.map((source) => source.key)).toEqual([
"claude:default",
"codex:default",
"claude:work",
]);
});
it("names a window by its label when the source gives no short label", () => {
const items = windows([codex], preferences([{ sourceId: "codex", windowId: "weekly" }]));
+11 -13
View File
@@ -1,8 +1,13 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
import {
formatPct,
formatDisplayPct,
formatProviderUsageLabel,
formatUsageAccountName,
} from "./format";
import { displayPercent, resolveUsageSnapshot } from "./model";
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
import { effectiveUsagePins, usageReportsOn, type UsagePreferences } from "./preferences";
import { windowTone } from "./tone";
import type { UsageReportEntry, UsageTone } from "./types";
@@ -55,19 +60,12 @@ export function choosePinnedUsageLayout(input: {
}
function describe(entry: UsageReportEntry, windowLabel: string): string {
const account = entry.account.label ? ` (${entry.account.label})` : "";
return `${entry.sourceLabel}${account} ${windowLabel}`;
}
/** Reports with every account of a source together, sources in the order they first appear. */
function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] {
const sourceIds = [...new Set(reports.map((entry) => entry.sourceId))];
return sourceIds.flatMap((sourceId) => reports.filter((entry) => entry.sourceId === sourceId));
return `${formatUsageAccountName(entry)} ${windowLabel}`;
}
/**
* The sidebar Usage item's summary: one group per account, grouped by source, each with its
* windows in its report's order. Accounts with nothing to show are left out.
* The sidebar Usage item's summary: one group per account in card order, each with its windows in
* its report's order. Accounts turned off or with nothing to show are left out.
*/
export function resolvePinnedUsage(
reports: readonly UsageReportEntry[],
@@ -75,7 +73,7 @@ export function resolvePinnedUsage(
t: TFunction = i18n.t,
): PinnedUsageSource[] {
const pins = effectiveUsagePins(preferences, reports);
return groupBySource(reports).flatMap((entry) => {
return usageReportsOn(reports, preferences).flatMap((entry) => {
const snapshot = resolveUsageSnapshot(entry);
if (!snapshot) return [];
const windows = snapshot.report.windows
+6 -6
View File
@@ -31,7 +31,7 @@ describe("usage preferences", () => {
it("unpins a pinned window and keeps the others in order", () => {
const three = [codexWeekly, claudeFiveHour, { sourceId: "claude", windowId: "weekly" }];
const unpinned = toggleUsagePin({
preferences: { displayAs: "used", pins: three, serverId: null },
preferences: { ...DEFAULT_USAGE_PREFERENCES, pins: three },
pin: claudeFiveHour,
reports: [],
});
@@ -40,18 +40,18 @@ describe("usage preferences", () => {
});
it("switches between used and remaining without touching pins", () => {
const preferences = { displayAs: "used" as const, pins: [codexWeekly], serverId: null };
const preferences = { ...DEFAULT_USAGE_PREFERENCES, pins: [codexWeekly] };
expect(setUsageDisplayAs(preferences, "remaining")).toEqual({
...DEFAULT_USAGE_PREFERENCES,
displayAs: "remaining",
pins: [codexWeekly],
serverId: null,
});
});
it("remembers the picked host without touching pins", () => {
expect(setUsageHost({ ...DEFAULT_USAGE_PREFERENCES, pins: [codexWeekly] }, "server")).toEqual({
displayAs: "used",
...DEFAULT_USAGE_PREFERENCES,
pins: [codexWeekly],
serverId: "server",
});
@@ -59,7 +59,7 @@ describe("usage preferences", () => {
it("reads preferences saved before hosts could be picked as no pick", () => {
expect(UsagePreferencesSchema.parse({ displayAs: "remaining", pinned: [codexWeekly] })).toEqual(
{ displayAs: "remaining", pins: [codexWeekly], serverId: null },
{ ...DEFAULT_USAGE_PREFERENCES, displayAs: "remaining", pins: [codexWeekly] },
);
});
});
@@ -125,6 +125,6 @@ it("reads old selections without losing display or host and preserves an explici
] as const) {
expect(
UsagePreferencesSchema.parse({ displayAs: "remaining", serverId: "host", ...input }),
).toEqual({ displayAs: "remaining", serverId: "host", pins });
).toEqual({ ...DEFAULT_USAGE_PREFERENCES, displayAs: "remaining", serverId: "host", pins });
}
});
+98 -2
View File
@@ -18,15 +18,22 @@ export interface UsagePreferences {
pins: UsagePin[] | null;
/** The host the user picked to show usage for; null until they pick one. */
serverId: string | null;
/** Report IDs the user arranged, in their order. Shared by every host. */
cardOrder: string[];
/** Report IDs the user turned off. Their cards collapse and leave the sidebar summary. */
cardsOff: string[];
}
export const DEFAULT_USAGE_PREFERENCES: UsagePreferences = {
displayAs: "used",
pins: null,
serverId: null,
cardOrder: [],
cardsOff: [],
};
const pinsSchema = z.array(z.object({ sourceId: z.string(), windowId: z.string() }));
const reportIdsSchema = z.array(z.string()).optional().catch(undefined);
export const UsagePreferencesSchema = z
.object({
@@ -36,10 +43,18 @@ export const UsagePreferencesSchema = z
// Old [] meant defaults. Read it only when the new selection is absent.
pinned: pinsSchema.optional().catch([]),
serverId: z.string().nullable().catch(null),
cardOrder: reportIdsSchema,
cardsOff: reportIdsSchema,
})
.transform(({ displayAs, pins, pinned, serverId }): UsagePreferences => {
.transform(({ displayAs, pins, pinned, serverId, cardOrder, cardsOff }): UsagePreferences => {
const legacyPins = pinned && pinned.length > 0 ? pinned : null;
return { displayAs, pins: pins === undefined ? legacyPins : pins, serverId };
return {
displayAs,
pins: pins === undefined ? legacyPins : pins,
serverId,
cardOrder: cardOrder ?? [],
cardsOff: cardsOff ?? [],
};
})
.catch(DEFAULT_USAGE_PREFERENCES);
@@ -104,3 +119,84 @@ export function setUsageDisplayAs(
export function setUsageHost(preferences: UsagePreferences, serverId: string): UsagePreferences {
return { ...preferences, serverId };
}
/** Reports the user has not arranged: by provider, then account, then ID. */
function compareByDefaultOrder(a: UsageReportEntry, b: UsageReportEntry): number {
return (
a.sourceLabel.localeCompare(b.sourceLabel) ||
(a.account.label ?? "").localeCompare(b.account.label ?? "") ||
a.id.localeCompare(b.id)
);
}
/**
* Reports in the order their cards show: the ones the user arranged first, in their order, then
* the rest in the default order. Arrival order never matters, so a report that streams in late or
* appears for a new login lands in its own slot.
*/
export function arrangeUsageReports(
reports: readonly UsageReportEntry[],
preferences: UsagePreferences,
): UsageReportEntry[] {
const rank = new Map(preferences.cardOrder.map((id, index) => [id, index]));
return [...reports].sort((a, b) => {
const rankA = rank.get(a.id);
const rankB = rank.get(b.id);
if (rankA !== undefined && rankB !== undefined) return rankA - rankB;
if (rankA !== undefined) return -1;
if (rankB !== undefined) return 1;
return compareByDefaultOrder(a, b);
});
}
/** Reports whose cards are on, in card order: what the sidebar summary reads. */
export function usageReportsOn(
reports: readonly UsageReportEntry[],
preferences: UsagePreferences,
): UsageReportEntry[] {
return arrangeUsageReports(reports, preferences).filter(
(entry) => !isUsageCardOff(preferences, entry.id),
);
}
/** A drop as the list reports it: the card the user moved and the shown cards' order after it. */
export interface UsageCardDrop {
moved: string;
after: readonly string[];
}
/**
* The order after the user drops a card. Only the moved card changes place: it goes just before
* its new successor among the shown cards, or just after its new predecessor when it lands last.
* Shown cards not yet arranged are added at the end, in their shown order. Other arranged cards
* keep their places relative to each other. The order is shared by every host, so a drop that
* arranges a card another host also shows ranks it ahead of that host's unarranged cards.
*/
export function arrangeUsageCards(
preferences: UsagePreferences,
{ moved, after }: UsageCardDrop,
): UsagePreferences {
const arranged = new Set([...preferences.cardOrder, moved]);
const rest = [
...preferences.cardOrder.filter((id) => id !== moved),
...after.filter((id) => !arranged.has(id)),
];
const index = after.indexOf(moved);
const successor = after[index + 1];
const at = successor === undefined ? rest.indexOf(after[index - 1]) + 1 : rest.indexOf(successor);
rest.splice(at, 0, moved);
return { ...preferences, cardOrder: rest };
}
export function isUsageCardOff(preferences: UsagePreferences, reportId: string): boolean {
return preferences.cardsOff.includes(reportId);
}
export function setUsageCardOn(
preferences: UsagePreferences,
reportId: string,
on: boolean,
): UsagePreferences {
const others = preferences.cardsOff.filter((id) => id !== reportId);
return { ...preferences, cardsOff: on ? others : [...others, reportId] };
}
+8 -5
View File
@@ -1,9 +1,12 @@
import { useTranslation } from "react-i18next";
import { RotateCw } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import {
mutedIconColorMapping,
useIconButtonChromeGlyphSize,
} from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
const ThemedRotateCw = withUnistyles(RotateCw);
@@ -16,7 +19,7 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
const { t } = useTranslation();
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
const iconSize = useIconButtonChromeGlyphSize("small", compact);
return (
<ToolbarButton
label={t("providerUsage.refreshAll")}
@@ -26,9 +29,9 @@ export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefre
testID="usage-refresh-all"
>
{busy ? (
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
<ThemedLoadingSpinner size={iconSize} uniProps={mutedIconColorMapping} />
) : (
<ThemedRotateCw size={iconSize} uniProps={extraMutedIconColorMapping} />
<ThemedRotateCw size={iconSize} uniProps={mutedIconColorMapping} />
)}
</ToolbarButton>
);
+7 -5
View File
@@ -17,8 +17,8 @@ interface UsageModalProps {
/**
* Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It
* shows the usage host's reports with pins, and the host filter, Refresh all, and Settings in its
* title row.
* shows the usage host's reports with pins, Refresh all and Settings in its title row, and the
* host filter at the top of its body.
*/
export function UsageModal(props: UsageModalProps) {
const { t } = useTranslation();
@@ -56,10 +56,10 @@ function HostUsageModal({
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
const { view, refresh, controls, hostFilter } = useHostUsageWithControls(hostSelection);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
return (
<UsageModalFrame {...props} actions={controls}>
<UsageModalFrame {...props} actions={controls} hostFilter={hostFilter}>
<View testID={`usage-host-${serverId}`}>
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
@@ -72,8 +72,9 @@ function UsageModalFrame({
onClose,
onDismiss,
actions,
hostFilter,
children,
}: UsageModalProps & { actions?: ReactNode; children: ReactNode }) {
}: UsageModalProps & { actions?: ReactNode; hostFilter?: ReactNode; children: ReactNode }) {
const { t } = useTranslation();
const title = t(builtinSidebarNavLabelKey("usage"));
const header = useMemo(() => ({ title, actions }), [actions, title]);
@@ -87,6 +88,7 @@ function UsageModalFrame({
contextBridge={null}
testID="usage-modal"
>
{hostFilter}
<View testID="usage-modal-body">{children}</View>
</AdaptiveModalSheet>
);
@@ -191,6 +191,7 @@ export function WorkspaceLabelManagerModal({
selectedHost={state.serverId}
onSelectHost={selectHost}
includeAllHost={false}
hang
triggerTestID="workspace-label-manager-host"
hostOptionTestID={managerHostOptionTestID}
/>
@@ -1,8 +1,8 @@
diff --git a/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts b/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts
index d3a4f1f..083bf19 100644
index d3a4f1f..66e5124 100644
--- a/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts
+++ b/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts
@@ -16,12 +16,14 @@ declare const _default: <T>(props: Omit<FlatListProps<T>, "keyExtractor" | "data
@@ -16,12 +16,15 @@ declare const _default: <T>(props: Omit<FlatListProps<T>, "keyExtractor" | "data
keyExtractor: (item: T, index: number) => string;
onDragBegin?: ((index: number) => void) | undefined;
onDragEnd?: ((params: import("../types").DragEndParams<T>) => void) | undefined;
@@ -14,14 +14,15 @@ index d3a4f1f..083bf19 100644
renderPlaceholder?: import("../types").RenderPlaceholder<T> | undefined;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[] | undefined;
+ dragGestureHostPresented?: boolean | undefined;
+ dragGestureRef?: React.MutableRefObject<any> | undefined;
outerScrollOffset?: Animated.SharedValue<number> | undefined;
onAnimValInit?: ((animVals: {
activeCellOffset: import("react-native-reanimated").SharedValue<number>;
diff --git a/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts b/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts
index d3c6cbb..36e9645 100644
index d3c6cbb..a586a09 100644
--- a/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts
+++ b/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts
@@ -15,12 +15,14 @@ export declare const NestableDraggableFlatList: <T>(props: Omit<import("react-na
@@ -15,12 +15,15 @@ export declare const NestableDraggableFlatList: <T>(props: Omit<import("react-na
keyExtractor: (item: T, index: number) => string;
onDragBegin?: ((index: number) => void) | undefined;
onDragEnd?: ((params: import("../types").DragEndParams<T>) => void) | undefined;
@@ -33,14 +34,15 @@ index d3c6cbb..36e9645 100644
renderPlaceholder?: import("../types").RenderPlaceholder<T> | undefined;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[] | undefined;
+ dragGestureHostPresented?: boolean | undefined;
+ dragGestureRef?: React.MutableRefObject<any> | undefined;
outerScrollOffset?: Animated.SharedValue<number> | undefined;
onAnimValInit?: ((animVals: {
activeCellOffset: import("react-native-reanimated").SharedValue<number>;
diff --git a/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts b/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts
index 2abb4a2..8ca967f 100644
index 2abb4a2..c60dedf 100644
--- a/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts
+++ b/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts
@@ -23,12 +23,16 @@ export declare type DraggableFlatListProps<T> = Modify<FlatListProps<T>, {
@@ -23,12 +23,21 @@ export declare type DraggableFlatListProps<T> = Modify<FlatListProps<T>, {
keyExtractor: (item: T, index: number) => string;
onDragBegin?: (index: number) => void;
onDragEnd?: (params: DragEndParams<T>) => void;
@@ -54,6 +56,11 @@ index 2abb4a2..8ca967f 100644
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[];
+ /** @internal Forces handler reattachment when a retained native host is presented. */
+ dragGestureHostPresented?: boolean;
+ /**
+ * Ref for the drag pan, so an enclosing gesture can wait for it to fail. The pan then arbitrates
+ * manually, as with `simultaneousHandlers`: it fails on a move unless a row is armed.
+ */
+ dragGestureRef?: React.MutableRefObject<any>;
outerScrollOffset?: Animated.SharedValue<number>;
onAnimValInit?: (animVals: ReturnType<typeof useAnimatedValues>) => void;
itemEnteringAnimation?: AnimateProps<Animated.View>["entering"];
@@ -174,7 +181,7 @@ index e4e861a..f485139 100644
declare global {
diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
index 7c88afc..ad75714 100644
index 7c88afc..a97cb80 100644
--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
+++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
@@ -16,9 +16,11 @@ import {
@@ -391,14 +398,16 @@ index 7c88afc..ad75714 100644
useAnimatedReaction(
() => {
return spacerIndexAnim.value;
@@ -271,11 +371,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -271,11 +371,26 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
);
const gestureDisabled = useSharedValue(false);
+ const touchStartX = useSharedValue(0);
+ const touchStartY = useSharedValue(0);
+ const manualPanActivated = useSharedValue(false);
+ const coordinatesDragWithExternalGesture = Boolean(props.simultaneousHandlers);
+ const coordinatesDragWithExternalGesture = Boolean(
+ props.simultaneousHandlers || props.dragGestureRef
+ );
- const panGesture = Gesture.Pan()
+ let panGesture = Gesture.Pan()
@@ -417,7 +426,7 @@ index 7c88afc..ad75714 100644
panGestureState.value = evt.state;
})
.onUpdate((evt) => {
@@ -286,8 +399,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -286,8 +401,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
: evt.translationY;
touchTranslate.value = translation;
})
@@ -434,7 +443,7 @@ index 7c88afc..ad75714 100644
// Set touch val to current translate val
isTouchActiveNative.value = false;
const translation = horizontalAnim.value
@@ -299,6 +419,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -299,6 +421,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
// Only call onDragEnd if actually dragging a cell
if (activeIndexAnim.value === -1 || disabled.value) return;
@@ -442,7 +451,7 @@ index 7c88afc..ad75714 100644
disabled.value = true;
runOnJS(onRelease)(activeIndexAnim.value);
const springTo = placeholderOffset.value - activeCellOffset.value;
@@ -309,22 +430,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -309,22 +432,85 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
runOnJS(onDragEnd)({
from: activeIndexAnim.value,
to: spacerIndexAnim.value,
@@ -524,13 +533,14 @@ index 7c88afc..ad75714 100644
if (dragHitSlop) panGesture.hitSlop(dragHitSlop);
- if (activationDistanceProp) {
+ if (props.dragGestureRef) panGesture.withRef(props.dragGestureRef);
+ if (coordinatesDragWithExternalGesture) {
+ panGesture = panGesture.manualActivation(true);
+ } else if (activationDistanceProp) {
const activeOffset = [-activationDistanceProp, activationDistanceProp];
if (props.horizontal) {
panGesture.activeOffsetX(activeOffset);
@@ -332,6 +515,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -332,6 +518,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
panGesture.activeOffsetY(activeOffset);
}
}
@@ -552,7 +562,7 @@ index 7c88afc..ad75714 100644
const onScroll = useStableCallback((scrollOffset: number) => {
props.onScrollOffsetChange?.(scrollOffset);
@@ -372,9 +570,11 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -372,9 +573,11 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
keyExtractor={keyExtractor}
horizontal={!!props.horizontal}
layoutAnimationDisabled={layoutAnimationDisabled}
@@ -636,10 +646,10 @@ index 75f7db7..e99faf3 100644
return (
diff --git a/node_modules/react-native-draggable-flatlist/src/types.ts b/node_modules/react-native-draggable-flatlist/src/types.ts
index d6755c8..1b244a6 100644
index d6755c8..af70a01 100644
--- a/node_modules/react-native-draggable-flatlist/src/types.ts
+++ b/node_modules/react-native-draggable-flatlist/src/types.ts
@@ -36,12 +36,16 @@ export type DraggableFlatListProps<T> = Modify<
@@ -36,12 +36,21 @@ export type DraggableFlatListProps<T> = Modify<
keyExtractor: (item: T, index: number) => string;
onDragBegin?: (index: number) => void;
onDragEnd?: (params: DragEndParams<T>) => void;
@@ -653,6 +663,11 @@ index d6755c8..1b244a6 100644
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[];
+ /** @internal Forces handler reattachment when a retained native host is presented. */
+ dragGestureHostPresented?: boolean;
+ /**
+ * Ref for the drag pan, so an enclosing gesture can wait for it to fail. The pan then arbitrates
+ * manually, as with `simultaneousHandlers`: it fails on a move unless a row is armed.
+ */
+ dragGestureRef?: React.MutableRefObject<any>;
outerScrollOffset?: Animated.SharedValue<number>;
onAnimValInit?: (animVals: ReturnType<typeof useAnimatedValues>) => void;
itemEnteringAnimation?: AnimateProps<Animated.View>["entering"];
@@ -328,9 +328,10 @@ test("reports every named additional limit with its own duration identity", asyn
test("a weekly pin means weekly after the provider moves it between slots", async () => {
const { resolvePinnedUsage } = await import("../../../packages/app/src/usage/pinned.js");
const { DEFAULT_USAGE_PREFERENCES } =
await import("../../../packages/app/src/usage/preferences.js");
const preferences = {
displayAs: "used" as const,
serverId: null,
...DEFAULT_USAGE_PREFERENCES,
pins: [{ sourceId: "codex", windowId: "weekly" }],
};
for (const primaryWeekly of [false, true]) {