Update files

This commit is contained in:
Mohamed Boudra
2026-01-28 21:57:31 +07:00
parent d158dcf77a
commit 2c138312b2
16 changed files with 1107 additions and 646 deletions

1
.gitignore vendored
View File

@@ -55,4 +55,5 @@ CLAUDE.local.md
.tasks/
.debug.conversations/
.debug/
.paseo/

View File

@@ -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>
);
}

View File

@@ -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",

View File

@@ -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={

View File

@@ -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,

View File

@@ -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,
},
}));

View 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],
},
}));

View File

@@ -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: {

View 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;

View File

@@ -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}>

View File

@@ -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
);
});

View File

@@ -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 });
});
});

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 {