Update files
This commit is contained in:
@@ -55,4 +55,5 @@ CLAUDE.local.md
|
|||||||
.tasks/
|
.tasks/
|
||||||
|
|
||||||
.debug.conversations/
|
.debug.conversations/
|
||||||
|
.debug/
|
||||||
.paseo/
|
.paseo/
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -18,11 +21,13 @@ import { X } from "lucide-react-native";
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
flex: 1,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0,0,0,0.55)",
|
backgroundColor: "rgba(0,0,0,0.55)",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
|
zIndex: OVERLAY_Z.modal,
|
||||||
|
pointerEvents: "auto" as const,
|
||||||
},
|
},
|
||||||
desktopCard: {
|
desktopCard: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -184,14 +189,7 @@ export function AdaptiveModalSheet({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const desktopContent = (
|
||||||
<Modal
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
visible={visible}
|
|
||||||
onRequestClose={onClose}
|
|
||||||
hardwareAccelerated
|
|
||||||
>
|
|
||||||
<View style={styles.desktopOverlay} testID={testID}>
|
<View style={styles.desktopOverlay} testID={testID}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityLabel="Dismiss"
|
accessibilityLabel="Dismiss"
|
||||||
@@ -219,6 +217,23 @@ export function AdaptiveModalSheet({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</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
|
||||||
|
animationType="fade"
|
||||||
|
visible={visible}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
hardwareAccelerated
|
||||||
|
>
|
||||||
|
{desktopContent}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,19 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
Modal,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
ScrollView,
|
|
||||||
Platform,
|
Platform,
|
||||||
StatusBar,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetTextInput,
|
|
||||||
BottomSheetBackgroundProps,
|
BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { ChevronDown, ChevronRight, Pencil, Check, X } from "lucide-react-native";
|
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 { theme as defaultTheme } from "@/styles/theme";
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
@@ -35,6 +24,7 @@ import type {
|
|||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||||
|
|
||||||
type DropdownTriggerRenderProps = {
|
type DropdownTriggerRenderProps = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -256,177 +246,8 @@ export function DropdownSheet({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdaptiveSelectProps {
|
// Re-export ComboboxItem as SelectOption for backwards compatibility
|
||||||
title: string;
|
const SelectOption = ComboboxItem;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComboSelectOption {
|
interface ComboSelectOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -459,54 +280,12 @@ export function ComboSelect({
|
|||||||
}: ComboSelectProps): ReactElement {
|
}: ComboSelectProps): ReactElement {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
|
|
||||||
const selectedOption = options.find((opt) => opt.id === value);
|
const selectedOption = options.find((opt) => opt.id === value);
|
||||||
const displayValue = selectedOption?.label ?? (value || "");
|
const displayValue = selectedOption?.label ?? (value || "");
|
||||||
|
|
||||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
const handleClose = useCallback(() => {
|
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -519,79 +298,17 @@ export function ComboSelect({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
controlRef={anchorRef}
|
controlRef={anchorRef}
|
||||||
/>
|
/>
|
||||||
<AdaptiveSelect
|
<Combobox
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onSelect={onSelect}
|
||||||
|
searchPlaceholder={`Search ${label.toLowerCase()}...`}
|
||||||
|
allowCustomValue={allowCustomValue}
|
||||||
title={title}
|
title={title}
|
||||||
visible={isOpen}
|
open={isOpen}
|
||||||
onClose={handleClose}
|
onOpenChange={handleOpenChange}
|
||||||
anchorRef={anchorRef}
|
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
|
(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 handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -774,36 +501,15 @@ export function AssistantDropdown({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
controlRef={anchorRef}
|
controlRef={anchorRef}
|
||||||
/>
|
/>
|
||||||
<AdaptiveSelect
|
<Combobox
|
||||||
|
options={options}
|
||||||
|
value={selectedProvider}
|
||||||
|
onSelect={(id) => onSelect(id as AgentProvider)}
|
||||||
title="Choose assistant"
|
title="Choose assistant"
|
||||||
visible={isOpen}
|
open={isOpen}
|
||||||
onClose={handleClose}
|
onOpenChange={handleOpenChange}
|
||||||
anchorRef={anchorRef}
|
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"
|
"Default"
|
||||||
: "Automatic";
|
: "Automatic";
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
modeOptions.map((mode) => ({
|
||||||
|
id: mode.id,
|
||||||
|
label: mode.label,
|
||||||
|
description: mode.description,
|
||||||
|
})),
|
||||||
|
[modeOptions]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpen = useCallback(() => {
|
const handleOpen = useCallback(() => {
|
||||||
if (hasOptions) {
|
if (hasOptions) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
}, [hasOptions]);
|
}, [hasOptions]);
|
||||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -854,36 +570,15 @@ export function PermissionsDropdown({
|
|||||||
controlRef={anchorRef}
|
controlRef={anchorRef}
|
||||||
/>
|
/>
|
||||||
{hasOptions ? (
|
{hasOptions ? (
|
||||||
<AdaptiveSelect
|
<Combobox
|
||||||
|
options={options}
|
||||||
|
value={selectedMode}
|
||||||
|
onSelect={onSelect}
|
||||||
title="Permissions"
|
title="Permissions"
|
||||||
visible={isOpen}
|
open={isOpen}
|
||||||
onClose={handleClose}
|
onOpenChange={handleOpenChange}
|
||||||
anchorRef={anchorRef}
|
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}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -923,8 +618,36 @@ export function ModelDropdown({
|
|||||||
? "This assistant did not expose selectable models."
|
? "This assistant did not expose selectable models."
|
||||||
: undefined;
|
: 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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -938,61 +661,15 @@ export function ModelDropdown({
|
|||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
controlRef={anchorRef}
|
controlRef={anchorRef}
|
||||||
/>
|
/>
|
||||||
<AdaptiveSelect title="Model" visible={isOpen} onClose={handleClose} anchorRef={anchorRef}>
|
<Combobox
|
||||||
<Pressable
|
options={options}
|
||||||
style={styles.dropdownSheetOption}
|
value={selectedModel}
|
||||||
onPress={() => {
|
onSelect={handleSelect}
|
||||||
onClear();
|
title="Model"
|
||||||
handleClose();
|
open={isOpen}
|
||||||
}}
|
onOpenChange={handleOpenChange}
|
||||||
>
|
anchorRef={anchorRef}
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1014,46 +691,18 @@ export function WorkingDirectoryDropdown({
|
|||||||
}: WorkingDirectoryDropdownProps): ReactElement {
|
}: WorkingDirectoryDropdownProps): ReactElement {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => suggestedPaths.map((path) => ({ id: path, label: path })),
|
||||||
|
[suggestedPaths]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||||
|
|
||||||
useEffect(() => {
|
const emptyText = suggestedPaths.length > 0
|
||||||
if (isOpen) {
|
? "No agent directories match your search."
|
||||||
setSearchQuery("");
|
: "We'll suggest directories from agents on this host once they exist.";
|
||||||
}
|
|
||||||
}, [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 handleSubmitSearch = useCallback(() => {
|
|
||||||
if (!showCustomOption) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleSelect(sanitizedSearchValue);
|
|
||||||
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1068,85 +717,20 @@ export function WorkingDirectoryDropdown({
|
|||||||
controlRef={anchorRef}
|
controlRef={anchorRef}
|
||||||
testID="working-directory-select"
|
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"
|
title="Working directory"
|
||||||
visible={isOpen}
|
open={isOpen}
|
||||||
onClose={handleClose}
|
onOpenChange={handleOpenChange}
|
||||||
anchorRef={anchorRef}
|
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)}
|
onClose={() => setIsWorktreeSheetOpen(false)}
|
||||||
>
|
>
|
||||||
{worktreeOptions.map((option) => (
|
{worktreeOptions.map((option) => (
|
||||||
<Pressable
|
<SelectOption
|
||||||
key={option.path}
|
key={option.path}
|
||||||
style={styles.dropdownSheetOption}
|
label={option.label}
|
||||||
|
selected={option.path === selectedWorktreePath}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onSelectWorktreePath(option.path);
|
onSelectWorktreePath(option.path);
|
||||||
setIsWorktreeSheetOpen(false);
|
setIsWorktreeSheetOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Text style={styles.dropdownSheetOptionLabel}>{option.label}</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
))}
|
||||||
</DropdownSheet>
|
</DropdownSheet>
|
||||||
</>
|
</>
|
||||||
@@ -1438,15 +1021,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
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: {
|
bottomSheetBackground: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||||
@@ -1719,22 +1293,23 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
left: 0,
|
left: 0,
|
||||||
},
|
},
|
||||||
desktopDropdownContainer: {
|
desktopDropdownContainer: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface0,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderAccent,
|
||||||
shadowColor: "#000",
|
shadowColor: "#000",
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.15,
|
shadowOpacity: 0.2,
|
||||||
shadowRadius: 12,
|
shadowRadius: 8,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
maxHeight: 400,
|
maxHeight: 400,
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
desktopDropdownScroll: {
|
desktopDropdownScroll: {
|
||||||
maxHeight: 400,
|
maxHeight: 400,
|
||||||
},
|
},
|
||||||
desktopDropdownScrollContent: {
|
desktopDropdownScrollContent: {
|
||||||
padding: theme.spacing[2],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
agentConfigRow: {
|
agentConfigRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export function DraggableList<T>({
|
|||||||
ListEmptyComponent={ListEmptyComponent}
|
ListEmptyComponent={ListEmptyComponent}
|
||||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||||
simultaneousHandlers={simultaneousHandlers}
|
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
|
// @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList
|
||||||
waitFor={waitFor}
|
waitFor={waitFor}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
|
|||||||
@@ -147,11 +147,24 @@ function SectionHeader({
|
|||||||
onHoverOut={() => setIsHovered(false)}
|
onHoverOut={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<View style={styles.sectionHeaderLeft}>
|
<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
|
<Image
|
||||||
source={{ uri: `data:${icon.mimeType};base64,${icon.data}` }}
|
source={{ uri: `data:${icon.mimeType};base64,${icon.data}` }}
|
||||||
style={styles.projectIcon}
|
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}>
|
<Text style={styles.sectionTitle} numberOfLines={1}>
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
@@ -162,22 +175,18 @@ function SectionHeader({
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={styles.createAgentButton}
|
style={styles.createAgentButton}
|
||||||
onPress={handleCreatePress}
|
onPress={handleCreatePress}
|
||||||
onHoverIn={() => setIsHovered(true)}
|
hitSlop={20}
|
||||||
onHoverOut={() => setIsHovered(true)}
|
onStartShouldSetResponder={() => true}
|
||||||
|
onStartShouldSetResponderCapture={() => true}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered, pressed }) => (
|
||||||
<Plus
|
<Plus
|
||||||
size={14}
|
size={18}
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
{isCollapsed ? (
|
|
||||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -271,9 +280,10 @@ export function GroupedAgentList({
|
|||||||
|
|
||||||
const handleCreateAgentInProject = useCallback(
|
const handleCreateAgentInProject = useCallback(
|
||||||
(workingDir: string) => {
|
(workingDir: string) => {
|
||||||
|
onAgentSelect?.();
|
||||||
router.push(`/agent?workingDir=${encodeURIComponent(workingDir)}` as any);
|
router.push(`/agent?workingDir=${encodeURIComponent(workingDir)}` as any);
|
||||||
},
|
},
|
||||||
[]
|
[onAgentSelect]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefetch checkout status for all agents in the sidebar.
|
// Prefetch checkout status for all agents in the sidebar.
|
||||||
@@ -436,6 +446,7 @@ export function GroupedAgentList({
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.agentItem,
|
styles.agentItem,
|
||||||
|
!isSelected && styles.agentItemUnselected,
|
||||||
isSelected && styles.agentItemSelected,
|
isSelected && styles.agentItemSelected,
|
||||||
isHovered && styles.agentItemHovered,
|
isHovered && styles.agentItemHovered,
|
||||||
pressed && styles.agentItemPressed,
|
pressed && styles.agentItemPressed,
|
||||||
@@ -462,9 +473,9 @@ export function GroupedAgentList({
|
|||||||
>
|
>
|
||||||
{agent.title || "New agent"}
|
{agent.title || "New agent"}
|
||||||
</Text>
|
</Text>
|
||||||
{isHovered && canArchive && (
|
{isHovered && canArchive ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.archiveButton}
|
style={styles.branchBadge}
|
||||||
onPress={(e) => handleArchiveAgent(e, agent)}
|
onPress={(e) => handleArchiveAgent(e, agent)}
|
||||||
onHoverIn={() => setIsHovered(true)}
|
onHoverIn={() => setIsHovered(true)}
|
||||||
onHoverOut={() => setIsHovered(true)}
|
onHoverOut={() => setIsHovered(true)}
|
||||||
@@ -472,7 +483,7 @@ export function GroupedAgentList({
|
|||||||
>
|
>
|
||||||
{({ hovered: archiveHovered }) => (
|
{({ hovered: archiveHovered }) => (
|
||||||
<Archive
|
<Archive
|
||||||
size={14}
|
size={12}
|
||||||
color={
|
color={
|
||||||
archiveHovered
|
archiveHovered
|
||||||
? theme.colors.foreground
|
? theme.colors.foreground
|
||||||
@@ -481,13 +492,15 @@ export function GroupedAgentList({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
) : activeBranchLabel ? (
|
||||||
</View>
|
<View style={styles.branchBadge}>
|
||||||
|
<Text style={styles.branchBadgeText} numberOfLines={1}>
|
||||||
<Text style={styles.secondaryRow} numberOfLines={1}>
|
{activeBranchLabel}
|
||||||
{activeBranchLabel ? `${activeBranchLabel} · ${timeAgo}` : timeAgo}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -508,7 +521,7 @@ export function GroupedAgentList({
|
|||||||
const isCollapsed = collapsedSections.has(section.key);
|
const isCollapsed = collapsedSections.has(section.key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={isActive && styles.sectionDragging}>
|
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
section={section}
|
section={section}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
@@ -624,14 +637,20 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
sectionHeaderExpanded: {
|
sectionHeaderExpanded: {
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
sectionHeaderDragging: {
|
sectionHeaderDragging: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
sectionContainer: {
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
sectionDragging: {
|
sectionDragging: {
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
},
|
},
|
||||||
|
chevron: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
sectionHeaderLeft: {
|
sectionHeaderLeft: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -645,6 +664,20 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
height: 16,
|
height: 16,
|
||||||
borderRadius: theme.borderRadius.sm,
|
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: {
|
sectionHeaderRight: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -653,8 +686,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
marginLeft: theme.spacing[2],
|
marginLeft: theme.spacing[2],
|
||||||
},
|
},
|
||||||
createAgentButton: {
|
createAgentButton: {
|
||||||
width: 14,
|
width: 18,
|
||||||
height: 14,
|
height: 18,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
@@ -672,6 +705,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
agentItemUnselected: {
|
||||||
|
opacity: 0.75,
|
||||||
|
},
|
||||||
agentItemSelected: {
|
agentItemSelected: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
@@ -705,6 +741,21 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
marginLeft: theme.spacing[1],
|
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: {
|
agentTitleHighlighted: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import { usePanelStore } from "@/stores/panel-store";
|
|||||||
|
|
||||||
interface MenuHeaderProps {
|
interface MenuHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
|
||||||
rightContent?: ReactNode;
|
rightContent?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MenuHeader({ title, subtitle, rightContent }: MenuHeaderProps) {
|
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
@@ -33,16 +32,9 @@ export function MenuHeader({ title, subtitle, rightContent }: MenuHeaderProps) {
|
|||||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{title && (
|
{title && (
|
||||||
<View style={styles.titleContainer}>
|
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle && (
|
|
||||||
<Text style={styles.subtitle} numberOfLines={1}>
|
|
||||||
{subtitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -63,11 +55,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
titleContainer: {
|
|
||||||
flex: 1,
|
|
||||||
gap: theme.spacing[0],
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: {
|
fontWeight: {
|
||||||
xs: "400",
|
xs: "400",
|
||||||
@@ -75,9 +64,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
subtitle: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: "300",
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -204,7 +206,7 @@ function ToastViewport({
|
|||||||
<AlertTriangle size={18} color={theme.colors.destructive} />
|
<AlertTriangle size={18} color={theme.colors.destructive} />
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<View style={styles.container} pointerEvents="box-none">
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
testID={toast.testID ?? "app-toast"}
|
testID={toast.testID ?? "app-toast"}
|
||||||
@@ -232,6 +234,13 @@ function ToastViewport({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -240,7 +249,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
left: theme.spacing[4],
|
left: theme.spacing[4],
|
||||||
right: theme.spacing[4],
|
right: theme.spacing[4],
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
zIndex: 1100,
|
zIndex: OVERLAY_Z.toast,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
|
|||||||
@@ -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
|
const headerProjectPath = effectiveAgent
|
||||||
? deriveProjectPath(effectiveAgent.cwd, checkout)
|
? deriveProjectPath(effectiveAgent.cwd, checkout)
|
||||||
: null;
|
: 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(() => {
|
useEffect(() => {
|
||||||
if (!isPendingCreateForRoute || !pendingCreate) {
|
if (!isPendingCreateForRoute || !pendingCreate) {
|
||||||
return;
|
return;
|
||||||
@@ -622,7 +612,6 @@ function AgentScreenContent({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<MenuHeader
|
<MenuHeader
|
||||||
title={effectiveAgent.title || "Agent"}
|
title={effectiveAgent.title || "Agent"}
|
||||||
subtitle={headerSubtitle}
|
|
||||||
rightContent={
|
rightContent={
|
||||||
<View style={styles.headerRightContent}>
|
<View style={styles.headerRightContent}>
|
||||||
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
|
<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 { mkdtempSync, rmSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import express from "express";
|
||||||
generateStructuredAgentResponse,
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
} from "./agent-response-loop.js";
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { generateStructuredAgentResponse } from "./agent-response-loop.js";
|
||||||
import { AgentManager } from "./agent-manager.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 { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
const CODEX_TEST_REASONING_EFFORT = "low";
|
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)", () => {
|
describe("getStructuredAgentResponse (e2e)", () => {
|
||||||
let manager: AgentManager;
|
let manager: AgentManager;
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
|
let agentMcpServer: AgentMcpServerHandle;
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
agentMcpServer = await startAgentMcpServer(logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await agentMcpServer?.close();
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
cwd = mkdtempSync(path.join(tmpdir(), "agent-response-loop-"));
|
cwd = mkdtempSync(path.join(tmpdir(), "agent-response-loop-"));
|
||||||
const logger = pino({ level: "silent" });
|
|
||||||
manager = new AgentManager({
|
manager = new AgentManager({
|
||||||
clients: createAllClients(logger),
|
clients: createAllClients(logger),
|
||||||
|
agentControlMcp: { url: agentMcpServer.url },
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
await shutdownProviders(pino({ level: "silent" }));
|
await shutdownProviders(logger);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
@@ -58,4 +191,30 @@ describe("getStructuredAgentResponse (e2e)", () => {
|
|||||||
},
|
},
|
||||||
180000
|
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).toHaveLength(2);
|
||||||
expect(prompts[1]).toContain("validation errors");
|
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");
|
].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>(
|
export async function getStructuredAgentResponse<T>(
|
||||||
options: StructuredAgentResponseOptions<T>
|
options: StructuredAgentResponseOptions<T>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
@@ -140,10 +148,11 @@ export async function getStructuredAgentResponse<T>(
|
|||||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||||
const response = await caller(attemptPrompt);
|
const response = await caller(attemptPrompt);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
|
const jsonText = extractJsonFromMarkdown(response);
|
||||||
|
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(response);
|
parsed = JSON.parse(jsonText);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
lastErrors = [`Invalid JSON: ${message}`];
|
lastErrors = [`Invalid JSON: ${message}`];
|
||||||
@@ -185,7 +194,12 @@ export async function generateStructuredAgentResponse<T>(
|
|||||||
try {
|
try {
|
||||||
const caller: AgentCaller = async (nextPrompt) => {
|
const caller: AgentCaller = async (nextPrompt) => {
|
||||||
const result = await manager.runAgent(agent.id, 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({
|
return await getStructuredAgentResponse({
|
||||||
caller,
|
caller,
|
||||||
|
|||||||
@@ -12,10 +12,53 @@ export interface DictationDebugAudioMetadata {
|
|||||||
format: string;
|
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(
|
export async function maybePersistDictationDebugAudio(
|
||||||
audio: Buffer,
|
audio: Buffer,
|
||||||
metadata: DictationDebugAudioMetadata,
|
metadata: DictationDebugAudioMetadata,
|
||||||
logger: pino.Logger
|
logger: pino.Logger,
|
||||||
|
chunkWriterFolder?: string | null
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR");
|
const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR");
|
||||||
if (!debugDir) {
|
if (!debugDir) {
|
||||||
@@ -28,12 +71,13 @@ export async function maybePersistDictationDebugAudio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
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 });
|
await mkdir(folder, { recursive: true });
|
||||||
|
|
||||||
const parts = [timestamp, sanitizeForFilename(metadata.dictationId, "dictation")];
|
const filename = chunkWriterFolder
|
||||||
const ext = inferAudioExtension(metadata.format);
|
? `combined.${inferAudioExtension(metadata.format)}`
|
||||||
const filePath = join(folder, `${parts.join("_")}.${ext}`);
|
: `${timestamp}_${sanitizeForFilename(metadata.dictationId, "dictation")}.${inferAudioExtension(metadata.format)}`;
|
||||||
|
const filePath = join(folder, filename);
|
||||||
await writeFile(filePath, audio);
|
await writeFile(filePath, audio);
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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 { isPaseoDictationDebugEnabled } from "../agent/recordings-debug.js";
|
||||||
import { Pcm16MonoResampler } from "../agent/pcm16-resampler.js";
|
import { Pcm16MonoResampler } from "../agent/pcm16-resampler.js";
|
||||||
import { OpenAIRealtimeTranscriptionSession } from "../agent/openai-realtime-transcription.js";
|
import { OpenAIRealtimeTranscriptionSession } from "../agent/openai-realtime-transcription.js";
|
||||||
@@ -118,6 +122,7 @@ type DictationStreamState = {
|
|||||||
resampler: Pcm16MonoResampler | null;
|
resampler: Pcm16MonoResampler | null;
|
||||||
debugAudioChunks: Buffer[];
|
debugAudioChunks: Buffer[];
|
||||||
debugRecordingPath: string | null;
|
debugRecordingPath: string | null;
|
||||||
|
debugChunkWriter: DictationDebugChunkWriter | null;
|
||||||
receivedChunks: Map<number, Buffer>;
|
receivedChunks: Map<number, Buffer>;
|
||||||
nextSeqToForward: number;
|
nextSeqToForward: number;
|
||||||
ackSeq: number;
|
ackSeq: number;
|
||||||
@@ -271,6 +276,11 @@ export class DictationStreamManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debugChunkWriter = createDictationDebugChunkWriter(
|
||||||
|
{ sessionId: this.sessionId, dictationId },
|
||||||
|
this.logger
|
||||||
|
);
|
||||||
|
|
||||||
this.streams.set(dictationId, {
|
this.streams.set(dictationId, {
|
||||||
dictationId,
|
dictationId,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
@@ -286,6 +296,7 @@ export class DictationStreamManager {
|
|||||||
}),
|
}),
|
||||||
debugAudioChunks: [],
|
debugAudioChunks: [],
|
||||||
debugRecordingPath: null,
|
debugRecordingPath: null,
|
||||||
|
debugChunkWriter,
|
||||||
receivedChunks: new Map(),
|
receivedChunks: new Map(),
|
||||||
nextSeqToForward: 0,
|
nextSeqToForward: 0,
|
||||||
ackSeq: -1,
|
ackSeq: -1,
|
||||||
@@ -345,6 +356,12 @@ export class DictationStreamManager {
|
|||||||
state.debugAudioChunks.push(resampled);
|
state.debugAudioChunks.push(resampled);
|
||||||
state.bytesSinceCommit += resampled.length;
|
state.bytesSinceCommit += resampled.length;
|
||||||
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
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;
|
state.nextSeqToForward += 1;
|
||||||
@@ -440,7 +457,8 @@ export class DictationStreamManager {
|
|||||||
const path = await maybePersistDictationDebugAudio(
|
const path = await maybePersistDictationDebugAudio(
|
||||||
wavBuffer,
|
wavBuffer,
|
||||||
{ sessionId: state.sessionId, dictationId: state.dictationId, format: "audio/wav" },
|
{ sessionId: state.sessionId, dictationId: state.dictationId, format: "audio/wav" },
|
||||||
this.logger
|
this.logger,
|
||||||
|
state.debugChunkWriter?.folder
|
||||||
);
|
);
|
||||||
state.debugRecordingPath = path;
|
state.debugRecordingPath = path;
|
||||||
return path;
|
return path;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { STTManager } from "./agent/stt-manager.js";
|
|||||||
import type { OpenAISTT } from "./agent/stt-openai.js";
|
import type { OpenAISTT } from "./agent/stt-openai.js";
|
||||||
import type { OpenAITTS } from "./agent/tts-openai.js";
|
import type { OpenAITTS } from "./agent/tts-openai.js";
|
||||||
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
||||||
|
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
||||||
import { DictationStreamManager } from "./dictation/dictation-stream-manager.js";
|
import { DictationStreamManager } from "./dictation/dictation-stream-manager.js";
|
||||||
import type { VoiceConversationStore } from "./voice-conversation-store.js";
|
import type { VoiceConversationStore } from "./voice-conversation-store.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
Reference in New Issue
Block a user