diff --git a/packages/app/e2e/browser/composer-attachments.spec.ts b/packages/app/e2e/browser/composer-attachments.spec.ts index 8b72243df..26a16f517 100644 --- a/packages/app/e2e/browser/composer-attachments.spec.ts +++ b/packages/app/e2e/browser/composer-attachments.spec.ts @@ -4,6 +4,7 @@ import { expectComposerVisible } from "../support/helpers/composer"; import { expectAgentIdle } from "../support/helpers/agent-stream"; import { openAttachmentMenu, + expectAttachmentSheetRowsOnTitleRail, openGithubPickerFromMenu, attachImageFromMenu, expectAttachmentPill, @@ -63,6 +64,22 @@ test.describe("Composer attachments", () => { await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible(); }); + test("compact Plus menu aligns attachment rows with its sheet title", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(60_000); + const workspace = await withWorkspace({ prefix: "attach-sheet-rails-" }); + await workspace.navigateTo(); + await clickNewChat(page); + await expectComposerVisible(page); + await page.setViewportSize({ width: 390, height: 844 }); + + await openAttachmentMenu(page); + + await expectAttachmentSheetRowsOnTitleRail(page); + }); + test("GitHub combobox does not render until the picker is opened", async ({ page, withWorkspace, diff --git a/packages/app/e2e/browser/new-workspace-entry.spec.ts b/packages/app/e2e/browser/new-workspace-entry.spec.ts index 1a1fcb5c2..1454d41ec 100644 --- a/packages/app/e2e/browser/new-workspace-entry.spec.ts +++ b/packages/app/e2e/browser/new-workspace-entry.spec.ts @@ -4,6 +4,7 @@ import { connectNewWorkspaceDaemonClient, expectNewWorkspaceControlsEnabled, expectNewWorkspaceProjectSelected, + expectNewWorkspaceTriggerLabelsAligned, openGlobalNewWorkspaceComposer, openMissingProjectNewWorkspaceComposer, openNewWorkspaceComposer, @@ -77,6 +78,10 @@ test.describe("New workspace entry points", () => { timeout: 30_000, }); await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 }); + await expectNewWorkspaceTriggerLabelsAligned(page, { + projectLabel: seeded.projectDisplayName, + hostLabel: "localhost", + }); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index 68600f023..a0d846cbc 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -7,6 +7,7 @@ import { connectWorkspaceSetupClient } from "./workspace-setup"; import { selectWorkspaceInSidebar } from "./sidebar"; import { getServerId } from "./server-id"; import { waitForTabBar } from "./launcher"; +import { waitForSettledPosition } from "./sheet-layout"; function composerInput(page: Page) { return page.getByRole("textbox", { name: "Message agent..." }).first(); @@ -71,7 +72,28 @@ export async function pressInterruptShortcut(page: Page): Promise { export async function openAttachmentMenu(page: Page): Promise { await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click(); - await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 }); + await expect( + page + .getByTestId("message-input-attachment-menu") + .or(page.getByTestId("message-input-attachment-menu-content")), + ).toBeVisible({ timeout: 5_000 }); +} + +export async function expectAttachmentSheetRowsOnTitleRail(page: Page): Promise { + const title = page.getByText("Add attachment", { exact: true }); + const firstItemGlyph = page + .getByRole("button", { name: "Add image", exact: true }) + .locator("svg") + .first(); + await waitForSettledPosition(title); + const [titleBox, glyphBox] = await Promise.all([ + title.boundingBox(), + firstItemGlyph.boundingBox(), + ]); + if (!titleBox || !glyphBox) { + throw new Error("Attachment sheet geometry could not be measured"); + } + expect(Math.abs(glyphBox.x - titleBox.x)).toBeLessThanOrEqual(1); } export async function expectAttachButtonDisabled(page: Page): Promise { diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index a20811591..099760da9 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -235,6 +235,27 @@ export async function expectNewWorkspaceProjectSelected( await expect(projectPicker).toContainText(projectDisplayName); } +export async function expectNewWorkspaceTriggerLabelsAligned( + page: Page, + input: { projectLabel: string; hostLabel: string }, +): Promise { + const projectTrigger = page.getByRole("button", { name: "Workspace project" }); + const hostTrigger = page.getByRole("button", { name: "Host", exact: true }); + const [projectTriggerBox, projectLabelBox, hostTriggerBox, hostLabelBox] = await Promise.all([ + projectTrigger.boundingBox(), + projectTrigger.getByText(input.projectLabel, { exact: true }).boundingBox(), + hostTrigger.boundingBox(), + hostTrigger.getByText(input.hostLabel, { exact: true }).boundingBox(), + ]); + if (!projectTriggerBox || !projectLabelBox || !hostTriggerBox || !hostLabelBox) { + throw new Error("New Workspace trigger geometry could not be measured"); + } + + const projectLabelInset = projectLabelBox.x - projectTriggerBox.x; + const hostLabelInset = hostLabelBox.x - hostTriggerBox.x; + expect(hostLabelInset).toBeCloseTo(projectLabelInset, 0); +} + export async function fillNewWorkspaceDraft(page: Page, draft: string): Promise { const composer = page.getByRole("textbox", { name: "Message agent..." }); await expect(composer).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/src/components/ui/menu/menu-sheet-layout.test.ts b/packages/app/src/components/ui/menu/menu-sheet-layout.test.ts new file mode 100644 index 000000000..462546268 --- /dev/null +++ b/packages/app/src/components/ui/menu/menu-sheet-layout.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { getMenuSheetBottomPadding } from "./menu-sheet-layout"; + +describe("getMenuSheetBottomPadding", () => { + it("keeps breathing room below the last row when there is no system inset", () => { + expect( + getMenuSheetBottomPadding({ + safeAreaBottom: 0, + breathingRoom: 4, + }), + ).toBe(4); + }); + + it("adds breathing room after the system navigation inset", () => { + expect( + getMenuSheetBottomPadding({ + safeAreaBottom: 48, + breathingRoom: 4, + }), + ).toBe(52); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-sheet-layout.ts b/packages/app/src/components/ui/menu/menu-sheet-layout.ts new file mode 100644 index 000000000..8181ae130 --- /dev/null +++ b/packages/app/src/components/ui/menu/menu-sheet-layout.ts @@ -0,0 +1,6 @@ +export function getMenuSheetBottomPadding(input: { + safeAreaBottom: number; + breathingRoom: number; +}): number { + return input.safeAreaBottom + input.breathingRoom; +} diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 3ee52a7ec..c94721fce 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -14,16 +14,18 @@ import { Pressable, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { ChevronLeft } from "lucide-react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, type ContextBridge, } from "@/components/ui/isolated-bottom-sheet-modal"; -import type { Theme } from "@/styles/theme"; +import { SPACING, type Theme } from "@/styles/theme"; import { useMenuContext, MenuContextProvider } from "./menu-context"; import { MenuPage } from "./menu-item"; import { currentPageId, isSubPageOpen } from "./menu-navigation"; import { AnchoredSurface, MenuOverlay } from "./menu-overlay"; +import { getMenuSheetBottomPadding } from "./menu-sheet-layout"; import type { Alignment, Placement } from "./menu-anchor"; const ThemedChevronLeft = withUnistyles(ChevronLeft); @@ -305,6 +307,17 @@ function MenuSheetSurface({ }: MenuSurfaceProps): ReactElement | null { const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); + const safeAreaInsets = useSafeAreaInsets(); + + const sheetScrollContentStyle = useMemo( + () => ({ + paddingBottom: getMenuSheetBottomPadding({ + safeAreaBottom: safeAreaInsets.bottom, + breathingRoom: SPACING[1], + }), + }), + [safeAreaInsets.bottom], + ); const handleClose = useCallback(() => menu.setOpen(false), [menu]); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ @@ -356,7 +369,7 @@ function MenuSheetSurface({ keyboardBlurBehavior="restore" > ({ - sheetScrollContent: { - paddingBottom: theme.spacing[6], - }, sheetHeader: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 4601915f9..dd34de73a 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -3,8 +3,6 @@ import { View, Text, TextInput, - Pressable, - Platform, useWindowDimensions, NativeSyntheticEvent, TextInputContentSizeChangeEventData, @@ -48,8 +46,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; -import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; @@ -164,8 +160,6 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46; const DEFAULT_MAX_INPUT_HEIGHT = 160; const MAX_INPUT_VIEWPORT_RATIO = 0.5; const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; -const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"]; - type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { metaKey?: boolean; @@ -225,55 +219,6 @@ function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) { ); } -function AttachmentSheetItem({ - item, - onSelect, -}: { - item: AttachmentMenuItem; - onSelect: (item: AttachmentMenuItem) => void; -}) { - const handlePress = useCallback(() => { - onSelect(item); - }, [item, onSelect]); - const pressableStyle = useCallback( - ({ pressed }: { pressed: boolean }) => [ - styles.attachmentSheetItem, - pressed && styles.attachmentSheetItemPressed, - item.disabled && styles.buttonDisabled, - ], - [item.disabled], - ); - - return ( - - {item.icon ? {item.icon} : null} - {item.label} - - ); -} - -function AttachmentSheetList({ - items, - onSelect, -}: { - items: AttachmentMenuItem[]; - onSelect: (item: AttachmentMenuItem) => void; -}) { - return ( - - {items.map((item) => ( - - ))} - - ); -} - function AttachmentDropdown({ isConnected, disabled, @@ -289,73 +234,9 @@ function AttachmentDropdown({ attachmentMenuItems: AttachmentMenuItem[]; addAttachmentLabel: string; }) { - const isCompact = useIsCompactFormFactor(); - const [isSheetOpen, setIsSheetOpen] = useState(false); - useDismissKeyboardOnOpen(isSheetOpen, isCompact); - const isButtonDisabled = !isConnected || disabled; - const attachmentSheetHeader = useMemo( - () => ({ title: addAttachmentLabel }), - [addAttachmentLabel], - ); - const handleOpenSheet = useCallback(() => { - if (isButtonDisabled) return; - setIsSheetOpen(true); - }, [isButtonDisabled]); - const handleCloseSheet = useCallback(() => { - setIsSheetOpen(false); - }, []); - const handleSheetItemSelect = useCallback((item: AttachmentMenuItem) => { - if (item.disabled) return; - setIsSheetOpen(false); - if (Platform.OS === "ios") { - setTimeout(item.onSelect, 250); - return; - } - item.onSelect(); - }, []); - const mobileAttachButtonStyle = useCallback( - (state: { pressed: boolean; hovered?: boolean }) => { - if (typeof attachButtonStyle === "function") { - return attachButtonStyle({ ...state, hovered: Boolean(state.hovered), open: isSheetOpen }); - } - return attachButtonStyle; - }, - [attachButtonStyle, isSheetOpen], - ); - const renderMobileAttachButtonIcon = useCallback( - ({ hovered }: { hovered?: boolean }) => renderAttachButtonIcon({ hovered }), - [renderAttachButtonIcon], - ); - - if (isCompact) { - return ( - <> - - {renderMobileAttachButtonIcon} - - - - - - ); - } - return ( - + @@ -1941,31 +1823,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ buttonDisabled: { opacity: 0.5, }, - attachmentSheetList: { - gap: theme.spacing[1], - }, - attachmentSheetItem: { - minHeight: 44, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.xl, - }, - attachmentSheetItemPressed: { - backgroundColor: theme.colors.surface2, - }, - attachmentSheetItemIcon: { - width: 20, - alignItems: "center", - justifyContent: "center", - }, - attachmentSheetItemText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, overlayContainer: { position: "absolute", display: "flex", diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 01988b411..8271ddb92 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1359,7 +1359,9 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme style={badgePressableStyle} testID="host-picker-trigger" > - + + + {selectedHostLabel}