diff --git a/.ai/lessons.md b/.ai/lessons.md index a8baa6148..724402b1b 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -111,3 +111,18 @@ - UI chrome is monochrome (`primary`, `foreground`, `muted-foreground`, `accent`). Use color only for status. - Status colors: `success`, `warning`, `info`, `destructive` (+ `*-foreground` on solid backgrounds). They have light and dark values in `src/App.css`; opacity modifiers work (`bg-warning/10`). - Do not add hardcoded hues such as `text-yellow-400`: they are tuned for one theme and fail contrast in the other. For categorical identity colors (file types, brands, GitHub closed/merged purple), use a `-600` + `dark:-400` pair. + +## Make managed installation reachable before source selection + +- Offer installation directly on the managed-source card even when PATH is selected; do not require selecting an absent installation first. +- Track managed installation separately from effective execution availability. Settings can show the selected source as missing without hiding a usable fallback backend elsewhere. + +## Typecheck test mocks before handing back + +- Verify module exports before referencing fixtures in partial mocks. Missing exports can silently return undefined at runtime. +- Run `bun run typecheck` alongside targeted tests; passing runtime tests alone does not validate typed mock setup. + +## Keep small fixes fast + +- Finish production edits and review before the final Docker build; do not trigger another expensive image build for a late review correction. +- Reuse a cached Rust quality container and report a short ETA when verification dominates the task. diff --git a/.ai/todo.md b/.ai/todo.md new file mode 100644 index 000000000..960883acf --- /dev/null +++ b/.ai/todo.md @@ -0,0 +1,7 @@ +# PR #16 main conflict resolution + +- [x] Merge main 2e23c71d, retain managed Claude installer and upstream settings behavior. +- [x] Resolve icon migration and removed smoke-test import. +- [x] Full check:all: 2,876 frontend tests, 1,339 Rust tests passed (one ignored); jean:dev rebuilt. +- [x] Agent-browser smoke check: Settings → Claude shows Install latest with PATH selected. +- [x] Pushed merge 342c6083 to open PR #16; GitHub reports MERGEABLE. diff --git a/docs/developer/architecture-guide.md b/docs/developer/architecture-guide.md index c462bc65c..1167dc9c9 100644 --- a/docs/developer/architecture-guide.md +++ b/docs/developer/architecture-guide.md @@ -629,6 +629,14 @@ When adding entirely new systems: 6. **Test everything** - Use quality gates to maintain code health 7. **Document patterns** - Keep docs current as patterns evolve +### CLI source preferences and installation status + +Auto-detect a system CLI source only when its source field is absent from saved preferences. Never overwrite an explicit managed or PATH choice during loading. + +Claude's installation status describes the binary available for execution, including fallback. Its separate `managed_installed` field describes the Jean-managed copy. Settings derives selected-source display status without changing backend availability for onboarding or chat. + +The managed Claude card can install while PATH is selected. Installation resolves the latest stable version through the existing installer, then selects the managed source only after success. A backend mutex rejects concurrent installations across all entry points. + ### Cross-platform CLI resolution and launch When resolving external CLIs from PATH, use `crate::platform::detect_cli_in_path()` or diff --git a/jean-core/src/claude_cli/commands.rs b/jean-core/src/claude_cli/commands.rs index 2d6c3eb9a..3470f8f2c 100644 --- a/jean-core/src/claude_cli/commands.rs +++ b/jean-core/src/claude_cli/commands.rs @@ -14,7 +14,7 @@ use tokio::sync::Mutex as AsyncMutex; use super::config::{ ensure_cli_dir, get_cli_binary_path, get_cli_dir, get_wsl_cli_binary_path, get_wsl_cli_dir, - resolve_cli_binary, + jean_managed_installed, resolve_cli_binary, }; use crate::http_server::EmitExt; #[cfg(target_os = "macos")] @@ -61,6 +61,7 @@ const CLAUDE_USAGE_STALE_CACHE_MAX_SECS: u64 = 24 * 60 * 60; /// Cooldown after a 429 without a usable `Retry-After` header. const CLAUDE_USAGE_DEFAULT_COOLDOWN_SECS: u64 = 5 * 60; const CLAUDE_USAGE_USER_AGENT: &str = "claude-code/2.1.69"; +static CLAUDE_INSTALL_LOCK: AsyncMutex<()> = AsyncMutex::const_new(()); static CLAUDE_USAGE_FETCH_LOCK: OnceLock> = OnceLock::new(); /// Epoch seconds until which the usage API must not be called (after a 429). static CLAUDE_USAGE_COOLDOWN_UNTIL: AtomicU64 = AtomicU64::new(0); @@ -74,6 +75,8 @@ fn claude_usage_fetch_lock() -> &'static AsyncMutex<()> { pub struct ClaudeCliStatus { /// Whether Claude CLI is installed pub installed: bool, + #[serde(default)] + pub managed_installed: bool, /// Installed version (if any) pub version: Option, /// Path to the CLI binary (if installed) @@ -111,6 +114,7 @@ pub struct InstallProgress { pub async fn check_claude_cli_installed(app: AppHandle) -> Result { log::trace!("Checking Claude CLI installation status"); + let managed_installed = jean_managed_installed(&app); let wsl = crate::platform::get_wsl_config(); let binary_path = resolve_cli_binary(&app); @@ -130,6 +134,7 @@ pub async fn check_claude_cli_installed(app: AppHandle) -> Result Result Result Result Result<(), String> { Ok(()) } +fn try_acquire_claude_install() -> Result, String> { + CLAUDE_INSTALL_LOCK + .try_lock() + .map_err(|_| "Claude CLI installation is already in progress".to_string()) +} + /// Install Claude CLI by downloading the binary directly from Anthropic's distribution bucket pub async fn install_claude_cli(app: AppHandle, version: Option) -> Result<(), String> { + let _install_guard = try_acquire_claude_install()?; log::trace!("Installing Claude CLI, version: {:?}", version); // Check if any Claude processes are running - cannot replace binary while in use @@ -1736,6 +1751,35 @@ fn emit_progress(app: &AppHandle, stage: &str, message: &str, percent: u8) { mod tests { use super::*; + #[test] + fn claude_install_lock_rejects_concurrent_install_and_releases() { + let guard = try_acquire_claude_install().unwrap(); + assert_eq!( + try_acquire_claude_install().unwrap_err(), + "Claude CLI installation is already in progress" + ); + drop(guard); + assert!(try_acquire_claude_install().is_ok()); + } + + #[test] + fn status_serializes_managed_installation_independently_of_selected_source() { + for installed in [false, true] { + for managed_installed in [false, true] { + let status = ClaudeCliStatus { + installed, + managed_installed, + version: None, + path: None, + supports_auth_command: false, + }; + let value = serde_json::to_value(status).unwrap(); + assert_eq!(value["installed"], installed); + assert_eq!(value["managed_installed"], managed_installed); + } + } + } + #[test] fn wsl_credentials_path_uses_wsl_home() { assert_eq!( diff --git a/jean-core/src/lib.rs b/jean-core/src/lib.rs index 8a485a65c..7ae6172ff 100644 --- a/jean-core/src/lib.rs +++ b/jean-core/src/lib.rs @@ -928,6 +928,37 @@ mod tests { }; use serde_json::json; + #[test] + fn cli_auto_selection_only_applies_to_absent_raw_source_fields() { + for field in [ + "claude_cli_source", + "codex_cli_source", + "opencode_cli_source", + "coderabbit_cli_source", + ] { + assert!(should_auto_select_cli_source(None, field)); + assert!(should_auto_select_cli_source(Some(&json!({})), field)); + for source in [json!("jean"), json!("path"), json!(""), json!(null)] { + let mut raw = json!({}); + raw[field] = source; + assert!(!should_auto_select_cli_source(Some(&raw), field)); + } + assert!(should_auto_select_cli_source( + Some(&json!({"theme": "dark"})), + field + )); + } + let raw = json!({"claude_cli_source": "jean"}); + assert!(!should_auto_select_cli_source( + Some(&raw), + "claude_cli_source" + )); + assert!(should_auto_select_cli_source( + Some(&raw), + "codex_cli_source" + )); + } + #[test] fn cli_source_auto_selection_only_applies_before_a_source_is_saved() { assert!(should_auto_select_cli_source(None, "codex_cli_source")); diff --git a/src/components/chat/toolbar/BackendModelPickerContent.test.tsx b/src/components/chat/toolbar/BackendModelPickerContent.test.tsx index 6dd01c533..244bf14e0 100644 --- a/src/components/chat/toolbar/BackendModelPickerContent.test.tsx +++ b/src/components/chat/toolbar/BackendModelPickerContent.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import userEvent from '@testing-library/user-event' +import type * as ModelCatalogModule from '@/services/model-catalog' import { within } from '@testing-library/react' import { render, screen } from '@/test/test-utils' import { BackendModelPickerContent } from './BackendModelPickerContent' @@ -17,6 +18,15 @@ class ResizeObserverMock { } } +vi.mock('@/services/model-catalog', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + // Use the production fallback without fetching the live CDN catalog. + useModelCatalog: () => ({ data: undefined }), + } +}) + vi.stubGlobal('ResizeObserver', ResizeObserverMock) HTMLCanvasElement.prototype.getContext = vi.fn(() => null) Element.prototype.scrollIntoView = vi.fn() @@ -220,9 +230,9 @@ describe('BackendModelPickerContent', () => { expect(screen.getByRole('tab', { name: 'OpenCode' })).toBeInTheDocument() await user.click(screen.getByRole('tab', { name: 'Codex' })) - await user.click(screen.getByText('GPT 5.4')) + await user.click(screen.getByText('GPT 5.6 Sol')) - expect(onBackendModelChange).toHaveBeenCalledWith('codex', 'gpt-5.4') + expect(onBackendModelChange).toHaveBeenCalledWith('codex', 'gpt-5.6-sol') expect(onModelChange).not.toHaveBeenCalled() expect(onRequestClose).toHaveBeenCalled() }) @@ -436,10 +446,10 @@ describe('BackendModelPickerContent', () => { ) const searchInput = screen.getByPlaceholderText(/search codex models/i) - await user.type(searchInput, 'gpt 5.4') - await user.click(screen.getByText('GPT 5.4')) + await user.type(searchInput, 'gpt 5.6 sol') + await user.click(screen.getByText('GPT 5.6 Sol')) - expect(onModelChange).toHaveBeenCalledWith('gpt-5.4') + expect(onModelChange).toHaveBeenCalledWith('gpt-5.6-sol') expect(onBackendModelChange).not.toHaveBeenCalled() }) diff --git a/src/components/chat/toolbar/DesktopBackendModelPicker.test.tsx b/src/components/chat/toolbar/DesktopBackendModelPicker.test.tsx index 5d81d8b45..32e270233 100644 --- a/src/components/chat/toolbar/DesktopBackendModelPicker.test.tsx +++ b/src/components/chat/toolbar/DesktopBackendModelPicker.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import userEvent from '@testing-library/user-event' +import type * as ModelCatalogModule from '@/services/model-catalog' import { render, screen, within } from '@/test/test-utils' import { DesktopBackendModelPicker } from './DesktopBackendModelPicker' import type * as EnvironmentModule from '@/lib/environment' @@ -17,6 +18,15 @@ class ResizeObserverMock { } } +vi.mock('@/services/model-catalog', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + // Use the production fallback without fetching the live CDN catalog. + useModelCatalog: () => ({ data: undefined }), + } +}) + vi.stubGlobal('ResizeObserver', ResizeObserverMock) HTMLCanvasElement.prototype.getContext = vi.fn(() => null) Element.prototype.scrollIntoView = vi.fn() @@ -177,9 +187,9 @@ describe('DesktopBackendModelPicker', () => { await user.click( within(list as HTMLElement).getByRole('tab', { name: 'Codex' }) ) - await user.click(within(list as HTMLElement).getByText('GPT 5.4')) + await user.click(within(list as HTMLElement).getByText('GPT 5.6 Sol')) - expect(onBackendModelChange).toHaveBeenCalledWith('codex', 'gpt-5.4') + expect(onBackendModelChange).toHaveBeenCalledWith('codex', 'gpt-5.6-sol') expect(onModelChange).not.toHaveBeenCalled() }) @@ -206,10 +216,10 @@ describe('DesktopBackendModelPicker', () => { const searchInput = await screen.findByPlaceholderText(/search codex models/i) - await user.type(searchInput, 'gpt 5.4') - await user.click(screen.getByText('GPT 5.4')) + await user.type(searchInput, 'gpt 5.6 sol') + await user.click(screen.getByText('GPT 5.6 Sol')) - expect(onModelChange).toHaveBeenCalledWith('gpt-5.4') + expect(onModelChange).toHaveBeenCalledWith('gpt-5.6-sol') expect(onBackendModelChange).not.toHaveBeenCalled() }) diff --git a/src/components/preferences/BackendCliSourceCards.tsx b/src/components/preferences/BackendCliSourceCards.tsx index b12499d66..8915d431d 100644 --- a/src/components/preferences/BackendCliSourceCards.tsx +++ b/src/components/preferences/BackendCliSourceCards.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import { Label } from '@/components/ui/label' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' @@ -5,6 +6,7 @@ interface BackendCliSourceCardsProps { value: 'jean' | 'path' onValueChange: (value: 'jean' | 'path') => void backendName: string + managedAction?: ReactNode managedDescription?: string path: string | null | undefined pathVersion?: string | null @@ -16,6 +18,7 @@ export function BackendCliSourceCards({ onValueChange, backendName, managedDescription, + managedAction, path, pathVersion, pathFound, @@ -29,19 +32,24 @@ export function BackendCliSourceCards({ }} className="w-full gap-3" > -