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(); - const listenerIds = new WeakMap(); - const activeListenerKeys = new Set(); - const activeListenerMeta = new Map(); - const listenerStatsByType = new Map(); - const listenerCallsiteCount = new Map(); - const componentStatsById = new Map(); - const activeWsByUrl = new Map(); - const timeoutHandles = new Map(); - const intervalHandles = new Map(); - const rafHandles = new Map(); - let nextTargetId = 1; - let nextListenerId = 1; - - const timerStats = { - timeout: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats, - interval: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats, - raf: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats, - }; - const websocketStats: WebSocketStats = { - created: 0, - opened: 0, - closed: 0, - errored: 0, - active: 0, - }; - - const eventTargetProto = EventTarget.prototype as EventTarget & { - addEventListener: EventTarget["addEventListener"]; - removeEventListener: EventTarget["removeEventListener"]; - }; - const nativeAddEventListener = eventTargetProto.addEventListener; - const nativeRemoveEventListener = eventTargetProto.removeEventListener; - - function getTargetId(target: EventTarget): string { - const targetObj = target as unknown as object; - const existing = targetIds.get(targetObj); - if (existing) { - return String(existing); - } - const next = nextTargetId++; - targetIds.set(targetObj, next); - return String(next); - } - - function getListenerId(listener: EventListenerOrEventListenerObject): string { - const listenerObj = listener as unknown as object; - const existing = listenerIds.get(listenerObj); - if (existing) { - return String(existing); - } - const next = nextListenerId++; - listenerIds.set(listenerObj, next); - return String(next); - } - - function toListenerKey( - target: EventTarget, - type: string, - listener: EventListenerOrEventListenerObject, - options?: AddEventListenerOptions | boolean, - ): string { - return [ - getTargetId(target), - type, - normalizeCapture(options) ? "capture" : "bubble", - getListenerId(listener), - ].join("|"); - } - - eventTargetProto.addEventListener = function patchedAddEventListener( - this: EventTarget, - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: AddEventListenerOptions | boolean, - ): void { - nativeAddEventListener.call(this, type, listener as any, options as any); - if (!listener) { - return; - } - const stats = ensureListenerStats(listenerStatsByType, type); - stats.adds += 1; - - const key = toListenerKey(this, type, listener, options); - if (!activeListenerKeys.has(key)) { - activeListenerKeys.add(key); - stats.active += 1; - activeListenerMeta.set(key, { - type, - target: describeEventTarget(this), - }); - } - - if (TRACKED_EVENT_TYPES.has(type)) { - const callsite = inferCallsite(); - const metricKey = `${type} :: ${callsite}`; - listenerCallsiteCount.set(metricKey, (listenerCallsiteCount.get(metricKey) ?? 0) + 1); - } - }; - - eventTargetProto.removeEventListener = function patchedRemoveEventListener( - this: EventTarget, - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: EventListenerOptions | boolean, - ): void { - nativeRemoveEventListener.call(this, type, listener as any, options as any); - if (!listener) { - return; - } - const stats = ensureListenerStats(listenerStatsByType, type); - stats.removes += 1; - - const key = toListenerKey(this, type, listener, options); - if (activeListenerKeys.delete(key)) { - stats.active = Math.max(0, stats.active - 1); - activeListenerMeta.delete(key); - } - }; - - const nativeSetTimeout = globalThis.setTimeout.bind(globalThis); - const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis); - const nativeSetInterval = globalThis.setInterval.bind(globalThis); - const nativeClearInterval = globalThis.clearInterval.bind(globalThis); - const nativeRequestAnimationFrame = globalThis.requestAnimationFrame.bind(globalThis); - const nativeCancelAnimationFrame = globalThis.cancelAnimationFrame.bind(globalThis); - - globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => { - timerStats.timeout.created += 1; - let timeoutId = -1; - const wrapped = - typeof handler === "function" - ? (...handlerArgs: unknown[]) => { - if (timeoutHandles.delete(timeoutId)) { - timerStats.timeout.fired += 1; - timerStats.timeout.active = timeoutHandles.size; - } - return handler(...handlerArgs); - } - : handler; - - timeoutId = nativeSetTimeout(wrapped, timeout, ...(args as any[])) as unknown as number; - timeoutHandles.set(timeoutId, true); - timerStats.timeout.active = timeoutHandles.size; - return timeoutId as unknown as ReturnType; - }) as unknown as typeof setTimeout; - - globalThis.clearTimeout = ((timeoutId?: number) => { - if (typeof timeoutId === "number" && timeoutHandles.delete(timeoutId)) { - timerStats.timeout.cleared += 1; - timerStats.timeout.active = timeoutHandles.size; - } - return nativeClearTimeout(timeoutId); - }) as typeof clearTimeout; - - globalThis.setInterval = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => { - timerStats.interval.created += 1; - let intervalId = -1; - const wrapped = - typeof handler === "function" - ? (...handlerArgs: unknown[]) => { - timerStats.interval.fired += 1; - return handler(...handlerArgs); - } - : handler; - - intervalId = nativeSetInterval(wrapped, timeout, ...(args as any[])) as unknown as number; - intervalHandles.set(intervalId, true); - timerStats.interval.active = intervalHandles.size; - return intervalId as unknown as ReturnType; - }) as unknown as typeof setInterval; - - globalThis.clearInterval = ((intervalId?: number) => { - if (typeof intervalId === "number" && intervalHandles.delete(intervalId)) { - timerStats.interval.cleared += 1; - timerStats.interval.active = intervalHandles.size; - } - return nativeClearInterval(intervalId); - }) as typeof clearInterval; - - globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { - timerStats.raf.created += 1; - let rafId = -1; - const wrapped = (timestamp: number) => { - if (rafHandles.delete(rafId)) { - timerStats.raf.fired += 1; - timerStats.raf.active = rafHandles.size; - } - callback(timestamp); - }; - rafId = nativeRequestAnimationFrame(wrapped) as unknown as number; - rafHandles.set(rafId, true); - timerStats.raf.active = rafHandles.size; - return rafId as unknown as ReturnType; - }) as typeof requestAnimationFrame; - - globalThis.cancelAnimationFrame = ((rafId: number) => { - if (rafHandles.delete(rafId)) { - timerStats.raf.cleared += 1; - timerStats.raf.active = rafHandles.size; - } - return nativeCancelAnimationFrame(rafId); - }) as typeof cancelAnimationFrame; - - const NativeWebSocket = globalThis.WebSocket; - if (typeof NativeWebSocket === "function") { - class InstrumentedWebSocket extends NativeWebSocket { - constructor(url: string | URL, protocols?: string | string[]) { - if (protocols === undefined) { - super(url); - } else { - super(url, protocols); - } - const urlKey = String(url); - websocketStats.created += 1; - websocketStats.active += 1; - activeWsByUrl.set(urlKey, (activeWsByUrl.get(urlKey) ?? 0) + 1); - - const handleOpen = () => { - websocketStats.opened += 1; - }; - const handleError = () => { - websocketStats.errored += 1; - }; - const handleClose = () => { - websocketStats.closed += 1; - websocketStats.active = Math.max(0, websocketStats.active - 1); - const current = activeWsByUrl.get(urlKey) ?? 0; - if (current <= 1) { - activeWsByUrl.delete(urlKey); - } else { - activeWsByUrl.set(urlKey, current - 1); - } - this.removeEventListener("open", handleOpen); - this.removeEventListener("error", handleError); - this.removeEventListener("close", handleClose); - }; - - this.addEventListener("open", handleOpen); - this.addEventListener("error", handleError); - this.addEventListener("close", handleClose); - } - } - globalThis.WebSocket = InstrumentedWebSocket as typeof WebSocket; - } - - const store: ScrollInvestigationStore = { - markRender(componentId: string) { - const stats = ensureComponentStats(componentStatsById, componentId); - stats.renders += 1; - stats.lastRenderAtMs = performance.now(); - }, - markEvent(componentId: string, event) { - const stats = ensureComponentStats(componentStatsById, componentId); - switch (event) { - case "mount": - stats.mounts += 1; - return; - case "unmount": - stats.unmounts += 1; - return; - case "scrollEvent": - stats.scrollEvents += 1; - return; - case "nearBottomTransition": - stats.nearBottomTransitions += 1; - return; - case "metricUpdate": - stats.metricUpdates += 1; - return; - case "itemRenderCall": - stats.itemRenderCalls += 1; - return; - case "wheelAttach": - stats.wheelAttach += 1; - return; - case "wheelDetach": - stats.wheelDetach += 1; - return; - case "inputChange": - stats.inputChanges += 1; - return; - case "keyPress": - stats.keyPresses += 1; - return; - default: - return; - } - }, - snapshot() { - const activeByTypeAndTarget: Record> = {}; - for (const { type, target } of activeListenerMeta.values()) { - const existingByType = activeByTypeAndTarget[type] ?? {}; - existingByType[target] = (existingByType[target] ?? 0) + 1; - activeByTypeAndTarget[type] = existingByType; - } - return { - listeners: { - byType: Object.fromEntries(listenerStatsByType.entries()), - byCallsite: Object.fromEntries(listenerCallsiteCount.entries()), - activeUniqueKeys: activeListenerKeys.size, - activeByTypeAndTarget, - }, - timers: { - timeout: { ...timerStats.timeout, active: timeoutHandles.size }, - interval: { ...timerStats.interval, active: intervalHandles.size }, - raf: { ...timerStats.raf, active: rafHandles.size }, - }, - websockets: { - totals: { ...websocketStats }, - activeByUrl: Object.fromEntries(activeWsByUrl.entries()), - }, - components: Object.fromEntries(componentStatsById.entries()), - }; - }, - printSnapshot(label?: string) { - console.log(`${SOURCE_LABEL} ${label ?? "snapshot"}`, this.snapshot()); - }, - _installedAtMs: Date.now(), - }; - - runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ = store; - console.log( - `${SOURCE_LABEL} installed`, - "Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()", - ); -} - -function getStore(): ScrollInvestigationStore | null { - const runtime = globalThis as ScrollInvestigationGlobal; - return runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ ?? null; -} - -export function markScrollInvestigationRender(componentId: string): void { - if (!shouldInstall()) { - return; - } - getStore()?.markRender(componentId); -} - -export function markScrollInvestigationEvent( - componentId: string, - event: - | "mount" - | "unmount" - | "scrollEvent" - | "nearBottomTransition" - | "metricUpdate" - | "itemRenderCall" - | "wheelAttach" - | "wheelDetach" - | "inputChange" - | "keyPress", -): void { - if (!shouldInstall()) { - return; - } - getStore()?.markEvent(componentId, event); -} diff --git a/packages/app/src/utils/thinking-tone.ts b/packages/app/src/utils/thinking-tone.ts deleted file mode 100644 index 87297c77b..000000000 --- a/packages/app/src/utils/thinking-tone.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Asset } from "expo-asset"; -import { File } from "expo-file-system"; -import { isWeb } from "@/constants/platform"; -export { parsePcm16Wav, type Pcm16Wav } from "@/utils/pcm16-wav"; - -export const THINKING_TONE_REPEAT_GAP_MS = 350; - -let thinkingToneArrayBufferPromise: Promise | null = null; - -async function readThinkingToneArrayBuffer(): Promise { - const toneModule = require("../../assets/audio/thinking-tone.wav"); - const asset = Asset.fromModule(toneModule); - - if (isWeb) { - const response = await fetch(asset.uri); - if (!response.ok) { - throw new Error(`Failed to fetch thinking tone asset: ${response.status}`); - } - return await response.arrayBuffer(); - } - - const resolvedAsset = asset.localUri ? asset : await asset.downloadAsync(); - const fileUri = resolvedAsset.localUri ?? resolvedAsset.uri; - const file = new File(fileUri); - return await file.arrayBuffer(); -} - -export async function loadThinkingToneArrayBuffer(): Promise { - if (!thinkingToneArrayBufferPromise) { - thinkingToneArrayBufferPromise = readThinkingToneArrayBuffer(); - } - return await thinkingToneArrayBufferPromise; -} diff --git a/packages/server/src/poc-commands/commands-poc.test.ts b/packages/server/src/poc-commands/commands-poc.test.ts deleted file mode 100644 index 70a3629b4..000000000 --- a/packages/server/src/poc-commands/commands-poc.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * TDD Test Suite for Command Support POC - * - * This tests the ability to: - * 1. Get available commands/skills from the Claude Agent SDK - * 2. Execute commands (determine if they're just prompts or something else) - * - * Key findings from SDK analysis: - * - `supportedCommands()` returns SlashCommand[] with name, description, argumentHint - * - Commands are executed by sending them as prompts with / prefix - * - The SDK init message includes `slash_commands: string[]` and `skills: string[]` - * - * IMPORTANT: Control methods like supportedCommands() work WITHOUT iterating the query first! - * Use an empty async generator for the prompt when you just need control methods. - * This pattern is used in claude-agent.ts listModels(). - */ - -import { beforeAll, beforeEach, describe, expect, test } from "vitest"; -import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; -import { isCommandAvailable } from "../utils/executable.js"; - -const hasClaudeCredentials = - !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; - -// Pattern from claude-agent.ts listModels(): -// Use an empty async generator when you just need control methods -function createEmptyPrompt(): AsyncGenerator { - return (async function* empty() {})(); -} - -describe("Claude Agent SDK Commands POC", () => { - let canRunClaudeIntegration = false; - - beforeAll(async () => { - canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials; - }); - - beforeEach((context) => { - if (!canRunClaudeIntegration) { - context.skip(); - } - }); - - describe("supportedCommands() API", () => { - test("should return an array of SlashCommand objects", async () => { - // Use the pattern from claude-agent.ts: - // Create a query with empty prompt generator for control methods - const emptyPrompt = createEmptyPrompt(); - - const claudeQuery = query({ - prompt: emptyPrompt, - options: { - cwd: process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - settingSources: ["user", "project"], // Required to load skills - }, - }); - - try { - // supportedCommands() is a control method - works without iterating - const commands = await claudeQuery.supportedCommands(); - - // Should be an array - expect(Array.isArray(commands)).toBe(true); - - // Verify structure - if (commands.length > 0) { - const firstCommand = commands[0]; - expect(typeof firstCommand.name).toBe("string"); - expect(typeof firstCommand.description).toBe("string"); - expect(typeof firstCommand.argumentHint).toBe("string"); - expect(firstCommand.name.startsWith("/")).toBe(false); - } - } finally { - if (typeof claudeQuery.return === "function") { - try { - await claudeQuery.return(); - } catch { - // ignore shutdown errors - } - } - } - }, 30000); - - test("should have valid SlashCommand structure for all commands", async () => { - const emptyPrompt = createEmptyPrompt(); - - const claudeQuery = query({ - prompt: emptyPrompt, - options: { - cwd: process.cwd(), - permissionMode: "plan", - settingSources: ["user", "project"], - }, - }); - - try { - const commands = await claudeQuery.supportedCommands(); - - expect(commands.length).toBeGreaterThan(0); - - // Verify all commands have valid structure - for (const cmd of commands) { - expect(cmd).toHaveProperty("name"); - expect(cmd).toHaveProperty("description"); - expect(cmd).toHaveProperty("argumentHint"); - expect(typeof cmd.name).toBe("string"); - expect(typeof cmd.description).toBe("string"); - expect(typeof cmd.argumentHint).toBe("string"); - expect(cmd.name.length).toBeGreaterThan(0); - expect(cmd.name.startsWith("/")).toBe(false); - } - } finally { - await claudeQuery.return?.(); - } - }, 30000); - }); - - describe("Command Execution", () => { - test("should explain that commands are prompts with / prefix", () => { - // This is a documentation test - commands ARE just prompts with / prefix - // To execute a command: - // 1. Create a user message with content: "/{commandName}" - // 2. Push it to the input stream - // 3. Iterate the query to receive responses - - // The SDK handles command expansion: - // - Slash commands expand to their template content - // - Skills invoke the Skill tool - // - The model processes the expanded prompt like any other - - expect(true).toBe(true); // Documentation-only test - }); - }); -}); diff --git a/packages/server/src/poc-commands/investigate-command-output.ts b/packages/server/src/poc-commands/investigate-command-output.ts deleted file mode 100644 index bb77c0f6b..000000000 --- a/packages/server/src/poc-commands/investigate-command-output.ts +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env npx tsx - -/** - * Investigation: What does command execution actually return? - * - * This script logs ALL message types and their full structure - * to understand how command output is delivered. - */ - -import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; - -class Pushable implements AsyncIterable { - private queue: T[] = []; - private resolvers: Array<(value: IteratorResult) => void> = []; - private closed = false; - - push(item: T) { - if (this.closed) return; - if (this.resolvers.length > 0) { - const resolve = this.resolvers.shift()!; - resolve({ value: item, done: false }); - } else { - this.queue.push(item); - } - } - - end() { - this.closed = true; - while (this.resolvers.length > 0) { - const resolve = this.resolvers.shift()!; - resolve({ value: undefined, done: true }); - } - } - - [Symbol.asyncIterator](): AsyncIterator { - return { - next: (): Promise> => { - if (this.queue.length > 0) { - const value = this.queue.shift(); - if (value !== undefined) { - return Promise.resolve({ value, done: false }); - } - } - if (this.closed) { - return Promise.resolve({ value: undefined, done: true }); - } - return new Promise>((resolve) => { - this.resolvers.push(resolve); - }); - }, - }; - } -} - -async function investigateCommand(commandName: string): Promise { - process.stdout.write(`\n${"=".repeat(60)}\n`); - process.stdout.write(`Investigating: /${commandName}\n`); - process.stdout.write(`${"=".repeat(60)}\n`); - - const input = new Pushable(); - - const claudeQuery = query({ - prompt: input, - options: { - cwd: process.cwd(), - permissionMode: "plan", - includePartialMessages: true, // Include streaming partial messages - settingSources: ["user", "project"], - }, - }); - - try { - const userMessage: SDKUserMessage = { - type: "user", - message: { - role: "user", - content: `/${commandName}`, - }, - parent_tool_use_id: null, - session_id: "", - }; - - input.push(userMessage); - - let messageCount = 0; - for await (const message of claudeQuery) { - messageCount++; - process.stdout.write(`\n--- Message ${messageCount} ---\n`); - process.stdout.write(`Type: ${message.type}\n`); - - // Log the full structure based on type - switch (message.type) { - case "system": - process.stdout.write(`Subtype: ${message.subtype}\n`); - if (message.subtype === "init") { - process.stdout.write(`Session: ${message.session_id}\n`); - process.stdout.write(`Model: ${message.model}\n`); - } - break; - - case "user": - process.stdout.write( - `User message content: ${JSON.stringify(message.message?.content, null, 2)}\n`, - ); - break; - - case "assistant": - process.stdout.write("Assistant message content:\n"); - const content = message.message?.content; - if (Array.isArray(content)) { - for (const block of content) { - process.stdout.write(` Block type: ${block.type}\n`); - if (block.type === "text") { - process.stdout.write(` Text: ${block.text}\n`); - } else if (block.type === "tool_use") { - process.stdout.write(` Tool: ${block.name}\n`); - process.stdout.write(` Input: ${JSON.stringify(block.input, null, 2)}\n`); - } else { - process.stdout.write(` Full block: ${JSON.stringify(block, null, 2)}\n`); - } - } - } else { - process.stdout.write(` Content: ${JSON.stringify(content, null, 2)}\n`); - } - break; - - case "stream_event": - process.stdout.write(`Stream event type: ${message.event?.type}\n`); - if (message.event?.type === "content_block_delta") { - const delta = message.event.delta; - if (delta?.type === "text_delta") { - process.stdout.write(` Text delta: ${delta.text}\n`); - } - } - break; - - case "result": - process.stdout.write(`Result subtype: ${message.subtype}\n`); - if ("errors" in message && message.errors) { - process.stdout.write(`Errors: ${JSON.stringify(message.errors)}\n`); - } - // Check for any other properties - const resultKeys = Object.keys(message).filter((k) => !["type", "subtype"].includes(k)); - if (resultKeys.length > 0) { - process.stdout.write(`Other result properties: ${resultKeys.join(", ")}\n`); - for (const key of resultKeys) { - process.stdout.write(` ${key}: ${JSON.stringify((message as any)[key], null, 2)}\n`); - } - } - break; - - default: - process.stdout.write(`Full message: ${JSON.stringify(message, null, 2)}\n`); - } - - if (message.type === "result") { - break; - } - } - - process.stdout.write(`\nTotal messages received: ${messageCount}\n`); - } finally { - input.end(); - await claudeQuery.return?.(); - } -} - -async function main() { - process.stdout.write("=== Command Output Investigation ===\n\n"); - - // Test /context - a local command that shows context info - await investigateCommand("context"); - - // Test /cost - another local command - await investigateCommand("cost"); - - // Test /prompt-engineer - a SKILL (not a local command) - await investigateCommand("prompt-engineer"); - - process.stdout.write("\n=== Investigation Complete ===\n"); -} - -main().catch((error) => { - process.stderr.write(`Fatal error: ${error}\n`); - process.exit(1); -}); diff --git a/packages/server/src/poc-commands/run-poc.ts b/packages/server/src/poc-commands/run-poc.ts deleted file mode 100644 index 39f2f7ba2..000000000 --- a/packages/server/src/poc-commands/run-poc.ts +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env npx tsx - -/** - * POC Script: Claude Agent SDK Commands - * - * This script demonstrates how to: - * 1. Get available slash commands/skills from the Claude Agent SDK - * 2. Execute commands (they are prompts sent with / prefix) - * - * Key insight from existing claude-agent.ts: - * - Control methods like supportedCommands() work WITHOUT iterating the query first - * - Use an empty async generator for the prompt when you just want to call control methods - * - Commands are executed by sending them as prompts with / prefix to a streaming query - * - * Usage: npx tsx src/poc-commands/run-poc.ts - */ - -import { query, type SlashCommand, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; - -// Pattern from claude-agent.ts listModels(): -// Use an empty async generator when you just need control methods -function createEmptyPrompt(): AsyncGenerator { - return (async function* empty() {})(); -} - -// Utility: Create a pushable stream for SDK input (for command execution demo) -class Pushable implements AsyncIterable { - private queue: T[] = []; - private resolvers: Array<(value: IteratorResult) => void> = []; - private closed = false; - - push(item: T) { - if (this.closed) return; - if (this.resolvers.length > 0) { - const resolve = this.resolvers.shift()!; - resolve({ value: item, done: false }); - } else { - this.queue.push(item); - } - } - - end() { - this.closed = true; - while (this.resolvers.length > 0) { - const resolve = this.resolvers.shift()!; - resolve({ value: undefined, done: true }); - } - } - - [Symbol.asyncIterator](): AsyncIterator { - return { - next: (): Promise> => { - if (this.queue.length > 0) { - const value = this.queue.shift(); - if (value !== undefined) { - return Promise.resolve({ value, done: false }); - } - } - if (this.closed) { - return Promise.resolve({ value: undefined, done: true }); - } - return new Promise>((resolve) => { - this.resolvers.push(resolve); - }); - }, - }; - } -} - -async function listAvailableCommands(): Promise { - // Use the pattern from claude-agent.ts listModels(): - // Create a query with an empty prompt generator to call control methods - const emptyPrompt = createEmptyPrompt(); - - const claudeQuery = query({ - prompt: emptyPrompt, - options: { - cwd: process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - settingSources: ["user", "project"], // Required to load skills - }, - }); - - try { - // supportedCommands() is a control method - works without iterating - const commands = await claudeQuery.supportedCommands(); - return commands; - } finally { - // Clean up - if (typeof claudeQuery.return === "function") { - try { - await claudeQuery.return(); - } catch { - // ignore shutdown errors - } - } - } -} - -async function executeCommand(commandName: string): Promise { - process.stdout.write(`\n=== Executing command: /${commandName} ===\n`); - - // For command execution, we need a proper input stream - const input = new Pushable(); - - const claudeQuery = query({ - prompt: input, - options: { - cwd: process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - settingSources: ["user", "project"], - }, - }); - - try { - // Push the command as a user message with / prefix - const userMessage: SDKUserMessage = { - type: "user", - message: { - role: "user", - content: `/${commandName}`, - }, - parent_tool_use_id: null, - session_id: "", - }; - - input.push(userMessage); - - // Iterate the query to process the command - let gotSystemInit = false; - for await (const message of claudeQuery) { - process.stdout.write( - ` [${message.type}] ${message.type === "system" ? message.subtype : ""}\n`, - ); - - if (message.type === "system" && message.subtype === "init") { - gotSystemInit = true; - process.stdout.write(` Session: ${message.session_id}\n`); - process.stdout.write(` Model: ${message.model}\n`); - } - - if (message.type === "assistant") { - const content = message.message?.content; - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text") { - process.stdout.write( - ` Response: ${block.text.slice(0, 200)}${block.text.length > 200 ? "..." : ""}\n`, - ); - } - } - } - } - - if (message.type === "result") { - process.stdout.write(` Result: ${message.subtype}\n`); - break; - } - } - } finally { - input.end(); - await claudeQuery.return?.(); - } -} - -async function main() { - process.stdout.write("=== Claude Agent SDK Commands POC ===\n\n"); - - // PART 1: List available commands using supportedCommands() - process.stdout.write("=== Part 1: List Available Commands ===\n\n"); - - try { - const commands = await listAvailableCommands(); - - process.stdout.write(`Found ${commands.length} commands:\n\n`); - commands.forEach((cmd, index) => { - process.stdout.write(` ${index + 1}. /${cmd.name}\n`); - process.stdout.write(` Description: ${cmd.description}\n`); - if (cmd.argumentHint) { - process.stdout.write(` Arguments: ${cmd.argumentHint}\n`); - } - process.stdout.write("\n"); - }); - - // PART 2: Demonstrate command execution (optional - uncomment to test) - // Commands are just prompts sent with / prefix - process.stdout.write("=== Part 2: Command Execution Explanation ===\n"); - process.stdout.write("\n"); - process.stdout.write("Commands are executed by sending them as prompts with / prefix.\n"); - process.stdout.write("For example, to execute the 'help' command:\n"); - process.stdout.write(' 1. Create a user message with content: "/help"\n'); - process.stdout.write(" 2. Push it to the input stream\n"); - process.stdout.write(" 3. Iterate the query to receive responses\n"); - process.stdout.write("\n"); - - // Actually execute a command to demonstrate it works: - // Using "context" as it's fast and doesn't require arguments - await executeCommand("context"); - } catch (error) { - process.stderr.write(`ERROR: ${error}\n`); - process.exit(1); - } - - process.stdout.write("=== POC Complete ===\n"); -} - -main().catch((error) => { - process.stderr.write(`Fatal error: ${error}\n`); - process.exit(1); -}); diff --git a/packages/server/src/server/agent/llm-openai.ts b/packages/server/src/server/agent/llm-openai.ts deleted file mode 100644 index a39da2980..000000000 --- a/packages/server/src/server/agent/llm-openai.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ToolSet } from "ai"; - -/** - * Get all tools for voice LLM - * @param agentTools - Agent control tools from MCP - */ -export function getAllTools(agentTools?: ToolSet): ToolSet { - return agentTools ?? {}; -} diff --git a/packages/server/src/server/agent/orchestrator.ts b/packages/server/src/server/agent/orchestrator.ts deleted file mode 100644 index dfadc0ee8..000000000 --- a/packages/server/src/server/agent/orchestrator.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * DEPRECATED: This file has been refactored. - * - * All orchestration logic has been moved to session.ts. - * Each Session now owns its own conversation state and manages - * the full lifecycle of user interactions. - * - * This file is kept as a stub to document the migration. - * It can be deleted once the refactoring is confirmed working. - */ diff --git a/packages/server/src/server/daemon-e2e/checkout-debug.ts b/packages/server/src/server/daemon-e2e/checkout-debug.ts deleted file mode 100644 index c68640789..000000000 --- a/packages/server/src/server/daemon-e2e/checkout-debug.ts +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Ad-hoc script to debug checkout_status_request timeouts. - * - * Usage: - * npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts [agentIdOrCwd1] [agentIdOrCwd2] - * - * To test against a different daemon: - * PASEO_LISTEN=127.0.0.1:7777 npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts - */ - -import { WebSocket } from "ws"; -import os from "node:os"; -import { DaemonClient } from "../../client/daemon-client.js"; - -// Patch WebSocket to log all messages -const OriginalWebSocket = WebSocket; -class LoggingWebSocket extends OriginalWebSocket { - constructor(url: string, ...args: any[]) { - super(url, ...args); - console.log(`[WS] Connecting to ${url}`); - this.on("open", () => console.log("[WS] Connection opened")); - this.on("close", (code, reason) => console.log(`[WS] Connection closed: ${code} ${reason}`)); - this.on("error", (err) => console.log(`[WS] Error: ${err}`)); - this.on("message", (data) => { - const str = data.toString().slice(0, 200); - console.log(`[WS] Message received (${data.toString().length} bytes): ${str}...`); - }); - } -} - -const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`; -const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767"; -const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`; -const CLIENT_ID = "clsk_checkout_debug"; - -function requestCheckoutStatus(client: DaemonClient, cwd: string) { - return (client as any)[`get${"Checkout"}Status`](cwd); -} - -async function testMultiAgentSequence() { - console.log("\n=== Testing multi-agent checkout sequence ==="); - console.log(`Daemon URL: ${DAEMON_URL}`); - - const client = new DaemonClient({ - url: DAEMON_URL, - clientId: CLIENT_ID, - webSocketFactory: (url) => new LoggingWebSocket(url) as any, - reconnect: { enabled: false }, - }); - - const agents: Array<{ id: string; title: string; cwd: string }> = []; - - // Also log raw messages for debugging - client.on("checkout_status_response", (msg: any) => { - console.log( - `[RAW checkout_status_response] requestId=${msg.payload.requestId} cwd=${msg.payload.cwd}`, - ); - }); - - // Listen to connection state changes - client.subscribeConnectionStatus((state) => { - console.log(`[Connection] status=${state.status}`); - }); - - try { - await client.connect(); - console.log("Connected to daemon"); - console.log(`Connection state: ${JSON.stringify(client.getConnectionState())}`); - - console.log("Fetching agents..."); - const agentsList = await client.fetchAgents(); - agents.length = 0; - for (const a of agentsList) { - agents.push({ id: a.id, title: a.title ?? "(untitled)", cwd: a.cwd }); - } - - if (agents.length === 0) { - console.log("No agents found!"); - return; - } - - console.log("\nAvailable agents:"); - for (const a of agents.slice(0, 10)) { - console.log(` - ${a.id.slice(0, 8)}... ${a.title}`); - } - if (agents.length > 10) { - console.log(` ... and ${agents.length - 10} more`); - } - - // Pick first two agents (or use command line args) - const arg1 = process.argv[2]; - const arg2 = process.argv[3]; - const agent1 = (arg1 ? agents.find((a) => a.id === arg1) : null) ?? agents[0] ?? null; - const agent2 = - (arg2 ? agents.find((a) => a.id === arg2) : null) ?? agents[1] ?? agents[0] ?? null; - - const cwd1 = arg1 && !agent1 ? arg1 : agent1?.cwd; - const cwd2 = arg2 && !agent2 ? arg2 : agent2?.cwd; - - if (!cwd1) { - console.log("No checkout cwd available to test"); - return; - } - - console.log(`\n=== Test 1: Request checkout for cwd1 (${cwd1}) ===`); - const start1 = Date.now(); - try { - const status1 = await requestCheckoutStatus(client, cwd1); - console.log( - `βœ“ Cwd1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}`, - ); - } catch (err) { - console.log(`βœ— Cwd1 failed after ${Date.now() - start1}ms:`, err); - } - - if (cwd2) { - console.log(`\n=== Test 2: Request checkout for cwd2 (${cwd2}) ===`); - const start2 = Date.now(); - try { - const status2 = await requestCheckoutStatus(client, cwd2); - console.log( - `βœ“ Cwd2 completed in ${Date.now() - start2}ms - branch: ${status2.currentBranch}`, - ); - } catch (err) { - console.log(`βœ— Cwd2 failed after ${Date.now() - start2}ms:`, err); - } - } - - console.log(`\n=== Test 3: Request checkout for cwd1 again ===`); - const start3 = Date.now(); - try { - const status3 = await requestCheckoutStatus(client, cwd1); - console.log( - `βœ“ Cwd1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`, - ); - } catch (err) { - console.log(`βœ— Cwd1 (retry) failed after ${Date.now() - start3}ms:`, err); - } - - if (cwd2) { - console.log(`\n=== Test 4: Request both cwds in parallel ===`); - const start4 = Date.now(); - try { - const [p1, p2] = await Promise.all([ - requestCheckoutStatus(client, cwd1), - requestCheckoutStatus(client, cwd2), - ]); - console.log(`βœ“ Parallel completed in ${Date.now() - start4}ms`); - console.log(` Cwd1 branch: ${p1.currentBranch}`); - console.log(` Cwd2 branch: ${p2.currentBranch}`); - } catch (err) { - console.log(`βœ— Parallel failed after ${Date.now() - start4}ms:`, err); - } - } - } catch (error) { - console.error("Test failed:", error); - } finally { - await client.close(); - } -} - -async function main() { - console.log("Checkout Debug Script - Multi-Agent Sequence Test"); - console.log("=================================================="); - console.log(`PASEO_HOME: ${PASEO_HOME}`); - - await testMultiAgentSequence(); - - console.log("\n=== Done ==="); -} - -main().catch(console.error); diff --git a/packages/server/src/server/daemon-e2e/setup.ts b/packages/server/src/server/daemon-e2e/setup.ts deleted file mode 100644 index 48fa1616d..000000000 --- a/packages/server/src/server/daemon-e2e/setup.ts +++ /dev/null @@ -1,48 +0,0 @@ -import "dotenv/config"; -import { beforeAll, afterAll } from "vitest"; -import { mkdtempSync } from "fs"; -import { tmpdir } from "os"; -import path from "path"; -import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; -import { agentConfigs } from "./agent-configs.js"; - -// Re-export for backward compatibility - prefer using agentConfigs instead -export const CODEX_TEST_MODEL = agentConfigs.codex.model; -export const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId; - -// Re-export agent configs -export { - agentConfigs, - getFullAccessConfig, - getAskModeConfig, - allProviders, - type AgentProvider, - type AgentTestConfig, -} from "./agent-configs.js"; - -export function tmpCwd(): string { - return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); -} - -// Shared daemon context for all e2e tests -let sharedCtx: DaemonTestContext | null = null; - -export function getTestContext(): DaemonTestContext { - if (!sharedCtx) { - throw new Error("Test context not initialized. Did you call setupDaemonE2E()?"); - } - return sharedCtx; -} - -export function setupDaemonE2E(): void { - beforeAll(async () => { - sharedCtx = await createDaemonTestContext(); - }, 30000); - - afterAll(async () => { - if (sharedCtx) { - await sharedCtx.cleanup(); - sharedCtx = null; - } - }, 60000); -} diff --git a/packages/server/src/server/types.ts b/packages/server/src/server/types.ts deleted file mode 100644 index 4bb2190d5..000000000 --- a/packages/server/src/server/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Shared server types - -export interface ServerConfig { - port: number; - isDev: boolean; -} diff --git a/packages/server/src/server/workspace-registry.test-helpers.ts b/packages/server/src/server/workspace-registry.test-helpers.ts deleted file mode 100644 index 7ce1e0b4a..000000000 --- a/packages/server/src/server/workspace-registry.test-helpers.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; -import path from "node:path"; - -import type { Logger } from "pino"; - -import { - type PersistedProjectRecord, - type PersistedWorkspaceRecord, - type ProjectRegistry, - type WorkspaceRegistry, - createPersistedProjectRecord, - createPersistedWorkspaceRecord, -} from "./workspace-registry.js"; - -type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; - -class FileBackedRegistry { - private readonly filePath: string; - private readonly logger: Logger; - private readonly schema: (record: unknown) => TRecord; - private readonly getId: (record: TRecord) => string; - private loaded = false; - private readonly cache = new Map(); - private persistQueue: Promise = Promise.resolve(); - - constructor(options: { - filePath: string; - logger: Logger; - schema: (record: unknown) => TRecord; - getId: (record: TRecord) => string; - component: string; - }) { - this.filePath = options.filePath; - this.schema = options.schema; - this.getId = options.getId; - this.logger = options.logger.child({ - module: "workspace-registry", - component: options.component, - }); - } - - async initialize(): Promise { - await this.load(); - } - - async existsOnDisk(): Promise { - try { - await fs.access(this.filePath); - return true; - } catch { - return false; - } - } - - async list(): Promise { - await this.load(); - return Array.from(this.cache.values()); - } - - async get(id: string): Promise { - await this.load(); - return this.cache.get(id) ?? null; - } - - async upsert(record: TRecord): Promise { - await this.load(); - const parsed = this.schema(record); - this.cache.set(this.getId(parsed), parsed); - await this.enqueuePersist(); - } - - async archive(id: string, archivedAt: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) { - return; - } - const next = this.schema({ - ...existing, - updatedAt: archivedAt, - archivedAt, - }); - this.cache.set(id, next); - await this.enqueuePersist(); - } - - async remove(id: string): Promise { - await this.load(); - if (!this.cache.delete(id)) { - return; - } - await this.enqueuePersist(); - } - - private async load(): Promise { - if (this.loaded) { - return; - } - - this.cache.clear(); - try { - const raw = await fs.readFile(this.filePath, "utf8"); - const parsed = JSON.parse(raw) as TRecord[]; - for (const record of parsed) { - const validated = this.schema(record); - this.cache.set(this.getId(validated), validated); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file"); - } - } - this.loaded = true; - } - - private async persist(): Promise { - const records = Array.from(this.cache.values()); - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; - await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8"); - await fs.rename(tempPath, this.filePath); - } - - private async enqueuePersist(): Promise { - const nextPersist = this.persistQueue.then(() => this.persist()); - this.persistQueue = nextPersist.catch(() => {}); - await nextPersist; - } -} - -export class FileBackedProjectRegistry - extends FileBackedRegistry - implements ProjectRegistry -{ - constructor(filePath: string, logger: Logger) { - super({ - filePath, - logger, - schema: (record) => createPersistedProjectRecord(record as PersistedProjectRecord), - getId: (record) => record.projectId, - component: "projects", - }); - } -} - -export class FileBackedWorkspaceRegistry - extends FileBackedRegistry - implements WorkspaceRegistry -{ - constructor(filePath: string, logger: Logger) { - super({ - filePath, - logger, - schema: (record) => createPersistedWorkspaceRecord(record as PersistedWorkspaceRecord), - getId: (record) => record.workspaceId, - component: "workspaces", - }); - } -} diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts deleted file mode 100644 index 083a5646b..000000000 --- a/packages/server/src/tasks/cli.ts +++ /dev/null @@ -1,1318 +0,0 @@ -#!/usr/bin/env node -import { Command } from "commander"; -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { FileTaskStore } from "./task-store.js"; -import { computeExecutionOrder, buildSortedChildrenMap } from "./execution-order.js"; -import { resolvePackageVersion } from "../server/package-version.js"; -import { spawnProcess } from "../utils/spawn.js"; -import type { AgentType, Task } from "./types.js"; - -const TASKS_DIR = resolve(process.cwd(), ".tasks"); -const store = new FileTaskStore(TASKS_DIR); -const TASK_CLI_VERSION = resolvePackageVersion({ - moduleUrl: import.meta.url, - packageName: "@getpaseo/server", -}); - -async function readStdin(): Promise { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - return Buffer.concat(chunks).toString("utf-8").trim(); -} - -const program = new Command() - .name("task") - .description("Minimal task management with dependency tracking") - .version(TASK_CLI_VERSION) - .addHelpText( - "after", - ` -Examples: - # Create an epic with subtasks (hierarchical) - task create "Build auth system" - task create "Add login endpoint" --parent abc123 - task create "Add logout endpoint" --parent abc123 - - # Create with body from stdin (use "-" for body) - cat spec.md | task create "Implement feature" --body - - - # Update task body - task update abc123 --body "New body content" - cat updated-spec.md | task update abc123 --body - - - # Move task to different parent - task move abc123 --parent def456 - task move abc123 --root # make it a root task - - # Create with dependencies (separate from hierarchy) - task create "Setup database" - task create "Add user model" --deps def456 - - # Assign to specific agent - task create "Complex refactor" --assignee codex - - # Create as draft (not actionable until opened) - task create "Future feature" --draft - task open abc123 # make it actionable - - # View task with parent context - task show abc123 - - # View the work breakdown - task tree abc123 - - # See what's ready to work on - task ready - task ready --scope abc123 - - # See completed work - task closed --scope abc123 - -Body vs Notes: - The BODY is the task's markdown document - edit it while grooming/defining the task. - NOTES are timestamped entries added during implementation to document progress. - - - While defining a task: edit the body with "task update --body ..." - - While implementing: add notes with "task note ..." - - When done: add a final note explaining what was done, then close - ---- - -The Run Command (task run): - - The run command executes an agent loop that works until acceptance criteria are met. - This can run for hours, days, or weeks for large epics. - - Basic usage: - task run # Run without planner (task must be well-defined) - task run --plan # Run with planner (for larger epics) - - Without --plan: The scoped task runs in a worker/judge loop. The worker implements, - the judge verifies. If not done, it loops. No breakdown happens - the task must be - atomic and well-defined from the start. - - With --plan: The planner first breaks down the scope into subtasks, then workers - execute leaf tasks while the planner can reorganize as needed. Use this for epics - where you define WHAT you want but not HOW to achieve it. - - When replanning happens: - - At the start (initial breakdown) - - When a task fails 5+ times (something is wrong with the approach) - - When you add a steering note (task steer "new direction") - - Monitoring a running loop: - task plan # See execution timeline and progress - task show # Check specific task details - tail -f task-run.*.log # Follow the live log - - Steering mid-run: - task steer "focus on X first" - task steer "skip Y, it's not needed" - task steer "run e2e tests every iteration" - - Steering triggers an immediate replan. Use it to course-correct without stopping. - -Writing Good Acceptance Criteria: - - Acceptance criteria determine when a task is DONE. They must be specific enough - that agents cannot "cheat" by taking shortcuts. - - Bad (agents can game these): - - "tests pass" β†’ could delete tests, skip them, or weaken assertions - - "code works" β†’ subjective, unverifiable - - "feature complete" β†’ vague - - Good (specific and non-gameable): - - "npm test exits 0 with 0 failures and 0 skipped" - - "test count >= 150 (no deleting tests)" - - "coverage does not decrease from baseline" - - "npm run typecheck exits 0" - - "GET /api/users returns 200 with JSON array" - - Philosophy: Specify the floor (minimum requirements) without capping the ceiling. - "Zero failures, zero skipped" is strict but doesn't prevent adding more tests. - - For test-related tasks, consider: - --accept "npm test exits 0" - --accept "no test files deleted" - --accept "no .skip or test.todo added" - --accept "no assertions removed or weakened" - -Acceptance Criteria vs Body Guidance: - - Acceptance criteria = required OUTPUT (must-haves, verified at the end) - Body guidance = HOW to approach the work (instructions for the planner) - - Acceptance criteria examples (verifiable end-state): - - "PR created with description and test plan" - - "Branch name follows convention: feat/" - - "npm run typecheck exits 0" - - Body guidance examples (process instructions): - - "Split work by module" - - "Commit after each subtask" - - "Run typecheck after each change" - - The planner reads body guidance and propagates it into subtask acceptance criteria. - For example, if the body says "commit after each chunk of work", the planner adds - "git status shows clean working tree" to each subtask's criteria. - - This makes process requirements verifiable at each step, not just at the end. -`, - ); - -program - .command("create ") - .alias("add") - .description("Create a new task") - .option("-b, --body <text>", "Task body (use '-' to read from stdin)") - .option("--deps <ids>", "Comma-separated dependency IDs") - .option("--parent <id>", "Parent task ID (for hierarchy)") - .option("--assignee <agent>", "Agent to assign (claude or codex)") - .option("--draft", "Create as draft (not actionable)") - .option("-p, --priority <n>", "Priority (lower number = higher priority)") - .option( - "-a, --accept <criterion>", - "Acceptance criterion (repeatable)", - (val: string, prev: string[]) => prev.concat(val), - [] as string[], - ) - .action(async (title, opts) => { - let body = opts.body ?? ""; - if (body === "-") { - body = await readStdin(); - } - - const task = await store.create(title, { - body, - deps: opts.deps ? opts.deps.split(",").map((s: string) => s.trim()) : [], - parentId: opts.parent, - status: opts.draft ? "draft" : "open", - assignee: opts.assignee as AgentType | undefined, - acceptanceCriteria: opts.accept, - priority: opts.priority ? parseInt(opts.priority, 10) : undefined, - }); - - process.stdout.write(`${task.id}\n`); - }); - -program - .command("list") - .alias("ls") - .description("List all tasks") - .option("-s, --status <status>", "Filter by status") - .option("--roots", "Show only root tasks (no parent)") - .action(async (opts) => { - const tasks = await store.list(); - let filtered = opts.status ? tasks.filter((t) => t.status === opts.status) : tasks; - - if (opts.roots) { - filtered = filtered.filter((t) => !t.parentId); - } - - for (const t of filtered) { - const deps = t.deps.length ? ` <- [${t.deps.join(", ")}]` : ""; - const assignee = t.assignee ? ` @${t.assignee}` : ""; - const parent = t.parentId ? ` ^${t.parentId}` : ""; - const priority = t.priority !== undefined ? ` !${t.priority}` : ""; - process.stdout.write( - `${t.id} [${t.status}] ${t.title}${priority}${assignee}${parent}${deps}\n`, - ); - } - }); - -program - .command("show <id>") - .description("Show task details with parent context") - .action(async (id) => { - const task = await store.get(id); - if (!task) { - process.stderr.write(`Task not found: ${id}\n`); - process.exit(1); - } - - // Get ancestors (parent chain from immediate to root) - const ancestors = await store.getAncestors(id); - - // Print ancestors first (root to immediate parent) - if (ancestors.length > 0) { - process.stdout.write("# Parent Context\n\n"); - for (const ancestor of ancestors.toReversed()) { - process.stdout.write(`## ${ancestor.title} (${ancestor.id}) [${ancestor.status}]\n`); - if (ancestor.body) { - process.stdout.write(`\n${ancestor.body}\n`); - } - process.stdout.write("\n"); - } - process.stdout.write("---\n\n"); - } - - // Print current task - process.stdout.write(`# ${task.title}\n\n`); - process.stdout.write(`id: ${task.id}\n`); - process.stdout.write(`status: ${task.status}\n`); - process.stdout.write(`created: ${task.created}\n`); - if (task.priority !== undefined) { - process.stdout.write(`priority: ${task.priority}\n`); - } - if (task.assignee) { - process.stdout.write(`assignee: ${task.assignee}\n`); - } - if (task.parentId) { - process.stdout.write(`parent: ${task.parentId}\n`); - } - if (task.deps.length) { - process.stdout.write(`deps: [${task.deps.join(", ")}]\n`); - } - if (task.body) { - process.stdout.write(`\n${task.body}\n`); - } - if (task.acceptanceCriteria.length) { - process.stdout.write("\n## Acceptance Criteria\n\n"); - for (const criterion of task.acceptanceCriteria) { - process.stdout.write(`- [ ] ${criterion}\n`); - } - } - if (task.notes.length) { - process.stdout.write("\n## Notes\n"); - for (const note of task.notes) { - process.stdout.write(`\n**${note.timestamp}**\n${note.content}\n`); - } - } - }); - -program - .command("ready") - .description("List tasks ready to work on (open + deps resolved)") - .option("--scope <id>", "Scope to epic/task dep tree") - .action(async (opts) => { - const tasks = await store.getReady(opts.scope); - for (const t of tasks) { - const assignee = t.assignee ? ` @${t.assignee}` : ""; - const priority = t.priority !== undefined ? ` !${t.priority}` : ""; - process.stdout.write(`${t.id} ${t.title}${priority}${assignee}\n`); - } - }); - -program - .command("blocked") - .description("List tasks blocked by unresolved deps") - .option("--scope <id>", "Scope to epic/task dep tree") - .action(async (opts) => { - const tasks = await store.getBlocked(opts.scope); - for (const t of tasks) { - process.stdout.write(`${t.id} ${t.title} <- [${t.deps.join(", ")}]\n`); - } - }); - -program - .command("closed") - .description("List completed tasks") - .option("--scope <id>", "Scope to epic/task dep tree") - .action(async (opts) => { - const tasks = await store.getClosed(opts.scope); - for (const t of tasks) { - process.stdout.write(`${t.id} ${t.title}\n`); - } - }); - -program - .command("plan [scope]") - .alias("tree") - .description("Show execution timeline (tree view by default)") - .option("--flat", "Show flat list instead of tree") - .option("-d, --depth <n>", "Limit tree depth (0 = root only)") - .action(async (scopeId: string | undefined, opts) => { - const { timeline, orderMap, blocked } = await computeExecutionOrder(store, scopeId); - const allTasks = await store.list(); - const taskMap = new Map(allTasks.map((t) => [t.id, t])); - const maxDepth = opts.depth !== undefined ? parseInt(opts.depth, 10) : Infinity; - - const formatDeps = (task: Task): string => { - if (task.deps.length === 0) return ""; - return ` (deps: ${task.deps.join(", ")})`; - }; - - if (opts.flat) { - // Flat list view - const printTask = (t: Task, idx: number) => { - const priority = t.priority !== undefined ? `!${t.priority} ` : ""; - const assignee = t.assignee ? ` @${t.assignee}` : ""; - const num = String(idx + 1).padStart(3, " "); - const mark = t.status === "done" ? "βœ“" : " "; - const deps = formatDeps(t); - process.stdout.write(`${mark}${num}. ${priority}${t.id} ${t.title}${assignee}${deps}\n`); - }; - - for (let i = 0; i < timeline.length; i++) { - printTask(timeline[i], i); - } - } else { - // Tree view (default) - if (!scopeId) { - process.stderr.write("Tree view requires a scope ID\n"); - process.exit(1); - } - - const root = await store.get(scopeId); - if (!root) { - process.stderr.write(`Task not found: ${scopeId}\n`); - process.exit(1); - } - - const sortedChildrenMap = buildSortedChildrenMap(allTasks, orderMap); - - const printTask = (task: Task, prefix: string, connector: string, depth: number) => { - const assignee = task.assignee ? ` @${task.assignee}` : ""; - const priority = task.priority !== undefined ? ` !${task.priority}` : ""; - const mark = task.status === "done" ? "βœ“ " : " "; - const deps = formatDeps(task); - process.stdout.write( - `${mark}${prefix}${connector}${task.id} [${task.status}] ${task.title}${priority}${assignee}${deps}\n`, - ); - }; - - // Print root - const rootAssignee = root.assignee ? ` @${root.assignee}` : ""; - const rootPriority = root.priority !== undefined ? ` !${root.priority}` : ""; - const rootMark = root.status === "done" ? "βœ“ " : " "; - const rootDeps = formatDeps(root); - process.stdout.write( - `${rootMark}${root.id} [${root.status}] ${root.title}${rootPriority}${rootAssignee}${rootDeps}\n`, - ); - - // Recursively print children in execution order - const printChildren = (parentId: string, prefix: string, depth: number) => { - if (depth >= maxDepth) return; - const children = sortedChildrenMap.get(parentId) ?? []; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - const isLast = i === children.length - 1; - const connector = isLast ? "└── " : "β”œβ”€β”€ "; - const childPrefix = prefix + (isLast ? " " : "β”‚ "); - - printTask(child, prefix, connector, depth); - printChildren(child.id, childPrefix, depth + 1); - } - }; - - printChildren(scopeId, "", 0); - } - - if (blocked.size > 0) { - process.stdout.write(`\n... +${blocked.size} blocked/unreachable\n`); - } - if (timeline.length === 0) { - process.stdout.write("No tasks.\n"); - } - }); - -program - .command("dep <id> <dep-id>") - .description("Add dependency (id depends on dep-id)") - .action(async (id, depId) => { - await store.addDep(id, depId); - process.stdout.write(`Added: ${id} -> ${depId}\n`); - }); - -program - .command("undep <id> <dep-id>") - .description("Remove dependency") - .action(async (id, depId) => { - await store.removeDep(id, depId); - process.stdout.write(`Removed: ${id} -> ${depId}\n`); - }); - -const VALID_STATUSES = ["draft", "open", "in_progress", "done", "failed"] as const; - -program - .command("update <id>") - .alias("edit") - .description("Update task properties") - .option("-t, --title <text>", "New title") - .option("-b, --body <text>", "New body (use '-' to read from stdin)") - .option("--assignee <agent>", "New assignee (claude or codex)") - .option("-p, --priority <n>", "Priority (lower number = higher priority)") - .option("-s, --status <status>", "Set status (draft, open, in_progress, done, failed)") - .option("--clear-acceptance", "Clear all acceptance criteria (combine with -a to replace)") - .option( - "-a, --accept <criterion>", - "Add acceptance criterion (repeatable)", - (val: string, prev: string[]) => prev.concat(val), - [] as string[], - ) - .action(async (id, opts) => { - const task = await store.get(id); - if (!task) { - process.stderr.write(`Task not found: ${id}\n`); - process.exit(1); - } - - const changes: Partial<Task> = {}; - - if (opts.title) { - changes.title = opts.title; - } - - if (opts.body !== undefined) { - changes.body = opts.body === "-" ? await readStdin() : opts.body; - } - - if (opts.assignee) { - changes.assignee = opts.assignee as AgentType; - } - - if (opts.priority !== undefined) { - changes.priority = parseInt(opts.priority, 10); - } - - if (opts.status) { - if (!VALID_STATUSES.includes(opts.status)) { - process.stderr.write( - `Invalid status: ${opts.status}. Must be one of: ${VALID_STATUSES.join(", ")}\n`, - ); - process.exit(1); - } - changes.status = opts.status as Task["status"]; - } - - // Handle acceptance criteria: --clear-acceptance clears, -a adds - if (opts.clearAcceptance) { - changes.acceptanceCriteria = [...opts.accept]; - } else { - for (const criterion of opts.accept) { - await store.addAcceptanceCriteria(id, criterion); - } - } - - if (Object.keys(changes).length === 0 && opts.accept.length === 0 && !opts.clearAcceptance) { - process.stderr.write("No changes specified\n"); - process.exit(1); - } - - if (Object.keys(changes).length > 0) { - await store.update(id, changes); - } - process.stdout.write(`Updated: ${id}\n`); - }); - -program - .command("move <id>") - .description("Move task to a different parent") - .option("--parent <id>", "New parent task ID") - .option("--root", "Make this a root task (remove parent)") - .action(async (id, opts) => { - if (!opts.parent && !opts.root) { - process.stderr.write("Must specify --parent <id> or --root\n"); - process.exit(1); - } - - if (opts.parent && opts.root) { - process.stderr.write("Cannot specify both --parent and --root\n"); - process.exit(1); - } - - await store.setParent(id, opts.root ? null : opts.parent); - if (opts.root) { - process.stdout.write(`${id} is now a root task\n`); - } else { - process.stdout.write(`${id} moved to parent ${opts.parent}\n`); - } - }); - -program - .command("children <id>") - .description("List direct children of a task") - .action(async (id) => { - const task = await store.get(id); - if (!task) { - process.stderr.write(`Task not found: ${id}\n`); - process.exit(1); - } - - const children = await store.getChildren(id); - if (children.length === 0) { - process.stdout.write("No children\n"); - return; - } - - for (const child of children) { - const assignee = child.assignee ? ` @${child.assignee}` : ""; - const priority = child.priority !== undefined ? ` !${child.priority}` : ""; - process.stdout.write( - `${child.id} [${child.status}] ${child.title}${priority}${assignee}\n`, - ); - } - }); - -program - .command("delete <id>") - .alias("rm") - .description("Delete a task") - .action(async (id) => { - await store.delete(id); - process.stdout.write(`Deleted: ${id}\n`); - }); - -program - .command("note <id> <content>") - .description("Add a timestamped note (timestamp is automatic, don't include one)") - .action(async (id, content) => { - await store.addNote(id, content); - process.stdout.write("Note added\n"); - }); - -program - .command("steer <id> <content>") - .description("Add a steering note to guide the agent loop (triggers replan)") - .action(async (id, content) => { - await store.addNote(id, `STEER: ${content}`); - process.stdout.write("Steering note added\n"); - }); - -program - .command("open <id>") - .description("Mark draft as open (actionable)") - .action(async (id) => { - await store.open(id); - process.stdout.write(`${id} -> open\n`); - }); - -program - .command("start <id>") - .description("Mark as in progress") - .action(async (id) => { - await store.start(id); - process.stdout.write(`${id} -> in_progress\n`); - }); - -program - .command("close <id>") - .alias("done") - .description("Mark as done") - .action(async (id) => { - await store.close(id); - process.stdout.write(`${id} -> done\n`); - }); - -program - .command("fail <id>") - .description("Mark as failed (catastrophically stuck)") - .action(async (id) => { - await store.fail(id); - process.stdout.write(`${id} -> failed\n`); - }); - -// Agent runner - -interface AgentConfig { - cli: string; - model?: string; - effort?: string; -} - -// Parse model string like "gpt-5.2-xhigh" into model and effort -function parseModelString(modelStr: string): { model: string; effort?: string } { - // GPT models with effort: gpt-5.2-low, gpt-5.2-medium, gpt-5.2-high, gpt-5.2-xhigh - const effortLevels = ["low", "medium", "high", "xhigh"]; - for (const effort of effortLevels) { - if (modelStr.endsWith(`-${effort}`)) { - return { - model: modelStr.slice(0, -(effort.length + 1)), - effort, - }; - } - } - return { model: modelStr }; -} - -function getAgentConfig(modelStr: string): AgentConfig { - const { model, effort } = parseModelString(modelStr); - if (model.startsWith("gpt-")) { - return { cli: "codex", model, effort }; - } - // Claude CLI accepts aliases directly: haiku, sonnet, opus - return { cli: "claude", model }; -} - -async function runAgentWithModel( - prompt: string, - modelStr: string, - logFile: string, -): Promise<{ success: boolean; output: string }> { - const config = getAgentConfig(modelStr); - let args: string[]; - - if (config.cli === "claude") { - args = ["--dangerously-skip-permissions"]; - if (config.model) { - args.push("--model", config.model); - } - args.push("-p", prompt); - } else { - const effort = config.effort ?? "medium"; - args = [ - "exec", - "--dangerously-bypass-approvals-and-sandbox", - "--skip-git-repo-check", - "-c", - `model_reasoning_effort="${effort}"`, - ]; - if (config.model) { - args.push("--model", config.model); - } - args.push(prompt); - } - - const { stdout, stderr, exitCode } = await new Promise<{ - stdout: string; - stderr: string; - exitCode: number | null; - }>((resolve, reject) => { - const child = spawnProcess(config.cli, args, { - stdio: ["inherit", "pipe", "pipe"], - cwd: process.cwd(), - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); - child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); - child.on("error", reject); - child.on("close", (code) => { - resolve({ - stdout: Buffer.concat(stdoutChunks).toString("utf8"), - stderr: Buffer.concat(stderrChunks).toString("utf8"), - exitCode: code, - }); - }); - }); - const output = stdout + stderr; - - // Append to log file for history - appendFileSync(logFile, output); - - return { success: exitCode === 0, output }; -} - -async function buildTaskContext(task: Task, scopeId?: string): Promise<string> { - const ancestors = await store.getAncestors(task.id); - let parentContext = ""; - if (ancestors.length > 0) { - parentContext = "# Parent Context\n\n"; - for (const ancestor of ancestors.toReversed()) { - parentContext += `## ${ancestor.title} (${ancestor.id})\n`; - if (ancestor.body) { - parentContext += `\n${ancestor.body}\n`; - } - parentContext += "\n"; - } - parentContext += "---\n\n"; - } - - let scopeContext = ""; - if (scopeId && !ancestors.some((a) => a.id === scopeId)) { - const scope = await store.get(scopeId); - if (scope) { - scopeContext = `Scope: ${scope.title} (${scopeId})\n${scope.body ? `\n${scope.body}\n` : ""}`; - } - } - - return `Working directory: ${process.cwd()} -${scopeContext}${parentContext} -${task.raw}`; -} - -function makePlannerPrompt(task: Task, context: string, reason: string): string { - return `${context} - ---- - -You are a PLANNER agent. You ORCHESTRATE work - you do NOT do the work yourself. - -Your job is to organize tasks so that WORKER agents can accomplish the top-level acceptance criteria. - -## Why you were called - -${reason} - -## Planning vs Working - -As a planner you MAY: -- Look at file structure and test patterns to write good acceptance criteria -- Read the task tree to understand what's been done and what needs reorganizing -- Check naming conventions to make specific, verifiable criteria - -As a planner you must NOT: -- Investigate WHY bugs happen (workers will do this) -- Debug or trace through code (workers will do this) -- Try to understand root causes (workers will do this) -- Fix or implement anything (workers will do this) - -Take the task descriptions AS GIVEN and create well-organized subtasks for workers to investigate and implement. - -## Task CLI Commands - -### Understanding the plan -- \`task plan ${task.id}\` - **START HERE**: shows full execution timeline (done βœ“ then pending in order) -- \`task tree ${task.id}\` - see task hierarchy with dependencies (parent β†’ children) -- \`task show <id>\` - view task details, body, acceptance criteria, and notes -- \`task children <id>\` - list direct children of a task -- \`task ready --scope ${task.id}\` - see what's ready to work on right now -- \`task blocked --scope ${task.id}\` - see what's blocked and why - -### Navigating the task tree - -The tree has two relationships: -1. **Parent/children** (hierarchy): \`--parent <id>\` groups tasks. Use \`task tree\` and \`task children\`. -2. **Dependencies** (ordering): \`--deps <id>\` blocks execution. Shown as \`← [dep1, dep2]\` in tree output. - -To understand what workers will actually implement, find the **leaf tasks** (tasks with no children). -Parent tasks are just containers - workers execute leaf tasks. - -**Always navigate down to leaves:** -\`\`\` -task tree ${task.id} # see full structure -task children <parent> # drill into a branch -task show <leaf> # read the actual work item -\`\`\` - -When planning, ensure leaf tasks have: -- Clear acceptance criteria (verifiable by judge) -- Correct dependencies (won't run until deps are done) -- Appropriate priority (controls order among siblings) - -### Modifying tasks -- \`task create "title" --parent <id> --body "..." --accept "criterion" -p <priority>\` - create task -- \`task update <id> --title "..." --body "..." -p <priority>\` - update task properties -- \`task update <id> --accept "criterion"\` - add acceptance criterion -- \`task update <id> --clear-acceptance -a "new criterion"\` - replace all acceptance criteria -- \`task delete <id>\` - remove a task permanently -- \`task note <id> "content"\` - add planning notes -- \`task fail <id>\` - mark task as failed if catastrophically stuck - -### Priority (-p flag) -Priority controls execution order. **Lower number = higher priority** (executed first). -- \`-p 0\` - critical/urgent, do immediately -- \`-p 1\` - high priority -- \`-p 2\` - normal priority -- (no priority) - lowest, done after all prioritized tasks - -When user steers with urgent changes, set priority on new tasks to ensure they run BEFORE existing tasks. -Example: \`task create "Fix critical bug" --parent ${task.id} -p 0 --accept "..."\` -Example: \`task update <id> -p 0\` - bump existing task to run next - -## Your Scope - -You can reorganize ANY task under this scope. The TOP-LEVEL task's acceptance criteria are IMMUTABLE - they are the north star. Everything else can be: - -- **Deleted** if no longer relevant (use \`task delete <id>\`) -- **Reprioritized** to change execution order (use \`task update <id> -p <n>\`) -- **Broken down** into subtasks -- **Updated** with better acceptance criteria - -**You are FREE to delete tasks and start over.** If the plan isn't working, throw it away. If user steers in a new direction, delete obsolete tasks rather than leaving them in the queue. - -User steering notes (STEER:) override the task body - follow them immediately by: -1. Creating new tasks with high priority (-p 0 or -p 1) -2. Deleting tasks that are now obsolete -3. Adjusting priorities on existing tasks if needed - -## Writing Good Acceptance Criteria - -Acceptance criteria MUST be: -- **Verifiable**: Can be checked programmatically or with a clear command (e.g., "npm run test passes", "file X exists", "API returns 200") -- **Objective**: No subjective judgments like "code is clean" or "good performance" - use measurable thresholds -- **Specific**: Reference exact files, functions, endpoints, or behaviors -- **Complete**: Cover edge cases, error handling, and integration points - -Bad examples: -- "Code is well-written" (subjective) -- "Feature works" (vague) -- "Tests pass" (which tests?) - -Good examples: -- "npm run test passes with 0 failures" -- "GET /api/users returns 200 with JSON array" -- "File src/utils/parser.ts exports parseConfig function" -- "Running \`node cli.js --help\` prints usage information" - -## Propagating Requirements to Subtasks - -Both body guidance AND acceptance criteria must be propagated to subtasks. Break them -down so requirements are verified at each step, not deferred until the end. - -**Propagate body guidance** (process instructions β†’ subtask criteria): - -Example: Body says "commit after each chunk of work" - β†’ Add to EACH subtask: --accept "git status shows clean working tree" - -Example: Body says "run typecheck after each change" - β†’ Add to EACH subtask: --accept "npm run typecheck exits 0" - -**Propagate acceptance criteria** (scope them to the subtask's module/area): - -Example: Top-level says "no test.skip added" - β†’ Subtask for module X: --accept "no test.skip in src/modules/X/**" - -Example: Top-level says "npm test passes with 0 failures" - β†’ Subtask for auth module: --accept "npm test src/auth passes with 0 failures" - -Example: Top-level says "coverage >= 80%" - β†’ Subtask for utils: --accept "coverage for src/utils >= 80%" - -This ensures requirements are enforced incrementally. Don't wait until the end to -verify - by then it's too late to fix without rework. - -## TDD Pattern - -When the top-level task mentions "TDD" or "test-driven", you MUST structure subtasks as test-first pairs: - -1. **Write failing test** task (comes first) - - Acceptance criteria: test file exists AND test fails for the RIGHT reason - - The "right reason" means the test fails because the feature doesn't exist yet, NOT because of syntax errors, import errors, or unrelated failures - -2. **Make test pass** task (depends on the failing test task) - - Acceptance criteria: the specific test passes AND overall test suite passes - -Example breakdown for "Add user login endpoint (TDD)": - -\`\`\` -task create "Write failing test for POST /api/login" --parent {parent_id} \\ - --body "Write a test that calls POST /api/login with valid credentials and expects a JWT token response" \\ - --accept "File src/auth/login.test.ts exists" \\ - --accept "npm test fails with error message containing 'login' or 'api/login'" \\ - --accept "Test failure is due to missing endpoint (404 or 'not found'), NOT syntax/import errors" - -task create "Implement login endpoint to pass test" --parent {parent_id} --deps {previous_task_id} \\ - --body "Implement POST /api/login to make the test pass" \\ - --accept "npm test -- src/auth/login.test.ts passes" \\ - --accept "POST /api/login with valid credentials returns 200 with JWT token" -\`\`\` - -Key points: -- The failing test task MUST verify the test fails for the correct reason (missing feature, not broken code) -- The implementation task depends on the test task (enforced ordering) -- Each pair focuses on ONE specific behavior - -## Your Options - -1. If the current task is simple enough to implement directly, add a note explaining why and exit -2. If it needs breakdown, create subtasks with clear, verifiable acceptance criteria -3. If TDD is requested, use the test-first pair pattern above -4. If other tasks in the tree need reorganization based on what's been learned, do that -5. If a task is catastrophically stuck with no clear path forward (repeated failures WITHOUT progress), mark it failed - -Note: Multiple iterations are fine if there's progress. Only mark failed if truly stuck with no way forward. - -## Before You Exit - Sanity Check - -ALWAYS run these checks before finishing: - -1. **Run \`task plan ${task.id}\`** - verify the execution order makes sense -2. **Check priorities** - are urgent/steering tasks at the top (low priority numbers)? -3. **Check acceptance criteria** - does every pending task have clear, verifiable criteria? -4. **Clean up obsolete tasks** - delete anything that's no longer relevant - -If something looks wrong, fix it before exiting. - -DO NOT implement tasks. Only plan and organize. -When done planning, simply exit. Do not mark tasks as done. -`; -} - -function makeWorkerPrompt(task: Task, context: string, iteration: number): string { - return `${context} - ---- - -You are a WORKER agent implementing this task. - -Current iteration: ${iteration} - -## How to Work - -Make MEANINGFUL progress toward the acceptance criteria: -- If the task can be completed in one iteration, do it all -- If it's too large, make the largest meaningful chunk of progress you can -- Read the notes from previous iterations - don't repeat work, BUILD on it -- A judge will verify your work against the acceptance criteria and leave feedback - -The judge's feedback (in notes) tells you exactly what's still failing. Address it. - -## Rules - -- You CANNOT mark this task as done (a judge verifies completion) -- You MUST add a note documenting what you did: \`task note ${task.id} "WORKER: what you did"\` -- You CAN use \`task show ${task.id}\` to refresh context - -When you've made meaningful progress, add a note explaining what you did and exit. -`; -} - -function makeJudgePrompt(task: Task): string { - return `You are a JUDGE agent. Your ONLY job is to verify if acceptance criteria are met. - -${task.raw} - -## Your Instructions - -1. For EACH criterion, verify if it is satisfied by running commands and checking actual state -2. You may run commands to check (e.g., \`npm run test\`, \`npm run typecheck\`, check file existence, run the code) -3. After checking all criteria, output your verdict - -## FAIL EARLY for NOT_DONE - -For large tasks, the worker may yield after implementing incremental progress. Don't waste time -checking every criterion if it's obvious the task isn't complete. - -**Fail early strategy:** -- Start with a quick smoke test (e.g., does \`npm run typecheck\` or \`npm test\` pass?) -- If the smoke test fails badly, you can immediately return NOT_DONE -- If you find ANY failing criterion, you can immediately return NOT_DONE -- No need to check all criteria if you already know the verdict is NOT_DONE - -**Example:** If you run \`npm test\` and see 15 failures, don't methodically check each acceptance -criterion - just note the failures and return NOT_DONE. - -## For DONE: Full Verification Required - -Unlike NOT_DONE, you CANNOT shortcut DONE. To mark something DONE: -- You MUST verify EVERY acceptance criterion explicitly -- You MUST run the actual commands to check (not assume from context) -- You MUST confirm each criterion passes before concluding DONE -- No shortcuts, no assumptions, no "it probably works" - -## CRITICAL: No Excuses Policy - -You are evaluating RESULTS, not effort or intent. The following are NOT acceptable reasons to mark something DONE: - -- "The agent tried their best" - IRRELEVANT -- "It mostly works" - NOT_DONE -- "The environment wasn't set up correctly" - NOT_DONE (setup is part of the task) -- "This feature requires X which isn't available" - NOT_DONE (making it available is part of the task) -- "The agent documented why it couldn't complete" - NOT_DONE -- "It's a good start" - NOT_DONE -- "The core functionality works" - Check ALL criteria, not just "core" -- "This is blocked by external factors" - NOT_DONE - -The ONLY question: Does the acceptance criterion pass when verified? Yes or No. - -If an agent left notes explaining why something couldn't be done, IGNORE THE EXPLANATION. Just check: is the criterion met? - -## REQUIRED: Provide Feedback to the Worker - -After verifying, you MUST leave feedback for the worker by adding a note. The worker will read -this note in the next iteration - it's their ONLY way to know what went wrong and what's missing. - -\`\`\` -task note ${task.id} "JUDGE: [verdict] - [observed facts only]" -\`\`\` - -**CRITICAL: State ONLY observed facts. Do NOT provide solutions or debugging advice.** - -You are a verification agent, not a debugging assistant. The worker has access to the task body -which contains the full context and instructions. Your job is to report WHAT failed, not HOW to fix it. - -DO: -- State which tests failed and their error messages -- State which commands returned non-zero exit codes -- State which files are missing or have wrong content -- Quote exact error output - -DO NOT: -- Suggest fixes or solutions -- Explain why something might have failed -- Offer debugging strategies -- Recommend approaches or alternatives - -BAD: "3 tests fail - try mocking the database connection" -BAD: "typecheck fails - you need to add the missing type annotation" -GOOD: "3 tests fail: auth.test.ts:42 'expected 200, got 401', user.test.ts:15 'timeout after 5000ms'" -GOOD: "npm run typecheck exit 1: src/api.ts:23 - Property 'foo' does not exist on type 'Bar'" - -For non-test criteria, state observable facts about the code: -GOOD: "validateUser() in auth.ts:45-72 duplicates validateAdmin() in admin.ts:23-50 (criterion: no code duplication)" -GOOD: "processOrder() is 187 lines (criterion: functions under 50 lines)" -GOOD: "UserService calls database directly at line 34 (criterion: all DB access via repository layer)" -GOOD: "auth module exports JWT secret at line 12 (criterion: secrets not exported from modules)" - -The worker has the task body with full instructions. Just tell them what's broken. - -## Output Format - CRITICAL - -You MUST output one of these XML tags at the END of your response: - -If ALL criteria pass: -<VERDICT>DONE</VERDICT> - -If ANY criterion fails: -<VERDICT>NOT_DONE</VERDICT> - -If you do not include this exact XML tag, your verdict will not be recorded. -`; -} - -function getLogFile(): string { - let num = 0; - while (existsSync(`task-run.${num}.log`)) { - num++; - } - return `task-run.${num}.log`; -} - -function log(logFile: string, message: string): void { - const timestamp = new Date().toISOString(); - appendFileSync(logFile, `[${timestamp}] ${message}\n`); - process.stdout.write(`[${timestamp}] ${message}\n`); -} - -function parseJudgeVerdict(output: string): "DONE" | "NOT_DONE" | null { - // Find all matches and return the last one (in case reasoning mentions verdict earlier) - const matches = [...output.matchAll(/<VERDICT>(DONE|NOT_DONE)<\/VERDICT>/g)]; - if (matches.length === 0) return null; - return matches[matches.length - 1][1] as "DONE" | "NOT_DONE"; -} - -program - .command("run [scope]") - .description("Run agent loop on tasks with planner/worker/judge") - .option("--plan", "Enable planner agent") - .option("--planner <model>", "Planner model (default: gpt-5.2)", "gpt-5.2") - .option( - "--worker-model <model>", - "Worker model (default: sonnet). For GPT models, append effort: gpt-5.2-high", - "sonnet", - ) - .option("--judge-model <model>", "Judge model (default: haiku)", "haiku") - .option("--max-iterations <n>", "Max worker/judge iterations per task (0 = no limit)", "0") - .option("-w, --watch", "Keep running and wait for new tasks") - .action(async (scopeId: string | undefined, opts) => { - const enablePlanner = opts.plan; - const plannerModel = opts.planner as string; - const baseWorkerModel = opts.workerModel as string; - const judgeModel = opts.judgeModel as string; - const maxIterations = parseInt(opts.maxIterations, 10); - const watchMode = opts.watch; - const logFile = getLogFile(); - - process.stdout.write("Task Runner started (planner/worker/judge loop)\n"); - process.stdout.write(`Planner: ${enablePlanner ? plannerModel : "disabled"}\n`); - process.stdout.write(`Worker: ${baseWorkerModel}\n`); - process.stdout.write(`Judge: ${judgeModel}\n`); - process.stdout.write(`Max iterations: ${maxIterations === 0 ? "unlimited" : maxIterations}\n`); - if (scopeId) process.stdout.write(`Scope: ${scopeId}\n`); - process.stdout.write(`Log: ${logFile}\n`); - process.stdout.write("\n"); - - log( - logFile, - `Started with planner=${enablePlanner ? plannerModel : "disabled"} worker=${baseWorkerModel} judge=${judgeModel} maxIter=${maxIterations} scope=${scopeId || "all"}`, - ); - - const runPlanner = async (task: Task, reason: string): Promise<boolean> => { - log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`); - const context = await buildTaskContext(task, scopeId); - const plannerPrompt = makePlannerPrompt(task, context, reason); - await runAgentWithModel(plannerPrompt, plannerModel, logFile); - - // Check if planner created subtasks for this task - const children = await store.getChildren(task.id); - if (children.some((c) => c.status !== "done")) { - log(logFile, `[PLANNER] Task has pending children, will process those first`); - return true; // Signal to restart loop - } - - // Check if planner marked task as failed - const updatedTask = await store.get(task.id); - if (updatedTask?.status === "failed") { - log(logFile, `[PLANNER] Marked task as failed`); - return true; // Signal to continue to next task - } - - return false; - }; - - const runTaskLoop = async (): Promise<void> => { - // First check for in_progress tasks (resuming from crash) - const allTasks = await store.list(); - let candidates = scopeId - ? ([await store.get(scopeId), ...(await store.getDescendants(scopeId))].filter( - Boolean, - ) as Task[]) - : allTasks; - - const inProgress = candidates.filter((t) => t.status === "in_progress"); - if (inProgress.length > 0) { - log( - logFile, - `Found ${inProgress.length} in_progress task(s) from previous run, resuming...`, - ); - for (const t of inProgress) { - await store.update(t.id, { status: "open" }); - log(logFile, `Reset ${t.id} to open`); - } - } - - // Step 1: Initial planner run on scope root (if enabled) - if (enablePlanner && scopeId) { - const scopeTask = await store.get(scopeId); - if (scopeTask) { - log(logFile, `[PLANNER] Initial planning on scope root...`); - await runPlanner(scopeTask, "initial planning"); - log(logFile, `[DEBUG] Planner finished, continuing to worker loop`); - } - } - - log(logFile, `[DEBUG] Entering worker loop`); - - // Step 2: Worker/Judge loop on ready tasks - while (true) { - log(logFile, `[DEBUG] Checking for ready tasks...`); - const ready = await store.getReady(scopeId); - log(logFile, `[DEBUG] Found ${ready.length} ready tasks`); - if (ready.length === 0) { - log(logFile, `[DEBUG] No ready tasks, exiting loop`); - break; - } - - const task = ready[0]; - // CLI --worker-model takes precedence, then task assignee, then default - let workerModel = baseWorkerModel; - if (!workerModel && task.assignee) { - workerModel = task.assignee === "codex" ? "gpt-5.2-codex" : task.assignee; - } - const canUpgrade = workerModel === "sonnet"; // Only upgrade if starting from sonnet - - log(logFile, `\n=== Starting task: ${task.id} - ${task.title} ===`); - await store.start(task.id); - - // Worker/Judge loop - let iteration = 1; - let taskDone = false; - let consecutiveNotDone = 0; - // Track steering notes on scope (not leaf task) since that's where user adds them - const scopeTaskForSteer = scopeId ? await store.get(scopeId) : null; - let lastSeenSteerCount = - scopeTaskForSteer?.notes.filter((n) => n.content.startsWith("STEER:")).length ?? 0; - - while ((maxIterations === 0 || iteration <= maxIterations) && !taskDone) { - const iterLabel = maxIterations === 0 ? `${iteration}` : `${iteration}/${maxIterations}`; - log(logFile, `[WORKER] Iteration ${iterLabel} with ${workerModel}...`); - - // Check for new steering notes on scope before worker runs - if (enablePlanner && scopeId) { - const freshScope = await store.get(scopeId); - if (freshScope) { - const currentSteerCount = freshScope.notes.filter((n) => - n.content.startsWith("STEER:"), - ).length; - if (currentSteerCount > lastSeenSteerCount) { - const newSteers = freshScope.notes - .filter((n) => n.content.startsWith("STEER:")) - .slice(lastSeenSteerCount) - .map((n) => n.content.replace(/^STEER:\s*/, "")); - log(logFile, `[STEER] New steering note detected - triggering planner`); - lastSeenSteerCount = currentSteerCount; - const reason = `User steering: ${newSteers.join("; ")}`; - const shouldRestart = await runPlanner(freshScope, reason); - if (shouldRestart) { - const updatedTask = await store.get(task.id); - if (updatedTask && updatedTask.status === "in_progress") { - await store.update(task.id, { status: "open" }); - } - break; - } - } - } - } - - // Refresh task context (notes may have been added) - const freshTask = await store.get(task.id); - if (!freshTask) break; - - const freshContext = await buildTaskContext(freshTask, scopeId); - const workerPrompt = makeWorkerPrompt(freshTask, freshContext, iteration); - await runAgentWithModel(workerPrompt, workerModel, logFile); - - // Step 3: Judge - log(logFile, `[JUDGE] Verifying with ${judgeModel}...`); - const judgeTask = await store.get(task.id); - if (!judgeTask) break; - - const judgePrompt = makeJudgePrompt(judgeTask); - const judgeResult = await runAgentWithModel(judgePrompt, judgeModel, logFile); - - const verdict = parseJudgeVerdict(judgeResult.output); - log(logFile, `[JUDGE] Verdict: ${verdict || "UNKNOWN"}`); - - if (verdict === "DONE") { - await store.close(task.id); - log(logFile, `βœ… Task ${task.id} completed`); - taskDone = true; - consecutiveNotDone = 0; - } else { - consecutiveNotDone++; - // Upgrade to opus on first NOT_DONE (if not already using opus) - if (canUpgrade && workerModel === "sonnet") { - workerModel = "opus"; - log(logFile, `[WORKER] Upgrading to opus after NOT_DONE`); - } - // Only replan after 5 consecutive NOT_DONEs - use scope for bird's eye view - if (enablePlanner && consecutiveNotDone >= 5 && scopeId) { - log( - logFile, - `[JUDGE] ${consecutiveNotDone} consecutive NOT_DONE on ${task.id} - triggering planner`, - ); - const scopeTask = await store.get(scopeId); - if (scopeTask) { - const reason = `task ${task.id} "${task.title}" got ${consecutiveNotDone} consecutive NOT_DONE`; - const shouldRestart = await runPlanner(scopeTask, reason); - consecutiveNotDone = 0; - if (shouldRestart) { - // Planner created subtasks or marked failed, restart the main loop - const updatedTask = await store.get(task.id); - if (updatedTask && updatedTask.status === "in_progress") { - await store.update(task.id, { status: "open" }); - } - break; - } - } - } - iteration++; - } - } - - if (!taskDone && maxIterations > 0) { - const finalTask = await store.get(task.id); - if (finalTask && finalTask.status === "in_progress") { - log(logFile, `⚠️ Task ${task.id} not completed after ${maxIterations} iterations`); - // Reset to open so it can be picked up again (planner may have adjusted things) - await store.update(task.id, { status: "open" }); - } - } - } - }; - - await runTaskLoop(); - - if (watchMode) { - process.stdout.write("πŸ’€ Waiting for new tasks...\n"); - while (true) { - await new Promise((r) => setTimeout(r, 5000)); - const ready = await store.getReady(scopeId); - if (ready.length > 0) { - await runTaskLoop(); - process.stdout.write("πŸ’€ Waiting for new tasks...\n"); - } - } - } - - process.stdout.write("\n"); - process.stdout.write(`All tasks complete. (${new Date().toISOString()})\n`); - log(logFile, "All tasks complete"); - }); - -program.parse();