mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 04:45:11 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f72fcbb0 | ||
|
|
89e2b84a96 | ||
|
|
cf377c8de0 |
+1
-6
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>,
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2226,6 +2226,8 @@ export const ar: TranslationResources = {
|
||||
loginExpired: "انتهت صلاحية تسجيل الدخول {{relativeTime}}. {{remedy}}",
|
||||
title: "استخدام الخطة",
|
||||
refresh: "تحديث",
|
||||
reorderCard: "إعادة ترتيب {{source}}",
|
||||
showCard: "إظهار {{source}}",
|
||||
refreshAll: "تحديث الكل",
|
||||
refreshing: "جارٍ التحديث...",
|
||||
loading: "جارٍ تحميل بيانات الاستخدام...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -2249,6 +2249,8 @@ export const ja: TranslationResources = {
|
||||
loginExpired: "ログインは{{relativeTime}}に期限切れになりました。{{remedy}}",
|
||||
title: "プラン使用量",
|
||||
refresh: "更新",
|
||||
reorderCard: "{{source}} を並べ替え",
|
||||
showCard: "{{source}} を表示",
|
||||
refreshAll: "すべて更新",
|
||||
refreshing: "更新中...",
|
||||
loading: "使用状況を読み込み中...",
|
||||
|
||||
@@ -2237,6 +2237,8 @@ export const ko: TranslationResources = {
|
||||
loginExpired: "로그인이 {{relativeTime}}에 만료되었습니다. {{remedy}}",
|
||||
title: "플랜 사용량",
|
||||
refresh: "새로 고침",
|
||||
reorderCard: "{{source}} 순서 변경",
|
||||
showCard: "{{source}} 표시",
|
||||
refreshAll: "모두 새로 고침",
|
||||
refreshing: "새로 고치는 중...",
|
||||
loading: "사용량 불러오는 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -2261,6 +2261,8 @@ export const ru: TranslationResources = {
|
||||
loginExpired: "Срок входа истёк {{relativeTime}}. {{remedy}}",
|
||||
title: "Использование плана",
|
||||
refresh: "Обновить",
|
||||
reorderCard: "Переместить {{source}}",
|
||||
showCard: "Показывать {{source}}",
|
||||
refreshAll: "Обновить всё",
|
||||
refreshing: "Обновление...",
|
||||
loading: "Загрузка данных об использовании...",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }]));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
Reference in New Issue
Block a user