Merge origin/main into show-image-previews-in-optimistic-messages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user