mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Update files
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -55,4 +55,5 @@ CLAUDE.local.md
|
||||
.tasks/
|
||||
|
||||
.debug.conversations/
|
||||
.debug/
|
||||
.paseo/
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetBackdrop,
|
||||
@@ -18,11 +21,13 @@ import { X } from "lucide-react-native";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
desktopOverlay: {
|
||||
flex: 1,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing[6],
|
||||
zIndex: OVERLAY_Z.modal,
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
desktopCard: {
|
||||
width: "100%",
|
||||
@@ -184,6 +189,42 @@ export function AdaptiveModalSheet({
|
||||
);
|
||||
}
|
||||
|
||||
const desktopContent = (
|
||||
<View style={styles.desktopOverlay} testID={testID}>
|
||||
<Pressable
|
||||
accessibilityLabel="Dismiss"
|
||||
style={{ ...StyleSheet.absoluteFillObject }}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={styles.desktopCard}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityLabel="Close"
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// On web, use portal to overlay root for consistent stacking with toasts
|
||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||
if (!visible) return null;
|
||||
return createPortal(desktopContent, getOverlayRoot());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
@@ -192,33 +233,7 @@ export function AdaptiveModalSheet({
|
||||
onRequestClose={onClose}
|
||||
hardwareAccelerated
|
||||
>
|
||||
<View style={styles.desktopOverlay} testID={testID}>
|
||||
<Pressable
|
||||
accessibilityLabel="Dismiss"
|
||||
style={{ ...StyleSheet.absoluteFillObject }}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={styles.desktopCard}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityLabel="Close"
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
{desktopContent}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,30 +4,19 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ChevronDown, ChevronRight, Pencil, Check, X } from "lucide-react-native";
|
||||
import {
|
||||
flip,
|
||||
offset as floatingOffset,
|
||||
shift,
|
||||
size as floatingSize,
|
||||
useFloating,
|
||||
} from "@floating-ui/react-native";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -35,6 +24,7 @@ import type {
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||
|
||||
type DropdownTriggerRenderProps = {
|
||||
label: string;
|
||||
@@ -256,177 +246,8 @@ export function DropdownSheet({
|
||||
);
|
||||
}
|
||||
|
||||
interface AdaptiveSelectProps {
|
||||
title: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
}
|
||||
|
||||
export function AdaptiveSelect({
|
||||
title,
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
anchorRef,
|
||||
}: AdaptiveSelectProps): ReactElement {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
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 collisionPadding = useMemo(() => {
|
||||
const basePadding = 16;
|
||||
if (Platform.OS !== "android") return basePadding;
|
||||
const statusBarHeight = StatusBar.currentHeight ?? 0;
|
||||
return Math.max(basePadding, statusBarHeight + basePadding);
|
||||
}, []);
|
||||
|
||||
const middleware = useMemo(
|
||||
() => [
|
||||
floatingOffset({ mainAxis: 4 }),
|
||||
flip({ padding: collisionPadding }),
|
||||
// Avoid `crossAxis: true` here: per Floating UI docs it can cause overlap with the reference.
|
||||
shift({ padding: collisionPadding }),
|
||||
floatingSize({
|
||||
padding: collisionPadding,
|
||||
apply({ availableWidth, availableHeight, rects }) {
|
||||
setAvailableSize((prev) => {
|
||||
const next = { width: availableWidth, height: availableHeight };
|
||||
if (!prev) return next;
|
||||
if (prev.width === next.width && prev.height === next.height) return prev;
|
||||
return next;
|
||||
});
|
||||
setReferenceWidth((prev) => {
|
||||
const next = rects.reference.width;
|
||||
if (prev === next) return prev;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
[collisionPadding]
|
||||
);
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: "bottom-start",
|
||||
middleware,
|
||||
sameScrollView: false,
|
||||
elements: {
|
||||
reference: anchorRef.current ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || isMobile) {
|
||||
setAvailableSize(null);
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isMobile, update, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.present();
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}
|
||||
}, [visible, isMobile]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={DropdownSheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.dropdownSheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopDropdownOverlay}>
|
||||
<Pressable style={styles.desktopDropdownBackdrop} onPress={onClose} />
|
||||
<View
|
||||
style={[
|
||||
styles.desktopDropdownContainer,
|
||||
{
|
||||
position: "absolute",
|
||||
minWidth: 200,
|
||||
width: referenceWidth ?? undefined,
|
||||
},
|
||||
floatingStyles,
|
||||
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
||||
typeof availableSize?.width === "number" ? { maxWidth: availableSize.width } : null,
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
collapsable={false}
|
||||
onLayout={() => update()}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopDropdownScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopDropdownScroll}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
// Re-export ComboboxItem as SelectOption for backwards compatibility
|
||||
const SelectOption = ComboboxItem;
|
||||
|
||||
interface ComboSelectOption {
|
||||
id: string;
|
||||
@@ -459,54 +280,12 @@ export function ComboSelect({
|
||||
}: ComboSelectProps): ReactElement {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const selectedOption = options.find((opt) => opt.id === value);
|
||||
const displayValue = selectedOption?.label ?? (value || "");
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const normalizedSearch = 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 hasMatches = filteredOptions.length > 0;
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption =
|
||||
allowCustomValue &&
|
||||
sanitizedSearchValue.length > 0 &&
|
||||
!options.some(
|
||||
(opt) =>
|
||||
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
||||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelect(id);
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, onSelect]
|
||||
);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -519,79 +298,17 @@ export function ComboSelect({
|
||||
isLoading={isLoading}
|
||||
controlRef={anchorRef}
|
||||
/>
|
||||
<AdaptiveSelect
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
searchPlaceholder={`Search ${label.toLowerCase()}...`}
|
||||
allowCustomValue={allowCustomValue}
|
||||
title={title}
|
||||
visible={isOpen}
|
||||
onClose={handleClose}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
>
|
||||
{Platform.OS === "web" ? (
|
||||
<TextInput
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder={`Search ${label.toLowerCase()}...`}
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<BottomSheetTextInput
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder={`Search ${label.toLowerCase()}...`}
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{showCustomOption ? (
|
||||
<View style={styles.dropdownSheetList}>
|
||||
<Pressable
|
||||
style={styles.dropdownSheetOption}
|
||||
onPress={() => handleSelect(sanitizedSearchValue)}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
|
||||
{`Use "${sanitizedSearchValue}"`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{hasMatches ? (
|
||||
<View style={styles.dropdownSheetList}>
|
||||
{filteredOptions.map((opt) => {
|
||||
const isSelected = opt.id === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.id}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isSelected && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => handleSelect(opt.id)}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>{opt.label}</Text>
|
||||
{opt.description ? (
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
{opt.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : !showCustomOption ? (
|
||||
<Text style={styles.helperText}>No options match your search.</Text>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<View style={styles.dropdownSheetLoading}>
|
||||
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
|
||||
</View>
|
||||
) : null}
|
||||
</AdaptiveSelect>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -761,8 +478,18 @@ export function AssistantDropdown({
|
||||
(definition) => definition.id === selectedProvider
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
providerDefinitions.map((def) => ({
|
||||
id: def.id,
|
||||
label: def.label,
|
||||
description: def.description,
|
||||
})),
|
||||
[providerDefinitions]
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -774,36 +501,15 @@ export function AssistantDropdown({
|
||||
disabled={disabled}
|
||||
controlRef={anchorRef}
|
||||
/>
|
||||
<AdaptiveSelect
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedProvider}
|
||||
onSelect={(id) => onSelect(id as AgentProvider)}
|
||||
title="Choose assistant"
|
||||
visible={isOpen}
|
||||
onClose={handleClose}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
>
|
||||
{providerDefinitions.map((definition) => {
|
||||
const isSelected = definition.id === selectedProvider;
|
||||
return (
|
||||
<Pressable
|
||||
key={definition.id}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isSelected && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelect(definition.id);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>{definition.label}</Text>
|
||||
{definition.description ? (
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
{definition.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</AdaptiveSelect>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -831,12 +537,22 @@ export function PermissionsDropdown({
|
||||
"Default"
|
||||
: "Automatic";
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
modeOptions.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
description: mode.description,
|
||||
})),
|
||||
[modeOptions]
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
if (hasOptions) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [hasOptions]);
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -854,36 +570,15 @@ export function PermissionsDropdown({
|
||||
controlRef={anchorRef}
|
||||
/>
|
||||
{hasOptions ? (
|
||||
<AdaptiveSelect
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedMode}
|
||||
onSelect={onSelect}
|
||||
title="Permissions"
|
||||
visible={isOpen}
|
||||
onClose={handleClose}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
>
|
||||
{modeOptions.map((mode) => {
|
||||
const isSelected = mode.id === selectedMode;
|
||||
return (
|
||||
<Pressable
|
||||
key={mode.id}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isSelected && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelect(mode.id);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>{mode.label}</Text>
|
||||
{mode.description ? (
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
{mode.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</AdaptiveSelect>
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -923,8 +618,36 @@ export function ModelDropdown({
|
||||
? "This assistant did not expose selectable models."
|
||||
: undefined;
|
||||
|
||||
const options = useMemo(() => {
|
||||
const opts: ComboSelectOption[] = [
|
||||
{
|
||||
id: "",
|
||||
label: "Automatic (provider default)",
|
||||
description: "Let the assistant pick the recommended model.",
|
||||
},
|
||||
];
|
||||
for (const model of models) {
|
||||
opts.push({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}, [models]);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
if (id === "") {
|
||||
onClear();
|
||||
} else {
|
||||
onSelect(id);
|
||||
}
|
||||
},
|
||||
[onClear, onSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -938,61 +661,15 @@ export function ModelDropdown({
|
||||
helperText={helperText}
|
||||
controlRef={anchorRef}
|
||||
/>
|
||||
<AdaptiveSelect title="Model" visible={isOpen} onClose={handleClose} anchorRef={anchorRef}>
|
||||
<Pressable
|
||||
style={styles.dropdownSheetOption}
|
||||
onPress={() => {
|
||||
onClear();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>
|
||||
Automatic (provider default)
|
||||
</Text>
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
Let the assistant pick the recommended model.
|
||||
</Text>
|
||||
</Pressable>
|
||||
{models.map((model) => {
|
||||
const isSelected = model.id === selectedModel;
|
||||
return (
|
||||
<Pressable
|
||||
key={model.id}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isSelected && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelect(model.id);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>{model.label}</Text>
|
||||
{model.description ? (
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
{model.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
<Pressable
|
||||
style={styles.dropdownSheetOption}
|
||||
onPress={() => {
|
||||
onRefresh();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>Refresh models</Text>
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
Request the latest catalog from the provider.
|
||||
</Text>
|
||||
</Pressable>
|
||||
{isLoading ? (
|
||||
<View style={styles.dropdownSheetLoading}>
|
||||
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
|
||||
</View>
|
||||
) : null}
|
||||
</AdaptiveSelect>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedModel}
|
||||
onSelect={handleSelect}
|
||||
title="Model"
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1014,46 +691,18 @@ export function WorkingDirectoryDropdown({
|
||||
}: WorkingDirectoryDropdownProps): ReactElement {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||
const filteredPaths = useMemo(() => {
|
||||
if (!normalizedSearch) {
|
||||
return suggestedPaths;
|
||||
}
|
||||
return suggestedPaths.filter((path) =>
|
||||
path.toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}, [suggestedPaths, normalizedSearch]);
|
||||
|
||||
const hasSuggestedPaths = suggestedPaths.length > 0;
|
||||
const hasMatches = filteredPaths.length > 0;
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption = sanitizedSearchValue.length > 0;
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(path: string) => {
|
||||
onSelectPath(path);
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, onSelectPath]
|
||||
const options = useMemo(
|
||||
() => suggestedPaths.map((path) => ({ id: path, label: path })),
|
||||
[suggestedPaths]
|
||||
);
|
||||
|
||||
const handleSubmitSearch = useCallback(() => {
|
||||
if (!showCustomOption) {
|
||||
return;
|
||||
}
|
||||
handleSelect(sanitizedSearchValue);
|
||||
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
|
||||
const emptyText = suggestedPaths.length > 0
|
||||
? "No agent directories match your search."
|
||||
: "We'll suggest directories from agents on this host once they exist.";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1068,85 +717,20 @@ export function WorkingDirectoryDropdown({
|
||||
controlRef={anchorRef}
|
||||
testID="working-directory-select"
|
||||
/>
|
||||
<AdaptiveSelect
|
||||
<Combobox
|
||||
options={options}
|
||||
value={workingDir}
|
||||
onSelect={onSelectPath}
|
||||
searchPlaceholder="/path/to/project"
|
||||
emptyText={emptyText}
|
||||
allowCustomValue
|
||||
customValuePrefix="Use"
|
||||
customValueDescription="Launch the agent in this directory"
|
||||
title="Working directory"
|
||||
visible={isOpen}
|
||||
onClose={handleClose}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
>
|
||||
{Platform.OS === "web" ? (
|
||||
<TextInput
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder="/path/to/project"
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
/>
|
||||
) : (
|
||||
<BottomSheetTextInput
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder="/path/to/project"
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
/>
|
||||
)}
|
||||
{!hasSuggestedPaths && !showCustomOption ? (
|
||||
<Text style={styles.helperText}>
|
||||
We'll suggest directories from agents on this host once they exist.
|
||||
</Text>
|
||||
) : null}
|
||||
{showCustomOption ? (
|
||||
<View style={styles.dropdownSheetList}>
|
||||
<Pressable
|
||||
key="working-dir-custom-option"
|
||||
testID="working-directory-custom-option"
|
||||
style={styles.dropdownSheetOption}
|
||||
onPress={() => handleSelect(sanitizedSearchValue)}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
|
||||
{`Use "${sanitizedSearchValue}"`}
|
||||
</Text>
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
Launch the agent in this directory
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{hasMatches ? (
|
||||
<View style={styles.dropdownSheetList}>
|
||||
{filteredPaths.map((path) => {
|
||||
const isActive = path === workingDir;
|
||||
return (
|
||||
<Pressable
|
||||
key={path}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isActive && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => handleSelect(path)}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
|
||||
{path}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : hasSuggestedPaths ? (
|
||||
<Text style={styles.helperText}>
|
||||
No agent directories match your search.
|
||||
</Text>
|
||||
) : null}
|
||||
</AdaptiveSelect>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1343,16 +927,15 @@ export function GitOptionsSection({
|
||||
onClose={() => setIsWorktreeSheetOpen(false)}
|
||||
>
|
||||
{worktreeOptions.map((option) => (
|
||||
<Pressable
|
||||
<SelectOption
|
||||
key={option.path}
|
||||
style={styles.dropdownSheetOption}
|
||||
label={option.label}
|
||||
selected={option.path === selectedWorktreePath}
|
||||
onPress={() => {
|
||||
onSelectWorktreePath(option.path);
|
||||
setIsWorktreeSheetOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>{option.label}</Text>
|
||||
</Pressable>
|
||||
/>
|
||||
))}
|
||||
</DropdownSheet>
|
||||
</>
|
||||
@@ -1438,15 +1021,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
dropdownSearchInput: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
@@ -1719,22 +1293,23 @@ const styles = StyleSheet.create((theme) => ({
|
||||
left: 0,
|
||||
},
|
||||
desktopDropdownContainer: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
maxHeight: 400,
|
||||
overflow: "hidden",
|
||||
},
|
||||
desktopDropdownScroll: {
|
||||
maxHeight: 400,
|
||||
},
|
||||
desktopDropdownScrollContent: {
|
||||
padding: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
agentConfigRow: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -69,6 +69,8 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
// Higher activationDistance prevents drag from interfering with nested onLongPress handlers
|
||||
activationDistance={20}
|
||||
// @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList
|
||||
waitFor={waitFor}
|
||||
refreshControl={
|
||||
|
||||
@@ -147,11 +147,24 @@ function SectionHeader({
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
>
|
||||
<View style={styles.sectionHeaderLeft}>
|
||||
{icon && (
|
||||
<View style={styles.chevron}>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
{icon ? (
|
||||
<Image
|
||||
source={{ uri: `data:${icon.mimeType};base64,${icon.data}` }}
|
||||
style={styles.projectIcon}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.projectIconPlaceholder}>
|
||||
<Text style={styles.projectIconPlaceholderText}>
|
||||
{(section.workingDir?.split("/").pop() ?? displayTitle).charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.sectionTitle} numberOfLines={1}>
|
||||
{displayTitle}
|
||||
@@ -162,22 +175,18 @@ function SectionHeader({
|
||||
<Pressable
|
||||
style={styles.createAgentButton}
|
||||
onPress={handleCreatePress}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(true)}
|
||||
hitSlop={20}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onStartShouldSetResponderCapture={() => true}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
{({ hovered, pressed }) => (
|
||||
<Plus
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
size={18}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
{isCollapsed ? (
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -271,9 +280,10 @@ export function GroupedAgentList({
|
||||
|
||||
const handleCreateAgentInProject = useCallback(
|
||||
(workingDir: string) => {
|
||||
onAgentSelect?.();
|
||||
router.push(`/agent?workingDir=${encodeURIComponent(workingDir)}` as any);
|
||||
},
|
||||
[]
|
||||
[onAgentSelect]
|
||||
);
|
||||
|
||||
// Prefetch checkout status for all agents in the sidebar.
|
||||
@@ -436,6 +446,7 @@ export function GroupedAgentList({
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.agentItem,
|
||||
!isSelected && styles.agentItemUnselected,
|
||||
isSelected && styles.agentItemSelected,
|
||||
isHovered && styles.agentItemHovered,
|
||||
pressed && styles.agentItemPressed,
|
||||
@@ -462,9 +473,9 @@ export function GroupedAgentList({
|
||||
>
|
||||
{agent.title || "New agent"}
|
||||
</Text>
|
||||
{isHovered && canArchive && (
|
||||
{isHovered && canArchive ? (
|
||||
<Pressable
|
||||
style={styles.archiveButton}
|
||||
style={styles.branchBadge}
|
||||
onPress={(e) => handleArchiveAgent(e, agent)}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(true)}
|
||||
@@ -472,7 +483,7 @@ export function GroupedAgentList({
|
||||
>
|
||||
{({ hovered: archiveHovered }) => (
|
||||
<Archive
|
||||
size={14}
|
||||
size={12}
|
||||
color={
|
||||
archiveHovered
|
||||
? theme.colors.foreground
|
||||
@@ -481,12 +492,14 @@ export function GroupedAgentList({
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
) : activeBranchLabel ? (
|
||||
<View style={styles.branchBadge}>
|
||||
<Text style={styles.branchBadgeText} numberOfLines={1}>
|
||||
{activeBranchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.secondaryRow} numberOfLines={1}>
|
||||
{activeBranchLabel ? `${activeBranchLabel} · ${timeAgo}` : timeAgo}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -508,7 +521,7 @@ export function GroupedAgentList({
|
||||
const isCollapsed = collapsedSections.has(section.key);
|
||||
|
||||
return (
|
||||
<View style={isActive && styles.sectionDragging}>
|
||||
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
|
||||
<SectionHeader
|
||||
section={section}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -624,14 +637,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sectionHeaderExpanded: {
|
||||
marginBottom: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
sectionHeaderDragging: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sectionContainer: {
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
sectionDragging: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
chevron: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
sectionHeaderLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -645,6 +664,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectIconPlaceholder: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectIconPlaceholderText: {
|
||||
fontSize: 10,
|
||||
fontWeight: "500",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sectionHeaderRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -653,8 +686,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
createAgentButton: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
@@ -672,6 +705,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
agentItemUnselected: {
|
||||
opacity: 0.75,
|
||||
},
|
||||
agentItemSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
@@ -705,6 +741,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
branchBadge: {
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
branchBadgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 12,
|
||||
},
|
||||
agentTitleHighlighted: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 1,
|
||||
|
||||
@@ -7,11 +7,10 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
rightContent?: ReactNode;
|
||||
}
|
||||
|
||||
export function MenuHeader({ title, subtitle, rightContent }: MenuHeaderProps) {
|
||||
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -33,16 +32,9 @@ export function MenuHeader({ title, subtitle, rightContent }: MenuHeaderProps) {
|
||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
||||
</Pressable>
|
||||
{title && (
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
@@ -63,11 +55,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[0],
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: {
|
||||
xs: "400",
|
||||
@@ -75,9 +64,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: "300",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
542
packages/app/src/components/ui/combobox.tsx
Normal file
542
packages/app/src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,542 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Check, Search } from "lucide-react-native";
|
||||
import {
|
||||
flip,
|
||||
offset as floatingOffset,
|
||||
shift,
|
||||
size as floatingSize,
|
||||
useFloating,
|
||||
} from "@floating-ui/react-native";
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
export interface ComboboxOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
value: string;
|
||||
onSelect: (id: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
allowCustomValue?: boolean;
|
||||
customValuePrefix?: string;
|
||||
customValueDescription?: string;
|
||||
title?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[style, styles.bottomSheetBackground]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchInputProps {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
placeholder,
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmitEditing,
|
||||
autoFocus = false,
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||
return (
|
||||
<View style={styles.searchInputContainer}>
|
||||
<Search size={16} color={theme.colors.foregroundMuted} />
|
||||
<InputComponent
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus={autoFocus}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ComboboxItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
selected?: boolean;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function ComboboxItem({
|
||||
label,
|
||||
description,
|
||||
selected,
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.comboboxItem,
|
||||
pressed && styles.comboboxItemPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.comboboxItemCheckSlot}>
|
||||
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
|
||||
</View>
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
||||
{description ? (
|
||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
|
||||
return <Text style={styles.emptyText}>{children}</Text>;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onSelect,
|
||||
placeholder = "Search...",
|
||||
searchPlaceholder,
|
||||
emptyText = "No options match your search.",
|
||||
allowCustomValue = false,
|
||||
customValuePrefix = "Use",
|
||||
customValueDescription,
|
||||
title = "Select",
|
||||
open,
|
||||
onOpenChange,
|
||||
anchorRef,
|
||||
children,
|
||||
}: ComboboxProps): ReactElement {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
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 [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const isControlled = typeof open === "boolean";
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isOpen = isControlled ? open : internalOpen;
|
||||
|
||||
const setOpen = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSearchQuery("");
|
||||
}, [setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const collisionPadding = useMemo(() => {
|
||||
const basePadding = 16;
|
||||
if (Platform.OS !== "android") return basePadding;
|
||||
const statusBarHeight = StatusBar.currentHeight ?? 0;
|
||||
return Math.max(basePadding, statusBarHeight + basePadding);
|
||||
}, []);
|
||||
|
||||
const middleware = useMemo(
|
||||
() => [
|
||||
floatingOffset({ mainAxis: 4 }),
|
||||
flip({ padding: collisionPadding }),
|
||||
shift({ padding: collisionPadding }),
|
||||
floatingSize({
|
||||
padding: collisionPadding,
|
||||
apply({ availableWidth, availableHeight, rects }) {
|
||||
setAvailableSize((prev) => {
|
||||
const next = { width: availableWidth, height: availableHeight };
|
||||
if (!prev) return next;
|
||||
if (prev.width === next.width && prev.height === next.height) return prev;
|
||||
return next;
|
||||
});
|
||||
setReferenceWidth((prev) => {
|
||||
const next = rects.reference.width;
|
||||
if (prev === next) return prev;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
[collisionPadding]
|
||||
);
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: "bottom-start",
|
||||
middleware,
|
||||
sameScrollView: false,
|
||||
elements: {
|
||||
reference: anchorRef.current ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setAvailableSize(null);
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isMobile, update, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (isOpen) {
|
||||
bottomSheetRef.current?.present();
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}
|
||||
}, [isOpen, isMobile]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose]
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const normalizedSearch = 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 hasMatches = filteredOptions.length > 0;
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption =
|
||||
allowCustomValue &&
|
||||
sanitizedSearchValue.length > 0 &&
|
||||
!options.some(
|
||||
(opt) =>
|
||||
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
||||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelect(id);
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, onSelect]
|
||||
);
|
||||
|
||||
const handleSubmitSearch = useCallback(() => {
|
||||
if (showCustomOption) {
|
||||
handleSelect(sanitizedSearchValue);
|
||||
}
|
||||
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
|
||||
|
||||
const searchInput = (
|
||||
<SearchInput
|
||||
placeholder={searchPlaceholder ?? placeholder}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
autoFocus={!isMobile}
|
||||
/>
|
||||
);
|
||||
|
||||
const optionsList = (
|
||||
<>
|
||||
{showCustomOption ? (
|
||||
<ComboboxItem
|
||||
label={`${customValuePrefix} "${sanitizedSearchValue}"`}
|
||||
description={customValueDescription}
|
||||
onPress={() => handleSelect(sanitizedSearchValue)}
|
||||
/>
|
||||
) : null}
|
||||
{hasMatches ? (
|
||||
filteredOptions.map((opt) => (
|
||||
<ComboboxItem
|
||||
key={opt.id}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
selected={opt.id === value}
|
||||
onPress={() => handleSelect(opt.id)}
|
||||
/>
|
||||
))
|
||||
) : !showCustomOption ? (
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const content = children ?? (
|
||||
<>
|
||||
{searchInput}
|
||||
{optionsList}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.comboboxTitle}>{title}</Text>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.comboboxScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{content}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOpen) return <></>;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="none"
|
||||
visible={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<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)}
|
||||
style={[
|
||||
styles.desktopContainer,
|
||||
{
|
||||
position: "absolute",
|
||||
minWidth: 200,
|
||||
width: referenceWidth ?? undefined,
|
||||
},
|
||||
floatingStyles,
|
||||
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
||||
typeof availableSize?.width === "number" ? { maxWidth: availableSize.width } : null,
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
collapsable={false}
|
||||
onLayout={() => update()}
|
||||
>
|
||||
{children ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{searchInput}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{optionsList}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
searchInputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
marginBottom: theme.spacing[1],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
comboboxItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: 36,
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
comboboxItemPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
comboboxItemCheckSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
comboboxItemContent: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
comboboxItemLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
comboboxItemDescription: {
|
||||
marginTop: 2,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
comboboxTitle: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
comboboxScrollContent: {
|
||||
paddingBottom: theme.spacing[8],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
desktopOverlay: {
|
||||
flex: 1,
|
||||
},
|
||||
desktopBackdrop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
desktopContainer: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
maxHeight: 400,
|
||||
overflow: "hidden",
|
||||
},
|
||||
desktopScroll: {
|
||||
maxHeight: 400,
|
||||
},
|
||||
desktopScrollContent: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
@@ -204,7 +206,7 @@ function ToastViewport({
|
||||
<AlertTriangle size={18} color={theme.colors.destructive} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
@@ -232,6 +234,13 @@ function ToastViewport({
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// On web, portal to overlay root to control stacking order
|
||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -240,7 +249,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
left: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
bottom: 0,
|
||||
zIndex: 1100,
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
|
||||
25
packages/app/src/lib/overlay-root.ts
Normal file
25
packages/app/src/lib/overlay-root.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Shared overlay root for web portals (modals, toasts, etc.)
|
||||
* This ensures consistent stacking order by controlling a single overlay container.
|
||||
*
|
||||
* Z-index scale within overlay root:
|
||||
* - Modal backdrop/content: 10
|
||||
* - Toast: 20
|
||||
*/
|
||||
export function getOverlayRoot(): HTMLElement {
|
||||
let el = document.getElementById("overlay-root");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "overlay-root";
|
||||
el.style.position = "fixed";
|
||||
el.style.inset = "0";
|
||||
el.style.pointerEvents = "none";
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export const OVERLAY_Z = {
|
||||
modal: 10,
|
||||
toast: 20,
|
||||
} as const;
|
||||
@@ -506,16 +506,6 @@ function AgentScreenContent({
|
||||
const headerProjectPath = effectiveAgent
|
||||
? deriveProjectPath(effectiveAgent.cwd, checkout)
|
||||
: null;
|
||||
const headerBranchLabel = deriveBranchLabel(checkout);
|
||||
const headerSubtitle = useMemo(() => {
|
||||
if (!headerProjectPath) return undefined;
|
||||
const path = shortenPath(headerProjectPath);
|
||||
if (headerBranchLabel) {
|
||||
return `${path} · ${headerBranchLabel}`;
|
||||
}
|
||||
return path;
|
||||
}, [headerProjectPath, headerBranchLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingCreateForRoute || !pendingCreate) {
|
||||
return;
|
||||
@@ -622,7 +612,6 @@ function AgentScreenContent({
|
||||
{/* Header */}
|
||||
<MenuHeader
|
||||
title={effectiveAgent.title || "Agent"}
|
||||
subtitle={headerSubtitle}
|
||||
rightContent={
|
||||
<View style={styles.headerRightContent}>
|
||||
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
|
||||
|
||||
@@ -1,34 +1,167 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import { createServer } from "http";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
generateStructuredAgentResponse,
|
||||
} from "./agent-response-loop.js";
|
||||
import express from "express";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { generateStructuredAgentResponse } from "./agent-response-loop.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||
import pino from "pino";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||
|
||||
type AgentMcpServerHandle = {
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerHandle> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const httpServer = createServer(app);
|
||||
|
||||
const registryDir = mkdtempSync(path.join(tmpdir(), "agent-mcp-registry-"));
|
||||
const storagePath = path.join(registryDir, "agents");
|
||||
const agentStorage = new AgentStorage(storagePath, logger);
|
||||
const agentManager = new AgentManager({
|
||||
clients: {},
|
||||
registry: agentStorage,
|
||||
logger,
|
||||
});
|
||||
|
||||
let allowedHosts: string[] | undefined;
|
||||
const agentMcpTransports = new Map<string, StreamableHTTPServerTransport>();
|
||||
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
const mcpServer = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
callerAgentId,
|
||||
logger,
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
agentMcpTransports.set(sessionId, transport);
|
||||
},
|
||||
onsessionclosed: (sessionId) => {
|
||||
agentMcpTransports.delete(sessionId);
|
||||
},
|
||||
enableDnsRebindingProtection: true,
|
||||
...(allowedHosts ? { allowedHosts } : {}),
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
agentMcpTransports.delete(transport.sessionId);
|
||||
}
|
||||
};
|
||||
transport.onerror = () => {
|
||||
// Ignore errors in test
|
||||
};
|
||||
|
||||
await mcpServer.connect(transport);
|
||||
return transport;
|
||||
};
|
||||
|
||||
const handleAgentMcpRequest: express.RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const sessionId = req.header("mcp-session-id");
|
||||
let transport = sessionId ? agentMcpTransports.get(sessionId) : undefined;
|
||||
|
||||
if (!transport) {
|
||||
if (req.method !== "POST") {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "Missing or invalid MCP session" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body;
|
||||
if (!isInitializeRequest(body)) {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32600, message: "First request must be initialize" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
transport = await createAgentMcpTransport();
|
||||
}
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal MCP server error" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.post("/mcp/agents", handleAgentMcpRequest);
|
||||
app.get("/mcp/agents", handleAgentMcpRequest);
|
||||
app.delete("/mcp/agents", handleAgentMcpRequest);
|
||||
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
httpServer.listen(0, () => {
|
||||
const address = httpServer.address();
|
||||
resolve(typeof address === "object" && address ? address.port : 0);
|
||||
});
|
||||
});
|
||||
|
||||
allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
||||
const url = `http://127.0.0.1:${port}/mcp/agents`;
|
||||
|
||||
return {
|
||||
url,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
rmSync(registryDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getStructuredAgentResponse (e2e)", () => {
|
||||
let manager: AgentManager;
|
||||
let cwd: string;
|
||||
let agentMcpServer: AgentMcpServerHandle;
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
beforeAll(async () => {
|
||||
agentMcpServer = await startAgentMcpServer(logger);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await agentMcpServer?.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(path.join(tmpdir(), "agent-response-loop-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
manager = new AgentManager({
|
||||
clients: createAllClients(logger),
|
||||
agentControlMcp: { url: agentMcpServer.url },
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
await shutdownProviders(pino({ level: "silent" }));
|
||||
await shutdownProviders(logger);
|
||||
}, 60000);
|
||||
|
||||
test(
|
||||
@@ -58,4 +191,30 @@ describe("getStructuredAgentResponse (e2e)", () => {
|
||||
},
|
||||
180000
|
||||
);
|
||||
|
||||
test(
|
||||
"returns schema-valid JSON from Claude Haiku on first try",
|
||||
async () => {
|
||||
const schema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const result = await generateStructuredAgentResponse({
|
||||
manager,
|
||||
agentConfig: {
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
cwd,
|
||||
title: "Claude Haiku Structured Test",
|
||||
internal: true,
|
||||
},
|
||||
prompt: 'Return JSON with a message field containing "hello".',
|
||||
schema,
|
||||
maxRetries: 0,
|
||||
});
|
||||
|
||||
expect(result.message).toBe("hello");
|
||||
},
|
||||
180000
|
||||
);
|
||||
});
|
||||
|
||||
@@ -109,4 +109,36 @@ describe("getStructuredAgentResponse", () => {
|
||||
expect(prompts).toHaveLength(2);
|
||||
expect(prompts[1]).toContain("validation errors");
|
||||
});
|
||||
|
||||
it("extracts JSON from markdown code fences", async () => {
|
||||
const schema = z.object({ message: z.string() });
|
||||
const { caller } = createScriptedCaller([
|
||||
'```json\n{"message": "hello"}\n```',
|
||||
]);
|
||||
|
||||
const result = await getStructuredAgentResponse({
|
||||
caller,
|
||||
prompt: "Provide a message",
|
||||
schema,
|
||||
maxRetries: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ message: "hello" });
|
||||
});
|
||||
|
||||
it("extracts JSON from plain code fences", async () => {
|
||||
const schema = z.object({ value: z.number() });
|
||||
const { caller } = createScriptedCaller([
|
||||
'```\n{"value": 42}\n```',
|
||||
]);
|
||||
|
||||
const result = await getStructuredAgentResponse({
|
||||
caller,
|
||||
prompt: "Provide a value",
|
||||
schema,
|
||||
maxRetries: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,14 @@ function buildRetryPrompt(basePrompt: string, errors: string[]): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function extractJsonFromMarkdown(text: string): string {
|
||||
const fencedMatch = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
||||
if (fencedMatch) {
|
||||
return fencedMatch[1].trim();
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
export async function getStructuredAgentResponse<T>(
|
||||
options: StructuredAgentResponseOptions<T>
|
||||
): Promise<T> {
|
||||
@@ -140,10 +148,11 @@ export async function getStructuredAgentResponse<T>(
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
const response = await caller(attemptPrompt);
|
||||
lastResponse = response;
|
||||
const jsonText = extractJsonFromMarkdown(response);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response);
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
lastErrors = [`Invalid JSON: ${message}`];
|
||||
@@ -185,7 +194,12 @@ export async function generateStructuredAgentResponse<T>(
|
||||
try {
|
||||
const caller: AgentCaller = async (nextPrompt) => {
|
||||
const result = await manager.runAgent(agent.id, nextPrompt);
|
||||
return result.finalText;
|
||||
// Accumulate all assistant_message items since Claude streams text as deltas
|
||||
const fullText = result.timeline
|
||||
.filter((item) => item.type === "assistant_message")
|
||||
.map((item) => item.text)
|
||||
.join("");
|
||||
return fullText || result.finalText;
|
||||
};
|
||||
return await getStructuredAgentResponse({
|
||||
caller,
|
||||
|
||||
@@ -12,10 +12,53 @@ export interface DictationDebugAudioMetadata {
|
||||
format: string;
|
||||
}
|
||||
|
||||
export interface DictationDebugChunkWriter {
|
||||
folder: string;
|
||||
writeChunk: (seq: number, pcm16: Buffer) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createDictationDebugChunkWriter(
|
||||
metadata: Pick<DictationDebugAudioMetadata, "sessionId" | "dictationId">,
|
||||
logger: pino.Logger
|
||||
): DictationDebugChunkWriter | null {
|
||||
const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR");
|
||||
if (!debugDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (announcedDir !== debugDir) {
|
||||
logger.info({ debugDir }, "Dictation audio capture enabled");
|
||||
announcedDir = debugDir;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const folder = join(
|
||||
debugDir,
|
||||
sanitizeForFilename(metadata.sessionId, "session"),
|
||||
`${timestamp}_${sanitizeForFilename(metadata.dictationId, "dictation")}`
|
||||
);
|
||||
|
||||
let folderCreated = false;
|
||||
|
||||
return {
|
||||
folder,
|
||||
writeChunk: async (seq: number, pcm16: Buffer) => {
|
||||
if (!folderCreated) {
|
||||
await mkdir(folder, { recursive: true });
|
||||
folderCreated = true;
|
||||
}
|
||||
const paddedSeq = String(seq).padStart(6, "0");
|
||||
const filePath = join(folder, `chunk_${paddedSeq}.pcm`);
|
||||
await writeFile(filePath, pcm16);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function maybePersistDictationDebugAudio(
|
||||
audio: Buffer,
|
||||
metadata: DictationDebugAudioMetadata,
|
||||
logger: pino.Logger
|
||||
logger: pino.Logger,
|
||||
chunkWriterFolder?: string | null
|
||||
): Promise<string | null> {
|
||||
const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR");
|
||||
if (!debugDir) {
|
||||
@@ -28,12 +71,13 @@ export async function maybePersistDictationDebugAudio(
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const folder = join(debugDir, sanitizeForFilename(metadata.sessionId, "session"));
|
||||
const folder = chunkWriterFolder ?? join(debugDir, sanitizeForFilename(metadata.sessionId, "session"));
|
||||
await mkdir(folder, { recursive: true });
|
||||
|
||||
const parts = [timestamp, sanitizeForFilename(metadata.dictationId, "dictation")];
|
||||
const ext = inferAudioExtension(metadata.format);
|
||||
const filePath = join(folder, `${parts.join("_")}.${ext}`);
|
||||
const filename = chunkWriterFolder
|
||||
? `combined.${inferAudioExtension(metadata.format)}`
|
||||
: `${timestamp}_${sanitizeForFilename(metadata.dictationId, "dictation")}.${inferAudioExtension(metadata.format)}`;
|
||||
const filePath = join(folder, filename);
|
||||
await writeFile(filePath, audio);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type pino from "pino";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { maybePersistDictationDebugAudio } from "../agent/dictation-debug.js";
|
||||
import {
|
||||
createDictationDebugChunkWriter,
|
||||
maybePersistDictationDebugAudio,
|
||||
type DictationDebugChunkWriter,
|
||||
} from "../agent/dictation-debug.js";
|
||||
import { isPaseoDictationDebugEnabled } from "../agent/recordings-debug.js";
|
||||
import { Pcm16MonoResampler } from "../agent/pcm16-resampler.js";
|
||||
import { OpenAIRealtimeTranscriptionSession } from "../agent/openai-realtime-transcription.js";
|
||||
@@ -118,6 +122,7 @@ type DictationStreamState = {
|
||||
resampler: Pcm16MonoResampler | null;
|
||||
debugAudioChunks: Buffer[];
|
||||
debugRecordingPath: string | null;
|
||||
debugChunkWriter: DictationDebugChunkWriter | null;
|
||||
receivedChunks: Map<number, Buffer>;
|
||||
nextSeqToForward: number;
|
||||
ackSeq: number;
|
||||
@@ -271,6 +276,11 @@ export class DictationStreamManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const debugChunkWriter = createDictationDebugChunkWriter(
|
||||
{ sessionId: this.sessionId, dictationId },
|
||||
this.logger
|
||||
);
|
||||
|
||||
this.streams.set(dictationId, {
|
||||
dictationId,
|
||||
sessionId: this.sessionId,
|
||||
@@ -286,6 +296,7 @@ export class DictationStreamManager {
|
||||
}),
|
||||
debugAudioChunks: [],
|
||||
debugRecordingPath: null,
|
||||
debugChunkWriter,
|
||||
receivedChunks: new Map(),
|
||||
nextSeqToForward: 0,
|
||||
ackSeq: -1,
|
||||
@@ -345,6 +356,12 @@ export class DictationStreamManager {
|
||||
state.debugAudioChunks.push(resampled);
|
||||
state.bytesSinceCommit += resampled.length;
|
||||
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
||||
|
||||
if (state.debugChunkWriter) {
|
||||
void state.debugChunkWriter.writeChunk(seq, resampled).catch((err) => {
|
||||
this.logger.warn({ dictationId: params.dictationId, seq, err }, "Failed to write debug chunk");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.nextSeqToForward += 1;
|
||||
@@ -440,7 +457,8 @@ export class DictationStreamManager {
|
||||
const path = await maybePersistDictationDebugAudio(
|
||||
wavBuffer,
|
||||
{ sessionId: state.sessionId, dictationId: state.dictationId, format: "audio/wav" },
|
||||
this.logger
|
||||
this.logger,
|
||||
state.debugChunkWriter?.folder
|
||||
);
|
||||
state.debugRecordingPath = path;
|
||||
return path;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { STTManager } from "./agent/stt-manager.js";
|
||||
import type { OpenAISTT } from "./agent/stt-openai.js";
|
||||
import type { OpenAITTS } from "./agent/tts-openai.js";
|
||||
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
||||
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
||||
import { DictationStreamManager } from "./dictation/dictation-stream-manager.js";
|
||||
import type { VoiceConversationStore } from "./voice-conversation-store.js";
|
||||
import {
|
||||
|
||||
Reference in New Issue
Block a user