Merge branch 'main' of github.com:boudra/paseo
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
DraggableList,
|
DraggableList,
|
||||||
type DraggableRenderItemInfo,
|
type DraggableRenderItemInfo,
|
||||||
} from "./draggable-list";
|
} from "./draggable-list";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
|
||||||
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
|
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
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({
|
export function GroupedAgentList({
|
||||||
agents,
|
agents,
|
||||||
isRefreshing = false,
|
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(
|
const renderSection = useCallback(
|
||||||
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
||||||
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
|
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
|
||||||
@@ -454,12 +511,34 @@ export function GroupedAgentList({
|
|||||||
/>
|
/>
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
section.agents.map((agent) => (
|
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>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[AgentListRow, collapsedProjectKeys, handleCreateAgentInProject, toggleProjectCollapsed]
|
[
|
||||||
|
collapsedProjectKeys,
|
||||||
|
handleAgentLongPress,
|
||||||
|
handleAgentPress,
|
||||||
|
handleArchiveAgent,
|
||||||
|
handleCreateAgentInProject,
|
||||||
|
selectedAgentId,
|
||||||
|
showShortcutBadges,
|
||||||
|
shortcutIndexByAgentKey,
|
||||||
|
toggleProjectCollapsed,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback(
|
const keyExtractor = useCallback(
|
||||||
@@ -675,6 +754,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
lineHeight: 12,
|
lineHeight: 12,
|
||||||
},
|
},
|
||||||
|
archiveConfirmBadge: {
|
||||||
|
minWidth: 76,
|
||||||
|
maxWidth: 9999,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
archiveConfirmText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
archiveConfirmTextHovered: {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
agentTitleHighlighted: {
|
agentTitleHighlighted: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
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 { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Menu, PanelLeft } from "lucide-react-native";
|
import { Menu, PanelLeft } from "lucide-react-native";
|
||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
|
||||||
interface MenuHeaderProps {
|
interface MenuHeaderProps {
|
||||||
@@ -28,19 +29,20 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
|||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<HeaderToggleButton
|
||||||
onPress={toggleAgentList}
|
onPress={toggleAgentList}
|
||||||
style={styles.menuButton}
|
tooltipLabel="Toggle sidebar"
|
||||||
|
tooltipKeys={["mod", "B"]}
|
||||||
|
tooltipSide="right"
|
||||||
testID="menu-button"
|
testID="menu-button"
|
||||||
nativeID="menu-button"
|
nativeID="menu-button"
|
||||||
collapsable={false}
|
|
||||||
accessible
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||||
accessibilityState={{ expanded: isOpen }}
|
accessibilityState={{ expanded: isOpen }}
|
||||||
>
|
>
|
||||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
||||||
</Pressable>
|
</HeaderToggleButton>
|
||||||
{title && (
|
{title && (
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
@@ -58,13 +60,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
left: {
|
left: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
menuButton: {
|
|
||||||
padding: {
|
|
||||||
xs: theme.spacing[3],
|
|
||||||
md: theme.spacing[2],
|
|
||||||
},
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
|
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
import { AgentDetailsSheet } from "@/components/agent-details-sheet";
|
import { AgentDetailsSheet } from "@/components/agent-details-sheet";
|
||||||
@@ -569,7 +570,17 @@ function AgentScreenContent({
|
|||||||
title={effectiveAgent.title || "Agent"}
|
title={effectiveAgent.title || "Agent"}
|
||||||
rightContent={
|
rightContent={
|
||||||
<View style={styles.headerRightContent}>
|
<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 ? (
|
{isMobile ? (
|
||||||
checkout?.isGit ? (
|
checkout?.isGit ? (
|
||||||
<GitBranch
|
<GitBranch
|
||||||
@@ -600,7 +611,7 @@ function AgentScreenContent({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</HeaderToggleButton>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open && agent?.cwd) {
|
if (open && agent?.cwd) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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("+");
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user