diff --git a/knip.json b/knip.json
index 47acba7e8..41b2b5f08 100644
--- a/knip.json
+++ b/knip.json
@@ -22,12 +22,15 @@
"entry": [
"index.ts",
"app.config.js",
+ "babel.config.js",
"app/**/*.{ts,tsx}",
"src/**/*.test.{ts,tsx}",
"src/**/*.e2e.{ts,tsx}",
+ "src/**/*.native.{ts,tsx}",
"e2e/**/*.{ts,tsx}",
"playwright.config.{ts,js}",
- "vitest.config.{ts,js}"
+ "vitest.config.{ts,js}",
+ "test-stubs/**/*.ts"
],
"project": ["**/*.{ts,tsx,js,jsx}"],
"ignore": ["android/**", "ios/**", ".expo/**", "dist/**", "scripts/reset-project.js"]
diff --git a/packages/app/playwright.firefox.config.ts b/packages/app/playwright.firefox.config.ts
deleted file mode 100644
index dee37bcbe..000000000
--- a/packages/app/playwright.firefox.config.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { defineConfig, devices } from "@playwright/test";
-
-const baseURL =
- process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
-
-export default defineConfig({
- testDir: "./e2e",
- globalSetup: "./e2e/global-setup.ts",
- timeout: 60_000,
- expect: {
- timeout: 10_000,
- },
- fullyParallel: false,
- workers: 1,
- retries: process.env.CI ? 1 : 0,
- reporter: [["list"]],
- use: {
- baseURL,
- trace: "retain-on-failure",
- screenshot: "only-on-failure",
- video: "retain-on-failure",
- },
- projects: [
- {
- name: "Desktop Firefox",
- use: { ...devices["Desktop Firefox"] },
- },
- ],
-});
diff --git a/packages/app/playwright.webkit.tmp.config.ts b/packages/app/playwright.webkit.tmp.config.ts
deleted file mode 100644
index 346d2c109..000000000
--- a/packages/app/playwright.webkit.tmp.config.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { defineConfig, devices } from "@playwright/test";
-
-const baseURL =
- process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
-
-export default defineConfig({
- testDir: "./e2e",
- globalSetup: "./e2e/global-setup.ts",
- timeout: 60_000,
- expect: {
- timeout: 10_000,
- },
- fullyParallel: false,
- workers: 1,
- retries: 0,
- reporter: [["list"]],
- use: {
- baseURL,
- trace: "retain-on-failure",
- screenshot: "only-on-failure",
- video: "retain-on-failure",
- },
- projects: [
- {
- name: "Desktop Safari",
- use: { ...devices["Desktop Safari"] },
- },
- ],
-});
diff --git a/packages/app/src/components/active-processes.tsx b/packages/app/src/components/active-processes.tsx
deleted file mode 100644
index 8827186e4..000000000
--- a/packages/app/src/components/active-processes.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import { View, Text, Pressable, ScrollView } from "react-native";
-import { StyleSheet } from "react-native-unistyles";
-import type { Agent } from "@/contexts/session-context";
-
-export interface ActiveProcessesProps {
- agents: Agent[];
- viewMode: "orchestrator" | "agent";
- activeAgentId: string | null;
- onSelectAgent: (serverId: string, id: string) => void;
- onSelectOrchestrator: () => void;
-}
-
-function getAgentStatusColor(status: Agent["status"]): string {
- switch (status) {
- case "initializing":
- return "#f59e0b";
- case "idle":
- return "#22c55e";
- case "running":
- return "#3b82f6";
- case "error":
- return "#ef4444";
- case "closed":
- return "#9ca3af";
- default:
- return "#9ca3af";
- }
-}
-
-function getModeName(modeId?: string, availableModes?: Agent["availableModes"]): string {
- if (!modeId) return "unknown";
- const mode = availableModes?.find((m) => m.id === modeId);
- return mode?.label || modeId;
-}
-
-function getModeColor(modeId?: string): string {
- if (!modeId) return "#9ca3af"; // gray
-
- if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
- if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
- return "#3b82f6"; // blue - build/auto
- if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
- if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
- return "#22c55e"; // green - safest
-
- return "#9ca3af"; // gray - unknown
-}
-
-const styles = StyleSheet.create((theme) => ({
- container: {
- backgroundColor: theme.colors.surface2,
- borderBottomWidth: 1,
- borderBottomColor: theme.colors.border,
- },
- header: {
- paddingHorizontal: theme.spacing[4],
- paddingVertical: theme.spacing[3],
- borderBottomWidth: 1,
- borderBottomColor: theme.colors.border,
- },
- backButton: {
- backgroundColor: theme.colors.surface2,
- paddingHorizontal: theme.spacing[4],
- paddingVertical: theme.spacing[2],
- borderRadius: theme.borderRadius.lg,
- },
- backButtonActive: {
- backgroundColor: theme.colors.palette.zinc[700],
- },
- backButtonText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- fontWeight: "600",
- textAlign: "center",
- },
- scrollView: {
- paddingHorizontal: theme.spacing[4],
- paddingVertical: theme.spacing[3],
- },
- processItem: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- paddingHorizontal: theme.spacing[3],
- paddingVertical: theme.spacing[2],
- borderRadius: theme.borderRadius.lg,
- },
- processItemActive: {
- backgroundColor: theme.colors.primary,
- },
- processItemInactive: {
- backgroundColor: theme.colors.surface2,
- },
- agentIcon: {
- width: 12,
- height: 12,
- borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.palette.blue[500],
- },
- commandIcon: {
- width: 12,
- height: 12,
- borderRadius: theme.borderRadius.sm,
- backgroundColor: theme.colors.palette.purple[500],
- },
- processText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.xs,
- fontWeight: "500",
- },
- processTextActive: {
- color: theme.colors.primaryForeground,
- },
- statusDot: {
- width: 8,
- height: 8,
- borderRadius: theme.borderRadius.full,
- },
- modeIndicator: {
- width: 6,
- height: 6,
- borderRadius: theme.borderRadius.full,
- opacity: 0.3,
- },
-}));
-
-export function ActiveProcesses({
- agents,
- viewMode,
- activeAgentId,
- onSelectAgent,
- onSelectOrchestrator,
-}: ActiveProcessesProps) {
- // Only show if there's at least one agent
- if (agents.length === 0) {
- return null;
- }
-
- return (
-
-
- {/* Orchestrator pill */}
- [
- styles.processItem,
- viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
- pressed && { opacity: 0.7 },
- ]}
- >
-
-
- Orchestrator
-
-
-
- {/* Agent pills */}
- {agents.map((agent) => {
- const isActive = viewMode === "agent" && activeAgentId === agent.id;
-
- return (
- onSelectAgent(agent.serverId, agent.id)}
- style={({ pressed }) => [
- styles.processItem,
- isActive ? styles.processItemActive : styles.processItemInactive,
- pressed && { opacity: 0.7 },
- ]}
- >
-
-
-
- {agent.id.substring(0, 8)}
-
-
-
-
- {agent.currentModeId && (
-
- )}
-
- );
- })}
-
-
- );
-}
diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx
deleted file mode 100644
index 5757c7c70..000000000
--- a/packages/app/src/components/agent-activity.tsx
+++ /dev/null
@@ -1,403 +0,0 @@
-import { useState } from "react";
-import { View, Text, Pressable } from "react-native";
-import { StyleSheet } from "react-native-unistyles";
-import { Fonts } from "@/constants/theme";
-import type {
- AgentActivity,
- GroupedTextMessage,
- MergedToolCall,
- SessionUpdate,
-} from "@/types/agent-activity";
-
-interface AgentActivityItemProps {
- item: GroupedTextMessage | MergedToolCall | AgentActivity;
-}
-
-function formatTimestamp(date: Date): string {
- return new Intl.DateTimeFormat("en-US", {
- hour: "numeric",
- minute: "2-digit",
- second: "2-digit",
- hour12: true,
- }).format(date);
-}
-
-function getToolIcon(toolKind?: string): string {
- switch (toolKind) {
- case "read":
- return "π";
- case "edit":
- return "βοΈ";
- case "delete":
- return "ποΈ";
- case "move":
- return "π¦";
- case "search":
- return "π";
- case "execute":
- return "βΆοΈ";
- case "think":
- return "π";
- case "fetch":
- return "π";
- case "switch_mode":
- return "π";
- default:
- return "π§";
- }
-}
-
-function getStatusColor(status?: string): string {
- switch (status) {
- case "pending":
- return "#9ca3af";
- case "in_progress":
- return "#fbbf24";
- case "completed":
- return "#22c55e";
- case "failed":
- return "#ef4444";
- default:
- return "#6b7280";
- }
-}
-
-function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
- const isThought = item.messageType === "thought";
-
- return (
-
-
- {formatTimestamp(item.startTimestamp)}
-
- {isThought && π Thinking}
- {item.text}
-
- );
-}
-
-function MergedToolCallItem({ item }: { item: MergedToolCall }) {
- const [isExpanded, setIsExpanded] = useState(false);
-
- return (
-
- setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
-
- {formatTimestamp(item.startTimestamp)}
-
- {getToolIcon(item.toolKind)}
- {item.title}
-
- {item.status}
-
-
-
- {isExpanded ? "βΌ" : "βΆ"}
-
-
- {isExpanded && (
-
- {item.input && (
-
- Input:
- {JSON.stringify(item.input, null, 2)}
-
- )}
- {item.output && (
-
- Output:
- {JSON.stringify(item.output, null, 2)}
-
- )}
- {!item.input && !item.output && (
- No details available
- )}
-
- )}
-
- );
-}
-
-function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
- const [isExpanded, setIsExpanded] = useState(true);
-
- if (update.kind !== "plan") {
- return null;
- }
-
- return (
-
- setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
-
- {formatTimestamp(timestamp)}
- π Tasks ({update.entries.length})
-
- {isExpanded ? "βΌ" : "βΆ"}
-
-
- {isExpanded && (
-
- {update.entries.map((entry, idx) => (
-
-
- {entry.status === "completed" ? "β" : entry.status === "in_progress" ? "β³" : "β"}
-
- {entry.content}
-
- ))}
-
- )}
-
- );
-}
-
-function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
- const [showDrawer, setShowDrawer] = useState(false);
-
- return (
-
- setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
- {formatTimestamp(timestamp)}
-
- {update.kind}
-
-
-
- {showDrawer && (
-
- {JSON.stringify(update, null, 2)}
-
- )}
-
- );
-}
-
-export function AgentActivityItem({ item }: AgentActivityItemProps) {
- // Grouped text message
- if ("kind" in item && item.kind === "grouped_text") {
- return ;
- }
-
- // Merged tool call
- if ("kind" in item && item.kind === "merged_tool_call") {
- return ;
- }
-
- // Individual activity
- const activity = item as AgentActivity;
- const update = activity.update;
-
- // Tasks
- if (update.kind === "plan") {
- return ;
- }
-
- // Available commands update
- if (update.kind === "available_commands_update") {
- return (
-
- {formatTimestamp(activity.timestamp)}
-
- Commands updated ({update.availableCommands.length} available)
-
-
- );
- }
-
- // Current mode update
- if (update.kind === "current_mode_update") {
- return (
-
- {formatTimestamp(activity.timestamp)}
- Mode changed to: {update.currentModeId}
-
- );
- }
-
- // Unknown activity type
- return ;
-}
-
-const stylesheet = StyleSheet.create((theme) => ({
- card: {
- backgroundColor: theme.colors.surface2,
- borderRadius: theme.borderRadius.lg,
- padding: theme.spacing[3],
- marginBottom: theme.spacing[2],
- },
- thoughtCard: {
- backgroundColor: theme.colors.surface2,
- borderLeftWidth: 3,
- borderLeftColor: theme.colors.palette.purple[500],
- paddingVertical: theme.spacing[2],
- },
- timestamp: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- marginBottom: theme.spacing[1],
- },
- thoughtTimestamp: {
- marginBottom: theme.spacing[0],
- },
- thoughtLabel: {
- color: theme.colors.palette.purple[500],
- fontSize: theme.fontSize.xs,
- fontWeight: theme.fontWeight.semibold,
- marginBottom: theme.spacing[0],
- },
- text: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- lineHeight: 20,
- },
- thoughtText: {
- color: theme.colors.foregroundMuted,
- fontStyle: "italic",
- },
- toolCard: {
- backgroundColor: theme.colors.surface2,
- borderRadius: theme.borderRadius.lg,
- marginBottom: theme.spacing[2],
- overflow: "hidden",
- },
- toolHeader: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- padding: theme.spacing[3],
- },
- toolHeaderLeft: {
- flex: 1,
- },
- toolTitleRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- marginTop: theme.spacing[1],
- },
- toolIcon: {
- fontSize: theme.fontSize.base,
- },
- toolTitle: {
- color: theme.colors.palette.blue[400],
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.semibold,
- flex: 1,
- },
- statusBadge: {
- paddingHorizontal: theme.spacing[2],
- paddingVertical: theme.spacing[1],
- borderRadius: theme.borderRadius.md,
- },
- statusText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.xs,
- fontWeight: theme.fontWeight.semibold,
- },
- expandIcon: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- },
- toolContent: {
- borderTopWidth: theme.borderWidth[1],
- borderTopColor: theme.colors.border,
- padding: theme.spacing[3],
- },
- section: {
- marginBottom: theme.spacing[3],
- },
- sectionTitle: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.xs,
- fontWeight: theme.fontWeight.semibold,
- marginBottom: theme.spacing[1],
- },
- code: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- fontFamily: Fonts.mono,
- backgroundColor: theme.colors.surface2,
- padding: theme.spacing[2],
- borderRadius: theme.borderRadius.md,
- },
- emptyText: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- fontStyle: "italic",
- },
- planCard: {
- backgroundColor: theme.colors.surface2,
- borderRadius: theme.borderRadius.lg,
- marginBottom: theme.spacing[2],
- overflow: "hidden",
- },
- planHeader: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- padding: theme.spacing[3],
- },
- planHeaderLeft: {
- flex: 1,
- },
- planTitle: {
- color: theme.colors.palette.green[400],
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.semibold,
- marginTop: theme.spacing[1],
- },
- planContent: {
- borderTopWidth: theme.borderWidth[1],
- borderTopColor: theme.colors.border,
- padding: theme.spacing[3],
- },
- planEntry: {
- flexDirection: "row",
- alignItems: "flex-start",
- gap: theme.spacing[2],
- marginBottom: theme.spacing[2],
- },
- planEntryStatus: {
- fontSize: theme.fontSize.sm,
- marginTop: 2,
- },
- planEntryText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- flex: 1,
- },
- infoText: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- unknownCard: {
- backgroundColor: theme.colors.surface2,
- borderRadius: theme.borderRadius.lg,
- marginBottom: theme.spacing[2],
- overflow: "hidden",
- },
- unknownHeader: {
- padding: theme.spacing[3],
- },
- unknownBadge: {
- backgroundColor: theme.colors.palette.orange[600],
- paddingHorizontal: theme.spacing[3],
- paddingVertical: theme.spacing[2],
- borderRadius: theme.borderRadius.md,
- marginTop: theme.spacing[2],
- alignSelf: "flex-start",
- },
- unknownBadgeText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.xs,
- fontWeight: theme.fontWeight.semibold,
- },
- drawerContent: {
- borderTopWidth: theme.borderWidth[1],
- borderTopColor: theme.colors.border,
- padding: theme.spacing[3],
- backgroundColor: theme.colors.surface2,
- },
-}));
diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
deleted file mode 100644
index 6a124f77a..000000000
--- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
+++ /dev/null
@@ -1,1519 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import type { ReactElement, ReactNode } from "react";
-import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
-import type { StyleProp, ViewStyle, TextProps } from "react-native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import {
- BottomSheetScrollView,
- BottomSheetBackdrop,
- BottomSheetBackgroundProps,
-} from "@gorhom/bottom-sheet";
-import Animated from "react-native-reanimated";
-import {
- ChevronDown,
- ChevronRight,
- Pencil,
- Check,
- X,
- Bot,
- Brain,
- ShieldCheck,
- ShieldAlert,
- ShieldOff,
-} from "lucide-react-native";
-import type {
- AgentMode,
- AgentModelDefinition,
- AgentProvider,
-} from "@server/server/agent/agent-sdk-types";
-import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
-import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
-import { Combobox, ComboboxItem } from "@/components/ui/combobox";
-import {
- IsolatedBottomSheetModal,
- useIsolatedBottomSheetVisibility,
-} from "@/components/ui/isolated-bottom-sheet-modal";
-import { baseColors } from "@/styles/theme";
-import { isNative } from "@/constants/platform";
-
-const MODE_ICON_MAP: Record = {
- ShieldCheck,
- ShieldAlert,
- ShieldOff,
-};
-
-const MODE_COLOR_MAP: Record = {
- default: baseColors.blue[500],
- safe: baseColors.green[500],
- moderate: baseColors.amber[500],
- dangerous: baseColors.red[500],
- readonly: baseColors.purple[500],
-};
-
-type DropdownTriggerRenderProps = {
- label: string;
- value: string;
- placeholder: string;
- onPress: () => void;
- disabled?: boolean;
- errorMessage?: string | null;
- warningMessage?: string | null;
- helperText?: string | null;
-};
-
-type DropdownTriggerRenderer = (props: DropdownTriggerRenderProps) => ReactNode;
-
-interface DropdownFieldProps {
- label: string;
- value: string;
- placeholder: string;
- onPress: () => void;
- disabled?: boolean;
- errorMessage?: string | null;
- warningMessage?: string | null;
- helperText?: string | null;
- renderTrigger?: DropdownTriggerRenderer;
- testID?: string;
-}
-
-export function DropdownField({
- label,
- value,
- placeholder,
- onPress,
- disabled,
- errorMessage,
- warningMessage,
- helperText,
- renderTrigger,
- testID,
-}: DropdownFieldProps): ReactElement {
- const { theme } = useUnistyles();
-
- if (renderTrigger) {
- return (
- <>
- {renderTrigger({
- label,
- value,
- placeholder,
- onPress,
- disabled,
- errorMessage,
- warningMessage,
- helperText,
- })}
- >
- );
- }
-
- return (
-
- {label}
-
-
- {value || placeholder}
-
-
-
- {errorMessage ? {errorMessage} : null}
- {warningMessage ? {warningMessage} : null}
- {!errorMessage && helperText ? {helperText} : null}
-
- );
-}
-
-interface SelectFieldProps {
- label: string;
- value: string;
- placeholder?: string;
- onPress: () => void;
- disabled?: boolean;
- errorMessage?: string | null;
- warningMessage?: string | null;
- helperText?: string | null;
- controlRef?: React.RefObject;
- valueEllipsizeMode?: "head" | "middle" | "tail" | "clip";
- testID?: string;
-}
-
-export function SelectField({
- label,
- value,
- placeholder,
- onPress,
- disabled,
- errorMessage,
- warningMessage,
- helperText,
- controlRef,
- valueEllipsizeMode,
- testID,
-}: SelectFieldProps): ReactElement {
- const { theme } = useUnistyles();
-
- const getWebKey = useCallback((event: unknown): string | null => {
- if (!event || typeof event !== "object") return null;
- const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
- if (typeof eventWithNative.key === "string") return eventWithNative.key;
- const nativeEvent = eventWithNative.nativeEvent as { key?: unknown } | undefined;
- return typeof nativeEvent?.key === "string" ? nativeEvent.key : null;
- }, []);
-
- const preventWebDefault = useCallback((event: unknown) => {
- if (!event || typeof event !== "object") return;
- const candidate = event as { preventDefault?: unknown };
- if (typeof candidate.preventDefault === "function") {
- candidate.preventDefault();
- }
- }, []);
-
- const handleKeyDown = useCallback(
- (event: unknown) => {
- if (isNative) return;
- const key = getWebKey(event);
- if (key === "Enter" || key === " ") {
- preventWebDefault(event);
- onPress();
- }
- },
- [getWebKey, onPress, preventWebDefault],
- );
-
- const normalizedValue = (value ?? "").trim();
- const normalizedPlaceholder = (placeholder ?? "").trim();
- const hasConcreteValue =
- normalizedValue.length > 0 &&
- (normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
- const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
-
- return (
-
-
-
- {label}
-
- {value || placeholder || "Select..."}
-
-
-
-
- {errorMessage ? {errorMessage} : null}
- {warningMessage ? {warningMessage} : null}
- {!errorMessage && !warningMessage && helperText ? (
- {helperText}
- ) : null}
-
- );
-}
-
-interface DropdownSheetProps {
- title: string;
- visible: boolean;
- onClose: () => void;
- children: ReactNode;
-}
-
-function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
- const { theme } = useUnistyles();
-
- return (
-
- );
-}
-
-export function DropdownSheet({
- title,
- visible,
- onClose,
- children,
-}: DropdownSheetProps): ReactElement {
- const { theme } = useUnistyles();
- const titleColor = theme.colors.foreground;
- const snapPoints = useMemo(() => ["60%", "90%"], []);
- const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
- visible,
- onClose,
- });
-
- const handleClose = useCallback(() => {
- onClose();
- }, [onClose]);
-
- const renderBackdrop = useCallback(
- (props: React.ComponentProps) => (
-
- ),
- [],
- );
-
- return (
-
-
-
- {title}
-
-
-
-
-
-
- {children}
-
-
- );
-}
-
-// Re-export ComboboxItem as SelectOption for backwards compatibility
-const SelectOption = ComboboxItem;
-
-interface ComboSelectOption {
- id: string;
- label: string;
- description?: string;
-}
-
-interface ComboSelectProps {
- label: string;
- title: string;
- value: string;
- options: ComboSelectOption[];
- placeholder?: string;
- disabled?: boolean;
- allowCustomValue?: boolean;
- isLoading?: boolean;
- onSelect: (id: string) => void;
- icon?: ReactElement;
- showLabel?: boolean;
- testID?: string;
-}
-
-export function ComboSelect({
- label,
- title,
- value,
- options,
- placeholder,
- disabled,
- allowCustomValue = false,
- isLoading,
- onSelect,
- icon,
- showLabel = true,
- testID,
-}: ComboSelectProps): ReactElement {
- const [isOpen, setIsOpen] = useState(false);
- const anchorRef = useRef(null);
-
- const selectedOption = options.find((opt) => opt.id === value);
- const displayValue = selectedOption?.label ?? "";
- const isEmpty = options.length === 0;
-
- const handleOpen = useCallback(() => setIsOpen(true), []);
- const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
-
- return (
- <>
-
-
- >
- );
-}
-
-interface CompactSelectFieldProps {
- label: string;
- value: string;
- placeholder?: string;
- onPress: () => void;
- disabled?: boolean;
- isLoading?: boolean;
- controlRef?: React.RefObject;
- icon?: ReactElement;
- showLabel?: boolean;
- containerStyle?: StyleProp;
- valueEllipsizeMode?: TextProps["ellipsizeMode"];
- testID?: string;
-}
-
-export function FormSelectTrigger({
- label,
- value,
- placeholder,
- onPress,
- disabled,
- isLoading,
- controlRef,
- icon,
- showLabel = true,
- containerStyle,
- valueEllipsizeMode,
- testID,
-}: CompactSelectFieldProps): ReactElement {
- const { theme } = useUnistyles();
-
- const getWebKey = useCallback((event: unknown): string | null => {
- if (!event || typeof event !== "object") return null;
- const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
- if (typeof eventWithNative.key === "string") return eventWithNative.key;
- const nativeEvent = eventWithNative.nativeEvent as { key?: unknown } | undefined;
- return typeof nativeEvent?.key === "string" ? nativeEvent.key : null;
- }, []);
-
- const preventWebDefault = useCallback((event: unknown) => {
- if (!event || typeof event !== "object") return;
- const candidate = event as { preventDefault?: unknown };
- if (typeof candidate.preventDefault === "function") {
- candidate.preventDefault();
- }
- }, []);
-
- const handleKeyDown = useCallback(
- (event: unknown) => {
- if (isNative) return;
- const key = getWebKey(event);
- if (key === "Enter" || key === " ") {
- preventWebDefault(event);
- onPress();
- }
- },
- [getWebKey, onPress, preventWebDefault],
- );
- const normalizedValue = (value ?? "").trim();
- const normalizedPlaceholder = (placeholder ?? "").trim();
- const hasConcreteValue =
- normalizedValue.length > 0 &&
- (normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
- const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
-
- return (
-
- {icon ? {icon} : null}
-
- {showLabel ? {label} : null}
- {isLoading ? (
-
- ) : (
-
- {displayText}
-
- )}
-
-
-
- );
-}
-
-interface AgentConfigRowProps {
- providerDefinitions: AgentProviderDefinition[];
- selectedProvider: AgentProvider;
- onSelectProvider: (provider: AgentProvider) => void;
- modeOptions: AgentMode[];
- selectedMode: string;
- onSelectMode: (modeId: string) => void;
- models: AgentModelDefinition[];
- selectedModel: string;
- isModelLoading: boolean;
- onSelectModel: (modelId: string) => void;
- thinkingOptions: NonNullable;
- selectedThinkingOptionId: string;
- onSelectThinkingOption: (thinkingOptionId: string) => void;
- disabled?: boolean;
-}
-
-export function AgentConfigRow({
- providerDefinitions,
- selectedProvider,
- onSelectProvider,
- modeOptions,
- selectedMode,
- onSelectMode,
- models,
- selectedModel,
- isModelLoading,
- onSelectModel,
- thinkingOptions,
- selectedThinkingOptionId,
- onSelectThinkingOption,
- disabled,
-}: AgentConfigRowProps): ReactElement {
- const { theme } = useUnistyles();
-
- const providerOptions: ComboSelectOption[] = useMemo(
- () =>
- providerDefinitions.map((def) => ({
- id: def.id,
- label: def.label,
- })),
- [providerDefinitions],
- );
-
- const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
- if (modeOptions.length === 0) {
- return [{ id: "", label: "Default" }];
- }
- return modeOptions.map((mode) => ({
- id: mode.id,
- label: mode.label,
- }));
- }, [modeOptions]);
-
- const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
- return models.map((model) => ({
- id: model.id,
- label: model.label,
- }));
- }, [models]);
-
- const thinkingSelectOptions: ComboSelectOption[] = useMemo(
- () =>
- thinkingOptions.map((option) => ({
- id: option.id,
- label: option.label,
- })),
- [thinkingOptions],
- );
-
- const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
- const effectiveSelectedThinkingOption =
- selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
-
- const selectedModeVisuals = getModeVisuals(
- selectedProvider,
- effectiveSelectedMode,
- providerDefinitions,
- );
- const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
- const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
-
- return (
-
-
- 0 ? "Select..." : "No providers available"}
- disabled={disabled || providerOptions.length === 0}
- onSelect={onSelectProvider}
- icon={}
- showLabel={false}
- testID="draft-provider-select"
- />
-
-
- }
- showLabel={false}
- testID="draft-model-select"
- />
-
-
- }
- showLabel={false}
- testID="draft-mode-select"
- />
-
- {thinkingSelectOptions.length > 0 ? (
-
- }
- showLabel={false}
- />
-
- ) : null}
-
- );
-}
-
-interface AssistantDropdownProps {
- providerDefinitions: AgentProviderDefinition[];
- selectedProvider: AgentProvider;
- disabled: boolean;
- onSelect: (provider: AgentProvider) => void;
-}
-
-export function AssistantDropdown({
- providerDefinitions,
- selectedProvider,
- disabled,
- onSelect,
-}: AssistantDropdownProps): ReactElement {
- const [isOpen, setIsOpen] = useState(false);
- const anchorRef = useRef(null);
-
- const selectedDefinition = providerDefinitions.find(
- (definition) => definition.id === selectedProvider,
- );
-
- const options = useMemo(
- () =>
- providerDefinitions.map((def) => ({
- id: def.id,
- label: def.label,
- })),
- [providerDefinitions],
- );
-
- const handleOpen = useCallback(() => setIsOpen(true), []);
- const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
-
- return (
- <>
-
- onSelect(id as AgentProvider)}
- title="Choose assistant"
- open={isOpen}
- onOpenChange={handleOpenChange}
- anchorRef={anchorRef}
- />
- >
- );
-}
-
-interface PermissionsDropdownProps {
- modeOptions: AgentMode[];
- selectedMode: string;
- disabled: boolean;
- onSelect: (modeId: string) => void;
-}
-
-export function PermissionsDropdown({
- modeOptions,
- selectedMode,
- disabled,
- onSelect,
-}: PermissionsDropdownProps): ReactElement {
- const [isOpen, setIsOpen] = useState(false);
- const anchorRef = useRef(null);
-
- const hasOptions = modeOptions.length > 0;
- const selectedModeLabel = hasOptions
- ? (modeOptions.find((mode) => mode.id === selectedMode)?.label ??
- modeOptions[0]?.label ??
- "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 handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
-
- return (
- <>
-
- {hasOptions ? (
-
- ) : null}
- >
- );
-}
-
-interface ModelDropdownProps {
- models: AgentModelDefinition[];
- selectedModel: string;
- isLoading: boolean;
- error: string | null;
- onSelect: (modelId: string) => void;
- onClear: () => void;
- onRefresh: () => void;
-}
-
-export function ModelDropdown({
- models,
- selectedModel,
- isLoading,
- error,
- onSelect,
- onClear,
- onRefresh,
-}: ModelDropdownProps): ReactElement {
- const [isOpen, setIsOpen] = useState(false);
- const anchorRef = useRef(null);
-
- const selectedLabel =
- models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
- const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
- const helperText = error
- ? undefined
- : isLoading
- ? "Fetching available models..."
- : models.length === 0
- ? "This assistant did not expose selectable models."
- : undefined;
-
- const options = useMemo(() => {
- return models.map((model) => ({
- id: model.id,
- label: model.label,
- description: model.description,
- }));
- }, [models]);
-
- const handleOpen = useCallback(() => setIsOpen(true), []);
- const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
- const handleSelect = useCallback(
- (id: string) => {
- onSelect(id);
- },
- [onSelect],
- );
-
- return (
- <>
-
-
- >
- );
-}
-
-interface WorkingDirectoryDropdownProps {
- workingDir: string;
- errorMessage: string;
- disabled: boolean;
- suggestedPaths: string[];
- onSelectPath: (value: string) => void;
-}
-
-export function WorkingDirectoryDropdown({
- workingDir,
- errorMessage,
- disabled,
- suggestedPaths,
- onSelectPath,
-}: WorkingDirectoryDropdownProps): ReactElement {
- const [isOpen, setIsOpen] = useState(false);
- const anchorRef = useRef(null);
-
- const options = useMemo(
- () => suggestedPaths.map((path) => ({ id: path, label: path })),
- [suggestedPaths],
- );
-
- const handleOpen = useCallback(() => setIsOpen(true), []);
- const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
-
- const emptyText = "No agent directories match your search.";
-
- return (
- <>
-
-
- >
- );
-}
-
-interface ToggleRowProps {
- label: string;
- description?: string;
- value: boolean;
- onToggle: (value: boolean) => void;
- disabled?: boolean;
-}
-
-export function ToggleRow({
- label,
- description,
- value,
- onToggle,
- disabled,
-}: ToggleRowProps): ReactElement {
- return (
- {
- if (!disabled) {
- onToggle(!value);
- }
- }}
- style={[styles.toggleRow, disabled && styles.toggleRowDisabled]}
- >
-
- {value ? : null}
-
-
- {label}
- {description ? {description} : null}
-
-
- );
-}
-
-export interface GitOptionsSectionProps {
- worktreeMode: "none" | "create" | "attach";
- onWorktreeModeChange: (value: "none" | "create" | "attach") => void;
- worktreeSlug: string;
- currentBranch: string | null;
- baseBranch: string;
- onBaseBranchChange: (value: string) => void;
- status: "idle" | "loading" | "ready" | "error";
- repoError: string | null;
- gitValidationError: string | null;
- baseBranchError: string | null;
- worktreeOptions: Array<{ path: string; label: string }>;
- selectedWorktreePath: string;
- worktreeOptionsStatus: "idle" | "loading" | "ready" | "error";
- worktreeOptionsError: string | null;
- attachWorktreeError: string | null;
- onSelectWorktreePath: (path: string) => void;
-}
-
-export function GitOptionsSection({
- worktreeMode,
- onWorktreeModeChange,
- worktreeSlug,
- currentBranch,
- baseBranch,
- onBaseBranchChange,
- status,
- repoError,
- gitValidationError,
- baseBranchError,
- worktreeOptions,
- selectedWorktreePath,
- worktreeOptionsStatus,
- worktreeOptionsError,
- attachWorktreeError,
- onSelectWorktreePath,
-}: GitOptionsSectionProps): ReactElement {
- const { theme } = useUnistyles();
-
- const isLoading = status === "loading";
- const isCreateMode = worktreeMode === "create";
- const isAttachMode = worktreeMode === "attach";
- const [isEditingBranch, setIsEditingBranch] = useState(false);
- const [editedBranch, setEditedBranch] = useState(baseBranch);
- const inputRef = useRef(null);
- const [isWorktreeSheetOpen, setIsWorktreeSheetOpen] = useState(false);
-
- useEffect(() => {
- setEditedBranch(baseBranch);
- }, [baseBranch]);
-
- useEffect(() => {
- if (isEditingBranch) {
- inputRef.current?.focus();
- }
- }, [isEditingBranch]);
-
- const handleStartEdit = useCallback(() => {
- setEditedBranch(baseBranch);
- setIsEditingBranch(true);
- }, [baseBranch]);
-
- const handleConfirmEdit = useCallback(() => {
- const trimmed = editedBranch.trim();
- if (trimmed) {
- onBaseBranchChange(trimmed);
- }
- setIsEditingBranch(false);
- }, [editedBranch, onBaseBranchChange]);
-
- const handleCancelEdit = useCallback(() => {
- setEditedBranch(baseBranch);
- setIsEditingBranch(false);
- }, [baseBranch]);
-
- const displayBranch = baseBranch || currentBranch || "HEAD";
- const selectedWorktreeLabel =
- worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? "";
- const worktreeHelperText =
- worktreeOptionsStatus === "loading"
- ? "Loading worktrees..."
- : worktreeOptions.length === 0
- ? "No worktrees found"
- : null;
-
- return (
-
- onWorktreeModeChange(isCreateMode ? "none" : "create")}
- disabled={isLoading}
- style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
- >
-
- {isCreateMode ? : null}
-
-
- Create worktree
-
- {isLoading
- ? "Inspecting repositoryβ¦"
- : isCreateMode
- ? `Will create: ${worktreeSlug || "preparingβ¦"}`
- : currentBranch
- ? `Run isolated from ${currentBranch}`
- : "Run in an isolated directory"}
-
-
-
-
- onWorktreeModeChange(isAttachMode ? "none" : "attach")}
- disabled={isLoading}
- style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
- >
-
- {isAttachMode ? : null}
-
-
- Attach to existing worktree
-
- {isLoading ? "Inspecting repositoryβ¦" : "Pick a Paseo worktree by branch"}
-
-
-
-
- {isAttachMode ? (
- <>
- setIsWorktreeSheetOpen(true)}
- disabled={isLoading || worktreeOptionsStatus === "loading"}
- helperText={worktreeHelperText}
- errorMessage={attachWorktreeError || worktreeOptionsError}
- testID="worktree-attach-picker"
- />
- setIsWorktreeSheetOpen(false)}
- >
- {worktreeOptions.map((option, index) => (
- {
- onSelectWorktreePath(option.path);
- setIsWorktreeSheetOpen(false);
- }}
- />
- ))}
-
- >
- ) : null}
-
- {isCreateMode ? (
-
- Base branch:
- {isEditingBranch ? (
-
-
-
-
-
-
-
-
-
- ) : (
-
- {displayBranch}
-
-
- )}
-
- ) : null}
-
- {baseBranchError ? {baseBranchError} : null}
-
- {repoError ? {repoError} : null}
-
- {gitValidationError ? {gitValidationError} : null}
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- formSection: {
- gap: theme.spacing[3],
- },
- label: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.semibold,
- },
- dropdownControl: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[3],
- backgroundColor: theme.colors.surface0,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- borderRadius: theme.borderRadius.lg,
- paddingVertical: theme.spacing[3],
- paddingHorizontal: theme.spacing[4],
- },
- dropdownControlDisabled: {
- opacity: theme.opacity[50],
- },
- dropdownValue: {
- flex: 1,
- color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
- },
- dropdownPlaceholder: {
- flex: 1,
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.base,
- },
- bottomSheetHeader: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- paddingHorizontal: theme.spacing[6],
- paddingBottom: theme.spacing[2],
- },
- dropdownSheetOverlay: {
- flex: 1,
- justifyContent: "flex-end",
- },
- dropdownSheetBackdrop: {
- position: "absolute",
- top: 0,
- right: 0,
- bottom: 0,
- left: 0,
- backgroundColor: theme.colors.palette.gray[900],
- opacity: 0.45,
- },
- dropdownSheetContainer: {
- backgroundColor: theme.colors.surface2,
- borderTopLeftRadius: theme.borderRadius["2xl"],
- borderTopRightRadius: theme.borderRadius["2xl"],
- paddingTop: theme.spacing[4],
- paddingHorizontal: theme.spacing[6],
- paddingBottom: theme.spacing[6] + theme.spacing[2],
- maxHeight: 560,
- width: "100%",
- },
- dropdownSheetHandle: {
- width: 56,
- height: 4,
- borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.border,
- alignSelf: "center",
- marginBottom: theme.spacing[3],
- },
- dropdownSheetTitle: {
- fontSize: theme.fontSize.lg,
- fontWeight: theme.fontWeight.semibold,
- textAlign: "center",
- },
- dropdownSheetScrollContent: {
- paddingBottom: theme.spacing[8],
- paddingHorizontal: theme.spacing[1],
- },
- dropdownSheetList: {
- marginTop: theme.spacing[3],
- },
- dropdownSheetOption: {
- paddingVertical: theme.spacing[3],
- paddingHorizontal: theme.spacing[4],
- borderRadius: theme.borderRadius.lg,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- backgroundColor: theme.colors.surface0,
- marginBottom: theme.spacing[2],
- },
- dropdownSheetOptionSelected: {
- borderColor: theme.colors.palette.blue[400],
- backgroundColor: "rgba(59, 130, 246, 0.18)",
- },
- dropdownSheetOptionLabel: {
- color: theme.colors.foreground,
- fontWeight: theme.fontWeight.semibold,
- },
- dropdownSheetOptionDescription: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- marginTop: theme.spacing[1],
- },
- dropdownSheetLoading: {
- alignItems: "center",
- paddingVertical: theme.spacing[4],
- },
- errorText: {
- color: theme.colors.palette.red[500],
- fontSize: theme.fontSize.sm,
- },
- warningText: {
- color: theme.colors.palette.orange[500],
- fontSize: theme.fontSize.sm,
- },
- helperText: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- selectorColumn: {
- flex: 1,
- gap: theme.spacing[3],
- },
- selectorColumnFull: {
- width: "100%",
- },
- toggleRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[3],
- paddingVertical: theme.spacing[2],
- },
- toggleRowDisabled: {
- opacity: theme.opacity[50],
- },
- checkbox: {
- width: 22,
- height: 22,
- borderRadius: theme.borderRadius.sm,
- borderWidth: theme.borderWidth[2],
- borderColor: theme.colors.border,
- alignItems: "center",
- justifyContent: "center",
- },
- checkboxChecked: {
- borderColor: theme.colors.palette.blue[500],
- backgroundColor: theme.colors.palette.blue[500],
- },
- checkboxDisabled: {
- borderColor: theme.colors.border,
- },
- checkboxDot: {
- width: 10,
- height: 10,
- borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.palette.white,
- },
- toggleTextContainer: {
- flex: 1,
- gap: theme.spacing[1],
- },
- toggleLabel: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
- fontWeight: theme.fontWeight.semibold,
- },
- input: {
- backgroundColor: theme.colors.surface0,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- borderRadius: theme.borderRadius.lg,
- paddingVertical: theme.spacing[3],
- paddingHorizontal: theme.spacing[4],
- color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
- },
- dropdownLoading: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- selectFieldContainer: {
- gap: theme.spacing[2],
- },
- selectFieldControl: {
- flexDirection: "row",
- alignItems: "center",
- backgroundColor: theme.colors.surface0,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- borderRadius: theme.borderRadius.lg,
- paddingVertical: theme.spacing[3],
- paddingHorizontal: theme.spacing[4],
- },
- selectFieldControlDisabled: {
- opacity: theme.opacity[50],
- },
- selectFieldContent: {
- flex: 1,
- gap: theme.spacing[1],
- },
- selectFieldLabel: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- fontWeight: theme.fontWeight.medium,
- textTransform: "uppercase",
- letterSpacing: 0.5,
- },
- selectFieldValue: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
- },
- selectFieldPlaceholder: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.base,
- },
- gitOptionsContainer: {
- gap: theme.spacing[3],
- },
- worktreeToggle: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[3],
- backgroundColor: theme.colors.surface0,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- borderRadius: theme.borderRadius.lg,
- paddingVertical: theme.spacing[3],
- paddingHorizontal: theme.spacing[4],
- },
- worktreeToggleDisabled: {
- opacity: theme.opacity[50],
- },
- worktreeToggleContent: {
- flex: 1,
- gap: theme.spacing[1],
- },
- worktreeToggleLabel: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
- fontWeight: theme.fontWeight.semibold,
- },
- worktreeToggleDescription: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- baseBranchRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- paddingHorizontal: theme.spacing[4],
- },
- baseBranchLabel: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- baseBranchValueRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- baseBranchValue: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.medium,
- },
- baseBranchEditRow: {
- flex: 1,
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- baseBranchInput: {
- flex: 1,
- backgroundColor: theme.colors.surface0,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- borderRadius: theme.borderRadius.md,
- paddingVertical: theme.spacing[1],
- paddingHorizontal: theme.spacing[2],
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- },
- baseBranchIconButton: {
- padding: theme.spacing[1],
- },
- desktopDropdownOverlay: {
- flex: 1,
- },
- desktopDropdownBackdrop: {
- position: "absolute",
- top: 0,
- right: 0,
- bottom: 0,
- left: 0,
- },
- desktopDropdownContainer: {
- backgroundColor: theme.colors.surface0,
- borderRadius: theme.borderRadius.lg,
- borderWidth: 1,
- borderColor: theme.colors.borderAccent,
- ...theme.shadow.md,
- maxHeight: 400,
- overflow: "hidden",
- },
- desktopDropdownScroll: {
- maxHeight: 400,
- },
- desktopDropdownScrollContent: {
- paddingVertical: theme.spacing[1],
- },
- agentConfigRow: {
- flexDirection: {
- xs: "column",
- md: "row",
- },
- gap: theme.spacing[2],
- },
- agentConfigColumn: {
- flex: 1,
- },
- compactSelectControl: {
- backgroundColor: theme.colors.surface0,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- borderRadius: theme.borderRadius.lg,
- paddingVertical: theme.spacing[1],
- paddingHorizontal: theme.spacing[3],
- gap: theme.spacing[1],
- },
- compactSelectControlInline: {
- minHeight: 40,
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- gap: theme.spacing[1],
- },
- compactSelectControlDisabled: {
- opacity: theme.opacity[50],
- },
- compactSelectTopRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- },
- compactSelectLeading: {
- alignItems: "center",
- justifyContent: "center",
- marginRight: theme.spacing[1],
- },
- compactSelectValueContainer: {
- flex: 1,
- minWidth: 0,
- gap: theme.spacing[1],
- },
- compactSelectLabel: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.medium,
- },
- compactSelectValue: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
- },
- compactSelectPlaceholder: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.base,
- },
-}));
diff --git a/packages/app/src/components/artifact-drawer.tsx b/packages/app/src/components/artifact-drawer.tsx
deleted file mode 100644
index 55699e1eb..000000000
--- a/packages/app/src/components/artifact-drawer.tsx
+++ /dev/null
@@ -1,244 +0,0 @@
-import { View, Text, ScrollView, Pressable, Modal } from "react-native";
-import { SafeAreaView } from "react-native-safe-area-context";
-import { StyleSheet } from "react-native-unistyles";
-import { Fonts } from "@/constants/theme";
-import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
-
-export interface Artifact {
- id: string;
- type: "markdown" | "diff" | "image" | "code";
- title: string;
- content: string;
- isBase64: boolean;
-}
-
-interface ArtifactDrawerProps {
- artifact: Artifact | null;
- onClose: () => void;
-}
-
-const styles = StyleSheet.create((theme) => ({
- container: {
- flex: 1,
- backgroundColor: theme.colors.surface0,
- flexDirection: "column",
- },
- header: {
- paddingBottom: theme.spacing[4],
- paddingHorizontal: theme.spacing[4],
- borderBottomWidth: theme.borderWidth[1],
- borderBottomColor: theme.colors.border,
- },
- headerRow: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- },
- titleContainer: {
- flex: 1,
- marginRight: theme.spacing[4],
- },
- title: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize["2xl"],
- fontWeight: theme.fontWeight.bold,
- },
- headerActions: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- badge: {
- paddingHorizontal: theme.spacing[3],
- paddingVertical: theme.spacing[1],
- borderRadius: theme.borderRadius.full,
- },
- badgeMarkdown: {
- backgroundColor: theme.colors.primary,
- },
- badgeDiff: {
- backgroundColor: theme.colors.palette.purple[600],
- },
- badgeImage: {
- backgroundColor: theme.colors.palette.green[600],
- },
- badgeCode: {
- backgroundColor: theme.colors.palette.orange[600],
- },
- badgeText: {
- color: theme.colors.primaryForeground,
- fontSize: theme.fontSize.xs,
- fontWeight: theme.fontWeight.semibold,
- },
- closeButton: {
- backgroundColor: theme.colors.surface2,
- width: 40,
- height: 40,
- borderRadius: 20,
- alignItems: "center",
- justifyContent: "center",
- },
- closeButtonText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize["2xl"],
- fontWeight: theme.fontWeight.bold,
- },
- contentScroll: {
- flex: 1,
- backgroundColor: theme.colors.surface0,
- },
- contentScrollContainer: {
- padding: theme.spacing[4],
- flexGrow: 1,
- },
- imagePlaceholder: {
- flex: 1,
- alignItems: "center",
- justifyContent: "center",
- },
- imagePlaceholderText: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- imagePlaceholderSubtext: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- marginTop: theme.spacing[2],
- },
- codeContainer: {
- backgroundColor: theme.colors.surface2,
- borderRadius: theme.borderRadius.lg,
- padding: theme.spacing[4],
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- },
- codeText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- fontFamily: Fonts.mono,
- },
- metadataContainer: {
- backgroundColor: theme.colors.surface2,
- padding: theme.spacing[3],
- borderTopWidth: theme.borderWidth[1],
- borderTopColor: theme.colors.border,
- marginTop: theme.spacing[2],
- },
- metadataTitle: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- fontWeight: theme.fontWeight.semibold,
- marginBottom: theme.spacing[2],
- },
- metadataRow: {
- flexDirection: "row",
- marginBottom: theme.spacing[1],
- },
- metadataLabel: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- width: 80,
- },
- metadataValue: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- flex: 1,
- fontFamily: Fonts.mono,
- },
-}));
-
-export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
- const webScrollbarStyle = useWebScrollbarStyle();
-
- if (!artifact) {
- return null;
- }
-
- // Decode content if base64
- const content = artifact.isBase64 ? atob(artifact.content) : artifact.content;
-
- // Type badge style mapping
- const typeBadgeStyles = {
- markdown: styles.badgeMarkdown,
- diff: styles.badgeDiff,
- image: styles.badgeImage,
- code: styles.badgeCode,
- };
-
- return (
-
-
- {/* Header */}
-
-
-
-
- {artifact.title}
-
-
-
-
- {artifact.type.toUpperCase()}
-
-
- Γ
-
-
-
-
- {/* Content */}
-
- {artifact.type === "image" ? (
-
- Image viewing not yet implemented
- Base64 image data received
-
- ) : (
-
-
- {content}
-
-
- )}
-
-
- {/* Metadata - Fixed at bottom */}
-
- METADATA
-
-
- ID:
- {artifact.id}
-
-
- Type:
- {artifact.type}
-
-
- Encoding:
-
- {artifact.isBase64 ? "Base64" : "Plain text"}
-
-
-
- Size:
- {content.length.toLocaleString()} characters
-
-
-
-
-
- );
-}
diff --git a/packages/app/src/components/audio-debug-notice.tsx b/packages/app/src/components/audio-debug-notice.tsx
deleted file mode 100644
index 5c0d4a48d..000000000
--- a/packages/app/src/components/audio-debug-notice.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import { useState, useMemo } from "react";
-import { View, Text, Pressable } from "react-native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import * as Clipboard from "expo-clipboard";
-import { Check, Copy, X } from "lucide-react-native";
-
-export interface AudioDebugInfo {
- requestId?: string | null;
- transcript?: string;
- debugRecordingPath?: string;
- format?: string;
- byteLength?: number;
- duration?: number;
- avgLogprob?: number;
- isLowConfidence?: boolean;
-}
-
-interface AudioDebugNoticeProps {
- info: AudioDebugInfo | null;
- onDismiss?: () => void;
- title?: string;
-}
-
-function formatBytes(bytes?: number): string | null {
- if (!bytes || bytes <= 0) {
- return null;
- }
- if (bytes < 1024) {
- return `${bytes} B`;
- }
- const units = ["KB", "MB", "GB"] as const;
- let value = bytes;
- let unitIndex = -1;
- while (value >= 1024 && unitIndex < units.length - 1) {
- value /= 1024;
- unitIndex += 1;
- }
- return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unitIndex]}`;
-}
-
-function formatDuration(duration?: number): string | null {
- if (!duration || duration <= 0) {
- return null;
- }
- const seconds = duration / 1000;
- if (seconds < 1) {
- return `${(seconds * 1000).toFixed(0)} ms`;
- }
- return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`;
-}
-
-export function AudioDebugNotice({
- info,
- onDismiss,
- title = "Dictation Debug",
-}: AudioDebugNoticeProps) {
- const { theme } = useUnistyles();
- const [copied, setCopied] = useState(false);
-
- const stats = useMemo(() => {
- if (!info) {
- return null;
- }
- const parts: string[] = [];
- if (info.format) {
- parts.push(info.format);
- }
- const size = formatBytes(info.byteLength);
- if (size) {
- parts.push(size);
- }
- const duration = formatDuration(info.duration);
- if (duration) {
- parts.push(duration);
- }
- if (info.avgLogprob !== undefined) {
- const label = `${info.avgLogprob.toFixed(2)} avg logprob`;
- parts.push(info.isLowConfidence ? `${label} (low confidence)` : label);
- }
- return parts.join(" Β· ");
- }, [info]);
-
- if (!info) {
- return null;
- }
-
- const handleCopyPath = async () => {
- if (!info.debugRecordingPath) {
- return;
- }
- try {
- await Clipboard.setStringAsync(info.debugRecordingPath);
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- } catch (error) {
- console.warn("[AudioDebug] Failed to copy path", error);
- }
- };
-
- const pathMissing = !info.debugRecordingPath;
-
- return (
-
-
- {title}
- {onDismiss ? (
-
-
-
- ) : null}
-
-
- {info.debugRecordingPath ? (
-
-
- {info.debugRecordingPath}
-
-
- {copied ? (
-
- ) : (
-
- )}
-
-
- ) : (
-
- Raw audio path unavailable. Set STT_DEBUG_AUDIO_DIR on the server to persist recordings.
-
- )}
-
- {stats ? (
- {stats}
- ) : null}
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- borderRadius: 10,
- borderWidth: StyleSheet.hairlineWidth,
- padding: 12,
- gap: 8,
- },
- headerRow: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- },
- title: {
- fontSize: 12,
- fontWeight: "600",
- textTransform: "uppercase",
- },
- pathRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: 8,
- },
- pathText: {
- flex: 1,
- fontSize: 13,
- },
- copyPill: {
- width: 28,
- height: 28,
- borderRadius: 14,
- alignItems: "center",
- justifyContent: "center",
- },
- stats: {
- fontSize: 12,
- },
- hint: {
- fontSize: 12,
- },
-});
diff --git a/packages/app/src/components/connection-status.tsx b/packages/app/src/components/connection-status.tsx
deleted file mode 100644
index f43741bc9..000000000
--- a/packages/app/src/components/connection-status.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { View, Text } from "react-native";
-import { StyleSheet } from "react-native-unistyles";
-
-interface ConnectionStatusProps {
- isConnected: boolean;
-}
-
-const styles = StyleSheet.create((theme) => ({
- container: {
- // No padding or border - parent handles layout
- },
- row: {
- flexDirection: "row",
- alignItems: "center",
- },
- dot: {
- width: 8,
- height: 8,
- borderRadius: theme.borderRadius.full,
- marginRight: theme.spacing[2],
- },
- dotConnected: {
- backgroundColor: theme.colors.palette.green[500],
- },
- dotDisconnected: {
- backgroundColor: theme.colors.destructive,
- },
- text: {
- fontSize: theme.fontSize.sm,
- },
- textConnected: {
- color: theme.colors.palette.green[500],
- },
- textDisconnected: {
- color: theme.colors.destructive,
- },
-}));
-
-export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
- return (
-
-
-
-
- {isConnected ? "Connected" : "Disconnected"}
-
-
-
- );
-}
diff --git a/packages/app/src/components/dictation-status-notice.tsx b/packages/app/src/components/dictation-status-notice.tsx
deleted file mode 100644
index 1ae655dcc..000000000
--- a/packages/app/src/components/dictation-status-notice.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import { View, Text, Pressable } from "react-native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import {
- AlertTriangle,
- CheckCircle2,
- Info,
- RefreshCcw,
- RotateCcw,
- WifiOff,
- X,
-} from "lucide-react-native";
-
-export type DictationToastVariant = "info" | "success" | "warning" | "error";
-
-interface DictationStatusNoticeProps {
- variant: DictationToastVariant;
- title: string;
- subtitle?: string;
- meta?: string;
- actionLabel?: string;
- onAction?: () => void;
- onDismiss?: () => void;
-}
-
-const variantIconMap: Record = {
- info: RefreshCcw,
- success: CheckCircle2,
- warning: AlertTriangle,
- error: AlertTriangle,
-};
-
-export function DictationStatusNotice({
- variant,
- title,
- subtitle,
- meta,
- actionLabel,
- onAction,
- onDismiss,
-}: DictationStatusNoticeProps) {
- const { theme } = useUnistyles();
-
- const VariantIcon = (() => {
- if (variant === "warning" && title.toLowerCase().includes("offline")) {
- return WifiOff;
- }
- return variantIconMap[variant] ?? Info;
- })();
-
- const backgroundColor = (() => {
- switch (variant) {
- case "success":
- return theme.colors.palette.green[500];
- case "warning":
- return theme.colors.palette.amber[500];
- case "error":
- return theme.colors.palette.red[500];
- default:
- return theme.colors.surface0;
- }
- })();
-
- const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white;
- const secondaryColor =
- variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
-
- return (
-
-
-
-
- {title}
-
- {onDismiss ? (
-
-
-
- ) : null}
-
-
- {subtitle ? (
- {subtitle}
- ) : null}
-
- {(meta || (actionLabel && onAction)) && (
-
- {meta ? {meta} : }
- {actionLabel && onAction ? (
-
-
-
- {actionLabel}
-
-
- ) : null}
-
- )}
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- container: {
- borderWidth: StyleSheet.hairlineWidth,
- borderRadius: theme.borderRadius.xl,
- paddingHorizontal: theme.spacing[4],
- paddingVertical: theme.spacing[3],
- gap: theme.spacing[2],
- },
- headerRow: {
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "center",
- },
- titleRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- title: {
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.semibold,
- },
- subtitle: {
- fontSize: theme.fontSize.sm,
- },
- actionsRow: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- },
- meta: {
- fontSize: theme.fontSize.xs,
- },
- actionButton: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- borderRadius: theme.borderRadius.full,
- paddingHorizontal: theme.spacing[3],
- paddingVertical: theme.spacing[1],
- },
- actionText: {
- fontSize: theme.fontSize.xs,
- fontWeight: theme.fontWeight.semibold,
- },
-}));
diff --git a/packages/app/src/components/empty-state.tsx b/packages/app/src/components/empty-state.tsx
deleted file mode 100644
index fdf34901b..000000000
--- a/packages/app/src/components/empty-state.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { View, Text, Pressable } from "react-native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { Plus, Download } from "lucide-react-native";
-
-interface EmptyStateProps {
- onCreateAgent: () => void;
- onImportAgent?: () => void;
-}
-
-export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
- const { theme } = useUnistyles();
- const hasImportCta = typeof onImportAgent === "function";
-
- return (
-
- Hammock
- What would you like to work on?
-
-
-
- New agent
-
- {hasImportCta ? (
-
-
- Import agent
-
- ) : null}
-
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- container: {
- flex: 1,
- alignItems: "center",
- justifyContent: "center",
- paddingHorizontal: theme.spacing[6],
- },
- title: {
- fontSize: theme.fontSize["4xl"],
- fontWeight: "700",
- color: theme.colors.foreground,
- marginBottom: theme.spacing[2],
- },
- subtitle: {
- fontSize: theme.fontSize.lg,
- color: theme.colors.foregroundMuted,
- textAlign: "center",
- marginBottom: theme.spacing[8],
- },
- buttonGroup: {
- width: "100%",
- gap: theme.spacing[3],
- },
- button: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- paddingVertical: theme.spacing[3],
- paddingHorizontal: theme.spacing[6],
- borderRadius: theme.borderRadius.lg,
- justifyContent: "center",
- },
- primaryButton: {
- backgroundColor: theme.colors.primary,
- },
- primaryButtonText: {
- fontSize: theme.fontSize.base,
- fontWeight: theme.fontWeight.semibold,
- color: theme.colors.primaryForeground,
- },
- secondaryButton: {
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- backgroundColor: theme.colors.surface2,
- },
- secondaryButtonText: {
- fontSize: theme.fontSize.base,
- fontWeight: theme.fontWeight.semibold,
- color: theme.colors.foreground,
- },
-}));
diff --git a/packages/app/src/components/mode-selector-modal.tsx b/packages/app/src/components/mode-selector-modal.tsx
deleted file mode 100644
index d52d2994a..000000000
--- a/packages/app/src/components/mode-selector-modal.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import { View, Text, Modal, Pressable } from "react-native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import type { Agent } from "@/contexts/session-context";
-
-interface ModeSelectorModalProps {
- visible: boolean;
- agent: Agent | null;
- onModeChange: (modeId: string) => void;
- onClose: () => void;
-}
-
-export function ModeSelectorModal({
- visible,
- agent,
- onModeChange,
- onClose,
-}: ModeSelectorModalProps) {
- const { theme } = useUnistyles();
-
- return (
-
-
-
- {agent?.availableModes?.map((mode) => {
- const isActive = mode.id === agent.currentModeId;
- return (
- {
- onModeChange(mode.id);
- onClose();
- }}
- style={[styles.modeItem, isActive && styles.modeItemActive]}
- >
-
- {mode.label}
-
- {mode.description && (
-
- {mode.description}
-
- )}
-
- );
- })}
-
-
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- modalOverlay: {
- flex: 1,
- backgroundColor: "rgba(0,0,0,0.5)",
- justifyContent: "center",
- alignItems: "center",
- },
- modeSelectorContent: {
- backgroundColor: theme.colors.surface2,
- borderRadius: theme.borderRadius.lg,
- padding: theme.spacing[4],
- minWidth: 280,
- maxWidth: 320,
- },
- modeItem: {
- padding: theme.spacing[4],
- borderRadius: theme.borderRadius.md,
- marginBottom: theme.spacing[2],
- backgroundColor: theme.colors.surface2,
- },
- modeItemActive: {
- backgroundColor: theme.colors.primary,
- },
- modeName: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
- fontWeight: theme.fontWeight.semibold,
- marginBottom: theme.spacing[1],
- },
- modeNameActive: {
- color: theme.colors.primaryForeground,
- },
- modeDescription: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- modeDescriptionActive: {
- color: theme.colors.primaryForeground,
- opacity: 0.8,
- },
-}));
diff --git a/packages/app/src/components/ui/dropdown-menu-floating.ts b/packages/app/src/components/ui/dropdown-menu-floating.ts
deleted file mode 100644
index 5d07b2b59..000000000
--- a/packages/app/src/components/ui/dropdown-menu-floating.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Dimensions, Platform, StatusBar, type View } from "react-native";
-
-export type Placement = "top" | "bottom" | "left" | "right";
-export type Alignment = "start" | "center" | "end";
-
-interface Rect {
- x: number;
- y: number;
- width: number;
- height: number;
-}
-
-interface FloatingStyles {
- position: "absolute";
- top: number;
- left: number;
-}
-
-interface GeometryResult {
- popoverOrigin: { x: number; y: number };
- placement: Placement;
- availableSize: { width: number; height: number };
-}
-
-function measureElement(element: View): Promise {
- return new Promise((resolve) => {
- element.measureInWindow((x, y, width, height) => {
- resolve({ x, y, width, height });
- });
- });
-}
-
-function computeGeometry({
- fromRect,
- contentSize,
- displayArea,
- placement,
- alignment,
- offset,
- padding,
-}: {
- fromRect: Rect;
- contentSize: { width: number; height: number };
- displayArea: Rect;
- placement: Placement;
- alignment: Alignment;
- offset: number;
- padding: number;
-}): GeometryResult {
- const { width: contentWidth, height: contentHeight } = contentSize;
-
- // Calculate available space in each direction
- const spaceTop = fromRect.y - displayArea.y - padding;
- const spaceBottom = displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - padding;
- const spaceLeft = fromRect.x - displayArea.x - padding;
- const spaceRight = displayArea.x + displayArea.width - (fromRect.x + fromRect.width) - padding;
-
- // Determine actual placement (may flip if not enough space)
- let actualPlacement = placement;
- if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
- actualPlacement = "top";
- } else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
- actualPlacement = "bottom";
- } else if (placement === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
- actualPlacement = "right";
- } else if (placement === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
- actualPlacement = "left";
- }
-
- let x: number;
- let y: number;
-
- // Position based on placement
- if (actualPlacement === "bottom") {
- y = fromRect.y + fromRect.height + offset;
- } else if (actualPlacement === "top") {
- y = fromRect.y - contentHeight - offset;
- } else if (actualPlacement === "left") {
- x = fromRect.x - contentWidth - offset;
- } else {
- x = fromRect.x + fromRect.width + offset;
- }
-
- // Alignment on cross axis
- if (actualPlacement === "top" || actualPlacement === "bottom") {
- if (alignment === "start") {
- x = fromRect.x;
- } else if (alignment === "end") {
- x = fromRect.x + fromRect.width - contentWidth;
- } else {
- x = fromRect.x + (fromRect.width - contentWidth) / 2;
- }
- } else {
- if (alignment === "start") {
- y = fromRect.y;
- } else if (alignment === "end") {
- y = fromRect.y + fromRect.height - contentHeight;
- } else {
- y = fromRect.y + (fromRect.height - contentHeight) / 2;
- }
- }
-
- // Constrain to display area (shift)
- const minX = displayArea.x + padding;
- const maxX = displayArea.x + displayArea.width - contentWidth - padding;
- const minY = displayArea.y + padding;
- const maxY = displayArea.y + displayArea.height - contentHeight - padding;
-
- x = Math.max(minX, Math.min(maxX, x!));
- y = Math.max(minY, Math.min(maxY, y!));
-
- // Calculate available size
- const availableWidth = displayArea.width - padding * 2;
- const availableHeight =
- actualPlacement === "bottom"
- ? displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - offset - padding
- : actualPlacement === "top"
- ? fromRect.y - displayArea.y - offset - padding
- : displayArea.height - padding * 2;
-
- return {
- popoverOrigin: { x, y },
- placement: actualPlacement,
- availableSize: {
- width: Math.max(0, availableWidth),
- height: Math.max(0, availableHeight),
- },
- };
-}
-
-export interface UseDropdownFloatingOptions {
- open: boolean;
- placement?: Placement;
- alignment?: Alignment;
- offset?: number;
- padding?: number;
- referenceEl: View | null;
-}
-
-export interface UseDropdownFloatingReturn {
- floatingRef: (el: View | null) => void;
- floatingStyles: FloatingStyles;
- update: () => void;
- onLayout: () => void;
- availableSize: { width: number; height: number } | null;
- actualPlacement: Placement;
-}
-
-export function useDropdownFloating({
- open,
- placement = "bottom",
- alignment = "start",
- offset = 8,
- padding = 8,
- referenceEl,
-}: UseDropdownFloatingOptions): UseDropdownFloatingReturn {
- const floatingElRef = useRef(null);
- const [geometry, setGeometry] = useState(null);
-
- const displayArea = useMemo(() => {
- const { width, height } = Dimensions.get("window");
- const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
- return {
- x: 0,
- y: statusBarHeight,
- width,
- height: height - statusBarHeight,
- };
- }, []);
-
- const update = useCallback(async () => {
- if (!referenceEl || !floatingElRef.current) {
- return;
- }
-
- try {
- const [fromRect, contentRect] = await Promise.all([
- measureElement(referenceEl),
- measureElement(floatingElRef.current),
- ]);
-
- const result = computeGeometry({
- fromRect,
- contentSize: { width: contentRect.width, height: contentRect.height },
- displayArea,
- placement,
- alignment,
- offset,
- padding,
- });
-
- setGeometry(result);
- } catch (e) {
- console.warn("[useDropdownFloating] measure failed:", e);
- }
- }, [referenceEl, displayArea, placement, alignment, offset, padding]);
-
- const floatingRef = useCallback((el: View | null) => {
- floatingElRef.current = el;
- }, []);
-
- // Track when floating element is ready
- const [floatingReady, setFloatingReady] = useState(false);
-
- const handleFloatingLayout = useCallback(() => {
- setFloatingReady(true);
- }, []);
-
- useEffect(() => {
- if (!open) {
- setGeometry(null);
- setFloatingReady(false);
- return;
- }
-
- if (floatingReady && referenceEl && floatingElRef.current) {
- update();
- }
- }, [open, floatingReady, referenceEl, update]);
-
- const floatingStyles: FloatingStyles = {
- position: "absolute",
- top: geometry?.popoverOrigin.y ?? 0,
- left: geometry?.popoverOrigin.x ?? 0,
- };
-
- return {
- floatingRef,
- floatingStyles,
- update,
- onLayout: handleFloatingLayout,
- availableSize: geometry?.availableSize ?? null,
- actualPlacement: geometry?.placement ?? placement,
- };
-}
diff --git a/packages/app/src/components/voice-button.tsx b/packages/app/src/components/voice-button.tsx
deleted file mode 100644
index 20aeb2c14..000000000
--- a/packages/app/src/components/voice-button.tsx
+++ /dev/null
@@ -1,222 +0,0 @@
-import { Pressable, View, Text, Animated } from "react-native";
-import { useEffect, useRef } from "react";
-import { StyleSheet } from "react-native-unistyles";
-
-interface VoiceButtonProps {
- state: "idle" | "recording" | "processing" | "playing";
- onPress: () => void;
- disabled?: boolean;
-}
-
-const styles = StyleSheet.create((theme) => ({
- container: {
- alignItems: "center",
- gap: theme.spacing[4],
- },
- pressable: {
- opacity: 1,
- },
- pressableDisabled: {
- opacity: 0.5,
- },
- button: {
- width: 80,
- height: 80,
- borderRadius: theme.borderRadius.full,
- alignItems: "center",
- justifyContent: "center",
- ...theme.shadow.md,
- },
- buttonIdle: {
- backgroundColor: theme.colors.surface2,
- },
- buttonRecording: {
- backgroundColor: theme.colors.destructive,
- },
- buttonProcessing: {
- backgroundColor: theme.colors.primary,
- },
- buttonPlaying: {
- backgroundColor: theme.colors.palette.green[500],
- },
- label: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- // Recording icon
- recordingIcon: {
- width: 24,
- height: 24,
- backgroundColor: theme.colors.foreground,
- borderRadius: theme.borderRadius.md,
- },
- // Processing icon
- processingIconContainer: {
- width: 24,
- height: 24,
- },
- processingDot: {
- width: 6,
- height: 6,
- backgroundColor: theme.colors.primaryForeground,
- borderRadius: theme.borderRadius.full,
- position: "absolute",
- },
- processingDotTop: {
- top: 0,
- left: 12,
- },
- processingDotRight: {
- top: 12,
- right: 0,
- },
- processingDotBottom: {
- bottom: 0,
- left: 12,
- },
- // Playing icon
- playingIconContainer: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- },
- playingBar: {
- backgroundColor: theme.colors.foreground,
- borderRadius: theme.borderRadius.sm,
- },
- playingBar1: {
- width: 4,
- height: 16,
- },
- playingBar2: {
- width: 4,
- height: 24,
- },
- playingBar3: {
- width: 4,
- height: 12,
- },
- // Idle icon (microphone)
- micContainer: {
- width: 24,
- height: 32,
- position: "relative",
- },
- micCapsule: {
- position: "absolute",
- bottom: 0,
- left: 4,
- width: 16,
- height: 24,
- backgroundColor: theme.colors.foreground,
- borderTopLeftRadius: 999,
- borderTopRightRadius: 999,
- },
- micBase: {
- position: "absolute",
- bottom: 0,
- left: 0,
- width: 24,
- height: 6,
- backgroundColor: theme.colors.foreground,
- borderRadius: theme.borderRadius.full,
- },
-}));
-
-export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonProps) {
- const pulseAnim = useRef(new Animated.Value(1)).current;
-
- useEffect(() => {
- if (state === "recording") {
- Animated.loop(
- Animated.sequence([
- Animated.timing(pulseAnim, {
- toValue: 1.2,
- duration: 800,
- useNativeDriver: true,
- }),
- Animated.timing(pulseAnim, {
- toValue: 1,
- duration: 800,
- useNativeDriver: true,
- }),
- ]),
- ).start();
- } else {
- pulseAnim.setValue(1);
- }
- }, [state, pulseAnim]);
-
- const getButtonStyle = () => {
- switch (state) {
- case "recording":
- return styles.buttonRecording;
- case "processing":
- return styles.buttonProcessing;
- case "playing":
- return styles.buttonPlaying;
- default:
- return styles.buttonIdle;
- }
- };
-
- const getIcon = () => {
- switch (state) {
- case "recording":
- return ;
- case "processing":
- return (
-
-
-
-
-
- );
- case "playing":
- return (
-
-
-
-
-
- );
- default:
- return (
-
-
-
-
- );
- }
- };
-
- const getLabel = () => {
- switch (state) {
- case "recording":
- return "Recording...";
- case "processing":
- return "Processing...";
- case "playing":
- return "Playing...";
- default:
- return "Tap to speak";
- }
- };
-
- return (
-
-
-
- {getIcon()}
-
-
- {getLabel()}
-
- );
-}
diff --git a/packages/app/src/components/voice-compact-indicator.tsx b/packages/app/src/components/voice-compact-indicator.tsx
deleted file mode 100644
index e025cfef9..000000000
--- a/packages/app/src/components/voice-compact-indicator.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { ActivityIndicator, Alert, Pressable, View } from "react-native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { Mic, MicOff, Square } from "lucide-react-native";
-import { VolumeMeter } from "@/components/volume-meter";
-import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
-
-export function VoiceCompactIndicator() {
- const { theme } = useUnistyles();
- const { volume, isSpeaking } = useVoiceTelemetry();
- const { isVoiceMode, isVoiceSwitching, isMuted, toggleMute, stopVoice } = useVoice();
- if (!isVoiceMode) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
- {isMuted ? (
-
- ) : (
-
- )}
-
-
- {
- void stopVoice().catch((error) => {
- console.error("[VoiceCompactIndicator] Failed to stop voice mode", error);
- Alert.alert("Voice failed", "Unable to stop realtime voice mode.");
- });
- }}
- disabled={isVoiceSwitching}
- accessibilityRole="button"
- accessibilityLabel="Disable realtime voice mode"
- style={[styles.stopButton, isVoiceSwitching ? styles.buttonDisabled : undefined]}
- hitSlop={8}
- >
- {isVoiceSwitching ? (
-
- ) : (
-
- )}
-
-
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- container: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- paddingLeft: theme.spacing[3],
- paddingRight: theme.spacing[1],
- height: 32,
- borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.surface2,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- },
- containerMuted: {
- backgroundColor: theme.colors.palette.red[600],
- borderWidth: 0,
- },
- meterContainer: {
- justifyContent: "center",
- },
- controlsRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- },
- muteButton: {
- width: 28,
- height: 28,
- borderRadius: theme.borderRadius.full,
- alignItems: "center",
- justifyContent: "center",
- backgroundColor: "transparent",
- borderWidth: 0,
- },
- stopButton: {
- width: 28,
- height: 28,
- borderRadius: theme.borderRadius.full,
- alignItems: "center",
- justifyContent: "center",
- backgroundColor: theme.colors.palette.red[600],
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.palette.red[800],
- },
- buttonDisabled: {
- opacity: 0.5,
- },
-}));
diff --git a/packages/app/src/components/voice-panel.tsx b/packages/app/src/components/voice-panel.tsx
deleted file mode 100644
index d2c0836ea..000000000
--- a/packages/app/src/components/voice-panel.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { View, Pressable } from "react-native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { MicOff, Square } from "lucide-react-native";
-import { VolumeMeter } from "./volume-meter";
-import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
-import { useHosts } from "@/runtime/host-runtime";
-
-export function VoicePanel() {
- const { theme } = useUnistyles();
- const daemons = useHosts();
- const { volume, isSpeaking } = useVoiceTelemetry();
- const { isMuted, stopVoice, toggleMute, activeServerId } = useVoice();
- const hostLabel = activeServerId
- ? (daemons.find((daemon) => daemon.serverId === activeServerId)?.label ?? null)
- : null;
- const hostSuffix = hostLabel ? ` (${hostLabel})` : "";
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- void stopVoice()}
- accessibilityRole="button"
- accessibilityLabel={`Stop voice mode${hostSuffix}`}
- style={[styles.iconButton, styles.iconButtonStop]}
- >
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- container: {
- marginHorizontal: theme.spacing[4],
- marginBottom: theme.spacing[3],
- borderRadius: theme.borderRadius["2xl"],
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- backgroundColor: theme.colors.surface2,
- paddingVertical: theme.spacing[2],
- paddingHorizontal: theme.spacing[3],
- },
- contentRow: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- gap: theme.spacing[3],
- },
- meterContainer: {
- flex: 1,
- justifyContent: "center",
- alignItems: "flex-start",
- paddingLeft: theme.spacing[1],
- },
- actionsRow: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "flex-end",
- gap: theme.spacing[2],
- },
- iconButton: {
- width: 40,
- height: 40,
- borderRadius: theme.borderRadius.full,
- alignItems: "center",
- justifyContent: "center",
- backgroundColor: theme.colors.surface0,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- },
- iconButtonMuted: {
- backgroundColor: theme.colors.palette.red[500],
- borderWidth: 0,
- },
- iconButtonStop: {
- backgroundColor: theme.colors.palette.red[600],
- borderColor: theme.colors.palette.red[800],
- },
-}));
diff --git a/packages/app/src/config/audio-debug.ts b/packages/app/src/config/audio-debug.ts
deleted file mode 100644
index a8c1b8003..000000000
--- a/packages/app/src/config/audio-debug.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const AUDIO_DEBUG_ENABLED =
- typeof process !== "undefined" && process.env?.EXPO_PUBLIC_ENABLE_AUDIO_DEBUG === "1";
diff --git a/packages/app/src/hooks/use-dictation.d.ts b/packages/app/src/hooks/use-dictation.d.ts
deleted file mode 100644
index 83efec689..000000000
--- a/packages/app/src/hooks/use-dictation.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./use-dictation";
diff --git a/packages/app/src/hooks/use-recent-paths.ts b/packages/app/src/hooks/use-recent-paths.ts
deleted file mode 100644
index 310a3c57b..000000000
--- a/packages/app/src/hooks/use-recent-paths.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { useState, useEffect, useCallback } from "react";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-
-const STORAGE_KEY = "@paseo:recent-paths";
-const MAX_RECENT_PATHS = 3;
-
-export interface UseRecentPathsReturn {
- recentPaths: string[];
- isLoading: boolean;
- addRecentPath: (path: string) => Promise;
- clearRecentPaths: () => Promise;
-}
-
-export function useRecentPaths(): UseRecentPathsReturn {
- const [recentPaths, setRecentPaths] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
-
- // Load recent paths from AsyncStorage on mount
- useEffect(() => {
- loadRecentPaths();
- }, []);
-
- async function loadRecentPaths() {
- try {
- const stored = await AsyncStorage.getItem(STORAGE_KEY);
- if (stored) {
- const parsed = JSON.parse(stored) as string[];
- setRecentPaths(parsed);
- }
- } catch (error) {
- console.error("[RecentPaths] Failed to load recent paths:", error);
- // Continue with empty array
- } finally {
- setIsLoading(false);
- }
- }
-
- const addRecentPath = useCallback(
- async (path: string) => {
- try {
- // Remove duplicates and add to front
- const filtered = recentPaths.filter((p) => p !== path);
- const updated = [path, ...filtered].slice(0, MAX_RECENT_PATHS);
-
- setRecentPaths(updated);
- await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
- } catch (error) {
- console.error("[RecentPaths] Failed to save recent path:", error);
- throw error;
- }
- },
- [recentPaths],
- );
-
- const clearRecentPaths = useCallback(async () => {
- try {
- setRecentPaths([]);
- await AsyncStorage.removeItem(STORAGE_KEY);
- } catch (error) {
- console.error("[RecentPaths] Failed to clear recent paths:", error);
- throw error;
- }
- }, []);
-
- return {
- recentPaths,
- isLoading,
- addRecentPath,
- clearRecentPaths,
- };
-}
diff --git a/packages/app/src/hooks/use-sidebar-agent-sections.ts b/packages/app/src/hooks/use-sidebar-agent-sections.ts
deleted file mode 100644
index 6631ea937..000000000
--- a/packages/app/src/hooks/use-sidebar-agent-sections.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { useEffect, useMemo } from "react";
-import { useQueries } from "@tanstack/react-query";
-import {
- checkoutStatusQueryKey,
- type CheckoutStatusPayload,
- CHECKOUT_STATUS_STALE_TIME,
-} from "@/hooks/use-checkout-status-query";
-import { groupAgents } from "@/utils/agent-grouping";
-import { useSectionOrderStore, sortProjectsByStoredOrder } from "@/stores/section-order-store";
-import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
-
-export interface SidebarSectionData {
- key: string;
- projectKey: string;
- title: string;
- agents: AggregatedAgent[];
- /** For project sections, the first agent's serverId (to lookup checkout status) */
- firstAgentServerId?: string;
- /** For project sections, the first agent's id (to lookup checkout status) */
- firstAgentId?: string;
- /** Working directory for the project (from first agent) */
- workingDir?: string;
-}
-
-export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] {
- // Subscribe to checkout status cache entries for each visible agent so that grouping
- // can switch from cwdβremote as soon as checkout status is prefetched.
- //
- // This avoids a brief UI state where two separate sections can render with the
- // same icon/title while grouping is still keyed by cwd.
- const checkoutStatusQueries = useQueries({
- queries: agents.map((agent) => ({
- queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd),
- queryFn: async (): Promise => {
- throw new Error("Checkout status query is disabled in sidebar grouping");
- },
- enabled: false,
- staleTime: CHECKOUT_STATUS_STALE_TIME,
- })),
- });
-
- const remoteUrlByAgentKey = useMemo(() => {
- const result = new Map();
- for (let idx = 0; idx < agents.length; idx++) {
- const agent = agents[idx];
- const checkout = checkoutStatusQueries[idx]?.data ?? null;
- result.set(`${agent.serverId}:${agent.id}`, checkout?.remoteUrl ?? null);
- }
- return result;
- }, [agents, checkoutStatusQueries]);
-
- const projectOrder = useSectionOrderStore((state) => state.projectOrder);
- const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
-
- const { activeGroups } = useMemo(
- () =>
- groupAgents(agents, {
- getRemoteUrl: (agent) => remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null,
- }),
- [agents, remoteUrlByAgentKey],
- );
-
- const sortedGroups = useMemo(
- () => sortProjectsByStoredOrder(activeGroups, projectOrder),
- [activeGroups, projectOrder],
- );
-
- const sections: SidebarSectionData[] = useMemo(() => {
- const result: SidebarSectionData[] = [];
-
- for (const group of sortedGroups) {
- const sectionKey = `project:${group.projectKey}`;
- const firstAgent = group.agents[0];
- result.push({
- key: sectionKey,
- projectKey: group.projectKey,
- title: group.projectName,
- agents: group.agents,
- firstAgentServerId: firstAgent?.serverId,
- firstAgentId: firstAgent?.id,
- workingDir: firstAgent?.cwd,
- });
- }
-
- return result;
- }, [sortedGroups]);
-
- // Sync section order when new projects appear.
- useEffect(() => {
- const currentKeys = sections.map((s) => s.projectKey);
- const storedKeys = new Set(projectOrder);
- const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
-
- if (newKeys.length > 0) {
- setProjectOrder([...projectOrder, ...newKeys]);
- }
- }, [projectOrder, sections, setProjectOrder]);
-
- return sections;
-}
diff --git a/packages/app/src/hooks/use-theme-color.ts b/packages/app/src/hooks/use-theme-color.ts
deleted file mode 100644
index 36b012ca9..000000000
--- a/packages/app/src/hooks/use-theme-color.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Learn more about light and dark modes:
- * https://docs.expo.dev/guides/color-schemes/
- */
-
-import { Colors } from "@/constants/theme";
-import { useColorScheme } from "@/hooks/use-color-scheme";
-
-export function useThemeColor(
- props: { light?: string; dark?: string },
- colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
-) {
- const theme = useColorScheme() ?? "light";
- const colorFromProps = props[theme];
-
- if (colorFromProps) {
- return colorFromProps;
- } else {
- return Colors[theme][colorName];
- }
-}
diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx
deleted file mode 100644
index a91695ba8..000000000
--- a/packages/app/src/screens/agent/draft-agent-screen.tsx
+++ /dev/null
@@ -1,1406 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
-import { createNameId } from "mnemonic-id";
-import type { ImageAttachment } from "@/components/message-input";
-import { View, Text, ScrollView, Keyboard } from "react-native";
-import { useLocalSearchParams, useRouter } from "expo-router";
-import { useIsFocused } from "@react-navigation/native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { GestureDetector } from "react-native-gesture-handler";
-import Animated from "react-native-reanimated";
-import { Folder, GitBranch, PanelRight } from "lucide-react-native";
-import { SidebarMenuToggle } from "@/components/headers/menu-header";
-import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
-import { Composer } from "@/components/composer";
-import { AgentStreamView } from "@/components/agent-stream-view";
-import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
-import { ExplorerSidebar } from "@/components/explorer-sidebar";
-import { Combobox } from "@/components/ui/combobox";
-import { FileDropZone } from "@/components/file-drop-zone";
-import { useQuery } from "@tanstack/react-query";
-import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
-import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
-import { useAllAgentsList } from "@/hooks/use-all-agents-list";
-import { useHosts } from "@/runtime/host-runtime";
-import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
-import { buildHostAgentDetailRoute } from "@/utils/host-routes";
-import { shortenPath } from "@/utils/shorten-path";
-import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
-import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
-import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
-import { useSessionStore } from "@/stores/session-store";
-import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
-import {
- getHostRuntimeStore,
- useHostRuntimeClient,
- useHostRuntimeIsConnected,
-} from "@/runtime/host-runtime";
-import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
-import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
-import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
-import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
-import { WelcomeScreen } from "@/components/welcome-screen";
-import type { Agent } from "@/contexts/session-context";
-import { encodeImages } from "@/utils/encode-images";
-import type {
- AgentProvider,
- AgentCapabilityFlags,
- AgentSessionConfig,
-} from "@server/server/agent/agent-sdk-types";
-import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
-import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
-import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
-import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
-import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
-import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
-import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
-import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
-import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
-import { isWeb } from "@/constants/platform";
-
-const EMPTY_PENDING_PERMISSIONS = new Map();
-const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
- supportsStreaming: true,
- supportsSessionPersistence: false,
- supportsDynamicModes: false,
- supportsMcpServers: false,
- supportsReasoningStream: false,
- supportsToolInvocations: false,
-};
-function getParamValue(value: string | string[] | undefined) {
- if (typeof value === "string") {
- const trimmed = value.trim();
- return trimmed.length > 0 ? trimmed : undefined;
- }
- if (Array.isArray(value)) {
- for (const entry of value) {
- const trimmed = entry.trim();
- if (trimmed.length > 0) {
- return trimmed;
- }
- }
- }
- return undefined;
-}
-
-function getValidProvider(value: string | undefined) {
- if (!value) {
- return undefined;
- }
- return value as AgentProvider;
-}
-
-function getValidMode(provider: AgentProvider | undefined, value: string | undefined) {
- if (!provider || !value) {
- return undefined;
- }
- return value;
-}
-
-type DraftAgentParams = {
- serverId?: string;
- provider?: string;
- modeId?: string;
- model?: string;
- thinkingOptionId?: string;
- workingDir?: string;
- worktreeMode?: string;
-};
-
-type DraftAgentScreenProps = {
- isVisible?: boolean;
- onCreateFlowActiveChange?: (active: boolean) => void;
- forcedServerId?: string;
-};
-
-export function DraftAgentScreen({
- isVisible = true,
- onCreateFlowActiveChange,
- forcedServerId,
-}: DraftAgentScreenProps = {}) {
- return (
-
-
-
- );
-}
-
-function DraftAgentScreenContent({
- isVisible = true,
- onCreateFlowActiveChange,
- forcedServerId,
-}: DraftAgentScreenProps = {}) {
- const isFocused = useIsFocused();
- const { theme } = useUnistyles();
- const router = useRouter();
- const insets = useSafeAreaInsets();
- const daemons = useHosts();
- const runtime = getHostRuntimeStore();
- const runtimeVersion = useSyncExternalStore(
- (onStoreChange) => runtime.subscribeAll(onStoreChange),
- () => runtime.getVersion(),
- () => runtime.getVersion(),
- );
- const params = useLocalSearchParams();
-
- const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
- mode: "translate",
- });
-
- const forcedServerIdParam = forcedServerId?.trim();
- const resolvedServerId =
- forcedServerIdParam && forcedServerIdParam.length > 0
- ? forcedServerIdParam
- : getParamValue(params.serverId);
- const resolvedProvider = getValidProvider(getParamValue(params.provider));
- const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
- const resolvedModel = getParamValue(params.model);
- const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId);
- const resolvedWorkingDir = getParamValue(params.workingDir);
- const resolvedWorktreeMode = getParamValue(params.worktreeMode);
- const initialWorktreeMode =
- resolvedWorktreeMode === "create" || resolvedWorktreeMode === "attach"
- ? resolvedWorktreeMode
- : "none";
-
- const onlineServerIds = useMemo(() => {
- if (daemons.length === 0) return [];
- const out: string[] = [];
- for (const daemon of daemons) {
- const status = runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting";
- if (status === "online") out.push(daemon.serverId);
- }
- return out;
- }, [daemons, runtime, runtimeVersion]);
-
- const initialValues = useMemo((): CreateAgentInitialValues => {
- const values: CreateAgentInitialValues = {};
- if (resolvedWorkingDir) {
- values.workingDir = resolvedWorkingDir;
- }
- if (resolvedProvider) {
- values.provider = resolvedProvider;
- }
- if (resolvedMode) {
- values.modeId = resolvedMode;
- }
- if (resolvedModel) {
- values.model = resolvedModel;
- }
- if (resolvedThinkingOptionId) {
- values.thinkingOptionId = resolvedThinkingOptionId;
- }
- return values;
- }, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]);
-
- const draftIdRef = useRef(generateDraftId());
- const draftAgentIdRef = useRef(generateDraftId());
- const draftInput = useAgentInputDraft({
- draftKey: ({ selectedServerId }) =>
- buildDraftStoreKey({
- serverId: selectedServerId ?? "",
- agentId: draftAgentIdRef.current,
- draftId: draftIdRef.current,
- }),
- initialCwd: resolvedWorkingDir ?? "",
- composer: {
- initialServerId: resolvedServerId ?? null,
- initialValues,
- isVisible,
- onlineServerIds,
- },
- });
- const composerState = draftInput.composerState;
- if (!composerState) {
- throw new Error("Draft agent composer state is required");
- }
-
- const {
- selectedServerId,
- setSelectedServerIdFromUser,
- providerDefinitions,
- workingDir,
- setWorkingDirFromUser,
- modeOptions,
- isModelLoading,
- modelError,
- refreshProviderModels,
- persistFormPreferences,
- effectiveModelId,
- effectiveThinkingOptionId,
- commandDraftConfig,
- statusControls,
- } = composerState;
- const isMobile = useIsCompactFormFactor();
- const isExplorerOpen = usePanelStore((state) =>
- selectIsFileExplorerOpen(state, { isCompact: isMobile }),
- );
- const canOpenExplorerFromAgentView = usePanelStore(
- (state) =>
- state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
- );
- const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
- const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
- const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
- const toggleFileExplorerForCheckout = usePanelStore(
- (state) => state.toggleFileExplorerForCheckout,
- );
- const activateExplorerTabForCheckout = usePanelStore(
- (state) => state.activateExplorerTabForCheckout,
- );
-
- const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
- initialWorktreeMode,
- );
- const [baseBranch, setBaseBranch] = useState("");
- const [worktreeSlug, setWorktreeSlug] = useState("");
- const [selectedWorktreePath, setSelectedWorktreePath] = useState("");
- const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false);
- const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false);
- const [isBranchOpen, setIsBranchOpen] = useState(false);
- const [branchSearchQuery, setBranchSearchQuery] = useState("");
- const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
- const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState("");
- const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState("");
- const workingDirAnchorRef = useRef(null);
- const worktreeAnchorRef = useRef(null);
- const branchAnchorRef = useRef(null);
- const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
-
- useEffect(() => {
- const trimmed = branchSearchQuery.trim();
- const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180);
- return () => clearTimeout(timer);
- }, [branchSearchQuery]);
-
- useEffect(() => {
- const trimmed = workingDirSearchQuery.trim();
- const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180);
- return () => clearTimeout(timer);
- }, [workingDirSearchQuery]);
-
- const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
- addImagesRef.current?.(files);
- }, []);
-
- const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
- addImagesRef.current = addImages;
- }, []);
- const sessionAgents = useSessionStore((state) =>
- selectedServerId ? state.sessions[selectedServerId]?.agents : undefined,
- );
- const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId });
- const worktreePathLastCreatedAt = useMemo(() => {
- const map = new Map();
- if (!sessionAgents) {
- return map;
- }
- sessionAgents.forEach((agent) => {
- if (!agent.cwd) {
- return;
- }
- const ts = agent.createdAt.getTime();
- const prev = map.get(agent.cwd);
- if (!prev || ts > prev) {
- map.set(agent.cwd, ts);
- }
- });
- return map;
- }, [sessionAgents]);
- const agentWorkingDirSuggestions = useMemo(() => {
- const liveSources = sessionAgents
- ? Array.from(sessionAgents.values()).map((agent) => ({
- cwd: agent.cwd,
- createdAt: agent.createdAt,
- lastActivityAt: agent.lastActivityAt,
- }))
- : [];
- const fetchedSources = allAgents.map((agent) => ({
- cwd: agent.cwd,
- lastActivityAt: agent.lastActivityAt,
- }));
-
- return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources]);
- }, [allAgents, sessionAgents]);
-
- const runtimeClient = useHostRuntimeClient(selectedServerId ?? "");
- const isHostOnline = useHostRuntimeIsConnected(selectedServerId ?? "");
- const sessionClient = runtimeClient;
- const trimmedWorkingDir = workingDir.trim();
- const shouldInspectRepo = trimmedWorkingDir.length > 0;
- const canQuerySelectedHost = Boolean(selectedServerId) && Boolean(sessionClient) && isHostOnline;
-
- const checkoutStatusQuery = useQuery({
- queryKey: checkoutStatusQueryKey(selectedServerId ?? "", trimmedWorkingDir),
- queryFn: async () => {
- const client = sessionClient;
- if (!client) {
- throw new Error("Daemon client unavailable");
- }
- return await client.getCheckoutStatus(trimmedWorkingDir);
- },
- enabled: Boolean(trimmedWorkingDir) && canQuerySelectedHost,
- retry: false,
- staleTime: Infinity,
- refetchOnMount: false,
- refetchOnReconnect: false,
- refetchOnWindowFocus: false,
- });
-
- const checkout = checkoutStatusQuery.data ?? null;
- const checkoutQueryError =
- checkoutStatusQuery.error instanceof Error ? checkoutStatusQuery.error.message : null;
- const checkoutPayloadError = checkout?.error ? checkout.error.message : null;
- const isGitDirectory = checkoutStatusQuery.isSuccess && checkout?.isGit === true;
-
- const isNonGitDirectory =
- Boolean(trimmedWorkingDir) &&
- checkoutStatusQuery.isSuccess &&
- checkout?.isGit === false &&
- checkout?.error == null;
-
- const isDirectoryNotExists =
- checkoutStatusQuery.isError &&
- /does not exist|no such file or directory|ENOENT/i.test(checkoutQueryError ?? "");
-
- const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo
- ? "idle"
- : !canQuerySelectedHost
- ? "idle"
- : checkoutStatusQuery.isPending || checkoutStatusQuery.isFetching
- ? "loading"
- : checkoutStatusQuery.isError || Boolean(checkoutPayloadError)
- ? "error"
- : checkout?.isGit
- ? "ready"
- : "idle";
-
- const repoInfoError =
- (checkoutStatusQuery.isError ? checkoutQueryError : null) ?? checkoutPayloadError;
- const isCreateWorktree = worktreeMode === "create";
- const isAttachWorktree = worktreeMode === "attach";
-
- const worktreeListRoot = checkout?.isGit ? checkout.repoRoot : "";
- const worktreeListQuery = useQuery({
- queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot],
- queryFn: async () => {
- const client = sessionClient;
- if (!client) {
- throw new Error("Daemon client unavailable");
- }
- const payload = await client.getPaseoWorktreeList({
- repoRoot: worktreeListRoot || undefined,
- cwd: worktreeListRoot ? undefined : trimmedWorkingDir || undefined,
- });
- if (payload.error) {
- throw new Error(payload.error.message);
- }
- return payload.worktrees ?? [];
- },
- enabled:
- isGitDirectory && Boolean(worktreeListRoot) && canQuerySelectedHost && !isNonGitDirectory,
- retry: false,
- staleTime: Infinity,
- refetchOnMount: false,
- refetchOnReconnect: false,
- refetchOnWindowFocus: false,
- });
- const worktreeOptions = useMemo(() => {
- const options = (worktreeListQuery.data ?? []).map((worktree) => ({
- path: worktree.worktreePath,
- label: worktree.branchName ?? worktree.head ?? "Unknown branch",
- }));
- return options.sort((a, b) => {
- const aTs = worktreePathLastCreatedAt.get(a.path) ?? 0;
- const bTs = worktreePathLastCreatedAt.get(b.path) ?? 0;
- if (aTs !== bTs) {
- return bTs - aTs;
- }
- return a.label.localeCompare(b.label);
- });
- }, [worktreeListQuery.data, worktreePathLastCreatedAt]);
- const worktreeOptionsError =
- worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null;
- const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" =
- worktreeListQuery.isPending || worktreeListQuery.isFetching
- ? "loading"
- : worktreeListQuery.isError
- ? "error"
- : "ready";
- const attachWorktreeError =
- isAttachWorktree &&
- worktreeOptionsStatus === "ready" &&
- worktreeOptions.length > 0 &&
- !selectedWorktreePath
- ? "Select a worktree to attach"
- : null;
-
- const branchSuggestionsQuery = useQuery({
- queryKey: [
- "branchSuggestions",
- selectedServerId,
- trimmedWorkingDir,
- debouncedBranchSearchQuery,
- ],
- queryFn: async () => {
- const client = sessionClient;
- if (!client) {
- throw new Error("Daemon client unavailable");
- }
- const payload = await client.getBranchSuggestions({
- cwd: trimmedWorkingDir || ".",
- query: debouncedBranchSearchQuery || undefined,
- limit: 50,
- });
- if (payload.error) {
- throw new Error(payload.error);
- }
- return payload.branches ?? [];
- },
- enabled:
- isCreateWorktree &&
- isBranchOpen &&
- isGitDirectory &&
- !isNonGitDirectory &&
- Boolean(trimmedWorkingDir) &&
- canQuerySelectedHost,
- retry: false,
- staleTime: 15_000,
- });
-
- const directorySuggestionsQuery = useQuery({
- queryKey: ["directorySuggestions", selectedServerId, debouncedWorkingDirSearchQuery],
- queryFn: async () => {
- const client = sessionClient;
- if (!client) {
- throw new Error("Daemon client unavailable");
- }
- const payload = await client.getDirectorySuggestions({
- query: debouncedWorkingDirSearchQuery,
- limit: 50,
- includeDirectories: true,
- includeFiles: false,
- });
- if (payload.error) {
- throw new Error(payload.error);
- }
- if (payload.entries.length > 0) {
- return payload.entries
- .filter((entry) => entry.kind === "directory")
- .map((entry) => entry.path);
- }
- return payload.directories ?? [];
- },
- enabled: Boolean(debouncedWorkingDirSearchQuery) && canQuerySelectedHost,
- retry: false,
- staleTime: 15_000,
- });
-
- const validateWorktreeName = useCallback((name: string): { valid: boolean; error?: string } => {
- if (!name) {
- return { valid: true };
- }
- if (name.length > 100) {
- return {
- valid: false,
- error: "Worktree name too long (max 100 characters)",
- };
- }
- if (!/^[a-z0-9-/]+$/.test(name)) {
- return {
- valid: false,
- error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes",
- };
- }
- if (name.startsWith("-") || name.endsWith("-")) {
- return { valid: false, error: "Cannot start or end with a hyphen" };
- }
- if (name.includes("--")) {
- return { valid: false, error: "Cannot have consecutive hyphens" };
- }
- return { valid: true };
- }, []);
-
- const gitBlockingError = useMemo(() => {
- if (!isCreateWorktree || isNonGitDirectory) {
- return null;
- }
- if (!worktreeSlug) {
- return null;
- }
- const validation = validateWorktreeName(worktreeSlug);
- if (!validation.valid) {
- return `Invalid worktree name: ${
- validation.error ?? "Must use lowercase letters, numbers, or hyphens"
- }`;
- }
- return null;
- }, [isCreateWorktree, isNonGitDirectory, worktreeSlug, validateWorktreeName]);
-
- // Validate branch exists (checks local first, then remote)
- const branchValidationQuery = useQuery({
- queryKey: ["validateBranch", selectedServerId, trimmedWorkingDir, baseBranch],
- queryFn: async () => {
- const client = sessionClient;
- if (!client) {
- throw new Error("Daemon client unavailable");
- }
- return client.validateBranch({
- cwd: trimmedWorkingDir || ".",
- branchName: baseBranch,
- });
- },
- enabled:
- isCreateWorktree &&
- isGitDirectory &&
- !isNonGitDirectory &&
- Boolean(baseBranch) &&
- Boolean(trimmedWorkingDir) &&
- Boolean(sessionClient) &&
- isHostOnline,
- retry: false,
- staleTime: 30_000,
- });
-
- const baseBranchError = useMemo(() => {
- if (!isCreateWorktree || isNonGitDirectory) {
- return null;
- }
- if (!baseBranch) {
- return "Base branch is required";
- }
- // While validating, don't show error
- if (branchValidationQuery.isPending || branchValidationQuery.isFetching) {
- return null;
- }
- // If validation query errored, show generic error
- if (branchValidationQuery.isError) {
- return "Failed to validate branch";
- }
- // If validation completed and branch doesn't exist
- const validationResult = branchValidationQuery.data;
- if (validationResult && !validationResult.exists) {
- return `Branch "${baseBranch}" not found in repository`;
- }
- return null;
- }, [isCreateWorktree, isNonGitDirectory, baseBranch, branchValidationQuery]);
-
- const handleBaseBranchChange = useCallback((value: string) => {
- setBaseBranch(value);
- }, []);
-
- const handleSelectWorktreePath = useCallback((path: string) => {
- setSelectedWorktreePath(path);
- }, []);
-
- useEffect(() => {
- if (!isCreateWorktree || isNonGitDirectory) {
- return;
- }
- if (baseBranch) {
- return;
- }
- const current = checkout?.isGit ? checkout.currentBranch?.trim() : null;
- if (!current || current === "HEAD") {
- return;
- }
- if (current) {
- setBaseBranch(current);
- }
- }, [isCreateWorktree, isNonGitDirectory, baseBranch, checkout]);
-
- useEffect(() => {
- if (isNonGitDirectory && worktreeMode !== "none") {
- setWorktreeMode("none");
- setSelectedWorktreePath("");
- }
- }, [isNonGitDirectory, worktreeMode]);
-
- const selectedWorktreeLabel =
- worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? "";
- const explorerCwd = useMemo(
- () => (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim(),
- [isAttachWorktree, selectedWorktreePath, workingDir],
- );
- const draftExplorerCheckout = useMemo(() => {
- if (!selectedServerId || !explorerCwd) {
- return null;
- }
- return {
- serverId: selectedServerId,
- cwd: explorerCwd,
- isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true,
- };
- }, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]);
- const draftExplorerWorkspaceId = useSessionStore(
- useCallback(
- (state) =>
- resolveWorkspaceIdByExecutionDirectory({
- workspaces: selectedServerId
- ? state.sessions[selectedServerId]?.workspaces?.values()
- : null,
- workspaceDirectory: explorerCwd,
- }),
- [explorerCwd, selectedServerId],
- ),
- );
- const canOpenExplorer = draftExplorerCheckout !== null;
- const openExplorerForDraftCheckout = useCallback(() => {
- if (!draftExplorerCheckout) {
- return;
- }
- openFileExplorerForCheckout({
- isCompact: isMobile,
- checkout: draftExplorerCheckout,
- });
- }, [draftExplorerCheckout, isMobile, openFileExplorerForCheckout]);
- const handleToggleExplorer = useCallback(() => {
- if (!canOpenExplorer) {
- return;
- }
- toggleFileExplorerForCheckout({
- isCompact: isMobile,
- checkout: draftExplorerCheckout,
- });
- }, [canOpenExplorer, draftExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
- const explorerOpenGesture = useExplorerOpenGesture({
- enabled: isMobile && canOpenExplorerFromAgentView && canOpenExplorer,
- onOpen: openExplorerForDraftCheckout,
- });
- const handleDraftSidebarAction = useCallback(
- (action: KeyboardActionDefinition): boolean => {
- if (action.id !== "sidebar.toggle.right") {
- return false;
- }
- handleToggleExplorer();
- return true;
- },
- [handleToggleExplorer],
- );
- useKeyboardActionHandler({
- handlerId: `draft-agent-sidebar-actions:${draftIdRef.current}`,
- actions: ["sidebar.toggle.right"] as const,
- enabled: Boolean(isFocused && canOpenExplorer),
- priority: 100,
- isActive: () => true,
- handle: handleDraftSidebarAction,
- });
- const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0;
- const workingDirSearchError =
- directorySuggestionsQuery.error instanceof Error
- ? directorySuggestionsQuery.error.message
- : null;
- const workingDirSuggestionPaths = useMemo(
- () =>
- buildWorkingDirectorySuggestions({
- recommendedPaths: agentWorkingDirSuggestions,
- serverPaths: hasWorkingDirectorySearch ? (directorySuggestionsQuery.data ?? []) : [],
- query: workingDirSearchQuery,
- }),
- [
- agentWorkingDirSuggestions,
- directorySuggestionsQuery.data,
- hasWorkingDirectorySearch,
- workingDirSearchQuery,
- ],
- );
- const workingDirComboOptions = useMemo(
- () =>
- workingDirSuggestionPaths.map((path) => ({
- id: path,
- label: shortenPath(path),
- kind: "directory" as const,
- })),
- [workingDirSuggestionPaths],
- );
- const workingDirEmptyText = useMemo(() => {
- if (hasWorkingDirectorySearch) {
- if (workingDirSearchError) {
- return "Failed to search directories on this host.";
- }
- return "No directories match your search.";
- }
-
- return agentWorkingDirSuggestions.length > 0
- ? "No agent directories match your search."
- : "No agent directories match your search.";
- }, [agentWorkingDirSuggestions.length, hasWorkingDirectorySearch, workingDirSearchError]);
- const displayWorkingDir = shortenPath(workingDir);
- const worktreeTriggerValue =
- worktreeMode === "create" ? "Create new worktree" : selectedWorktreeLabel || "Select worktree";
- const worktreeComboOptions = useMemo(
- () => [
- {
- id: "__none__",
- label: "None",
- },
- {
- id: "__create_new__",
- label: "Create new worktree",
- },
- ...worktreeOptions.map((option) => ({
- id: option.path,
- label: option.label,
- description: shortenPath(option.path),
- })),
- ],
- [worktreeOptions],
- );
-
- const branchComboOptions = useMemo(() => {
- const options = buildBranchComboOptions({
- suggestedBranches: branchSuggestionsQuery.data ?? [],
- currentBranch: checkout?.isGit ? checkout.currentBranch : null,
- baseRef: checkout?.isGit ? checkout.baseRef : null,
- typedBaseBranch: baseBranch,
- worktreeBranchLabels: worktreeOptions.map((option) => option.label),
- });
-
- const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? "";
- if (!normalizedQuery) {
- return options;
- }
-
- return options.sort((a, b) => {
- const aLower = a.label.toLowerCase();
- const bLower = b.label.toLowerCase();
- const aPrefix = aLower.startsWith(normalizedQuery);
- const bPrefix = bLower.startsWith(normalizedQuery);
- if (aPrefix !== bPrefix) {
- return aPrefix ? -1 : 1;
- }
- return aLower.localeCompare(bLower);
- });
- }, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
-
- const createAgentClient = sessionClient;
-
- const {
- formErrorMessage,
- isSubmitting,
- optimisticStreamItems,
- draftAgent,
- handleCreateFromInput,
- } = useDraftAgentCreateFlow({
- draftId: draftIdRef.current,
- getPendingServerId: () => selectedServerId,
- validateBeforeSubmit: ({ text }) => {
- const trimmedPath = workingDir.trim();
- if (!trimmedPath) {
- return "Working directory is required";
- }
- if (isDirectoryNotExists) {
- return "Working directory does not exist on the selected host";
- }
- if (!text.trim()) {
- return "Initial prompt is required";
- }
- if (!selectedServerId) {
- return "No host selected";
- }
- if (providerDefinitions.length === 0) {
- return "No available providers on the selected host";
- }
- if (!composerState.selectedProvider) {
- return "Select a model";
- }
- if (gitBlockingError) {
- return gitBlockingError;
- }
- if (isModelLoading) {
- return "Model defaults are still loading";
- }
- if (!effectiveModelId) {
- return "No model is available for the selected provider";
- }
- if (isAttachWorktree && !selectedWorktreePath) {
- return "Select a worktree to attach";
- }
- if (baseBranchError) {
- return baseBranchError;
- }
- if (!createAgentClient) {
- return "Host is not connected";
- }
- return null;
- },
- onBeforeSubmit: () => {
- void persistFormPreferences();
- if (isWeb) {
- (document.activeElement as HTMLElement | null)?.blur?.();
- }
- Keyboard.dismiss();
- },
- onCreateStart: () => {
- onCreateFlowActiveChange?.(true);
- },
- onCreateError: () => {
- onCreateFlowActiveChange?.(false);
- },
- buildDraftAgent: (attempt) => {
- const serverId = selectedServerId ?? "";
- const now = attempt.timestamp;
- const cwd =
- (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
- ".";
- const provider = composerState.selectedProvider;
- if (!provider) {
- throw new Error("Select a model");
- }
- const model = effectiveModelId || null;
- const thinkingOptionId = effectiveThinkingOptionId || null;
- const modeId =
- composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
- ? composerState.selectedMode
- : null;
-
- return {
- serverId,
- id: draftAgentIdRef.current,
- provider,
- status: "running",
- createdAt: now,
- updatedAt: now,
- lastUserMessageAt: now,
- lastActivityAt: now,
- capabilities: DRAFT_CAPABILITIES,
- currentModeId: modeId,
- availableModes: [],
- pendingPermissions: [],
- persistence: null,
- runtimeInfo: {
- provider,
- sessionId: null,
- model,
- modeId,
- },
- title: "New agent",
- cwd,
- model,
- thinkingOptionId,
- labels: {},
- };
- },
- createRequest: async ({ attempt, text, images, attachments }) => {
- const trimmedPath = workingDir.trim();
- const resolvedWorkingDir =
- isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
-
- const modeId =
- composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
- ? composerState.selectedMode
- : undefined;
- const provider = composerState.selectedProvider;
- if (!provider) {
- throw new Error("Select a model");
- }
- const config: AgentSessionConfig = {
- provider,
- cwd: resolvedWorkingDir,
- ...(modeId ? { modeId } : {}),
- ...(effectiveModelId ? { model: effectiveModelId } : {}),
- ...(effectiveThinkingOptionId ? { thinkingOptionId: effectiveThinkingOptionId } : {}),
- };
-
- const effectiveBaseBranch = baseBranch.trim();
- const effectiveWorktreeSlug =
- isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug;
- if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) {
- setWorktreeSlug(effectiveWorktreeSlug);
- }
-
- const gitOptions =
- isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug
- ? {
- createWorktree: true,
- createNewBranch: true,
- newBranchName: effectiveWorktreeSlug,
- worktreeSlug: effectiveWorktreeSlug,
- baseBranch: effectiveBaseBranch,
- }
- : undefined;
-
- const client = createAgentClient;
- if (!client) {
- throw new Error("Host is not connected");
- }
-
- const imagesData = await encodeImages(images);
- const result = await client.createAgent({
- config,
- initialPrompt: text,
- clientMessageId: attempt.clientMessageId,
- ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
- ...(attachments && attachments.length > 0 ? { attachments } : {}),
- git: gitOptions,
- });
-
- if (!result.id || !selectedServerId) {
- throw new Error("Failed to create agent");
- }
-
- useSessionStore.getState().setAgents(selectedServerId, (prev) => {
- const next = new Map(prev);
- next.set(result.id, normalizeAgentSnapshot(result, selectedServerId));
- return next;
- });
-
- const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : "";
- const configuredWorkingDir = config.cwd.trim();
- const workspaceId = resolveWorkspaceIdByExecutionDirectory({
- workspaces: useSessionStore.getState().sessions[selectedServerId]?.workspaces?.values(),
- workspaceDirectory: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir,
- });
-
- return {
- agentId: result.id,
- result: {
- id: result.id,
- workspaceId,
- },
- };
- },
- onCreateSuccess: ({ result }) => {
- if (!result.workspaceId) {
- router.replace(buildHostAgentDetailRoute(selectedServerId as string, result.id) as any);
- return;
- }
- const route = prepareWorkspaceTab({
- serverId: selectedServerId as string,
- workspaceId: result.workspaceId,
- target: { kind: "agent", agentId: result.id },
- });
- router.replace(route as any);
- },
- });
- useEffect(() => {
- if (!isFocused || !draftExplorerCheckout) {
- return;
- }
- activateExplorerTabForCheckout(draftExplorerCheckout);
- }, [activateExplorerTabForCheckout, draftExplorerCheckout, isFocused]);
- useEffect(() => {
- if (!isFocused || canOpenExplorer || !isExplorerOpen) {
- return;
- }
- if (isMobile) {
- showMobileAgent();
- return;
- }
- closeDesktopFileExplorer();
- }, [
- canOpenExplorer,
- closeDesktopFileExplorer,
- isExplorerOpen,
- isFocused,
- isMobile,
- showMobileAgent,
- ]);
- if (daemons.length === 0) {
- return (
- {
- setSelectedServerIdFromUser(profile.serverId);
- }}
- />
- );
- }
-
- const explorerServerId = draftExplorerCheckout?.serverId ?? null;
- const explorerIsGit = draftExplorerCheckout?.isGit ?? false;
- const mainContent = (
-
-
-
-
-
-
-
- {!isMobile && canOpenExplorer ? (
-
-
-
- ) : null}
-
-
-
-
- {isSubmitting && draftAgent && selectedServerId ? (
-
-
-
- ) : (
-
-
-
- setIsWorkingDirOpen(true)}
- icon={
-
- }
- showLabel={false}
- valueEllipsizeMode="middle"
- testID="working-directory-select"
- />
-
- {isDirectoryNotExists && (
-
-
- Directory does not exist on the selected host
-
-
- )}
- {isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
-
- ) : null}
- {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
-
- setIsWorktreePickerOpen(true)}
- icon={
-
- }
- showLabel={false}
- valueEllipsizeMode="middle"
- testID="worktree-select-trigger"
- />
- {worktreeMode === "create" ? (
- setIsBranchOpen(true)}
- disabled={repoInfoStatus === "loading"}
- icon={
-
- }
- showLabel={false}
- testID="worktree-base-branch-trigger"
- />
- ) : null}
-
- ) : null}
- {baseBranchError ? (
- {baseBranchError}
- ) : null}
- {repoInfoError ? (
- {repoInfoError}
- ) : null}
- {gitBlockingError ? (
- {gitBlockingError}
- ) : null}
- {attachWorktreeError ? (
- {attachWorktreeError}
- ) : null}
- {worktreeOptionsError ? (
- {worktreeOptionsError}
- ) : null}
-
- {
- if (id === "__create_new__") {
- setWorktreeMode("create");
- if (!worktreeSlug) {
- setWorktreeSlug(createNameId());
- }
- setSelectedWorktreePath("");
- return;
- }
- if (id === "__none__") {
- setWorktreeMode("none");
- setSelectedWorktreePath("");
- return;
- }
- handleSelectWorktreePath(id);
- setWorktreeMode("attach");
- }}
- title="Select worktree"
- searchPlaceholder="Search worktrees..."
- open={isWorktreePickerOpen}
- onOpenChange={setIsWorktreePickerOpen}
- emptyText="No worktrees found"
- anchorRef={worktreeAnchorRef}
- />
-
-
-
- {
- setIsBranchOpen(nextOpen);
- if (!nextOpen) {
- setBranchSearchQuery("");
- }
- }}
- anchorRef={branchAnchorRef}
- />
-
- {formErrorMessage ? (
-
- {formErrorMessage}
-
- ) : null}
-
- )}
-
-
-
-
-
-
- {!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? (
-
- ) : null}
-
-
- );
-
- return (
-
- <>
- {isMobile ? (
-
- {mainContent}
-
- ) : (
- mainContent
- )}
-
- {isMobile && explorerServerId && draftExplorerCheckout ? (
-
- ) : null}
- >
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- container: {
- position: "relative",
- flex: 1,
- backgroundColor: theme.colors.surface0,
- },
- outerContainer: {
- flex: 1,
- flexDirection: "row",
- },
- agentPanel: {
- flex: 1,
- },
- menuToggleContainer: {
- paddingHorizontal: theme.spacing[2],
- paddingTop: theme.spacing[2],
- },
- menuToggleRow: {
- width: "100%",
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- },
- menuButton: {
- marginLeft: theme.spacing[2],
- },
- contentContainer: {
- flex: 1,
- overflow: "hidden",
- },
- scrollView: {
- flex: 1,
- },
- inputAreaWrapper: {
- backgroundColor: theme.colors.surface0,
- },
- streamContainer: {
- flex: 1,
- },
- configScrollContent: {
- flexGrow: 1,
- justifyContent: "flex-end",
- },
- configSection: {
- paddingHorizontal: {
- xs: theme.spacing[4],
- md: theme.spacing[0],
- },
- paddingTop: theme.spacing[3],
- paddingBottom: theme.spacing[4],
- gap: theme.spacing[2],
- maxWidth: MAX_CONTENT_WIDTH,
- alignSelf: "center",
- width: "100%",
- },
- topSelectorRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- stackedSelectorGroup: {
- gap: theme.spacing[2],
- },
- fullSelector: {
- width: "100%",
- },
- formSeparator: {
- height: theme.borderWidth[1],
- backgroundColor: {
- xs: theme.colors.surface1,
- sm: theme.colors.surface1,
- md: theme.colors.border,
- },
- marginHorizontal: theme.spacing[1],
- marginVertical: theme.spacing[2],
- },
- topSelectorPrimary: {
- flex: 7,
- },
- topSelectorSecondary: {
- flex: 3,
- },
- halfSelector: {
- flex: 1,
- },
- errorContainer: {
- paddingHorizontal: theme.spacing[4],
- paddingVertical: theme.spacing[3],
- marginHorizontal: theme.spacing[0],
- marginBottom: theme.spacing[2],
- borderRadius: theme.borderRadius.lg,
- backgroundColor: theme.colors.destructive,
- },
- errorText: {
- color: theme.colors.destructiveForeground,
- fontSize: theme.fontSize.base,
- },
- warningContainer: {
- paddingHorizontal: theme.spacing[3],
- paddingVertical: theme.spacing[2],
- borderRadius: theme.borderRadius.md,
- backgroundColor: theme.colors.palette.yellow[400],
- },
- warningText: {
- color: "#000000",
- fontSize: theme.fontSize.base,
- fontWeight: theme.fontWeight.semibold,
- },
- errorInlineText: {
- color: theme.colors.palette.red[500],
- fontSize: theme.fontSize.base,
- },
-}));
diff --git a/packages/app/src/stores/section-order-store.ts b/packages/app/src/stores/section-order-store.ts
deleted file mode 100644
index 1b73d1e2f..000000000
--- a/packages/app/src/stores/section-order-store.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { create } from "zustand";
-import { persist, createJSONStorage } from "zustand/middleware";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-
-interface SectionOrderState {
- /** Ordered array of project keys. Projects not in this list appear at the end in their natural order. */
- projectOrder: string[];
-
- /** Set the full order of project keys */
- setProjectOrder: (order: string[]) => void;
-
- /** Move a project to a new index */
- moveProject: (fromIndex: number, toIndex: number) => void;
-}
-
-export const useSectionOrderStore = create()(
- persist(
- (set) => ({
- projectOrder: [],
-
- setProjectOrder: (order) => set({ projectOrder: order }),
-
- moveProject: (fromIndex, toIndex) =>
- set((state) => {
- const newOrder = [...state.projectOrder];
- const [removed] = newOrder.splice(fromIndex, 1);
- if (removed !== undefined) {
- newOrder.splice(toIndex, 0, removed);
- }
- return { projectOrder: newOrder };
- }),
- }),
- {
- name: "section-order",
- storage: createJSONStorage(() => AsyncStorage),
- partialize: (state) => ({
- projectOrder: state.projectOrder,
- }),
- },
- ),
-);
-
-/**
- * Sort project groups according to persisted order.
- * Projects not in the persisted order appear at the end in their original order.
- */
-export function sortProjectsByStoredOrder(
- groups: T[],
- storedOrder: string[],
-): T[] {
- if (storedOrder.length === 0) {
- return groups;
- }
-
- const orderMap = new Map(storedOrder.map((key, index) => [key, index]));
-
- return [...groups].sort((a, b) => {
- const aIndex = orderMap.get(a.projectKey);
- const bIndex = orderMap.get(b.projectKey);
-
- // Both have stored order - sort by stored order
- if (aIndex !== undefined && bIndex !== undefined) {
- return aIndex - bIndex;
- }
-
- // Only a has stored order - a comes first
- if (aIndex !== undefined) {
- return -1;
- }
-
- // Only b has stored order - b comes first
- if (bIndex !== undefined) {
- return 1;
- }
-
- // Neither has stored order - maintain original order (stable sort)
- return 0;
- });
-}
diff --git a/packages/app/src/utils/agent-display-info.ts b/packages/app/src/utils/agent-display-info.ts
deleted file mode 100644
index 71ed4f9bd..000000000
--- a/packages/app/src/utils/agent-display-info.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
-
-/**
- * Derives the branch label to display for an agent.
- * Returns null if there's no branch to show (not a git repo, or on the base branch).
- */
-export function deriveBranchLabel(checkout: CheckoutStatusPayload | null): string | null {
- if (!checkout || !checkout.isGit) {
- return null;
- }
- const currentBranch: string | null = checkout.currentBranch ?? null;
- const baseRef: string | null = checkout.baseRef ?? null;
- if (!currentBranch) {
- return null;
- }
- if (currentBranch === "HEAD") {
- return null;
- }
- if (baseRef && currentBranch === baseRef) {
- return null;
- }
- return currentBranch;
-}
-
-/**
- * Derives the project path to display for an agent.
- * If inside a Paseo worktree, shows just the worktree-relative path.
- * Otherwise uses the repo root or cwd.
- */
-export function deriveProjectPath(cwd: string, checkout: CheckoutStatusPayload | null): string {
- const basePath = checkout?.isGit ? (checkout.repoRoot ?? cwd) : cwd;
- const worktreeMarker = ".paseo/worktrees/";
- const idx = basePath.indexOf(worktreeMarker);
- if (idx !== -1) {
- const afterMarker = basePath.slice(idx + worktreeMarker.length);
- return afterMarker;
- }
- return basePath;
-}
diff --git a/packages/app/src/utils/agent-status.ts b/packages/app/src/utils/agent-status.ts
deleted file mode 100644
index dc20e0fcf..000000000
--- a/packages/app/src/utils/agent-status.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { Agent } from "@/contexts/session-context";
-
-type AgentStatus = Agent["status"];
-
-const STATUS_COLOR_MAP: Record = {
- initializing: "#f59e0b",
- idle: "#22c55e",
- running: "#3b82f6",
- error: "#ef4444",
- closed: "#6b7280",
-};
-
-const STATUS_LABEL_MAP: Record = {
- initializing: "Initializing",
- idle: "Idle",
- running: "Running",
- error: "Error",
- closed: "Closed",
-};
-
-export function getAgentStatusColor(status: AgentStatus): string {
- return STATUS_COLOR_MAP[status] ?? "#9ca3af";
-}
-
-export function getAgentStatusLabel(status: AgentStatus): string {
- return STATUS_LABEL_MAP[status] ?? "Unknown";
-}
diff --git a/packages/app/src/utils/analytics.ts b/packages/app/src/utils/analytics.ts
deleted file mode 100644
index 7ad5bc91f..000000000
--- a/packages/app/src/utils/analytics.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-type OfflineAction = "create" | "resume" | "dictation" | "import_list";
-
-type AnalyticsEvent =
- | {
- type: "daemon_active_changed";
- daemonId: string;
- previousDaemonId: string | null;
- source?: string;
- }
- | {
- type: "offline_daemon_action_attempt";
- action: OfflineAction;
- daemonId: string | null;
- status: string | null;
- reason?: string | null;
- };
-
-export function trackAnalyticsEvent(_event: AnalyticsEvent) {
- // Placeholder until a real analytics sink is wired in.
-}
diff --git a/packages/app/src/utils/scroll-jank-investigation.ts b/packages/app/src/utils/scroll-jank-investigation.ts
deleted file mode 100644
index 2582a79b5..000000000
--- a/packages/app/src/utils/scroll-jank-investigation.ts
+++ /dev/null
@@ -1,565 +0,0 @@
-import { isDev, isWeb } from "@/constants/platform";
-
-type ListenerStats = {
- adds: number;
- removes: number;
- active: number;
-};
-
-type TimerStats = {
- created: number;
- fired: number;
- cleared: number;
- active: number;
-};
-
-type WebSocketStats = {
- created: number;
- opened: number;
- closed: number;
- errored: number;
- active: number;
-};
-
-type ComponentStats = {
- mounts: number;
- unmounts: number;
- renders: number;
- scrollEvents: number;
- nearBottomTransitions: number;
- metricUpdates: number;
- itemRenderCalls: number;
- wheelAttach: number;
- wheelDetach: number;
- inputChanges: number;
- keyPresses: number;
- lastRenderAtMs: number;
-};
-
-type ScrollInvestigationStore = {
- markRender: (componentId: string) => void;
- markEvent: (
- componentId: string,
- event:
- | "mount"
- | "unmount"
- | "scrollEvent"
- | "nearBottomTransition"
- | "metricUpdate"
- | "itemRenderCall"
- | "wheelAttach"
- | "wheelDetach"
- | "inputChange"
- | "keyPress",
- ) => void;
- snapshot: () => {
- listeners: {
- byType: Record;
- byCallsite: Record;
- activeUniqueKeys: number;
- activeByTypeAndTarget: Record>;
- };
- timers: {
- timeout: TimerStats;
- interval: TimerStats;
- raf: TimerStats;
- };
- websockets: {
- totals: WebSocketStats;
- activeByUrl: Record;
- };
- components: Record;
- };
- printSnapshot: (label?: string) => void;
- _installedAtMs: number;
-};
-
-type ScrollInvestigationGlobal = typeof globalThis & {
- __PASEO_SCROLL_JANK_INVESTIGATION__?: ScrollInvestigationStore;
- __PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__?: boolean;
-};
-
-const TRACKED_EVENT_TYPES = new Set([
- "wheel",
- "scroll",
- "pointermove",
- "pointerup",
- "pointercancel",
-]);
-
-const SOURCE_LABEL = "[ScrollJankInvestigation]";
-
-function shouldInstall(): boolean {
- const runtime = globalThis as ScrollInvestigationGlobal;
- return isWeb && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__;
-}
-
-function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
- if (typeof options === "boolean") {
- return options;
- }
- return Boolean(options?.capture);
-}
-
-function describeEventTarget(target: EventTarget): string {
- const element = target as Element;
- if (element && typeof element === "object" && "tagName" in element) {
- const tagName = (element.tagName || "unknown").toLowerCase();
- const testId = element.getAttribute?.("data-testid");
- const role = element.getAttribute?.("role");
- const id = (element as HTMLElement).id || null;
- const className = (element as HTMLElement).className;
- const classLabel =
- typeof className === "string" && className.trim().length > 0
- ? className.trim().split(/\s+/).slice(0, 2).join(".")
- : null;
- const connectivityLabel =
- typeof (element as Node).isConnected === "boolean"
- ? (element as Node).isConnected
- ? "[connected]"
- : "[detached]"
- : null;
-
- return [
- tagName,
- id ? `#${id}` : null,
- testId ? `[data-testid=${testId}]` : null,
- role ? `[role=${role}]` : null,
- classLabel ? `.${classLabel}` : null,
- connectivityLabel,
- ]
- .filter(Boolean)
- .join("");
- }
-
- const ctorName = (target as { constructor?: { name?: string } }).constructor?.name;
- return ctorName || "unknown-target";
-}
-
-function inferCallsite(): string {
- const stack = new Error().stack;
- if (!stack) {
- return "unknown";
- }
- const frames = stack.split("\n");
- for (const raw of frames.slice(2)) {
- const line = raw.trim();
- if (!line) {
- continue;
- }
- if (line.includes("scroll-jank-investigation")) {
- continue;
- }
- if (line.includes("patchedAddEventListener")) {
- continue;
- }
- return line;
- }
- return "unknown";
-}
-
-function ensureListenerStats(map: Map, type: string): ListenerStats {
- const existing = map.get(type);
- if (existing) {
- return existing;
- }
- const next: ListenerStats = { adds: 0, removes: 0, active: 0 };
- map.set(type, next);
- return next;
-}
-
-function ensureComponentStats(
- map: Map,
- componentId: string,
-): ComponentStats {
- const existing = map.get(componentId);
- if (existing) {
- return existing;
- }
- const next: ComponentStats = {
- mounts: 0,
- unmounts: 0,
- renders: 0,
- scrollEvents: 0,
- nearBottomTransitions: 0,
- metricUpdates: 0,
- itemRenderCalls: 0,
- wheelAttach: 0,
- wheelDetach: 0,
- inputChanges: 0,
- keyPresses: 0,
- lastRenderAtMs: 0,
- };
- map.set(componentId, next);
- return next;
-}
-
-export function installScrollJankInvestigation(): void {
- if (!shouldInstall()) {
- return;
- }
-
- const runtime = globalThis as ScrollInvestigationGlobal;
- if (runtime.__PASEO_SCROLL_JANK_INVESTIGATION__) {
- return;
- }
-
- const targetIds = new WeakMap