Merge origin/main into show-image-previews-in-optimistic-messages

This commit is contained in:
Mohamed Boudra
2026-02-18 10:02:46 +07:00
159 changed files with 10110 additions and 1480 deletions
@@ -883,11 +883,12 @@ export function WorkingDirectoryDropdown({
options={options}
value={workingDir}
onSelect={onSelectPath}
searchPlaceholder="/path/to/project"
searchPlaceholder="Search directories..."
emptyText={emptyText}
allowCustomValue
customValuePrefix="Use"
customValueDescription="Launch the agent in this directory"
customValuePrefix=""
customValueKind="directory"
optionsPosition="above-search"
title="Working directory"
open={isOpen}
onOpenChange={handleOpenChange}
@@ -26,7 +26,10 @@ import {
} from "./message-input";
import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import {
useAgentCommandsQuery,
type DraftCommandConfig,
} from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { focusWithRetries } from "@/utils/web-focus";
@@ -55,6 +58,8 @@ interface AgentInputAreaProps {
autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -69,6 +74,7 @@ export function AgentInputArea({
onChangeText,
autoFocus = false,
onAddImages,
commandDraftConfig,
}: AgentInputAreaProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
@@ -117,13 +123,39 @@ export function AgentInputArea({
// Command autocomplete logic
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
const normalizedCommandDraftConfig = useMemo(() => {
if (!commandDraftConfig) {
return undefined;
}
// Prefetch commands when agent is available (not on new agent screen)
const cwd = commandDraftConfig.cwd.trim();
if (!cwd) {
return undefined;
}
const modeId = commandDraftConfig.modeId?.trim() ?? "";
const model = commandDraftConfig.model?.trim() ?? "";
const thinkingOptionId = commandDraftConfig.thinkingOptionId?.trim() ?? "";
return {
provider: commandDraftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [commandDraftConfig]);
// Prefetch commands for real agents and for draft screens when context is available.
const isRealAgent = agentId && !agentId.startsWith("__");
const commandQueryDraftConfig = isRealAgent
? undefined
: normalizedCommandDraftConfig;
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!commandQueryDraftConfig);
const { commands } = useAgentCommandsQuery({
serverId,
agentId,
enabled: !!isRealAgent && !!serverId,
enabled: canLoadCommands,
draftConfig: commandQueryDraftConfig,
});
// Filter commands for keyboard navigation
@@ -234,15 +266,38 @@ export function AgentInputArea({
}, [onSubmitMessage]);
const isAgentRunning = agent?.status === "running";
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
const prevIsAgentRunningRef = useRef(isAgentRunning);
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
useEffect(() => {
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
if (agentUpdatedAtMs < previousUpdatedAt) {
return;
}
const wasRunning = prevIsAgentRunningRef.current;
let shouldClearProcessing = false;
if (isProcessing) {
const hasEnteredRunning = !wasRunning && isAgentRunning;
const hasFreshRunningUpdateWhileRunning =
wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt;
const hasStoppedRunning = wasRunning && !isAgentRunning;
shouldClearProcessing =
hasEnteredRunning ||
hasFreshRunningUpdateWhileRunning ||
hasStoppedRunning;
}
prevIsAgentRunningRef.current = isAgentRunning;
if (!wasRunning && isAgentRunning && isProcessing) {
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
if (shouldClearProcessing) {
setIsProcessing(false);
}
}, [isAgentRunning, isProcessing]);
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
const updateQueue = useCallback(
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
@@ -612,7 +667,7 @@ export function AgentInputArea({
]
);
const cancelButton = isAgentRunning && !hasSendableContent ? (
const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
@@ -720,13 +775,14 @@ export function AgentInputArea({
)}
{/* Command autocomplete dropdown */}
{showCommandAutocomplete && isRealAgent && (
{showCommandAutocomplete && canLoadCommands && (
<CommandAutocomplete
serverId={serverId}
agentId={agentId}
filter={commandFilter}
selectedIndex={commandSelectedIndex}
onSelect={handleCommandSelect}
draftConfig={commandQueryDraftConfig}
/>
)}
@@ -756,6 +812,7 @@ export function AgentInputArea({
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress}
/>
</View>
@@ -105,12 +105,6 @@ export function AgentList({
return;
}
// Clear attention flag when opening agent
const session = useSessionStore.getState().sessions[serverId];
if (session?.client) {
session.client.clearAgentAttention(agentId);
}
const navigationKey = buildAgentNavigationKey(serverId, agentId);
startNavigationTiming(navigationKey, {
from: "home",
@@ -1,6 +1,9 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import {
useAgentCommandsQuery,
type DraftCommandConfig,
} from "@/hooks/use-agent-commands-query";
import { Fonts } from "@/constants/theme";
import { Theme } from "@/styles/theme";
@@ -16,6 +19,7 @@ interface CommandAutocompleteProps {
filter: string;
selectedIndex: number;
onSelect: (command: AgentSlashCommand) => void;
draftConfig?: DraftCommandConfig;
}
export function CommandAutocomplete({
@@ -24,12 +28,14 @@ export function CommandAutocomplete({
filter,
selectedIndex,
onSelect,
draftConfig,
}: CommandAutocompleteProps) {
const { theme } = useUnistyles();
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
serverId,
agentId,
enabled: true,
draftConfig,
});
// Filter commands based on input after /
@@ -129,6 +129,12 @@ const styles = StyleSheet.create((theme) => {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
headerLine: {
backgroundColor: theme.colors.surface1,
@@ -240,8 +240,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
width: resizeWidth.value,
}));
// Mobile: full-screen overlay with gesture
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
if (isMobile) {
return (
@@ -14,7 +14,6 @@ import {
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Linking from "expo-linking";
import {
Archive,
ChevronDown,
@@ -52,6 +51,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import { openExternalUrl } from "@/utils/open-external-url";
// =============================================================================
// Git Actions Data Structure
@@ -85,11 +85,7 @@ interface GitActions {
}
function openURLInNewTab(url: string): void {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener");
} else {
void Linking.openURL(url);
}
void openExternalUrl(url);
}
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
@@ -11,7 +11,11 @@ interface BackHeaderProps {
onBack?: () => void;
}
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
export function BackHeader({
title,
rightContent,
onBack,
}: BackHeaderProps) {
const { theme } = useUnistyles();
return (
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { Text } from "react-native";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Menu, PanelLeft } from "lucide-react-native";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
@@ -12,7 +12,39 @@ interface MenuHeaderProps {
rightContent?: ReactNode;
}
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 2;
function MobileMenuIcon({ color }: { color: string }) {
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View
style={[
styles.mobileMenuLine,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
/>
</View>
);
}
export function SidebarMenuToggle({
style,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -22,29 +54,42 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const MenuIcon = isMobile ? Menu : PanelLeft;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={style}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
{isMobile ? (
<MobileMenuIcon color={menuIconColor} />
) : (
<PanelLeft size={16} color={menuIconColor} />
)}
</HeaderToggleButton>
);
}
export function MenuHeader({
title,
rightContent,
}: MenuHeaderProps) {
return (
<ScreenHeader
left={
<>
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</HeaderToggleButton>
<SidebarMenuToggle />
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
@@ -71,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({
},
color: theme.colors.foreground,
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));
@@ -16,7 +16,12 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
export function ScreenHeader({
left,
right,
leftStyle,
rightStyle,
}: ScreenHeaderProps) {
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
+24 -16
View File
@@ -75,6 +75,8 @@ export interface MessageInputProps {
isAgentRunning?: boolean;
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
onSubmitLoadingPress?: () => void;
/** Intercept key press events before default handling. Return true to prevent default. */
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
}
@@ -132,6 +134,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId,
isAgentRunning = false,
onQueue,
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
},
ref
@@ -576,6 +579,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const hasImages = images.length > 0;
const hasSendableContent = value.trim().length > 0 || hasImages;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const canPressLoadingButton =
isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled ||
(!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const submitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent"
: isAgentRunning
? "Send and interrupt"
: "Send message";
return (
<View style={styles.container} testID="message-input-root">
@@ -724,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
</TooltipContent>
</Tooltip>
{rightContent}
{shouldShowSendButton && isAgentRunning && onQueue && (
{hasSendableContent && isAgentRunning && onQueue && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleQueueMessage}
@@ -748,22 +761,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
)}
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleSendMessage}
disabled={
isSubmitDisabled ||
isSubmitLoading ||
disabled
<TooltipTrigger
onPress={
canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage
}
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[
styles.sendButton,
(isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityRole="button"
style={[
styles.sendButton,
isSendButtonDisabled && styles.buttonDisabled,
]}
>
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
+138 -7
View File
@@ -21,7 +21,6 @@ import {
} from "react";
import type { ReactNode, ComponentType } from "react";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import * as Linking from "expo-linking";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
@@ -72,6 +71,7 @@ import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
export type { InlinePathTarget } from "@/utils/inline-path";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
@@ -119,6 +119,8 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
}
`;
let webToolCallShimmerRegistered = false;
const SCROLL_EDGE_EPSILON = 0.5;
type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() {
if (Platform.OS !== "web") {
@@ -145,6 +147,113 @@ function ensureWebToolCallShimmerKeyframes() {
webToolCallShimmerRegistered = true;
}
function getWheelEventElementTarget(
event: WheelEvent,
fallback: HTMLElement
): HTMLElement {
const { target } = event;
if (target instanceof HTMLElement) {
return target;
}
if (target instanceof Node && target.parentElement) {
return target.parentElement;
}
return fallback;
}
function canElementScrollInDirection(
element: HTMLElement,
axis: ScrollAxis,
delta: number
): boolean {
if (delta === 0) {
return false;
}
const computedStyle = window.getComputedStyle(element);
const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY;
const isScrollableOverflow =
overflow === "auto" || overflow === "scroll" || overflow === "overlay";
if (!isScrollableOverflow) {
return false;
}
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
const scrollSize =
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
if (scrollSize <= SCROLL_EDGE_EPSILON) {
return false;
}
if (delta > 0) {
return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON;
}
return scrollPosition > SCROLL_EDGE_EPSILON;
}
function canScrollInsideDetailFromTarget(
detailRoot: HTMLElement,
startElement: HTMLElement,
axis: ScrollAxis,
delta: number
): boolean {
if (delta === 0) {
return false;
}
let current: HTMLElement | null = startElement;
while (current) {
if (canElementScrollInDirection(current, axis, delta)) {
return true;
}
if (current === detailRoot) {
break;
}
current = current.parentElement;
}
return false;
}
function shouldStopDetailWheelPropagation(
detailRoot: HTMLElement,
event: WheelEvent
): boolean {
const startElement = getWheelEventElementTarget(event, detailRoot);
const verticalDelta = event.deltaY;
const horizontalDelta =
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
if (!hasVerticalIntent && !hasHorizontalIntent) {
return false;
}
const canScrollVertically = hasVerticalIntent
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
: false;
const canScrollHorizontally = hasHorizontalIntent
? canScrollInsideDetailFromTarget(
detailRoot,
startElement,
"x",
horizontalDelta
)
: false;
if (hasVerticalIntent && hasHorizontalIntent) {
const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta);
return isVerticalDominant
? canScrollVertically || canScrollHorizontally
: canScrollHorizontally || canScrollVertically;
}
if (hasVerticalIntent) {
return canScrollVertically;
}
return canScrollHorizontally;
}
const userMessageStylesheet = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
@@ -587,12 +696,10 @@ export const AssistantMessage = memo(function AssistantMessage({
);
const handleLinkPress = useCallback((url: string) => {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener,noreferrer");
} else {
void Linking.openURL(url);
}
return true;
void openExternalUrl(url);
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
}, []);
const markdownRules = useMemo(() => {
@@ -1170,6 +1277,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
const hasDetailContent = Boolean(renderDetails);
const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
@@ -1289,6 +1397,28 @@ const ExpandableBadge = memo(function ExpandableBadge({
shimmerTranslateX,
]);
useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
return;
}
const node = detailWrapperRef.current as unknown as HTMLElement | null;
if (!node || typeof node.addEventListener !== "function") {
return;
}
const stopWheelPropagation = (event: WheelEvent) => {
if (shouldStopDetailWheelPropagation(node, event)) {
event.stopPropagation();
}
};
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
return () => {
node.removeEventListener("wheel", stopWheelPropagation);
};
}, [isExpanded, hasDetailContent]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
}));
@@ -1525,6 +1655,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</Pressable>
{detailContent ? (
<Pressable
ref={detailWrapperRef}
style={expandableBadgeStylesheet.detailWrapper}
onHoverIn={() => onDetailHoverChange?.(true)}
onHoverOut={() => onDetailHoverChange?.(false)}
@@ -592,11 +592,6 @@ export function SidebarAgentList({
return;
}
const session = useSessionStore.getState().sessions[entry.agent.serverId];
if (session?.client) {
session.client.clearAgentAttention(entry.agent.id);
}
const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id);
startNavigationTiming(navigationKey, {
from: "home",
@@ -1032,17 +1027,19 @@ const styles = StyleSheet.create((theme) => ({
agentTitle: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
opacity: 0.85,
opacity: 1,
},
agentTitleInactive: {
color: theme.colors.foregroundMuted,
opacity: 0.8,
},
agentTitleSelected: {
opacity: 1,
},
relativeTimeText: {
color: theme.colors.foregroundMuted,
opacity: 0.78,
fontSize: theme.fontSize.xs,
},
shortcutBadge: {
@@ -1084,10 +1081,10 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.5,
},
agentMetaProjectName: {
color: theme.colors.foregroundMuted,
color: theme.colors.foreground,
opacity: 0.78,
fontSize: theme.fontSize.xs,
flexShrink: 1,
minWidth: 0,
flexShrink: 0,
},
agentMetaBranchBadge: {
borderRadius: theme.borderRadius.full,
@@ -1095,14 +1092,18 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[1],
paddingVertical: 1,
flexShrink: 0,
flexShrink: 1,
minWidth: 0,
},
agentMetaBranchBadgeSelected: {
borderColor: theme.colors.borderAccent,
},
agentMetaBranchText: {
color: theme.colors.foregroundMuted,
color: theme.colors.foreground,
opacity: 0.76,
fontSize: theme.fontSize.xs,
flexShrink: 1,
minWidth: 0,
},
selectionBar: {
position: "absolute",
@@ -16,7 +16,7 @@ import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers } from "@/utils/tauri-window";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { Combobox } from "@/components/ui/combobox";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
@@ -121,6 +121,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
closeGestureRef,
} = useSidebarAnimation();
const dragHandlers = useTauriDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -324,8 +325,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
// Render mobile sidebar
// On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
// On web, keep the overlay interactive only while the sidebar is open.
// This preserves swipe/scroll behavior without blocking taps when closed.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
if (isMobile) {
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -465,7 +467,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
<View style={styles.sidebarHeader} {...dragHandlers}>
<View
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
{...dragHandlers}
>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
@@ -26,8 +26,10 @@ interface TerminalEmulatorProps {
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
}
declare global {
@@ -47,8 +49,10 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onOutputChunkConsumed,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
@@ -108,19 +112,30 @@ export default function TerminalEmulator({
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
if (outputChunkSequence <= 0) {
return;
}
if (outputChunkText.length === 0) {
runtime.clear();
if (!runtime) {
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
runtime.write({ text: outputChunkText });
}, [outputChunkSequence, outputChunkText]);
if (outputChunkText.length === 0) {
runtime.clear({
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
return;
}
runtime.write({
text: outputChunkText,
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
useEffect(() => {
if (focusRequestToken <= 0) {
@@ -129,6 +144,13 @@ export default function TerminalEmulator({
runtimeRef.current?.focus();
}, [focusRequestToken]);
useEffect(() => {
if (resizeRequestToken <= 0) {
return;
}
runtimeRef.current?.resize({ force: true });
}, [resizeRequestToken]);
return (
<div
ref={rootRef}
+199 -39
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useFocusEffect } from "@react-navigation/native";
import {
ActivityIndicator,
Pressable,
@@ -15,16 +16,19 @@ import Svg, {
Stop,
} from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { useSessionStore } from "@/stores/session-store";
import {
hasPendingTerminalModifiers,
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import {
TerminalOutputPump,
type TerminalOutputChunk,
} from "@/terminal/runtime/terminal-output-pump";
import { TerminalOutputDeliveryQueue } from "@/terminal/runtime/terminal-output-delivery-queue";
import {
TerminalStreamController,
type TerminalStreamControllerStatus,
@@ -42,6 +46,7 @@ interface TerminalPaneProps {
const MAX_OUTPUT_CHARS = 200_000;
const TERMINAL_TAB_MAX_WIDTH = 220;
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const MODIFIER_LABELS = {
ctrl: "Ctrl",
@@ -72,6 +77,24 @@ type TerminalOutputChunkState = {
text: string;
};
type PendingTerminalInput =
| {
type: "data";
data: string;
}
| {
type: "key";
input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta?: boolean;
};
};
type ListTerminalsPayload = ListTerminalsResponse["payload"];
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
@@ -117,10 +140,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
@@ -136,22 +161,40 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
null
);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
useEffect(() => {
selectedTerminalIdRef.current = selectedTerminalId;
}, [selectedTerminalId]);
useEffect(() => {
const outputDeliveryQueue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
setSelectedOutputChunk(chunk);
},
});
outputDeliveryQueueRef.current = outputDeliveryQueue;
return () => {
if (outputDeliveryQueueRef.current === outputDeliveryQueue) {
outputDeliveryQueueRef.current = null;
}
outputDeliveryQueue.reset();
};
}, []);
useEffect(() => {
const outputPump = new TerminalOutputPump({
maxOutputChars: MAX_OUTPUT_CHARS,
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
setSelectedOutputChunk(chunk);
outputDeliveryQueueRef.current?.enqueue(chunk);
},
});
outputPumpRef.current = outputPump;
@@ -210,8 +253,36 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
const requestTerminalReflow = useCallback(() => {
setResizeRequestToken((current) => current + 1);
}, []);
useFocusEffect(
useCallback(() => {
if (!selectedTerminalId) {
return;
}
// Navigation transitions can temporarily report stale dimensions.
// Pulse forced refits so xterm fills the pane when returning to an agent.
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs)
);
return () => {
for (const handle of timeoutHandles) {
clearTimeout(handle);
}
};
}, [requestTerminalReflow, selectedTerminalId])
);
const terminalsQuery = useQuery({
queryKey: ["terminals", serverId, cwd] as const,
queryKey: terminalsQueryKey,
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
queryFn: async () => {
if (!client) {
@@ -246,14 +317,43 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
setModifiers({ ...EMPTY_MODIFIERS });
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
type: "active",
});
});
}, [client, cwd, isConnected, queryClient, serverId]);
}, [client, isConnected, queryClient, terminalsQueryKey]);
useEffect(() => {
if (!client || !isConnected || !cwd.startsWith("/")) {
return;
}
const unsubscribe = client.on("terminals_changed", (message) => {
if (message.type !== "terminals_changed") {
return;
}
if (message.payload.cwd !== cwd) {
return;
}
void queryClient.invalidateQueries({
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: terminalsQueryKey,
type: "active",
});
});
client.subscribeTerminals({ cwd });
return () => {
unsubscribe();
client.unsubscribeTerminals({ cwd });
};
}, [client, cwd, isConnected, queryClient, terminalsQueryKey]);
const createTerminalMutation = useMutation({
mutationFn: async () => {
@@ -263,12 +363,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return await client.createTerminal(cwd);
},
onSuccess: (payload) => {
if (payload.terminal) {
selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id);
setSelectedTerminalId(payload.terminal.id);
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
const nextTerminals = upsertTerminalListEntry({
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
return {
cwd: current?.cwd ?? cwd,
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
setSelectedTerminalId(createdTerminal.id);
requestTerminalFocus();
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
},
});
@@ -294,10 +408,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
setModifiers({ ...EMPTY_MODIFIERS });
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
type: "active",
});
},
@@ -405,6 +519,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}, [client, handleStreamControllerStatus, isConnected]);
useEffect(() => {
outputDeliveryQueueRef.current?.reset();
pendingTerminalInputRef.current = [];
setSelectedOutputChunk({ sequence: 0, text: "" });
outputPumpRef.current?.setSelectedTerminal({
terminalId: selectedTerminalId,
});
@@ -422,6 +539,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
activeStream && activeStream.terminalId === selectedTerminalId
? activeStream.streamId
: null;
const getCurrentActiveStreamId = useCallback(() => {
return streamControllerRef.current?.getActiveStreamId() ?? null;
}, []);
const selectedTerminal = useMemo(
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
@@ -431,6 +551,42 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
createTerminalMutation.mutate();
}, [createTerminalMutation]);
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current;
queue.push(entry);
if (queue.length > 512) {
queue.splice(0, queue.length - 512);
}
}, []);
const flushPendingTerminalInput = useCallback(() => {
if (!client) {
return;
}
const currentStreamId = getCurrentActiveStreamId();
if (currentStreamId === null) {
return;
}
const queue = pendingTerminalInputRef.current;
if (queue.length === 0) {
return;
}
const pending = queue.splice(0, queue.length);
for (const entry of pending) {
if (entry.type === "data") {
client.sendTerminalStreamInput(currentStreamId, entry.data);
continue;
}
client.sendTerminalStreamKey(currentStreamId, entry.input);
}
}, [client, getCurrentActiveStreamId]);
useEffect(() => {
flushPendingTerminalInput();
}, [activeStreamId, flushPendingTerminalInput]);
const handleCloseTerminal = useCallback(
(terminalId: string) => {
if (
@@ -444,10 +600,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
[killTerminalMutation]
);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
const clearPendingModifiers = useCallback(() => {
setModifiers({ ...EMPTY_MODIFIERS });
}, []);
@@ -462,19 +614,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
meta?: boolean;
}
): boolean => {
if (!client || activeStreamId === null) {
terminalDebugLog({
scope: "terminal-pane",
event: "input:key:drop-no-stream",
details: {
key: input.key,
const currentStreamId = getCurrentActiveStreamId();
if (!client || currentStreamId === null) {
enqueuePendingTerminalInput({
type: "key",
input: {
key: normalizeTerminalTransportKey(input.key),
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
activeStreamId,
meta: input.meta,
},
});
return false;
return true;
}
const normalizedKey = normalizeTerminalTransportKey(input.key);
@@ -486,10 +638,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
activeStreamId,
activeStreamId: currentStreamId,
},
});
client.sendTerminalStreamKey(activeStreamId, {
client.sendTerminalStreamKey(currentStreamId, {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
@@ -498,7 +650,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
});
return true;
},
[client, activeStreamId]
[client, enqueuePendingTerminalInput, getCurrentActiveStreamId]
);
const handleTerminalData = useCallback(
@@ -506,13 +658,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
if (data.length === 0) {
return;
}
const currentStreamId = getCurrentActiveStreamId();
terminalDebugLog({
scope: "terminal-pane",
event: "input:data:received",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
activeStreamId,
activeStreamId: currentStreamId,
},
});
@@ -541,14 +694,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}
}
if (!client || activeStreamId === null) {
terminalDebugLog({
scope: "terminal-pane",
event: "input:data:drop-no-stream",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
},
if (!client || currentStreamId === null) {
enqueuePendingTerminalInput({
type: "data",
data,
});
return;
}
@@ -558,19 +707,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
activeStreamId,
activeStreamId: currentStreamId,
},
});
client.sendTerminalStreamInput(activeStreamId, data);
client.sendTerminalStreamInput(currentStreamId, data);
},
[
activeStreamId,
clearPendingModifiers,
client,
getCurrentActiveStreamId,
modifiers.alt,
modifiers.ctrl,
modifiers.shift,
sendTerminalKey,
enqueuePendingTerminalInput,
]
);
@@ -626,6 +776,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleOutputChunkConsumed = useCallback((sequence: number) => {
outputDeliveryQueueRef.current?.consume({ sequence });
}, []);
const toggleModifier = useCallback(
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
@@ -808,8 +962,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
onOutputChunkConsumed={handleOutputChunkConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}
resizeRequestToken={resizeRequestToken}
/>
</View>
) : (
@@ -819,7 +975,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
)}
{isAttaching ? (
<View style={styles.attachOverlay} testID="terminal-attach-loading">
<View
style={styles.attachOverlay}
pointerEvents="none"
testID="terminal-attach-loading"
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
) : null}
@@ -89,6 +89,43 @@ export function ToolCallDetailsContent({
</View>
</View>
);
} else if (detail?.type === "worktree_setup") {
const setupLog = detail.log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
sections.push(
<View
key="worktree-setup"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<Text selectable style={styles.scrollText}>
{hasLog
? setupLog
: `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
} else if (detail?.type === "edit") {
sections.push(
<View
@@ -356,6 +393,12 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
shellPrompt: {
color: theme.colors.foregroundMuted,
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
buildVisibleComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
} from "./combobox-options";
describe("buildVisibleComboboxOptions", () => {
const options = [
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
];
it("keeps a custom row visible while searching with no matches", () => {
const visible = buildVisibleComboboxOptions({
options,
searchQuery: "/tmp/new-project",
searchable: true,
allowCustomValue: true,
customValuePrefix: "",
customValueKind: "directory",
});
expect(visible).toHaveLength(1);
expect(visible[0]).toEqual({
id: "/tmp/new-project",
label: "/tmp/new-project",
description: undefined,
kind: "directory",
});
});
it("does not duplicate a row when search exactly matches an existing option", () => {
const visible = buildVisibleComboboxOptions({
options,
searchQuery: "/Users/me/project-a",
searchable: true,
allowCustomValue: true,
customValuePrefix: "",
customValueKind: "directory",
});
expect(visible).toEqual([
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" },
]);
});
});
describe("combobox above-search ordering", () => {
const visible = [
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
];
it("renders first logical option closest to the search box in above-search mode", () => {
const ordered = orderVisibleComboboxOptions(visible, "above-search");
expect(ordered.map((option) => option.id)).toEqual([
"/Users/me/project-b",
"/Users/me/project-a",
"/tmp/new-project",
]);
expect(getComboboxFallbackIndex(ordered.length, "above-search")).toBe(2);
});
it("keeps normal top-down order in below-search mode", () => {
const ordered = orderVisibleComboboxOptions(visible, "below-search");
expect(ordered.map((option) => option.id)).toEqual([
"/tmp/new-project",
"/Users/me/project-a",
"/Users/me/project-b",
]);
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
});
});
@@ -0,0 +1,95 @@
export interface ComboboxOptionModel {
id: string;
label: string;
description?: string;
kind?: "directory";
}
export interface BuildVisibleComboboxOptionsInput {
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
customValuePrefix: string;
customValueDescription?: string;
customValueKind?: "directory";
}
export function shouldShowCustomComboboxOption(input: {
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
}): boolean {
const sanitizedSearchValue = input.searchQuery.trim();
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
return false;
}
return !input.options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
);
}
export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput
): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
const filteredOptions = normalizedSearch
? input.options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
)
: input.options;
const sanitizedSearchValue = input.searchQuery.trim();
const showCustomOption = shouldShowCustomComboboxOption({
options: input.options,
searchQuery: input.searchQuery,
searchable: input.searchable,
allowCustomValue: input.allowCustomValue,
});
const visibleOptions: ComboboxOptionModel[] = [];
if (showCustomOption) {
const trimmedPrefix = input.customValuePrefix.trim();
const customLabel =
trimmedPrefix.length > 0
? `${trimmedPrefix} "${sanitizedSearchValue}"`
: sanitizedSearchValue;
visibleOptions.push({
id: sanitizedSearchValue,
label: customLabel,
description: input.customValueDescription,
kind: input.customValueKind,
});
}
visibleOptions.push(...filteredOptions);
return visibleOptions;
}
export function orderVisibleComboboxOptions(
visibleOptions: ComboboxOptionModel[],
optionsPosition: "below-search" | "above-search"
): ComboboxOptionModel[] {
if (optionsPosition !== "above-search") {
return visibleOptions;
}
return [...visibleOptions].reverse();
}
export function getComboboxFallbackIndex(
itemCount: number,
optionsPosition: "below-search" | "above-search"
): number {
if (itemCount <= 0) {
return -1;
}
return optionsPosition === "above-search" ? itemCount - 1 : 0;
}
+234 -85
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
@@ -9,6 +9,7 @@ import {
ScrollView,
Platform,
StatusBar,
useWindowDimensions,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
@@ -19,17 +20,20 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, Search } from "lucide-react-native";
import { Check, Folder, Search } from "lucide-react-native";
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
import { getNextActiveIndex } from "./combobox-keyboard";
import {
buildVisibleComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
const IS_WEB = Platform.OS === "web";
export interface ComboboxOption {
id: string;
label: string;
description?: string;
}
export type ComboboxOption = ComboboxOptionModel;
export interface ComboboxProps {
options: ComboboxOption[];
@@ -43,14 +47,35 @@ export interface ComboboxProps {
allowCustomValue?: boolean;
customValuePrefix?: string;
customValueDescription?: string;
customValueKind?: "directory";
optionsPosition?: "below-search" | "above-search";
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
* coordinates resolve. This intentionally disables fade enter/exit animation
* for that combobox instance to avoid animation overriding hidden opacity.
*/
desktopPreventInitialFlash?: boolean;
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
}
function toNumericStyleValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return (
<Animated.View
@@ -110,6 +135,7 @@ function SearchInput({
export interface ComboboxItemProps {
label: string;
description?: string;
kind?: "directory";
selected?: boolean;
active?: boolean;
onPress: () => void;
@@ -119,6 +145,7 @@ export interface ComboboxItemProps {
export function ComboboxItem({
label,
description,
kind,
selected,
active,
onPress,
@@ -136,6 +163,11 @@ export function ComboboxItem({
active && styles.comboboxItemActive,
]}
>
{kind === "directory" ? (
<View style={styles.comboboxItemLeadingSlot}>
<Folder size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
<View style={styles.comboboxItemContent}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
{description ? (
@@ -152,7 +184,7 @@ export function ComboboxItem({
}
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
return <Text style={styles.emptyText}>{children}</Text>;
return <Text testID="combobox-empty-text" style={styles.emptyText}>{children}</Text>;
}
export function Combobox({
@@ -167,21 +199,32 @@ export function Combobox({
allowCustomValue = false,
customValuePrefix = "Use",
customValueDescription,
customValueKind,
optionsPosition = "below-search",
title = "Select",
open,
onOpenChange,
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
anchorRef,
children,
}: ComboboxProps): ReactElement {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveOptionsPosition =
isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch =
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
const { height: windowHeight } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null);
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
@@ -227,7 +270,7 @@ export function Combobox({
() => [
floatingOffset(Platform.OS === "web" ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
shift({ padding: collisionPadding }),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({
padding: collisionPadding,
apply({ availableWidth, availableHeight, rects }) {
@@ -245,7 +288,7 @@ export function Combobox({
},
}),
],
[collisionPadding]
[collisionPadding, isDesktopAboveSearch]
);
const { refs, floatingStyles, update } = useFloating({
@@ -263,10 +306,57 @@ export function Combobox({
setReferenceWidth(null);
return;
}
const raf = requestAnimationFrame(() => update());
const raf = requestAnimationFrame(() => void update());
return () => cancelAnimationFrame(raf);
}, [desktopPlacement, isMobile, update, isOpen]);
useEffect(() => {
if (!isOpen || isMobile) {
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
}
const referenceEl = anchorRef.current;
if (!referenceEl) {
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
}
const measure = () => {
referenceEl.measureInWindow((x, y) => {
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y));
});
};
measure();
const raf = requestAnimationFrame(measure);
return () => cancelAnimationFrame(raf);
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]);
const floatingTop = toNumericStyleValue(floatingStyles.top);
const floatingLeft = toNumericStyleValue(floatingStyles.left);
const desktopAboveSearchBottom =
isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding)
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent =
desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: floatingStyles;
useEffect(() => {
if (!isMobile) return;
if (isOpen) {
@@ -298,78 +388,105 @@ export function Combobox({
);
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const filteredOptions = useMemo(() => {
if (!normalizedSearch) {
return options;
}
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
);
}, [options, normalizedSearch]);
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption =
searchable &&
allowCustomValue &&
sanitizedSearchValue.length > 0 &&
!options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
);
const showCustomOption = useMemo(
() =>
shouldShowCustomComboboxOption({
options,
searchQuery,
searchable,
allowCustomValue,
}),
[allowCustomValue, options, searchQuery, searchable]
);
const visibleOptions = useMemo(() => {
const next: Array<{
id: string;
label: string;
description?: string;
}> = [];
const visibleOptions = useMemo(
() =>
buildVisibleComboboxOptions({
options,
searchQuery,
searchable,
allowCustomValue,
customValuePrefix,
customValueDescription,
customValueKind,
}),
[
allowCustomValue,
customValueDescription,
customValueKind,
customValuePrefix,
options,
searchQuery,
searchable,
]
);
if (showCustomOption) {
next.push({
id: sanitizedSearchValue,
label: `${customValuePrefix} "${sanitizedSearchValue}"`,
description: customValueDescription,
});
const orderedVisibleOptions = useMemo(
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
[effectiveOptionsPosition, visibleOptions]
);
const pinDesktopOptionsToBottom = useCallback(() => {
if (isMobile || effectiveOptionsPosition !== "above-search") {
return;
}
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
requestAnimationFrame(() => {
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
});
}, [effectiveOptionsPosition, isMobile]);
for (const opt of filteredOptions) {
next.push({
id: opt.id,
label: opt.label,
description: opt.description,
});
const handleDesktopOptionsContentSizeChange = useCallback(() => {
if (!isOpen) {
return;
}
pinDesktopOptionsToBottom();
}, [isOpen, pinDesktopOptionsToBottom]);
return next;
}, [
customValueDescription,
customValuePrefix,
filteredOptions,
sanitizedSearchValue,
showCustomOption,
]);
useEffect(() => {
if (!isOpen) {
return;
}
pinDesktopOptionsToBottom();
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
useLayoutEffect(() => {
if (!isOpen || isMobile) {
return;
}
void update();
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]);
useEffect(() => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (visibleOptions.length === 0) {
if (orderedVisibleOptions.length === 0) {
setActiveIndex(-1);
return;
}
const fallbackIndex = getComboboxFallbackIndex(
orderedVisibleOptions.length,
effectiveOptionsPosition
);
if (normalizedSearch) {
setActiveIndex(0);
setActiveIndex(fallbackIndex);
return;
}
const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
}, [isMobile, isOpen, normalizedSearch, value, visibleOptions]);
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
}, [
effectiveOptionsPosition,
isMobile,
isOpen,
normalizedSearch,
value,
orderedVisibleOptions,
]);
const handleSelect = useCallback(
(id: string) => {
@@ -395,7 +512,7 @@ export function Combobox({
setActiveIndex((currentIndex) =>
getNextActiveIndex({
currentIndex,
itemCount: visibleOptions.length,
itemCount: orderedVisibleOptions.length,
key,
})
);
@@ -403,11 +520,11 @@ export function Combobox({
}
if (key === "Enter") {
if (visibleOptions.length === 0) return;
if (orderedVisibleOptions.length === 0) return;
event?.preventDefault();
const index =
activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0;
handleSelect(visibleOptions[index]!.id);
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
handleSelect(orderedVisibleOptions[index]!.id);
return;
}
@@ -416,7 +533,7 @@ export function Combobox({
handleClose();
}
},
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
);
useEffect(() => {
@@ -449,12 +566,13 @@ export function Combobox({
const optionsList = (
<>
{visibleOptions.length > 0 ? (
visibleOptions.map((opt, index) => (
{orderedVisibleOptions.length > 0 ? (
orderedVisibleOptions.map((opt, index) => (
<ComboboxItem
key={opt.id}
label={opt.label}
description={opt.description}
kind={opt.kind}
selected={opt.id === value}
active={index === activeIndex}
onPress={() => handleSelect(opt.id)}
@@ -466,13 +584,16 @@ export function Combobox({
</>
);
const content = children ?? (
const defaultContent = (
<>
{effectiveOptionsPosition === "above-search" ? optionsList : null}
{searchable ? searchInput : null}
{optionsList}
{effectiveOptionsPosition === "below-search" ? optionsList : null}
</>
);
const content = children ?? defaultContent;
if (isMobile) {
return (
<BottomSheetModal
@@ -514,8 +635,9 @@ export function Combobox({
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
testID="combobox-desktop-container"
entering={shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
exiting={shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
style={[
styles.desktopContainer,
{
@@ -523,8 +645,8 @@ export function Combobox({
minWidth: referenceWidth ?? 200,
maxWidth: 400,
},
floatingStyles,
referenceWidth === null ? { opacity: 0 } : null,
desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
]}
ref={refs.setFloating}
@@ -542,15 +664,32 @@ export function Combobox({
</ScrollView>
) : (
<>
{effectiveOptionsPosition === "above-search" ? (
<ScrollView
ref={desktopOptionsScrollRef}
contentContainerStyle={[
styles.desktopScrollContent,
styles.desktopScrollContentAboveSearch,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
onContentSizeChange={handleDesktopOptionsContentSizeChange}
>
{optionsList}
</ScrollView>
) : null}
{searchable ? searchInput : null}
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{optionsList}
</ScrollView>
{effectiveOptionsPosition === "below-search" ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{optionsList}
</ScrollView>
) : null}
</>
)}
</Animated.View>
@@ -613,6 +752,11 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
flexShrink: 1,
},
comboboxItemLeadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
comboboxItemLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
@@ -675,9 +819,14 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
desktopScroll: {
maxHeight: 400,
flexShrink: 1,
minHeight: 0,
},
desktopScrollContent: {
paddingVertical: theme.spacing[1],
},
desktopScrollContentAboveSearch: {
flexGrow: 1,
justifyContent: "flex-end",
},
}));