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 {