From 6784a3feec7395a67fee180f2c94573ae6975d77 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Feb 2026 22:40:20 +0700 Subject: [PATCH 1/2] Update changes (#15) * Update files * Update files --- .../app/src/components/grouped-agent-list.tsx | 298 ++++++++++++------ 1 file changed, 194 insertions(+), 104 deletions(-) diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index c5f7d4b83..b8c682835 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -12,6 +12,7 @@ import { useMemo, useState, useEffect, + useRef, type ReactElement, type MutableRefObject, } from "react"; @@ -24,7 +25,6 @@ import { DraggableList, type DraggableRenderItemInfo, } from "./draggable-list"; -import { formatTimeAgo } from "@/utils/time"; import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; @@ -185,6 +185,164 @@ function SectionHeader({ ); } +interface GroupedAgentRowProps { + agent: AggregatedAgent; + isSelected: boolean; + shortcutNumber: number | null; + onPress: () => void; + onLongPress: () => void; + onArchive: (e: { stopPropagation: () => void }) => void; +} + +function GroupedAgentRow({ + agent, + isSelected, + shortcutNumber, + onPress, + onLongPress, + onArchive, +}: GroupedAgentRowProps) { + const { theme } = useUnistyles(); + const [isHovered, setIsHovered] = useState(false); + const [isArchiveConfirmVisible, setIsArchiveConfirmVisible] = useState(false); + const hoverOutTimeoutRef = useRef | null>(null); + + const clearHoverOutTimeout = useCallback(() => { + if (!hoverOutTimeoutRef.current) { + return; + } + clearTimeout(hoverOutTimeoutRef.current); + hoverOutTimeoutRef.current = null; + }, []); + + useEffect(() => { + return () => clearHoverOutTimeout(); + }, [clearHoverOutTimeout]); + + const handleHoverIn = useCallback(() => { + clearHoverOutTimeout(); + setIsHovered(true); + }, [clearHoverOutTimeout]); + + const handleHoverOut = useCallback(() => { + clearHoverOutTimeout(); + hoverOutTimeoutRef.current = setTimeout(() => { + setIsHovered(false); + setIsArchiveConfirmVisible(false); + }, 50); + }, [clearHoverOutTimeout]); + + const checkoutQuery = useCheckoutStatusCacheOnly({ + serverId: agent.serverId, + cwd: agent.cwd, + }); + const checkout = checkoutQuery.data ?? null; + const activeBranchLabel = checkout?.isGit + ? ((checkout.currentBranch && checkout.currentBranch !== "HEAD" + ? checkout.currentBranch + : null) ?? + checkout.baseRef ?? + "git") + : null; + + const canArchive = agent.status !== "running" && !agent.requiresAttention; + const showArchive = canArchive && shortcutNumber === null && (isHovered || isArchiveConfirmVisible); + + return ( + [ + styles.agentItem, + !isSelected && styles.agentItemUnselected, + isSelected && styles.agentItemSelected, + isHovered && styles.agentItemHovered, + pressed && styles.agentItemPressed, + ]} + onPress={onPress} + onPressIn={() => { + if (Platform.OS !== "web") { + return; + } + handleHoverIn(); + }} + onLongPress={onLongPress} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} + testID={`agent-row-${agent.serverId}-${agent.id}`} + > + + + + + {agent.title || "New agent"} + + {shortcutNumber !== null ? ( + + + {shortcutNumber} + + + ) : showArchive ? ( + { + e.stopPropagation(); + if (!isArchiveConfirmVisible) { + setIsArchiveConfirmVisible(true); + return; + } + onArchive(e); + setIsArchiveConfirmVisible(false); + }} + testID={ + isArchiveConfirmVisible + ? `agent-archive-confirm-${agent.serverId}-${agent.id}` + : `agent-archive-${agent.serverId}-${agent.id}` + } + > + {({ hovered: archiveHovered }) => + isArchiveConfirmVisible ? ( + + Confirm + + ) : ( + + ) + } + + ) : activeBranchLabel ? ( + + + {activeBranchLabel} + + + ) : null} + + + + ); +} + export function GroupedAgentList({ agents, isRefreshing = false, @@ -337,107 +495,6 @@ export function GroupedAgentList({ [] ); - const AgentListRow = useCallback( - ({ agent }: { agent: AggregatedAgent }) => { - const [isHovered, setIsHovered] = useState(false); - const timeAgo = formatTimeAgo(agent.lastActivityAt); - const agentKey = `${agent.serverId}:${agent.id}`; - const isSelected = selectedAgentId === agentKey; - const isRunning = agent.status === "running"; - const shortcutNumber = - showShortcutBadges ? (shortcutIndexByAgentKey.get(agentKey) ?? null) : null; - - const checkoutQuery = useCheckoutStatusCacheOnly({ - serverId: agent.serverId, - cwd: agent.cwd, - }); - const checkout = checkoutQuery.data ?? null; - const activeBranchLabel = checkout?.isGit - ? ((checkout.currentBranch && checkout.currentBranch !== "HEAD" - ? checkout.currentBranch - : null) ?? - checkout.baseRef ?? - "git") - : null; - - const canArchive = !isRunning && !agent.requiresAttention; - - return ( - [ - styles.agentItem, - !isSelected && styles.agentItemUnselected, - isSelected && styles.agentItemSelected, - isHovered && styles.agentItemHovered, - pressed && styles.agentItemPressed, - ]} - onPress={() => handleAgentPress(agent.serverId, agent.id)} - onLongPress={() => handleAgentLongPress(agent)} - onHoverIn={() => setIsHovered(true)} - onHoverOut={() => setIsHovered(false)} - testID={`agent-row-${agent.serverId}-${agent.id}`} - > - - - - - {agent.title || "New agent"} - - {shortcutNumber !== null ? ( - - - {shortcutNumber} - - - ) : isHovered && canArchive ? ( - handleArchiveAgent(e, agent)} - onHoverIn={() => setIsHovered(true)} - onHoverOut={() => setIsHovered(true)} - testID={`agent-archive-${agent.serverId}-${agent.id}`} - > - {({ hovered: archiveHovered }) => ( - - )} - - ) : activeBranchLabel ? ( - - - {activeBranchLabel} - - - ) : null} - - - - ); - }, - [ - handleAgentLongPress, - handleAgentPress, - handleArchiveAgent, - selectedAgentId, - showShortcutBadges, - shortcutIndexByAgentKey, - theme.colors.foreground, - theme.colors.foregroundMuted, - ] - ); - const renderSection = useCallback( ({ item: section, drag, isActive }: DraggableRenderItemInfo) => { const isCollapsed = collapsedProjectKeys.has(section.projectKey); @@ -454,12 +511,34 @@ export function GroupedAgentList({ /> {!isCollapsed && section.agents.map((agent) => ( - + handleAgentPress(agent.serverId, agent.id)} + onLongPress={() => handleAgentLongPress(agent)} + onArchive={(e) => handleArchiveAgent(e, agent)} + /> ))} ); }, - [AgentListRow, collapsedProjectKeys, handleCreateAgentInProject, toggleProjectCollapsed] + [ + collapsedProjectKeys, + handleAgentLongPress, + handleAgentPress, + handleArchiveAgent, + handleCreateAgentInProject, + selectedAgentId, + showShortcutBadges, + shortcutIndexByAgentKey, + toggleProjectCollapsed, + ] ); const keyExtractor = useCallback( @@ -675,6 +754,17 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, lineHeight: 12, }, + archiveConfirmBadge: { + minWidth: 76, + maxWidth: 9999, + flexShrink: 0, + }, + archiveConfirmText: { + color: theme.colors.foreground, + }, + archiveConfirmTextHovered: { + opacity: 0.9, + }, agentTitleHighlighted: { color: theme.colors.foreground, opacity: 1, From a2eab7b00b3dd107889b18ffeebdfb1c26aa725f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Feb 2026 22:40:29 +0700 Subject: [PATCH 2/2] feat(app): add header tooltips + shortcuts (#16) --- .../app/e2e/sidebar-toggle-tooltip.spec.ts | 47 ++ .../headers/header-toggle-button.tsx | 79 +++ .../src/components/headers/menu-header.tsx | 19 +- packages/app/src/components/ui/shortcut.tsx | 37 ++ packages/app/src/components/ui/tooltip.tsx | 477 ++++++++++++++++++ .../src/screens/agent/agent-ready-screen.tsx | 15 +- .../app/src/utils/format-shortcut.test.ts | 16 + packages/app/src/utils/format-shortcut.ts | 53 ++ packages/app/src/utils/shortcut-platform.ts | 18 + 9 files changed, 747 insertions(+), 14 deletions(-) create mode 100644 packages/app/e2e/sidebar-toggle-tooltip.spec.ts create mode 100644 packages/app/src/components/headers/header-toggle-button.tsx create mode 100644 packages/app/src/components/ui/shortcut.tsx create mode 100644 packages/app/src/components/ui/tooltip.tsx create mode 100644 packages/app/src/utils/format-shortcut.test.ts create mode 100644 packages/app/src/utils/format-shortcut.ts create mode 100644 packages/app/src/utils/shortcut-platform.ts diff --git a/packages/app/e2e/sidebar-toggle-tooltip.spec.ts b/packages/app/e2e/sidebar-toggle-tooltip.spec.ts new file mode 100644 index 000000000..952afaeb1 --- /dev/null +++ b/packages/app/e2e/sidebar-toggle-tooltip.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from './fixtures'; +import { gotoHome, openSettings } from './helpers/app'; + +test('sidebar toggle shows tooltip on the right', async ({ page }) => { + await gotoHome(page); + await openSettings(page); + + const menuButton = page.getByRole('button', { name: /menu/i }).first(); + await expect(menuButton).toBeVisible(); + + // Baseline: tooltip should appear on keyboard focus (a11y requirement). + await menuButton.focus(); + + const tooltip = page.getByTestId('menu-button-tooltip'); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText('Toggle sidebar'); + await expect(tooltip).toContainText(/⌘B|Ctrl\+B/); + await page.waitForTimeout(250); + await expect(tooltip).toBeVisible(); + + // Tooltip should also appear on hover. + await menuButton.blur(); + await expect(tooltip).toHaveCount(0); + await menuButton.hover(); + await expect(tooltip).toBeVisible(); + + const triggerBox = await menuButton.boundingBox(); + const tooltipBox = await tooltip.boundingBox(); + expect(triggerBox).not.toBeNull(); + expect(tooltipBox).not.toBeNull(); + if (!triggerBox || !tooltipBox) return; + + // side=right => tooltip starts to the right of the trigger. + expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1); + // Keep it reasonably close (should be ~trigger.right + offset). + const expectedX = triggerBox.x + triggerBox.width + 8; + expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12); + expect(tooltipBox.width).toBeGreaterThanOrEqual(60); + expect(tooltipBox.width).toBeLessThanOrEqual(500); + expect(tooltipBox.height).toBeGreaterThanOrEqual(20); + expect(tooltipBox.height).toBeLessThanOrEqual(80); + + // align=center => centers should be roughly aligned (allow some clamping tolerance). + const triggerCenterY = triggerBox.y + triggerBox.height / 2; + const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2; + expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24); +}); diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx new file mode 100644 index 000000000..764e0de2e --- /dev/null +++ b/packages/app/src/components/headers/header-toggle-button.tsx @@ -0,0 +1,79 @@ +import type { PropsWithChildren, ReactElement } from "react"; +import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Shortcut } from "@/components/ui/shortcut"; +import type { ShortcutKey } from "@/utils/format-shortcut"; + +export function HeaderToggleButton({ + onPress, + tooltipLabel, + tooltipKeys, + tooltipSide, + tooltipDelayDuration = 0, + style, + disabled, + children, + ...props +}: PropsWithChildren< + Omit & { + onPress: NonNullable; + tooltipLabel: string; + tooltipKeys: ShortcutKey[]; + tooltipSide: "left" | "right" | "top" | "bottom"; + tooltipDelayDuration?: number; + style?: StyleProp; + } +>): ReactElement { + const tooltipTestID = + typeof props.testID === "string" && props.testID.length > 0 + ? `${props.testID}-tooltip` + : undefined; + + return ( + + { + onPress(e); + }} + style={[styles.button, style]} + > + {children} + + + + {tooltipLabel} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + button: { + padding: { + xs: theme.spacing[3], + md: theme.spacing[2], + }, + borderRadius: theme.borderRadius.lg, + }, + tooltipRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.popoverForeground, + }, + shortcut: { + backgroundColor: theme.colors.surface3, + borderColor: theme.colors.borderAccent, + }, + shortcutText: { + color: theme.colors.popoverForeground, + }, +})); diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 8ca4fa23a..01b146607 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -1,8 +1,9 @@ import type { ReactNode } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Text } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Menu, PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; +import { HeaderToggleButton } from "./header-toggle-button"; import { usePanelStore } from "@/stores/panel-store"; interface MenuHeaderProps { @@ -28,19 +29,20 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) { - - + {title && ( {title} @@ -58,13 +60,6 @@ const styles = StyleSheet.create((theme) => ({ left: { gap: theme.spacing[2], }, - menuButton: { - padding: { - xs: theme.spacing[3], - md: theme.spacing[2], - }, - borderRadius: theme.borderRadius.lg, - }, title: { flex: 1, fontSize: theme.fontSize.base, diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx new file mode 100644 index 000000000..292895506 --- /dev/null +++ b/packages/app/src/components/ui/shortcut.tsx @@ -0,0 +1,37 @@ +import type { ReactElement } from "react"; +import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; +import { getShortcutOs } from "@/utils/shortcut-platform"; + +export function Shortcut({ + keys, + style, + textStyle, +}: { + keys: ShortcutKey[]; + style?: StyleProp; + textStyle?: StyleProp; +}): ReactElement { + return ( + + {formatShortcut(keys, getShortcutOs())} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + root: { + paddingHorizontal: theme.spacing[1], + paddingVertical: 2, + borderRadius: theme.borderRadius.sm, + backgroundColor: theme.colors.surface2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + }, + text: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx new file mode 100644 index 000000000..a6c4c08dd --- /dev/null +++ b/packages/app/src/components/ui/tooltip.tsx @@ -0,0 +1,477 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type PropsWithChildren, + type ReactElement, +} from "react"; +import { + Dimensions, + Platform, + Modal, + Pressable, + StatusBar, + View, + type PressableProps, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { Portal } from "@gorhom/portal"; +import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { StyleSheet } from "react-native-unistyles"; + +type Side = "top" | "bottom" | "left" | "right"; +type Align = "start" | "center" | "end"; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +type TooltipContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + triggerRef: React.RefObject; + enabled: boolean; + delayDuration: number; +}; + +const TooltipContext = createContext(null); + +function useTooltipContext(componentName: string): TooltipContextValue { + const ctx = useContext(TooltipContext); + if (!ctx) { + throw new Error(`${componentName} must be used within `); + } + return ctx; +} + +function useControllableOpenState({ + open, + defaultOpen, + onOpenChange, +}: { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}): [boolean, (next: boolean) => void] { + const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen)); + const isControlled = typeof open === "boolean"; + const value = isControlled ? Boolean(open) : internalOpen; + const setValue = useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange] + ); + return [value, setValue]; +} + +function measureElement(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => { + resolve({ x, y, width, height }); + }); + }); +} + +function computePosition({ + triggerRect, + contentSize, + displayArea, + side, + align, + offset, +}: { + triggerRect: Rect; + contentSize: { width: number; height: number }; + displayArea: Rect; + side: Side; + align: Align; + offset: number; +}): { x: number; y: number; actualSide: Side } { + const { width: contentWidth, height: contentHeight } = contentSize; + + const spaceTop = triggerRect.y - displayArea.y; + const spaceBottom = + displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); + const spaceLeft = triggerRect.x - displayArea.x; + const spaceRight = + displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width); + + let actualSide = side; + if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) { + actualSide = "top"; + } else if (side === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) { + actualSide = "bottom"; + } else if (side === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) { + actualSide = "right"; + } else if (side === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) { + actualSide = "left"; + } + + let x = 0; + let y = 0; + + if (actualSide === "bottom") { + y = triggerRect.y + triggerRect.height + offset; + if (align === "start") { + x = triggerRect.x; + } else if (align === "end") { + x = triggerRect.x + triggerRect.width - contentWidth; + } else { + x = triggerRect.x + (triggerRect.width - contentWidth) / 2; + } + } else if (actualSide === "top") { + y = triggerRect.y - contentHeight - offset; + if (align === "start") { + x = triggerRect.x; + } else if (align === "end") { + x = triggerRect.x + triggerRect.width - contentWidth; + } else { + x = triggerRect.x + (triggerRect.width - contentWidth) / 2; + } + } else if (actualSide === "left") { + x = triggerRect.x - contentWidth - offset; + if (align === "start") { + y = triggerRect.y; + } else if (align === "end") { + y = triggerRect.y + triggerRect.height - contentHeight; + } else { + y = triggerRect.y + (triggerRect.height - contentHeight) / 2; + } + } else { + x = triggerRect.x + triggerRect.width + offset; + if (align === "start") { + y = triggerRect.y; + } else if (align === "end") { + y = triggerRect.y + triggerRect.height - contentHeight; + } else { + y = triggerRect.y + (triggerRect.height - contentHeight) / 2; + } + } + + const padding = 8; + x = Math.max( + padding, + Math.min(displayArea.width - contentWidth - padding, x) + ); + y = Math.max( + displayArea.y + padding, + Math.min(displayArea.y + displayArea.height - contentHeight - padding, y) + ); + + return { x, y, actualSide }; +} + +export function Tooltip({ + open, + defaultOpen, + onOpenChange, + delayDuration = 0, + enabledOnDesktop = true, + enabledOnMobile = false, + children, +}: PropsWithChildren<{ + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + delayDuration?: number; + enabledOnDesktop?: boolean; + enabledOnMobile?: boolean; +}>): ReactElement { + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useControllableOpenState({ + open, + defaultOpen, + onOpenChange, + }); + + const isWeb = Platform.OS === "web"; + const isMobileWeb = + isWeb && + typeof navigator !== "undefined" && + /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? ""); + const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile; + + const value = useMemo( + () => ({ + open: isOpen, + setOpen: setIsOpen, + triggerRef, + enabled, + delayDuration, + }), + [isOpen, setIsOpen, enabled, delayDuration] + ); + + return {children}; +} + +export function TooltipTrigger({ + children, + disabled, + onHoverIn, + onHoverOut, + onFocus, + onBlur, + onPress, + ...props +}: PropsWithChildren): ReactElement { + const ctx = useTooltipContext("TooltipTrigger"); + const openTimerRef = useRef | null>(null); + + const clearOpenTimer = useCallback(() => { + if (openTimerRef.current) { + clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + }, []); + + const scheduleOpen = useCallback(() => { + if (!ctx.enabled || disabled) return; + clearOpenTimer(); + if (ctx.delayDuration <= 0) { + ctx.setOpen(true); + return; + } + openTimerRef.current = setTimeout(() => { + ctx.setOpen(true); + openTimerRef.current = null; + }, ctx.delayDuration); + }, [clearOpenTimer, ctx, disabled]); + + const close = useCallback(() => { + clearOpenTimer(); + ctx.setOpen(false); + }, [clearOpenTimer, ctx]); + + useEffect(() => { + return () => { + clearOpenTimer(); + }; + }, [clearOpenTimer]); + + const handleHoverIn = useCallback( + (e?: any) => { + onHoverIn?.(e); + scheduleOpen(); + }, + [onHoverIn, scheduleOpen] + ); + + const handleHoverOut = useCallback( + (e?: any) => { + onHoverOut?.(e); + close(); + }, + [onHoverOut, close] + ); + + return ( + { + onFocus?.(e); + if (!ctx.enabled || disabled) return; + clearOpenTimer(); + ctx.setOpen(true); + }} + onBlur={(e) => { + onBlur?.(e); + close(); + }} + onPress={(e) => { + onPress?.(e); + close(); + }} + {...(Platform.OS === "web" + ? ({ + // RN Web's hover handling can vary across environments; pointer events are the most reliable. + onPointerEnter: handleHoverIn, + onPointerLeave: handleHoverOut, + onMouseEnter: handleHoverIn, + onMouseLeave: handleHoverOut, + } as any) + : null)} + > + {children} + + ); +} + +export function TooltipContent({ + children, + side = "top", + align = "center", + offset = 6, + style, + testID, + maxWidth = 280, +}: PropsWithChildren<{ + side?: Side; + align?: Align; + offset?: number; + style?: StyleProp; + testID?: string; + maxWidth?: number; +}>): ReactElement | null { + const ctx = useTooltipContext("TooltipContent"); + const bottomSheetInternal = useBottomSheetModalInternal(true); + const [triggerRect, setTriggerRect] = useState(null); + const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>( + null + ); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + + useEffect(() => { + if (!ctx.open || !ctx.enabled || !ctx.triggerRef.current) { + setTriggerRect(null); + setContentSize(null); + setPosition(null); + return; + } + + const statusBarHeight = + Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; + let cancelled = false; + + measureElement(ctx.triggerRef.current).then((rect) => { + if (cancelled) return; + setTriggerRect({ ...rect, y: rect.y + statusBarHeight }); + }); + + return () => { + cancelled = true; + }; + }, [ctx.enabled, ctx.open, ctx.triggerRef]); + + useEffect(() => { + if (!triggerRect || !contentSize) return; + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight }; + const result = computePosition({ + triggerRect, + contentSize, + displayArea, + side, + align, + offset, + }); + setPosition({ x: result.x, y: result.y }); + }, [triggerRect, contentSize, side, align, offset]); + + const handleLayout = useCallback( + (event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width, height } = event.nativeEvent.layout; + setContentSize({ width, height }); + }, + [] + ); + + if (!ctx.open || !ctx.enabled) return null; + + // On web, avoid React Native's implementation (it uses and can + // steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the + // exact same positioning math as DropdownMenu, without hover feedback loops. + if (Platform.OS === "web") { + return ( + + + + {children} + + + + ); + } + + return ( + ctx.setOpen(false)} + > + + + {children} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { flex: 1 }, + portalOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 1000, + }, + content: { + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.popover, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + shadowColor: "#000", + shadowOpacity: 0.2, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 6, + zIndex: 1000, + }, +})); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index fa20ad24e..b9508bc8c 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -31,6 +31,7 @@ import { } from "lucide-react-native"; import { MenuHeader } from "@/components/headers/menu-header"; import { BackHeader } from "@/components/headers/back-header"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; import { AgentDetailsSheet } from "@/components/agent-details-sheet"; @@ -569,7 +570,17 @@ function AgentScreenContent({ title={effectiveAgent.title || "Agent"} rightContent={ - + {isMobile ? ( checkout?.isGit ? ( )} - + { if (open && agent?.cwd) { diff --git a/packages/app/src/utils/format-shortcut.test.ts b/packages/app/src/utils/format-shortcut.test.ts new file mode 100644 index 000000000..dc25889e8 --- /dev/null +++ b/packages/app/src/utils/format-shortcut.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { formatShortcut } from "./format-shortcut"; + +describe("formatShortcut", () => { + it("uses symbols on macOS", () => { + expect(formatShortcut(["mod", "B"], "mac")).toBe("⌘B"); + expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E"); + }); + + it("uses Ctrl+ on non-mac platforms", () => { + expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B"); + expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E"); + }); +}); + diff --git a/packages/app/src/utils/format-shortcut.ts b/packages/app/src/utils/format-shortcut.ts new file mode 100644 index 000000000..091c90fd4 --- /dev/null +++ b/packages/app/src/utils/format-shortcut.ts @@ -0,0 +1,53 @@ +export type ShortcutKey = + | "mod" + | "shift" + | "alt" + | "ctrl" + | "meta" + | string; + +export type ShortcutOs = "mac" | "non-mac"; + +function normalizeKey(key: string): string { + if (!key) return ""; + if (key.length === 1) return key.toUpperCase(); + return key; +} + +export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string { + const normalized = keys.map((k) => (typeof k === "string" ? k : String(k))); + + if (os === "mac") { + const order = ["ctrl", "alt", "shift", "mod", "meta"]; + const symbols: Record = { + mod: "⌘", + shift: "⇧", + alt: "⌥", + ctrl: "⌃", + meta: "⌘", + }; + + const modifierSet = new Set(normalized); + const mods = order + .filter((k) => modifierSet.has(k)) + .map((k) => symbols[k] ?? ""); + const main = normalized + .filter((k) => !order.includes(k)) + .map(normalizeKey) + .join(""); + return `${mods.join("")}${main}`; + } + + const labels: Record = { + mod: "Ctrl", + shift: "Shift", + alt: "Alt", + ctrl: "Ctrl", + meta: "Win", + }; + return normalized + .map((k) => labels[k] ?? normalizeKey(k)) + .filter(Boolean) + .join("+"); +} + diff --git a/packages/app/src/utils/shortcut-platform.ts b/packages/app/src/utils/shortcut-platform.ts new file mode 100644 index 000000000..a54e2550f --- /dev/null +++ b/packages/app/src/utils/shortcut-platform.ts @@ -0,0 +1,18 @@ +import { Platform } from "react-native"; +import { getIsTauriMac } from "@/constants/layout"; +import type { ShortcutOs } from "@/utils/format-shortcut"; + +export function getShortcutOs(): ShortcutOs { + if (Platform.OS !== "web") { + return Platform.OS === "ios" ? "mac" : "non-mac"; + } + if (getIsTauriMac()) return "mac"; + if (typeof navigator === "undefined") return "non-mac"; + const ua = navigator.userAgent ?? ""; + const platform = (navigator as any).platform ?? ""; + const isApple = + /Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) || + /Mac|iPhone|iPad|iPod/i.test(platform); + return isApple ? "mac" : "non-mac"; +} +