From 2c138312b2d1d14170ebe9dc63b736296472b935 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 28 Jan 2026 21:57:31 +0700 Subject: [PATCH] Update files --- .gitignore | 1 + .../src/components/adaptive-modal-sheet.tsx | 71 +- .../agent-form/agent-form-dropdowns.tsx | 665 ++++-------------- .../src/components/draggable-list.native.tsx | 2 + .../app/src/components/grouped-agent-list.tsx | 99 ++- .../src/components/headers/menu-header.tsx | 26 +- packages/app/src/components/ui/combobox.tsx | 542 ++++++++++++++ packages/app/src/contexts/toast-context.tsx | 13 +- packages/app/src/lib/overlay-root.ts | 25 + .../src/screens/agent/agent-ready-screen.tsx | 11 - .../agent/agent-response-loop.e2e.test.ts | 171 ++++- .../server/agent/agent-response-loop.test.ts | 32 + .../src/server/agent/agent-response-loop.ts | 18 +- .../src/server/agent/dictation-debug.ts | 54 +- .../dictation/dictation-stream-manager.ts | 22 +- packages/server/src/server/session.ts | 1 + 16 files changed, 1107 insertions(+), 646 deletions(-) create mode 100644 packages/app/src/components/ui/combobox.tsx create mode 100644 packages/app/src/lib/overlay-root.ts diff --git a/.gitignore b/.gitignore index 6fc114ac0..202f121ce 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,5 @@ CLAUDE.local.md .tasks/ .debug.conversations/ +.debug/ .paseo/ diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 4a21ba260..ff7a47cea 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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 = ( + + + + + {title} + + + + + + {children} + + + + ); + + // 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 ( - - - - - {title} - - - - - - {children} - - - + {desktopContent} ); } diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 831daefef..70a80e696 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -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; -} - -export function AdaptiveSelect({ - title, - visible, - onClose, - children, - anchorRef, -}: AdaptiveSelectProps): ReactElement { - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const bottomSheetRef = useRef(null); - const snapPoints = useMemo(() => ["60%", "90%"], []); - const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); - const [referenceWidth, setReferenceWidth] = useState(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) => ( - - ), - [] - ); - - if (isMobile) { - return ( - - - {title} - - - {children} - - - ); - } - - return ( - - - - update()} - > - - {children} - - - - - ); -} +// 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(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} /> - - {Platform.OS === "web" ? ( - - ) : ( - - )} - {showCustomOption ? ( - - handleSelect(sanitizedSearchValue)} - > - - {`Use "${sanitizedSearchValue}"`} - - - - ) : null} - {hasMatches ? ( - - {filteredOptions.map((opt) => { - const isSelected = opt.id === value; - return ( - handleSelect(opt.id)} - > - {opt.label} - {opt.description ? ( - - {opt.description} - - ) : null} - - ); - })} - - ) : !showCustomOption ? ( - No options match your search. - ) : null} - {isLoading ? ( - - - - ) : null} - + /> ); } @@ -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} /> - 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 ( - { - onSelect(definition.id); - handleClose(); - }} - > - {definition.label} - {definition.description ? ( - - {definition.description} - - ) : null} - - ); - })} - + /> ); } @@ -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 ? ( - - {modeOptions.map((mode) => { - const isSelected = mode.id === selectedMode; - return ( - { - onSelect(mode.id); - handleClose(); - }} - > - {mode.label} - {mode.description ? ( - - {mode.description} - - ) : null} - - ); - })} - + /> ) : 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} /> - - { - onClear(); - handleClose(); - }} - > - - Automatic (provider default) - - - Let the assistant pick the recommended model. - - - {models.map((model) => { - const isSelected = model.id === selectedModel; - return ( - { - onSelect(model.id); - handleClose(); - }} - > - {model.label} - {model.description ? ( - - {model.description} - - ) : null} - - ); - })} - { - onRefresh(); - }} - > - Refresh models - - Request the latest catalog from the provider. - - - {isLoading ? ( - - - - ) : null} - + ); } @@ -1014,46 +691,18 @@ export function WorkingDirectoryDropdown({ }: WorkingDirectoryDropdownProps): ReactElement { const [isOpen, setIsOpen] = useState(false); const anchorRef = useRef(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" /> - - {Platform.OS === "web" ? ( - - ) : ( - - )} - {!hasSuggestedPaths && !showCustomOption ? ( - - We'll suggest directories from agents on this host once they exist. - - ) : null} - {showCustomOption ? ( - - handleSelect(sanitizedSearchValue)} - > - - {`Use "${sanitizedSearchValue}"`} - - - Launch the agent in this directory - - - - ) : null} - {hasMatches ? ( - - {filteredPaths.map((path) => { - const isActive = path === workingDir; - return ( - handleSelect(path)} - > - - {path} - - - ); - })} - - ) : hasSuggestedPaths ? ( - - No agent directories match your search. - - ) : null} - + /> ); } @@ -1343,16 +927,15 @@ export function GitOptionsSection({ onClose={() => setIsWorktreeSheetOpen(false)} > {worktreeOptions.map((option) => ( - { onSelectWorktreePath(option.path); setIsWorktreeSheetOpen(false); }} - > - {option.label} - + /> ))} @@ -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", diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index d4046e6a6..3bf1b6a30 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -69,6 +69,8 @@ export function DraggableList({ 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={ diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index cad7a8a20..22b2c471b 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -147,11 +147,24 @@ function SectionHeader({ onHoverOut={() => setIsHovered(false)} > - {icon && ( + + {isCollapsed ? ( + + ) : ( + + )} + + {icon ? ( + ) : ( + + + {(section.workingDir?.split("/").pop() ?? displayTitle).charAt(0).toUpperCase()} + + )} {displayTitle} @@ -162,22 +175,18 @@ function SectionHeader({ setIsHovered(true)} - onHoverOut={() => setIsHovered(true)} + hitSlop={20} + onStartShouldSetResponder={() => true} + onStartShouldSetResponderCapture={() => true} > - {({ hovered }) => ( + {({ hovered, pressed }) => ( )} )} - {isCollapsed ? ( - - ) : ( - - )} ); @@ -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({ [ 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"} - {isHovered && canArchive && ( + {isHovered && canArchive ? ( handleArchiveAgent(e, agent)} onHoverIn={() => setIsHovered(true)} onHoverOut={() => setIsHovered(true)} @@ -472,7 +483,7 @@ export function GroupedAgentList({ > {({ hovered: archiveHovered }) => ( )} - )} + ) : activeBranchLabel ? ( + + + {activeBranchLabel} + + + ) : null} - - - {activeBranchLabel ? `${activeBranchLabel} · ${timeAgo}` : timeAgo} - ); @@ -508,7 +521,7 @@ export function GroupedAgentList({ const isCollapsed = collapsedSections.has(section.key); return ( - + ({ 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, diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index dc93bfdc3..405cb4f95 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -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) { {title && ( - - - {title} - - {subtitle && ( - - {subtitle} - - )} - + + {title} + )} } @@ -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, - }, })); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx new file mode 100644 index 000000000..553dc6b96 --- /dev/null +++ b/packages/app/src/components/ui/combobox.tsx @@ -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; + children?: ReactNode; +} + +function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { + return ( + + ); +} + +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 ( + + + + + ); +} + +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 ( + [ + styles.comboboxItem, + pressed && styles.comboboxItemPressed, + ]} + > + + {selected ? : null} + + + {label} + {description ? ( + {description} + ) : null} + + + ); +} + +export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement { + return {children}; +} + +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(null); + const snapPoints = useMemo(() => ["60%", "90%"], []); + const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); + const [referenceWidth, setReferenceWidth] = useState(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) => ( + + ), + [] + ); + + 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 = ( + + ); + + const optionsList = ( + <> + {showCustomOption ? ( + handleSelect(sanitizedSearchValue)} + /> + ) : null} + {hasMatches ? ( + filteredOptions.map((opt) => ( + handleSelect(opt.id)} + /> + )) + ) : !showCustomOption ? ( + {emptyText} + ) : null} + + ); + + const content = children ?? ( + <> + {searchInput} + {optionsList} + + ); + + if (isMobile) { + return ( + + + {title} + + + {content} + + + ); + } + + if (!isOpen) return <>; + + return ( + + + + update()} + > + {children ? ( + + {content} + + ) : ( + <> + {searchInput} + + {optionsList} + + + )} + + + + ); +} + +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], + }, +})); diff --git a/packages/app/src/contexts/toast-context.tsx b/packages/app/src/contexts/toast-context.tsx index 4fefe99c7..8c09aceb9 100644 --- a/packages/app/src/contexts/toast-context.tsx +++ b/packages/app/src/contexts/toast-context.tsx @@ -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({ ) : null; - return ( + const content = ( ); + + // 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: { diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts new file mode 100644 index 000000000..907688cc3 --- /dev/null +++ b/packages/app/src/lib/overlay-root.ts @@ -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; diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 2ebdcc4e9..5942534ba 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -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 */} diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts index d2c7e08fa..d8f52e484 100644 --- a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts @@ -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; +}; + +async function startAgentMcpServer(logger: pino.Logger): Promise { + 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(); + + 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((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((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 + ); }); diff --git a/packages/server/src/server/agent/agent-response-loop.test.ts b/packages/server/src/server/agent/agent-response-loop.test.ts index 90e7afb49..8759ebc62 100644 --- a/packages/server/src/server/agent/agent-response-loop.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.test.ts @@ -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 }); + }); }); diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index 2d5c98012..6b100267c 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -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( options: StructuredAgentResponseOptions ): Promise { @@ -140,10 +148,11 @@ export async function getStructuredAgentResponse( 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( 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, diff --git a/packages/server/src/server/agent/dictation-debug.ts b/packages/server/src/server/agent/dictation-debug.ts index af7a44408..2178cc4be 100644 --- a/packages/server/src/server/agent/dictation-debug.ts +++ b/packages/server/src/server/agent/dictation-debug.ts @@ -12,10 +12,53 @@ export interface DictationDebugAudioMetadata { format: string; } +export interface DictationDebugChunkWriter { + folder: string; + writeChunk: (seq: number, pcm16: Buffer) => Promise; +} + +export function createDictationDebugChunkWriter( + metadata: Pick, + 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 { 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; } diff --git a/packages/server/src/server/dictation/dictation-stream-manager.ts b/packages/server/src/server/dictation/dictation-stream-manager.ts index ea5a25c75..cadcd464d 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.ts @@ -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; 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; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 107c3c1fe..f5940aecb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 {