Merge branch 'main' of github.com:boudra/paseo

This commit is contained in:
Mohamed Boudra
2026-02-06 11:52:09 +07:00
10 changed files with 941 additions and 118 deletions

View File

@@ -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);
});

View File

@@ -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<ReturnType<typeof setTimeout> | 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 (
<Pressable
style={({ pressed }) => [
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}`}
>
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || isHovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
{shortcutNumber !== null ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{shortcutNumber}
</Text>
</View>
) : showArchive ? (
<Pressable
style={[
styles.branchBadge,
isArchiveConfirmVisible && styles.archiveConfirmBadge,
]}
onPress={(e) => {
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 ? (
<Text
style={[
styles.branchBadgeText,
styles.archiveConfirmText,
archiveHovered && styles.archiveConfirmTextHovered,
]}
numberOfLines={1}
>
Confirm
</Text>
) : (
<Archive
size={12}
color={
archiveHovered ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)
}
</Pressable>
) : activeBranchLabel ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{activeBranchLabel}
</Text>
</View>
) : null}
</View>
</View>
</Pressable>
);
}
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 (
<Pressable
style={({ pressed }) => [
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}`}
>
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || isHovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
{shortcutNumber !== null ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{shortcutNumber}
</Text>
</View>
) : isHovered && canArchive ? (
<Pressable
style={styles.branchBadge}
onPress={(e) => handleArchiveAgent(e, agent)}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(true)}
testID={`agent-archive-${agent.serverId}-${agent.id}`}
>
{({ hovered: archiveHovered }) => (
<Archive
size={12}
color={
archiveHovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
) : activeBranchLabel ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{activeBranchLabel}
</Text>
</View>
) : null}
</View>
</View>
</Pressable>
);
},
[
handleAgentLongPress,
handleAgentPress,
handleArchiveAgent,
selectedAgentId,
showShortcutBadges,
shortcutIndexByAgentKey,
theme.colors.foreground,
theme.colors.foregroundMuted,
]
);
const renderSection = useCallback(
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
@@ -454,12 +511,34 @@ export function GroupedAgentList({
/>
{!isCollapsed &&
section.agents.map((agent) => (
<AgentListRow key={`${agent.serverId}:${agent.id}`} agent={agent} />
<GroupedAgentRow
key={`${agent.serverId}:${agent.id}`}
agent={agent}
isSelected={selectedAgentId === `${agent.serverId}:${agent.id}`}
shortcutNumber={
showShortcutBadges
? (shortcutIndexByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null)
: null
}
onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
onArchive={(e) => handleArchiveAgent(e, agent)}
/>
))}
</View>
);
},
[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,

View File

@@ -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<PressableProps, "style" | "onPress"> & {
onPress: NonNullable<PressableProps["onPress"]>;
tooltipLabel: string;
tooltipKeys: ShortcutKey[];
tooltipSide: "left" | "right" | "top" | "bottom";
tooltipDelayDuration?: number;
style?: StyleProp<ViewStyle>;
}
>): ReactElement {
const tooltipTestID =
typeof props.testID === "string" && props.testID.length > 0
? `${props.testID}-tooltip`
: undefined;
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
{...props}
disabled={disabled}
onPress={(e) => {
onPress(e);
}}
style={[styles.button, style]}
>
{children}
</TooltipTrigger>
<TooltipContent testID={tooltipTestID} side={tooltipSide} align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
<Shortcut keys={tooltipKeys} style={styles.shortcut} textStyle={styles.shortcutText} />
</View>
</TooltipContent>
</Tooltip>
);
}
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,
},
}));

View File

@@ -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) {
<ScreenHeader
left={
<>
<Pressable
<HeaderToggleButton
onPress={toggleAgentList}
style={styles.menuButton}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
collapsable={false}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</Pressable>
</HeaderToggleButton>
{title && (
<Text style={styles.title} numberOfLines={1}>
{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,

View File

@@ -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<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}): ReactElement {
return (
<View style={[styles.root, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
</View>
);
}
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,
},
}));

View File

@@ -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<View | null>;
enabled: boolean;
delayDuration: number;
};
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltipContext(componentName: string): TooltipContextValue {
const ctx = useContext(TooltipContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <Tooltip />`);
}
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<Rect> {
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<View>(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<TooltipContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
enabled,
delayDuration,
}),
[isOpen, setIsOpen, enabled, delayDuration]
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
export function TooltipTrigger({
children,
disabled,
onHoverIn,
onHoverOut,
onFocus,
onBlur,
onPress,
...props
}: PropsWithChildren<PressableProps>): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<Pressable
{...props}
ref={ctx.triggerRef}
collapsable={false}
disabled={disabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
onFocus={(e) => {
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}
</Pressable>
);
}
export function TooltipContent({
children,
side = "top",
align = "center",
offset = 6,
style,
testID,
maxWidth = 280,
}: PropsWithChildren<{
side?: Side;
align?: Align;
offset?: number;
style?: StyleProp<ViewStyle>;
testID?: string;
maxWidth?: number;
}>): ReactElement | null {
const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(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 <Modal/> implementation (it uses <dialog> 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 (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
{children}
</Animated.View>
</View>
</Portal>
);
}
return (
<Modal
visible={ctx.open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)}
>
<View pointerEvents="box-none" style={styles.overlay}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
{children}
</Animated.View>
</View>
</Modal>
);
}
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,
},
}));

View File

@@ -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={
<View style={styles.headerRightContent}>
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
<HeaderToggleButton
onPress={toggleFileExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.menuButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{isMobile ? (
checkout?.isGit ? (
<GitBranch
@@ -600,7 +611,7 @@ function AgentScreenContent({
}
/>
)}
</Pressable>
</HeaderToggleButton>
<DropdownMenu
onOpenChange={(open) => {
if (open && agent?.cwd) {

View File

@@ -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");
});
});

View File

@@ -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<string, string> = {
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<string, string> = {
mod: "Ctrl",
shift: "Shift",
alt: "Alt",
ctrl: "Ctrl",
meta: "Win",
};
return normalized
.map((k) => labels[k] ?? normalizeKey(k))
.filter(Boolean)
.join("+");
}

View File

@@ -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";
}