diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md deleted file mode 100644 index f12c02a3..00000000 --- a/SECURITY_TODO.md +++ /dev/null @@ -1,300 +0,0 @@ -# Security Audit Findings - v0.13.0rc Branch - -**Date:** $(date) -**Audit Type:** Git diff security review against v0.13.0rc branch -**Status:** ⚠️ Security vulnerabilities found - requires fixes before release - -## Executive Summary - -No intentionally malicious code was detected in the changes. However, several **critical security vulnerabilities** were identified that could allow command injection attacks. These must be fixed before release. - ---- - -## 🔴 Critical Security Issues - -### 1. Command Injection in Merge Handler - -**File:** `apps/server/src/routes/worktree/routes/merge.ts` -**Lines:** 43, 54, 65-66, 93 -**Severity:** CRITICAL - -**Issue:** -User-controlled inputs (`branchName`, `mergeTo`, `options?.message`) are directly interpolated into shell commands without validation, allowing command injection attacks. - -**Vulnerable Code:** - -```typescript -// Line 43 - branchName not validated -await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); - -// Line 54 - mergeTo not validated -await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath }); - -// Lines 65-66 - branchName and message not validated -const mergeCmd = options?.squash - ? `git merge --squash ${branchName}` - : `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`; - -// Line 93 - message not sanitized -await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, { - cwd: projectPath, -}); -``` - -**Attack Vector:** -An attacker could inject shell commands via branch names or commit messages: - -- Branch name: `main; rm -rf /` -- Commit message: `"; malicious_command; "` - -**Fix Required:** - -1. Validate `branchName` and `mergeTo` using `isValidBranchName()` before use -2. Sanitize commit messages or use `execGitCommand` with proper escaping -3. Replace `execAsync` template literals with `execGitCommand` array-based calls - -**Note:** `isValidBranchName` is imported but only used AFTER deletion (line 119), not before execAsync calls. - ---- - -### 2. Command Injection in Push Handler - -**File:** `apps/server/src/routes/worktree/routes/push.ts` -**Lines:** 44, 49 -**Severity:** CRITICAL - -**Issue:** -User-controlled `remote` parameter and `branchName` are directly interpolated into shell commands without validation. - -**Vulnerable Code:** - -```typescript -// Line 38 - remote defaults to 'origin' but not validated -const targetRemote = remote || 'origin'; - -// Lines 44, 49 - targetRemote and branchName not validated -await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, { - cwd: worktreePath, -}); -await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, { - cwd: worktreePath, -}); -``` - -**Attack Vector:** -An attacker could inject commands via the remote name: - -- Remote: `origin; malicious_command; #` - -**Fix Required:** - -1. Validate `targetRemote` parameter (alphanumeric + `-`, `_` only) -2. Validate `branchName` before use (even though it comes from git output) -3. Use `execGitCommand` with array arguments instead of template literals - ---- - -### 3. Unsafe Environment Variable Export in Shell Script - -**File:** `start-automaker.sh` -**Lines:** 5068, 5085 -**Severity:** CRITICAL - -**Issue:** -Unsafe parsing and export of `.env` file contents using `xargs` without proper handling of special characters. - -**Vulnerable Code:** - -```bash -export $(grep -v '^#' .env | xargs) -``` - -**Attack Vector:** -If `.env` file contains malicious content with spaces, special characters, or code, it could be executed: - -- `.env` entry: `VAR="value; malicious_command"` -- Could lead to code execution during startup - -**Fix Required:** -Replace with safer parsing method: - -```bash -# Safer approach -set -a -source <(grep -v '^#' .env | sed 's/^/export /') -set +a - -# Or even safer - validate each line -while IFS= read -r line; do - [[ "$line" =~ ^[[:space:]]*# ]] && continue - [[ -z "$line" ]] && continue - if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then - export "${BASH_REMATCH[1]}"="${BASH_REMATCH[2]}" - fi -done < .env -``` - ---- - -## 🟡 Moderate Security Concerns - -### 4. Inconsistent Use of Secure Command Execution - -**Issue:** -The codebase has `execGitCommand()` function available (which uses array arguments and is safer), but it's not consistently used. Some places still use `execAsync` with template literals. - -**Files Affected:** - -- `apps/server/src/routes/worktree/routes/merge.ts` -- `apps/server/src/routes/worktree/routes/push.ts` - -**Recommendation:** - -- Audit all `execAsync` calls with template literals -- Replace with `execGitCommand` where possible -- Document when `execAsync` is acceptable (only with fully validated inputs) - ---- - -### 5. Missing Input Validation - -**Issues:** - -1. `targetRemote` in `push.ts` defaults to 'origin' but isn't validated -2. Commit messages in `merge.ts` aren't sanitized before use in shell commands -3. `worktreePath` validation relies on middleware but should be double-checked - -**Recommendation:** - -- Add validation functions for remote names -- Sanitize commit messages (remove shell metacharacters) -- Add defensive validation even when middleware exists - ---- - -## ✅ Positive Security Findings - -1. **No Hardcoded Credentials:** No API keys, passwords, or tokens found in the diff -2. **No Data Exfiltration:** No suspicious network requests or data transmission patterns -3. **No Backdoors:** No hidden functionality or unauthorized access patterns detected -4. **Safe Command Execution:** `execGitCommand` function properly uses array arguments in some places -5. **Environment Variable Handling:** `init-script-service.ts` properly sanitizes environment variables (lines 194-220) - ---- - -## 📋 Action Items - -### Immediate (Before Release) - -- [ ] **Fix command injection in `merge.ts`** - - [ ] Validate `branchName` with `isValidBranchName()` before line 43 - - [ ] Validate `mergeTo` with `isValidBranchName()` before line 54 - - [ ] Sanitize commit messages or use `execGitCommand` for merge commands - - [ ] Replace `execAsync` template literals with `execGitCommand` array calls - -- [ ] **Fix command injection in `push.ts`** - - [ ] Add validation function for remote names - - [ ] Validate `targetRemote` before use - - [ ] Validate `branchName` before use (defensive programming) - - [ ] Replace `execAsync` template literals with `execGitCommand` - -- [ ] **Fix shell script security issue** - - [ ] Replace unsafe `export $(grep ... | xargs)` with safer parsing - - [ ] Add validation for `.env` file contents - - [ ] Test with edge cases (spaces, special chars, quotes) - -### Short-term (Next Sprint) - -- [ ] **Audit all `execAsync` calls** - - [ ] Create inventory of all `execAsync` calls with template literals - - [ ] Replace with `execGitCommand` where possible - - [ ] Document exceptions and why they're safe - -- [ ] **Add input validation utilities** - - [ ] Create `isValidRemoteName()` function - - [ ] Create `sanitizeCommitMessage()` function - - [ ] Add validation for all user-controlled inputs - -- [ ] **Security testing** - - [ ] Add unit tests for command injection prevention - - [ ] Add integration tests with malicious inputs - - [ ] Test shell script with malicious `.env` files - -### Long-term (Security Hardening) - -- [ ] **Code review process** - - [ ] Add security checklist for PR reviews - - [ ] Require security review for shell command execution changes - - [ ] Add automated security scanning - -- [ ] **Documentation** - - [ ] Document secure coding practices for shell commands - - [ ] Create security guidelines for contributors - - [ ] Add security section to CONTRIBUTING.md - ---- - -## 🔍 Testing Recommendations - -### Command Injection Tests - -```typescript -// Test cases for merge.ts -describe('merge handler security', () => { - it('should reject branch names with shell metacharacters', () => { - // Test: branchName = "main; rm -rf /" - // Expected: Validation error, command not executed - }); - - it('should sanitize commit messages', () => { - // Test: message = '"; malicious_command; "' - // Expected: Sanitized or rejected - }); -}); - -// Test cases for push.ts -describe('push handler security', () => { - it('should reject remote names with shell metacharacters', () => { - // Test: remote = "origin; malicious_command; #" - // Expected: Validation error, command not executed - }); -}); -``` - -### Shell Script Tests - -```bash -# Test with malicious .env content -echo 'VAR="value; echo PWNED"' > test.env -# Expected: Should not execute the command - -# Test with spaces in values -echo 'VAR="value with spaces"' > test.env -# Expected: Should handle correctly - -# Test with special characters -echo 'VAR="value\$with\$dollars"' > test.env -# Expected: Should handle correctly -``` - ---- - -## 📚 References - -- [OWASP Command Injection](https://owasp.org/www-community/attacks/Command_Injection) -- [Node.js Child Process Security](https://nodejs.org/api/child_process.html#child_process_security_concerns) -- [Shell Script Security Best Practices](https://mywiki.wooledge.org/BashGuide/Practices) - ---- - -## Notes - -- All findings are based on code diff analysis -- No runtime testing was performed -- Assumes attacker has access to API endpoints (authenticated or unauthenticated) -- Fixes should be tested thoroughly before deployment - ---- - -**Last Updated:** $(date) -**Next Review:** After fixes are implemented diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 4ea7cf34..00000000 --- a/TODO.md +++ /dev/null @@ -1,25 +0,0 @@ -# Bugs - -- Setting the default model does not seem like it works. - -# Performance (completed) - -- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering) -- [x] Render containment on heavy scroll regions (kanban columns, chat history) -- [x] Reduce blur/shadow effects when lists get large -- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect) -- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections) - -# UX - -- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff -- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex. -- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live -- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card. -- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them. -- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time. -- Typing in the text area of the plan mode was super laggy. -- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something. -- modals are not scrollable if height of the screen is small enough -- and the Agent Runner add an archival button for the new sessions. -- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue. diff --git a/graph-layout-bug.md b/graph-layout-bug.md deleted file mode 100644 index c78ab118..00000000 --- a/graph-layout-bug.md +++ /dev/null @@ -1,203 +0,0 @@ -# Graph View Layout Bug - -## Problem - -When navigating directly to the graph view route (e.g., refreshing on `/graph` or opening the app on that route), all feature cards appear in a single vertical column instead of being properly arranged in a hierarchical dependency graph. - -**Works correctly when:** User navigates to Kanban view first, then to Graph view. -**Broken when:** User loads the graph route directly (refresh, direct URL, app opens on that route). - -## Expected Behavior - -Nodes should be positioned by the dagre layout algorithm in a hierarchical DAG based on their dependency relationships (edges). - -## Actual Behavior - -All nodes appear stacked in a single column/row, as if dagre computed the layout with no edges. - -## Technology Stack - -- React 19 -- @xyflow/react (React Flow) for graph rendering -- dagre for layout algorithm -- Zustand for state management - -## Architecture - -### Data Flow - -1. `GraphViewPage` loads features via `useBoardFeatures` hook -2. Shows loading spinner while `isLoading === true` -3. When loaded, renders `GraphView` → `GraphCanvas` -4. `GraphCanvas` uses three hooks: - - `useGraphNodes`: Transforms features → React Flow nodes and edges (edges from `feature.dependencies`) - - `useGraphLayout`: Applies dagre layout to position nodes based on edges - - `useNodesState`/`useEdgesState`: React Flow's state management - -### Key Files - -- `apps/ui/src/components/views/graph-view-page.tsx` - Page component with loading state -- `apps/ui/src/components/views/graph-view/graph-canvas.tsx` - React Flow integration -- `apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts` - Dagre layout logic -- `apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts` - Feature → node/edge transformation -- `apps/ui/src/components/views/board-view/hooks/use-board-features.ts` - Data fetching - -## Relevant Code - -### use-graph-layout.ts (layout computation) - -```typescript -export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) { - const positionCache = useRef>(new Map()); - const lastStructureKey = useRef(''); - const layoutVersion = useRef(0); - - const getLayoutedElements = useCallback((inputNodes, inputEdges, direction = 'LR') => { - const dagreGraph = new dagre.graphlib.Graph(); - dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 100 }); - - inputNodes.forEach((node) => { - dagreGraph.setNode(node.id, { width: 280, height: 120 }); - }); - - inputEdges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target); // THIS IS WHERE EDGES MATTER - }); - - dagre.layout(dagreGraph); - // ... returns positioned nodes - }, []); - - // Structure key includes both nodes AND edges - const structureKey = useMemo(() => { - const nodeIds = nodes - .map((n) => n.id) - .sort() - .join(','); - const edgeConnections = edges - .map((e) => `${e.source}->${e.target}`) - .sort() - .join(','); - return `${nodeIds}|${edgeConnections}`; - }, [nodes, edges]); - - const layoutedElements = useMemo(() => { - if (nodes.length === 0) return { nodes: [], edges: [] }; - - const structureChanged = structureKey !== lastStructureKey.current; - if (structureChanged) { - lastStructureKey.current = structureKey; - layoutVersion.current += 1; - return getLayoutedElements(nodes, edges, 'LR'); // Full layout with edges - } else { - // Use cached positions - } - }, [nodes, edges, structureKey, getLayoutedElements]); - - return { layoutedNodes, layoutedEdges, layoutVersion: layoutVersion.current, runLayout }; -} -``` - -### graph-canvas.tsx (React Flow integration) - -```typescript -function GraphCanvasInner({ features, ... }) { - // Transform features to nodes/edges - const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({ features, ... }); - - // Apply layout - const { layoutedNodes, layoutedEdges, layoutVersion, runLayout } = useGraphLayout({ - nodes: initialNodes, - edges: initialEdges, - }); - - // React Flow state - INITIALIZES with layoutedNodes - const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges); - - // Effect to update nodes when layout changes - useEffect(() => { - // ... updates nodes/edges state when layoutedNodes/layoutedEdges change - }, [layoutedNodes, layoutedEdges, layoutVersion, ...]); - - // Attempted fix: Force layout after mount when edges are available - useEffect(() => { - if (!hasLayoutWithEdges.current && layoutedNodes.length > 0 && layoutedEdges.length > 0) { - hasLayoutWithEdges.current = true; - setTimeout(() => runLayout('LR'), 100); - } - }, [layoutedNodes.length, layoutedEdges.length, runLayout]); - - return ; -} -``` - -### use-board-features.ts (data loading) - -```typescript -export function useBoardFeatures({ currentProject }) { - const { features, setFeatures } = useAppStore(); // From Zustand store - const [isLoading, setIsLoading] = useState(true); - - const loadFeatures = useCallback(async () => { - setIsLoading(true); - const result = await api.features.getAll(currentProject.path); - if (result.success) { - const featuresWithIds = result.features.map((f) => ({ - ...f, // dependencies come from here via spread - id: f.id || `...`, - status: f.status || 'backlog', - })); - setFeatures(featuresWithIds); // Updates Zustand store - } - setIsLoading(false); - }, [currentProject, setFeatures]); - - useEffect(() => { loadFeatures(); }, [loadFeatures]); - - return { features, isLoading, ... }; // features is from useAppStore() -} -``` - -### graph-view-page.tsx (loading gate) - -```typescript -export function GraphViewPage() { - const { features: hookFeatures, isLoading } = useBoardFeatures({ currentProject }); - - if (isLoading) { - return ; // Graph doesn't render until loading is done - } - - return ; -} -``` - -## What I've Tried - -1. **Added edges to structureKey** - So layout recalculates when dependencies change, not just when nodes change - -2. **Added layoutVersion tracking** - To signal when a fresh layout was computed vs cached positions used - -3. **Track layoutVersion in GraphCanvas** - To detect when to apply fresh positions instead of preserving old ones - -4. **Force runLayout after mount** - Added useEffect that calls `runLayout('LR')` after 100ms when nodes and edges are available - -5. **Reset all refs on project change** - Clear layout state when switching projects - -## Hypothesis - -The issue appears to be a timing/race condition where: - -- When going Kanban → Graph: Features are already in Zustand store, so graph mounts with complete data -- When loading Graph directly: Something causes the initial layout to compute before edges are properly available, or the layout result isn't being applied to React Flow's state correctly - -The fact that clicking Kanban then Graph works suggests the data IS correct, just something about the initial render timing when loading the route directly. - -## Questions to Investigate - -1. Is `useNodesState(layoutedNodes)` capturing stale initial positions? -2. Is there a React 19 / StrictMode double-render issue with the refs? -3. Is React Flow's `fitView` prop interfering with initial positions? -4. Is there a race between Zustand store updates and React renders? -5. Should the graph component not render until layout is definitively computed with edges? diff --git a/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx deleted file mode 100644 index 69392afa..00000000 --- a/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ /dev/null @@ -1,1582 +0,0 @@ -import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; -import { cn } from '@/lib/utils'; -import { useAppStore } from '@/store/app-store'; -import { useIsMobile } from '@/hooks/use-media-query'; -import type { - ModelAlias, - CursorModelId, - CodexModelId, - OpencodeModelId, - GroupedModel, - PhaseModelEntry, -} from '@automaker/types'; -import { - stripProviderPrefix, - STANDALONE_CURSOR_MODELS, - getModelGroup, - isGroupSelected, - getSelectedVariant, - codexModelHasThinking, -} from '@automaker/types'; -import { - CLAUDE_MODELS, - CURSOR_MODELS, - OPENCODE_MODELS, - THINKING_LEVELS, - THINKING_LEVEL_LABELS, - REASONING_EFFORT_LEVELS, - REASONING_EFFORT_LABELS, - type ModelOption, -} from '@/components/views/board-view/shared/model-constants'; -import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; -import { - AnthropicIcon, - CursorIcon, - OpenAIIcon, - getProviderIconForModel, -} from '@/components/ui/provider-icon'; -import { Button } from '@/components/ui/button'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from '@/components/ui/command'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; - -const OPENCODE_CLI_GROUP_LABEL = 'OpenCode CLI'; -const OPENCODE_PROVIDER_FALLBACK = 'opencode'; -const OPENCODE_PROVIDER_WORD_SEPARATOR = '-'; -const OPENCODE_MODEL_ID_SEPARATOR = '/'; -const OPENCODE_SECTION_GROUP_PADDING = 'pt-2'; - -const OPENCODE_STATIC_PROVIDER_LABELS: Record = { - [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', -}; - -const OPENCODE_DYNAMIC_PROVIDER_LABELS: Record = { - 'github-copilot': 'GitHub Copilot', - 'zai-coding-plan': 'Z.AI Coding Plan', - google: 'Google AI', - openai: 'OpenAI', - openrouter: 'OpenRouter', - anthropic: 'Anthropic', - xai: 'xAI', - deepseek: 'DeepSeek', - ollama: 'Ollama (Local)', - lmstudio: 'LM Studio (Local)', - azure: 'Azure OpenAI', - [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', -}; - -const OPENCODE_DYNAMIC_PROVIDER_ORDER = [ - 'github-copilot', - 'google', - 'openai', - 'openrouter', - 'anthropic', - 'xai', - 'deepseek', - 'ollama', - 'lmstudio', - 'azure', - 'zai-coding-plan', -]; - -const OPENCODE_SECTION_ORDER = ['free', 'dynamic'] as const; - -const OPENCODE_SECTION_LABELS: Record<(typeof OPENCODE_SECTION_ORDER)[number], string> = { - free: 'Free Tier', - dynamic: 'Connected Providers', -}; - -const OPENCODE_STATIC_PROVIDER_BY_ID = new Map( - OPENCODE_MODELS.map((model) => [model.id, model.provider]) -); - -function formatProviderLabel(providerKey: string): string { - return providerKey - .split(OPENCODE_PROVIDER_WORD_SEPARATOR) - .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word)) - .join(' '); -} - -function getOpencodeSectionKey(providerKey: string): (typeof OPENCODE_SECTION_ORDER)[number] { - if (providerKey === OPENCODE_PROVIDER_FALLBACK) { - return 'free'; - } - return 'dynamic'; -} - -function getOpencodeGroupLabel( - providerKey: string, - sectionKey: (typeof OPENCODE_SECTION_ORDER)[number] -): string { - if (sectionKey === 'free') { - return OPENCODE_STATIC_PROVIDER_LABELS[providerKey] || 'OpenCode Free Tier'; - } - return OPENCODE_DYNAMIC_PROVIDER_LABELS[providerKey] || formatProviderLabel(providerKey); -} - -interface PhaseModelSelectorProps { - /** Label shown in full mode */ - label?: string; - /** Description shown in full mode */ - description?: string; - /** Current model selection */ - value: PhaseModelEntry; - /** Callback when model is selected */ - onChange: (entry: PhaseModelEntry) => void; - /** Compact mode - just shows the button trigger without label/description wrapper */ - compact?: boolean; - /** Custom trigger class name */ - triggerClassName?: string; - /** Popover alignment */ - align?: 'start' | 'end'; - /** Disabled state */ - disabled?: boolean; -} - -export function PhaseModelSelector({ - label, - description, - value, - onChange, - compact = false, - triggerClassName, - align = 'end', - disabled = false, -}: PhaseModelSelectorProps) { - const [open, setOpen] = useState(false); - const [expandedGroup, setExpandedGroup] = useState(null); - const [expandedClaudeModel, setExpandedClaudeModel] = useState(null); - const [expandedCodexModel, setExpandedCodexModel] = useState(null); - const commandListRef = useRef(null); - const expandedTriggerRef = useRef(null); - const expandedClaudeTriggerRef = useRef(null); - const expandedCodexTriggerRef = useRef(null); - const { - enabledCursorModels, - favoriteModels, - toggleFavoriteModel, - codexModels, - codexModelsLoading, - fetchCodexModels, - dynamicOpencodeModels, - enabledDynamicModelIds, - opencodeModelsLoading, - fetchOpencodeModels, - disabledProviders, - } = useAppStore(); - - // Detect mobile devices to use inline expansion instead of nested popovers - const isMobile = useIsMobile(); - - // Extract model and thinking/reasoning levels from value - const selectedModel = value.model; - const selectedThinkingLevel = value.thinkingLevel || 'none'; - const selectedReasoningEffort = value.reasoningEffort || 'none'; - - // Fetch Codex models on mount - useEffect(() => { - if (codexModels.length === 0 && !codexModelsLoading) { - fetchCodexModels().catch(() => { - // Silently fail - user will see empty Codex section - }); - } - }, [codexModels.length, codexModelsLoading, fetchCodexModels]); - - // Fetch OpenCode models on mount - useEffect(() => { - if (dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) { - fetchOpencodeModels().catch(() => { - // Silently fail - user will see only static OpenCode models - }); - } - }, [dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]); - - // Close expanded group when trigger scrolls out of view - useEffect(() => { - const triggerElement = expandedTriggerRef.current; - const listElement = commandListRef.current; - if (!triggerElement || !listElement || !expandedGroup) return; - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (!entry.isIntersecting) { - setExpandedGroup(null); - } - }, - { - root: listElement, - threshold: 0.1, // Close when less than 10% visible - } - ); - - observer.observe(triggerElement); - return () => observer.disconnect(); - }, [expandedGroup]); - - // Close expanded Claude model popover when trigger scrolls out of view - useEffect(() => { - const triggerElement = expandedClaudeTriggerRef.current; - const listElement = commandListRef.current; - if (!triggerElement || !listElement || !expandedClaudeModel) return; - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (!entry.isIntersecting) { - setExpandedClaudeModel(null); - } - }, - { - root: listElement, - threshold: 0.1, - } - ); - - observer.observe(triggerElement); - return () => observer.disconnect(); - }, [expandedClaudeModel]); - - // Close expanded Codex model popover when trigger scrolls out of view - useEffect(() => { - const triggerElement = expandedCodexTriggerRef.current; - const listElement = commandListRef.current; - if (!triggerElement || !listElement || !expandedCodexModel) return; - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (!entry.isIntersecting) { - setExpandedCodexModel(null); - } - }, - { - root: listElement, - threshold: 0.1, - } - ); - - observer.observe(triggerElement); - return () => observer.disconnect(); - }, [expandedCodexModel]); - - // Transform dynamic Codex models from store to component format - const transformedCodexModels = useMemo(() => { - return codexModels.map((model) => ({ - id: model.id, - label: model.label, - description: model.description, - provider: 'codex' as const, - badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Speed' : undefined, - })); - }, [codexModels]); - - // Filter Cursor models to only show enabled ones - // With canonical IDs, both CURSOR_MODELS and enabledCursorModels use prefixed format - const availableCursorModels = CURSOR_MODELS.filter((model) => { - return enabledCursorModels.includes(model.id as CursorModelId); - }); - - // Helper to find current selected model details - const currentModel = useMemo(() => { - const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel); - if (claudeModel) { - // Add thinking level to label if not 'none' - const thinkingLabel = - selectedThinkingLevel !== 'none' - ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)` - : ''; - return { - ...claudeModel, - label: `${claudeModel.label}${thinkingLabel}`, - icon: AnthropicIcon, - }; - } - - // With canonical IDs, direct comparison works - const cursorModel = availableCursorModels.find((m) => m.id === selectedModel); - if (cursorModel) return { ...cursorModel, icon: CursorIcon }; - - // Check if selectedModel is part of a grouped model - const group = getModelGroup(selectedModel as CursorModelId); - if (group) { - const variant = getSelectedVariant(group, selectedModel as CursorModelId); - return { - id: selectedModel, - label: `${group.label} (${variant?.label || 'Unknown'})`, - description: group.description, - provider: 'cursor' as const, - icon: CursorIcon, - }; - } - - // Check Codex models - const codexModel = transformedCodexModels.find((m) => m.id === selectedModel); - if (codexModel) return { ...codexModel, icon: OpenAIIcon }; - - // Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons - const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); - if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) }; - - // Check dynamic OpenCode models - use dynamic icon resolution for provider-specific icons - const dynamicModel = dynamicOpencodeModels.find((m) => m.id === selectedModel); - if (dynamicModel) { - return { - id: dynamicModel.id, - label: dynamicModel.name, - description: dynamicModel.description, - provider: 'opencode' as const, - icon: getProviderIconForModel(dynamicModel.id), - }; - } - - return null; - }, [ - selectedModel, - selectedThinkingLevel, - availableCursorModels, - transformedCodexModels, - dynamicOpencodeModels, - ]); - - // Compute grouped vs standalone Cursor models - const { groupedModels, standaloneCursorModels } = useMemo(() => { - const grouped: GroupedModel[] = []; - const standalone: typeof CURSOR_MODELS = []; - const seenGroups = new Set(); - - availableCursorModels.forEach((model) => { - const cursorId = model.id as CursorModelId; - - // Check if this model is standalone - if (STANDALONE_CURSOR_MODELS.includes(cursorId)) { - standalone.push(model); - return; - } - - // Check if this model belongs to a group - const group = getModelGroup(cursorId); - if (group && !seenGroups.has(group.baseId)) { - // Filter variants to only include enabled models - const enabledVariants = group.variants.filter((v) => enabledCursorModels.includes(v.id)); - if (enabledVariants.length > 0) { - grouped.push({ - ...group, - variants: enabledVariants, - }); - seenGroups.add(group.baseId); - } - } - }); - - return { groupedModels: grouped, standaloneCursorModels: standalone }; - }, [availableCursorModels, enabledCursorModels]); - - // Combine static and dynamic OpenCode models - const allOpencodeModels: ModelOption[] = useMemo(() => { - // Start with static models - const staticModels = [...OPENCODE_MODELS]; - - // Add dynamic models (convert ModelDefinition to ModelOption) - // Only include dynamic models that are enabled by the user - const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels - .filter((model) => enabledDynamicModelIds.includes(model.id)) - .map((model) => ({ - id: model.id, - label: model.name, - description: model.description, - badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined, - provider: 'opencode' as const, - })); - - // Merge, avoiding duplicates (static models take precedence for same ID) - // In practice, static and dynamic IDs don't overlap - const staticIds = new Set(staticModels.map((m) => m.id)); - const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id)); - - return [...staticModels, ...uniqueDynamic]; - }, [dynamicOpencodeModels, enabledDynamicModelIds]); - - // Group models (filtering out disabled providers) - const { favorites, claude, cursor, codex, opencode } = useMemo(() => { - const favs: typeof CLAUDE_MODELS = []; - const cModels: typeof CLAUDE_MODELS = []; - const curModels: typeof CURSOR_MODELS = []; - const codModels: typeof transformedCodexModels = []; - const ocModels: ModelOption[] = []; - - const isClaudeDisabled = disabledProviders.includes('claude'); - const isCursorDisabled = disabledProviders.includes('cursor'); - const isCodexDisabled = disabledProviders.includes('codex'); - const isOpencodeDisabled = disabledProviders.includes('opencode'); - - // Process Claude Models (skip if provider is disabled) - if (!isClaudeDisabled) { - CLAUDE_MODELS.forEach((model) => { - if (favoriteModels.includes(model.id)) { - favs.push(model); - } else { - cModels.push(model); - } - }); - } - - // Process Cursor Models (skip if provider is disabled) - if (!isCursorDisabled) { - availableCursorModels.forEach((model) => { - if (favoriteModels.includes(model.id)) { - favs.push(model); - } else { - curModels.push(model); - } - }); - } - - // Process Codex Models (skip if provider is disabled) - if (!isCodexDisabled) { - transformedCodexModels.forEach((model) => { - if (favoriteModels.includes(model.id)) { - favs.push(model); - } else { - codModels.push(model); - } - }); - } - - // Process OpenCode Models (skip if provider is disabled) - if (!isOpencodeDisabled) { - allOpencodeModels.forEach((model) => { - if (favoriteModels.includes(model.id)) { - favs.push(model); - } else { - ocModels.push(model); - } - }); - } - - return { - favorites: favs, - claude: cModels, - cursor: curModels, - codex: codModels, - opencode: ocModels, - }; - }, [ - favoriteModels, - availableCursorModels, - transformedCodexModels, - allOpencodeModels, - disabledProviders, - ]); - - // Group OpenCode models by model type for better organization - const opencodeSections = useMemo(() => { - type OpencodeSectionKey = (typeof OPENCODE_SECTION_ORDER)[number]; - type OpencodeGroup = { key: string; label: string; models: ModelOption[] }; - type OpencodeSection = { - key: OpencodeSectionKey; - label: string; - showGroupLabels: boolean; - groups: OpencodeGroup[]; - }; - - const sections: Record> = { - free: {}, - dynamic: {}, - }; - const dynamicProviderById = new Map( - dynamicOpencodeModels.map((model) => [model.id, model.provider]) - ); - - const resolveProviderKey = (modelId: string): string => { - const staticProvider = OPENCODE_STATIC_PROVIDER_BY_ID.get(modelId); - if (staticProvider) return staticProvider; - - const dynamicProvider = dynamicProviderById.get(modelId); - if (dynamicProvider) return dynamicProvider; - - return modelId.includes(OPENCODE_MODEL_ID_SEPARATOR) - ? modelId.split(OPENCODE_MODEL_ID_SEPARATOR)[0] - : OPENCODE_PROVIDER_FALLBACK; - }; - - const addModelToGroup = ( - sectionKey: OpencodeSectionKey, - providerKey: string, - model: ModelOption - ) => { - if (!sections[sectionKey][providerKey]) { - sections[sectionKey][providerKey] = { - key: providerKey, - label: getOpencodeGroupLabel(providerKey, sectionKey), - models: [], - }; - } - sections[sectionKey][providerKey].models.push(model); - }; - - opencode.forEach((model) => { - const providerKey = resolveProviderKey(model.id); - const sectionKey = getOpencodeSectionKey(providerKey); - addModelToGroup(sectionKey, providerKey, model); - }); - - const buildGroupList = (sectionKey: OpencodeSectionKey): OpencodeGroup[] => { - const groupMap = sections[sectionKey]; - const priorityOrder = sectionKey === 'dynamic' ? OPENCODE_DYNAMIC_PROVIDER_ORDER : []; - const priorityMap = new Map(priorityOrder.map((provider, index) => [provider, index])); - - return Object.keys(groupMap) - .sort((a, b) => { - const aPriority = priorityMap.get(a); - const bPriority = priorityMap.get(b); - - if (aPriority !== undefined && bPriority !== undefined) { - return aPriority - bPriority; - } - if (aPriority !== undefined) return -1; - if (bPriority !== undefined) return 1; - - return groupMap[a].label.localeCompare(groupMap[b].label); - }) - .map((key) => groupMap[key]); - }; - - const builtSections = OPENCODE_SECTION_ORDER.map((sectionKey) => { - const groups = buildGroupList(sectionKey); - if (groups.length === 0) return null; - - return { - key: sectionKey, - label: OPENCODE_SECTION_LABELS[sectionKey], - showGroupLabels: sectionKey !== 'free', - groups, - }; - }).filter(Boolean) as OpencodeSection[]; - - return builtSections; - }, [opencode, dynamicOpencodeModels]); - - // Render Codex model item with secondary popover for reasoning effort (only for models that support it) - const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => { - const isSelected = selectedModel === model.id; - const isFavorite = favoriteModels.includes(model.id); - const hasReasoning = codexModelHasThinking(model.id as CodexModelId); - const isExpanded = expandedCodexModel === model.id; - const currentReasoning = isSelected ? selectedReasoningEffort : 'none'; - - // If model doesn't support reasoning, render as simple selector (like Cursor models) - if (!hasReasoning) { - return ( - { - onChange({ model: model.id as CodexModelId }); - setOpen(false); - }} - className="group flex items-center justify-between py-2" - > -
- -
- - {model.label} - - {model.description} -
-
- -
- - {isSelected && } -
-
- ); - } - - // Model supports reasoning - show popover with reasoning effort options - // On mobile, render inline expansion instead of nested popover - if (isMobile) { - return ( -
- setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} - className="group flex items-center justify-between py-2" - > -
- -
- - {model.label} - - - {isSelected && currentReasoning !== 'none' - ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` - : model.description} - -
-
- -
- - {isSelected && !isExpanded && } - -
-
- - {/* Inline reasoning effort options on mobile */} - {isExpanded && ( -
-
- Reasoning Effort -
- {REASONING_EFFORT_LEVELS.map((effort) => ( - - ))} -
- )} -
- ); - } - - // Desktop: Use nested popover - return ( - setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} - className="p-0 data-[selected=true]:bg-transparent" - > - { - if (!isOpen) { - setExpandedCodexModel(null); - } - }} - > - -
-
- -
- - {model.label} - - - {isSelected && currentReasoning !== 'none' - ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` - : model.description} - -
-
- -
- - {isSelected && } - -
-
-
- e.preventDefault()} - > -
-
- Reasoning Effort -
- {REASONING_EFFORT_LEVELS.map((effort) => ( - - ))} -
-
-
-
- ); - }; - - // Render OpenCode model item (simple selector, no thinking/reasoning options) - const renderOpencodeModelItem = (model: (typeof OPENCODE_MODELS)[0]) => { - const isSelected = selectedModel === model.id; - const isFavorite = favoriteModels.includes(model.id); - - // Get the appropriate icon based on the specific model ID - const ProviderIcon = getProviderIconForModel(model.id); - - return ( - { - onChange({ model: model.id as OpencodeModelId }); - setOpen(false); - }} - className="group flex items-center justify-between py-2" - > -
- -
- - {model.label} - - {model.description} -
-
- -
- {model.badge && ( - - {model.badge} - - )} - - {isSelected && } -
-
- ); - }; - - // Render Cursor model item (no thinking level needed) - const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { - // With canonical IDs, store the full prefixed ID - const isSelected = selectedModel === model.id; - const isFavorite = favoriteModels.includes(model.id); - - return ( - { - onChange({ model: model.id as CursorModelId }); - setOpen(false); - }} - className="group flex items-center justify-between py-2" - > -
- -
- - {model.label} - - {model.description} -
-
- -
- - {isSelected && } -
-
- ); - }; - - // Render Claude model item with secondary popover for thinking level - const renderClaudeModelItem = (model: (typeof CLAUDE_MODELS)[0]) => { - const isSelected = selectedModel === model.id; - const isFavorite = favoriteModels.includes(model.id); - const isExpanded = expandedClaudeModel === model.id; - const currentThinking = isSelected ? selectedThinkingLevel : 'none'; - - // On mobile, render inline expansion instead of nested popover - if (isMobile) { - return ( -
- setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} - className="group flex items-center justify-between py-2" - > -
- -
- - {model.label} - - - {isSelected && currentThinking !== 'none' - ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` - : model.description} - -
-
- -
- - {isSelected && !isExpanded && } - -
-
- - {/* Inline thinking level options on mobile */} - {isExpanded && ( -
-
- Thinking Level -
- {THINKING_LEVELS.map((level) => ( - - ))} -
- )} -
- ); - } - - // Desktop: Use nested popover - return ( - setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} - className="p-0 data-[selected=true]:bg-transparent" - > - { - if (!isOpen) { - setExpandedClaudeModel(null); - } - }} - > - -
-
- -
- - {model.label} - - - {isSelected && currentThinking !== 'none' - ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` - : model.description} - -
-
- -
- - {isSelected && } - -
-
-
- e.preventDefault()} - > -
-
- Thinking Level -
- {THINKING_LEVELS.map((level) => ( - - ))} -
-
-
-
- ); - }; - - // Render a grouped model with secondary popover for variant selection - const renderGroupedModelItem = (group: GroupedModel) => { - const groupIsSelected = isGroupSelected(group, selectedModel as CursorModelId); - const selectedVariant = getSelectedVariant(group, selectedModel as CursorModelId); - const isExpanded = expandedGroup === group.baseId; - - const variantTypeLabel = - group.variantType === 'compute' - ? 'Compute Level' - : group.variantType === 'thinking' - ? 'Reasoning Mode' - : 'Capacity Options'; - - // On mobile, render inline expansion instead of nested popover - if (isMobile) { - return ( -
- setExpandedGroup(isExpanded ? null : group.baseId)} - className="group flex items-center justify-between py-2" - > -
- -
- - {group.label} - - - {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description} - -
-
- -
- {groupIsSelected && !isExpanded && ( - - )} - -
-
- - {/* Inline variant options on mobile */} - {isExpanded && ( -
-
- {variantTypeLabel} -
- {group.variants.map((variant) => ( - - ))} -
- )} -
- ); - } - - // Desktop: Use nested popover - return ( - setExpandedGroup(isExpanded ? null : group.baseId)} - className="p-0 data-[selected=true]:bg-transparent" - > - { - if (!isOpen) { - setExpandedGroup(null); - } - }} - > - -
-
- -
- - {group.label} - - - {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description} - -
-
- -
- {groupIsSelected && } - -
-
-
- e.preventDefault()} - > -
-
- {variantTypeLabel} -
- {group.variants.map((variant) => ( - - ))} -
-
-
-
- ); - }; - - // Compact trigger button (for agent view etc.) - const compactTrigger = ( - - ); - - // Full trigger button (for settings view) - const fullTrigger = ( - - ); - - // The popover content (shared between both modes) - const popoverContent = ( - e.stopPropagation()} - onTouchMove={(e) => e.stopPropagation()} - onPointerDownOutside={(e) => { - // Only prevent close if clicking inside a nested popover (thinking level panel) - const target = e.target as HTMLElement; - if (target.closest('[data-slot="popover-content"]')) { - e.preventDefault(); - } - }} - > - - - - No model found. - - {favorites.length > 0 && ( - <> - - {(() => { - const renderedGroups = new Set(); - return favorites.map((model) => { - // Check if this favorite is part of a grouped model - if (model.provider === 'cursor') { - const cursorId = model.id as CursorModelId; - const group = getModelGroup(cursorId); - if (group) { - // Skip if we already rendered this group - if (renderedGroups.has(group.baseId)) { - return null; - } - renderedGroups.add(group.baseId); - // Find the group in groupedModels (which has filtered variants) - const filteredGroup = groupedModels.find((g) => g.baseId === group.baseId); - if (filteredGroup) { - return renderGroupedModelItem(filteredGroup); - } - } - // Standalone Cursor model - return renderCursorModelItem(model); - } - // Codex model - if (model.provider === 'codex') { - return renderCodexModelItem(model as (typeof transformedCodexModels)[0]); - } - // OpenCode model - if (model.provider === 'opencode') { - return renderOpencodeModelItem(model); - } - // Claude model - return renderClaudeModelItem(model); - }); - })()} - - - - )} - - {claude.length > 0 && ( - - {claude.map((model) => renderClaudeModelItem(model))} - - )} - - {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && ( - - {/* Grouped models with secondary popover */} - {groupedModels.map((group) => renderGroupedModelItem(group))} - {/* Standalone models */} - {standaloneCursorModels.map((model) => renderCursorModelItem(model))} - - )} - - {codex.length > 0 && ( - - {codex.map((model) => renderCodexModelItem(model))} - - )} - - {opencodeSections.length > 0 && ( - - {opencodeSections.map((section, sectionIndex) => ( - -
- {section.label} -
-
- {section.groups.map((group) => ( -
- {section.showGroupLabels && ( -
- {group.label} -
- )} - {group.models.map((model) => renderOpencodeModelItem(model))} -
- ))} -
-
- ))} -
- )} -
-
-
- ); - - // Compact mode - just the popover with compact trigger - if (compact) { - return ( - - {compactTrigger} - {popoverContent} - - ); - } - - // Full mode - with label and description wrapper - return ( -
- {/* Label and Description */} -
-

{label}

-

{description}

-
- - {/* Model Selection Popover */} - - {fullTrigger} - {popoverContent} - -
- ); -}