From 38082059a5240b009fa2419040fc8ef8a21bc975 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 12 Mar 2026 09:26:58 -0700 Subject: [PATCH] Rename AuthMode to Backend, fix LiteLLM variant typo, add image update alerts, clean up Settings - Fix serde deserialization error: TypeScript sent "lit_llm" but Rust expected "lite_llm" - Rename AuthMode enum to Backend across Rust and TypeScript (with serde alias for backward compat) - Add container image update checking via registry digest comparison - Improve Settings page: fix image address display spacing, remove per-project auth section - Update UI labels from "Auth" to "Backend" throughout Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- HOW-TO-USE.md | 12 +- README.md | 4 +- TECHNICAL.md | 2 +- .../src/commands/project_commands.rs | 18 +-- .../src/commands/terminal_commands.rs | 4 +- app/src-tauri/src/commands/update_commands.rs | 103 +++++++++++++++++- app/src-tauri/src/docker/container.rs | 24 ++-- app/src-tauri/src/docker/image.rs | 32 ++++++ app/src-tauri/src/lib.rs | 1 + app/src-tauri/src/models/app_settings.rs | 3 + app/src-tauri/src/models/project.rs | 18 +-- app/src-tauri/src/models/update_info.rs | 11 ++ app/src/App.tsx | 7 +- app/src/components/layout/TopBar.tsx | 33 +++++- .../components/projects/ProjectCard.test.tsx | 2 +- app/src/components/projects/ProjectCard.tsx | 24 ++-- app/src/components/settings/ApiKeyInput.tsx | 4 +- .../components/settings/DockerSettings.tsx | 4 +- .../components/settings/ImageUpdateDialog.tsx | 91 ++++++++++++++++ app/src/components/settings/SettingsPanel.tsx | 12 +- app/src/hooks/useUpdates.ts | 53 +++++++-- app/src/lib/tauri-commands.ts | 4 +- app/src/lib/types.ts | 11 +- app/src/store/appState.ts | 10 +- 25 files changed, 409 insertions(+), 80 deletions(-) create mode 100644 app/src/components/settings/ImageUpdateDialog.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 4c4281c..e3ed77f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li - `container.rs` — Container lifecycle (create, start, stop, remove, inspect) - `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming - `image.rs` — Image build/pull with progress streaming -- **`models/`** — Serde structs (`Project`, `AuthMode`, `BedrockConfig`, `OllamaConfig`, `LiteLlmConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend. +- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `LiteLlmConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend. - **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs` ### Container (`container/`) diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 8534d7e..9fd8589 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -86,21 +86,21 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside **AWS Bedrock:** 1. Stop the container first (settings can only be changed while stopped). -2. In the project card, switch the auth mode to **Bedrock**. +2. In the project card, switch the backend to **Bedrock**. 3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below). 4. Start the container again. **Ollama:** 1. Stop the container first (settings can only be changed while stopped). -2. In the project card, switch the auth mode to **Ollama**. +2. In the project card, switch the backend to **Ollama**. 3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to `http://host.docker.internal:11434` for a local instance). Optionally set a model ID. 4. Start the container again. **LiteLLM:** 1. Stop the container first (settings can only be changed while stopped). -2. In the project card, switch the auth mode to **LiteLLM**. +2. In the project card, switch the backend to **LiteLLM**. 3. Expand the **Config** panel and set the base URL of your LiteLLM proxy (defaults to `http://host.docker.internal:4000`). Optionally set an API key and model ID. 4. Start the container again. @@ -361,7 +361,7 @@ MCP server configuration is tracked via SHA-256 fingerprints stored as Docker la ## AWS Bedrock Configuration -To use Claude via AWS Bedrock instead of Anthropic's API, switch the auth mode to **Bedrock** on the project card. +To use Claude via AWS Bedrock instead of Anthropic's API, switch the backend to **Bedrock** on the project card. ### Authentication Methods @@ -390,7 +390,7 @@ Per-project settings always override these global defaults. ## Ollama Configuration -To use Claude Code with a local or remote Ollama server, switch the auth mode to **Ollama** on the project card. +To use Claude Code with a local or remote Ollama server, switch the backend to **Ollama** on the project card. ### Settings @@ -407,7 +407,7 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in ## LiteLLM Configuration -To use Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy gateway, switch the auth mode to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy. +To use Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy gateway, switch the backend to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy. ### Settings diff --git a/README.md b/README.md index 02ef1af..83a24c7 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Users can override this in Settings via the global `docker_socket_path` option. | `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators | | `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) | | `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts | -| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons | +| `app/src/components/projects/ProjectCard.tsx` | Project config, backend selector, action buttons | | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | | `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) | | `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress | @@ -122,7 +122,7 @@ Users can override this in Settings via the global `docker_socket_path` option. | `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers | | `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) | | `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands | -| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, MCP servers, Mission Control) | +| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, MCP servers, Mission Control) | | `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) | | `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, microphone) | | `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) | diff --git a/TECHNICAL.md b/TECHNICAL.md index 3985378..a1cca8b 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -290,7 +290,7 @@ triple-c/ │ ├── image.rs # Build from Dockerfile, pull from registry │ └── network.rs # Per-project bridge networks for MCP ├── models/ # Data structures - │ ├── project.rs # Project, AuthMode, BedrockConfig + │ ├── project.rs # Project, Backend, BedrockConfig │ ├── mcp_server.rs # MCP server configuration │ ├── app_settings.rs # Global settings (image source, AWS, etc.) │ ├── container_config.rs # Image name resolution diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 636a6d5..ec62076 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1,7 +1,7 @@ use tauri::{Emitter, State}; use crate::docker; -use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus}; +use crate::models::{container_config, Backend, McpServer, Project, ProjectPath, ProjectStatus}; use crate::storage::secure; use crate::AppState; @@ -179,27 +179,27 @@ pub async fn start_project_container( // Resolve enabled MCP servers for this project let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); - // Validate auth mode requirements - if project.auth_mode == AuthMode::Bedrock { + // Validate backend requirements + if project.backend == Backend::Bedrock { let bedrock = project.bedrock_config.as_ref() - .ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?; + .ok_or_else(|| "Bedrock backend selected but no Bedrock configuration found.".to_string())?; // Region can come from per-project or global if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() { - return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string()); + return Err("AWS region is required for Bedrock backend. Set it per-project or in global AWS settings.".to_string()); } } - if project.auth_mode == AuthMode::Ollama { + if project.backend == Backend::Ollama { let ollama = project.ollama_config.as_ref() - .ok_or_else(|| "Ollama auth mode selected but no Ollama configuration found.".to_string())?; + .ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?; if ollama.base_url.is_empty() { return Err("Ollama base URL is required.".to_string()); } } - if project.auth_mode == AuthMode::LiteLlm { + if project.backend == Backend::LiteLlm { let litellm = project.litellm_config.as_ref() - .ok_or_else(|| "LiteLLM auth mode selected but no LiteLLM configuration found.".to_string())?; + .ok_or_else(|| "LiteLLM backend selected but no LiteLLM configuration found.".to_string())?; if litellm.base_url.is_empty() { return Err("LiteLLM base URL is required.".to_string()); } diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index f0702af..6b91e27 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -1,6 +1,6 @@ use tauri::{AppHandle, Emitter, State}; -use crate::models::{AuthMode, BedrockAuthMethod, Project}; +use crate::models::{Backend, BedrockAuthMethod, Project}; use crate::AppState; /// Build the command to run in the container terminal. @@ -9,7 +9,7 @@ use crate::AppState; /// the AWS session first. If the SSO session is expired, runs `aws sso login` /// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon). fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec { - let is_bedrock_profile = project.auth_mode == AuthMode::Bedrock + let is_bedrock_profile = project.backend == Backend::Bedrock && project .bedrock_config .as_ref() diff --git a/app/src-tauri/src/commands/update_commands.rs b/app/src-tauri/src/commands/update_commands.rs index f92168b..715ddec 100644 --- a/app/src-tauri/src/commands/update_commands.rs +++ b/app/src-tauri/src/commands/update_commands.rs @@ -1,8 +1,16 @@ -use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo}; +use tauri::State; + +use crate::docker; +use crate::models::{container_config, GiteaRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo}; +use crate::AppState; const RELEASES_URL: &str = "https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases"; +/// Gitea container-registry tag object (v2 manifest). +const REGISTRY_API_BASE: &str = + "https://repo.anhonesthost.net/v2/cybercovellc/triple-c/triple-c-sandbox"; + #[tauri::command] pub fn get_app_version() -> String { env!("CARGO_PKG_VERSION").to_string() @@ -115,3 +123,96 @@ fn extract_version_from_tag(tag: &str) -> Option { None } } + +/// Check whether a newer container image is available in the registry. +/// +/// Compares the local image digest with the remote registry digest using the +/// Docker Registry HTTP API v2. Only applies when the image source is +/// "registry" (the default); for local builds or custom images we cannot +/// meaningfully check for remote updates. +#[tauri::command] +pub async fn check_image_update( + state: State<'_, AppState>, +) -> Result, String> { + let settings = state.settings_store.get(); + + // Only check for registry images + if settings.image_source != crate::models::app_settings::ImageSource::Registry { + return Ok(None); + } + + let image_name = + container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); + + // 1. Get local image digest via Docker + let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten(); + + // 2. Get remote digest from the Gitea container registry (OCI distribution spec) + let remote_digest = fetch_remote_digest("latest").await?; + + // No remote digest available — nothing to compare + let remote_digest = match remote_digest { + Some(d) => d, + None => return Ok(None), + }; + + // If local digest matches remote, no update + if let Some(ref local) = local_digest { + if *local == remote_digest { + return Ok(None); + } + } + + // There's a difference (or no local image at all) + Ok(Some(ImageUpdateInfo { + remote_digest, + local_digest, + remote_updated_at: None, + })) +} + +/// Fetch the digest of a tag from the Gitea container registry using the +/// OCI / Docker Registry HTTP API v2. +/// +/// We issue a HEAD request to /v2//manifests/ and read the +/// `Docker-Content-Digest` header that the registry returns. +async fn fetch_remote_digest(tag: &str) -> Result, String> { + let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let response = client + .head(&url) + .header( + "Accept", + "application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json", + ) + .send() + .await; + + match response { + Ok(resp) => { + if !resp.status().is_success() { + log::warn!( + "Registry returned status {} when checking image digest", + resp.status() + ); + return Ok(None); + } + // The digest is returned in the Docker-Content-Digest header + if let Some(digest) = resp.headers().get("docker-content-digest") { + if let Ok(val) = digest.to_str() { + return Ok(Some(val.to_string())); + } + } + Ok(None) + } + Err(e) => { + log::warn!("Failed to check registry for image update: {}", e); + Ok(None) + } + } +} diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 0c76a92..1ae0a08 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use sha2::{Sha256, Digest}; use super::client::get_docker; -use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; +use crate::models::{Backend, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks @@ -453,7 +453,7 @@ pub async fn create_container( } // Bedrock configuration - if project.auth_mode == AuthMode::Bedrock { + if project.backend == Backend::Bedrock { if let Some(ref bedrock) = project.bedrock_config { env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string()); @@ -506,7 +506,7 @@ pub async fn create_container( } // Ollama configuration - if project.auth_mode == AuthMode::Ollama { + if project.backend == Backend::Ollama { if let Some(ref ollama) = project.ollama_config { env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url)); env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string()); @@ -517,7 +517,7 @@ pub async fn create_container( } // LiteLLM configuration - if project.auth_mode == AuthMode::LiteLlm { + if project.backend == Backend::LiteLlm { if let Some(ref litellm) = project.litellm_config { env_vars.push(format!("ANTHROPIC_BASE_URL={}", litellm.base_url)); if let Some(ref key) = litellm.api_key { @@ -624,7 +624,7 @@ pub async fn create_container( // AWS config mount (read-only) // Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set - let should_mount_aws = if project.auth_mode == AuthMode::Bedrock { + let should_mount_aws = if project.backend == Backend::Bedrock { if let Some(ref bedrock) = project.bedrock_config { bedrock.auth_method == BedrockAuthMethod::Profile } else { @@ -694,7 +694,7 @@ pub async fn create_container( labels.insert("triple-c.managed".to_string(), "true".to_string()); labels.insert("triple-c.project-id".to_string(), project.id.clone()); labels.insert("triple-c.project-name".to_string(), project.name.clone()); - labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode)); + labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend)); labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths)); labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project)); labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project)); @@ -897,11 +897,13 @@ pub async fn container_needs_recreation( // Code settings stored in the named volume). The change takes effect // on the next explicit rebuild instead. - // ── Auth mode ──────────────────────────────────────────────────────── - let current_auth_mode = format!("{:?}", project.auth_mode); - if let Some(container_auth_mode) = get_label("triple-c.auth-mode") { - if container_auth_mode != current_auth_mode { - log::info!("Auth mode mismatch (container={:?}, project={:?})", container_auth_mode, current_auth_mode); + // ── Backend ────────────────────────────────────────────────────────── + let current_backend = format!("{:?}", project.backend); + // Check new label name, falling back to old "triple-c.auth-mode" for pre-rename containers + let container_backend = get_label("triple-c.backend").or_else(|| get_label("triple-c.auth-mode")); + if let Some(container_backend) = container_backend { + if container_backend != current_backend { + log::info!("Backend mismatch (container={:?}, project={:?})", container_backend, current_backend); return Ok(true); } } diff --git a/app/src-tauri/src/docker/image.rs b/app/src-tauri/src/docker/image.rs index 215bfa0..23326fa 100644 --- a/app/src-tauri/src/docker/image.rs +++ b/app/src-tauri/src/docker/image.rs @@ -31,6 +31,38 @@ pub async fn image_exists(image_name: &str) -> Result { Ok(!images.is_empty()) } +/// Returns the first repo digest (e.g. "sha256:abc...") for the given image, +/// or None if the image doesn't exist locally or has no repo digests. +pub async fn get_local_image_digest(image_name: &str) -> Result, String> { + let docker = get_docker()?; + + let filters: HashMap> = HashMap::from([( + "reference".to_string(), + vec![image_name.to_string()], + )]); + + let images: Vec = docker + .list_images(Some(ListImagesOptions { + filters, + ..Default::default() + })) + .await + .map_err(|e| format!("Failed to list images: {}", e))?; + + if let Some(img) = images.first() { + // RepoDigests contains entries like "registry/repo@sha256:abc..." + if let Some(digest_str) = img.repo_digests.first() { + // Extract the sha256:... part after '@' + if let Some(pos) = digest_str.find('@') { + return Ok(Some(digest_str[pos + 1..].to_string())); + } + return Ok(Some(digest_str.clone())); + } + } + + Ok(None) +} + pub async fn pull_image(image_name: &str, on_progress: F) -> Result<(), String> where F: Fn(String) + Send + 'static, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index f84dbd4..2c0d71b 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -119,6 +119,7 @@ pub fn run() { // Updates commands::update_commands::get_app_version, commands::update_commands::check_for_updates, + commands::update_commands::check_image_update, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 401a0f1..4f2d5f7 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -72,6 +72,8 @@ pub struct AppSettings { pub timezone: Option, #[serde(default)] pub default_microphone: Option, + #[serde(default)] + pub dismissed_image_digest: Option, } impl Default for AppSettings { @@ -90,6 +92,7 @@ impl Default for AppSettings { dismissed_update_version: None, timezone: None, default_microphone: None, + dismissed_image_digest: None, } } } diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index 27e09b3..1c9da6d 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -31,7 +31,8 @@ pub struct Project { pub paths: Vec, pub container_id: Option, pub status: ProjectStatus, - pub auth_mode: AuthMode, + #[serde(alias = "auth_mode")] + pub backend: Backend, pub bedrock_config: Option, pub ollama_config: Option, pub litellm_config: Option, @@ -65,13 +66,14 @@ pub enum ProjectStatus { Error, } -/// How the project authenticates with Claude. -/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console, -/// persisted in the config volume) -/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials +/// Which AI model backend/provider the project uses. +/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container) +/// - `Bedrock`: AWS Bedrock with per-project AWS credentials +/// - `Ollama`: Local or remote Ollama server +/// - `LiteLlm`: LiteLLM proxy gateway for 100+ model providers #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] -pub enum AuthMode { +pub enum Backend { /// Backward compat: old projects stored as "login" or "api_key" map to Anthropic. #[serde(alias = "login", alias = "api_key")] Anthropic, @@ -81,7 +83,7 @@ pub enum AuthMode { LiteLlm, } -impl Default for AuthMode { +impl Default for Backend { fn default() -> Self { Self::Anthropic } @@ -152,7 +154,7 @@ impl Project { paths, container_id: None, status: ProjectStatus::Stopped, - auth_mode: AuthMode::default(), + backend: Backend::default(), bedrock_config: None, ollama_config: None, litellm_config: None, diff --git a/app/src-tauri/src/models/update_info.rs b/app/src-tauri/src/models/update_info.rs index 87d5bd1..85fd8f6 100644 --- a/app/src-tauri/src/models/update_info.rs +++ b/app/src-tauri/src/models/update_info.rs @@ -35,3 +35,14 @@ pub struct GiteaAsset { pub browser_download_url: String, pub size: u64, } + +/// Info returned to the frontend about an available container image update. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageUpdateInfo { + /// The remote digest (e.g. sha256:abc...) + pub remote_digest: String, + /// The local digest, if available + pub local_digest: Option, + /// When the remote image was last updated (if known) + pub remote_updated_at: Option, +} diff --git a/app/src/App.tsx b/app/src/App.tsx index a06b551..8378f29 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -17,7 +17,7 @@ export default function App() { const { loadSettings } = useSettings(); const { refresh } = useProjects(); const { refresh: refreshMcp } = useMcpServers(); - const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates(); + const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates(); const { sessions, activeSessionId, setProjects } = useAppState( useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects })) ); @@ -46,7 +46,10 @@ export default function App() { // Update detection loadVersion(); - const updateTimer = setTimeout(() => checkForUpdates(), 3000); + const updateTimer = setTimeout(() => { + checkForUpdates(); + checkImageUpdate(); + }, 3000); const cleanup = startPeriodicCheck(); return () => { clearTimeout(updateTimer); diff --git a/app/src/components/layout/TopBar.tsx b/app/src/components/layout/TopBar.tsx index a417f88..b9c5148 100644 --- a/app/src/components/layout/TopBar.tsx +++ b/app/src/components/layout/TopBar.tsx @@ -4,19 +4,23 @@ import TerminalTabs from "../terminal/TerminalTabs"; import { useAppState } from "../../store/appState"; import { useSettings } from "../../hooks/useSettings"; import UpdateDialog from "../settings/UpdateDialog"; +import ImageUpdateDialog from "../settings/ImageUpdateDialog"; export default function TopBar() { - const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState( + const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState( useShallow(s => ({ dockerAvailable: s.dockerAvailable, imageExists: s.imageExists, updateInfo: s.updateInfo, + imageUpdateInfo: s.imageUpdateInfo, appVersion: s.appVersion, setUpdateInfo: s.setUpdateInfo, + setImageUpdateInfo: s.setImageUpdateInfo, })) ); const { appSettings, saveSettings } = useSettings(); const [showUpdateDialog, setShowUpdateDialog] = useState(false); + const [showImageUpdateDialog, setShowImageUpdateDialog] = useState(false); const handleDismiss = async () => { if (appSettings && updateInfo) { @@ -29,6 +33,17 @@ export default function TopBar() { setShowUpdateDialog(false); }; + const handleImageUpdateDismiss = async () => { + if (appSettings && imageUpdateInfo) { + await saveSettings({ + ...appSettings, + dismissed_image_digest: imageUpdateInfo.remote_digest, + }); + } + setImageUpdateInfo(null); + setShowImageUpdateDialog(false); + }; + return ( <>
@@ -44,6 +59,15 @@ export default function TopBar() { Update )} + {imageUpdateInfo && ( + + )}
@@ -56,6 +80,13 @@ export default function TopBar() { onClose={() => setShowUpdateDialog(false)} /> )} + {showImageUpdateDialog && imageUpdateInfo && ( + setShowImageUpdateDialog(false)} + /> + )} ); } diff --git a/app/src/components/projects/ProjectCard.test.tsx b/app/src/components/projects/ProjectCard.test.tsx index 2b2f658..3d2e468 100644 --- a/app/src/components/projects/ProjectCard.test.tsx +++ b/app/src/components/projects/ProjectCard.test.tsx @@ -57,7 +57,7 @@ const mockProject: Project = { paths: [{ host_path: "/home/user/project", mount_name: "project" }], container_id: null, status: "stopped", - auth_mode: "anthropic", + backend: "anthropic", bedrock_config: null, allow_docker_access: false, ssh_key_path: null, diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index ecfc17a..d4ec492 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { listen } from "@tauri-apps/api/event"; -import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod, OllamaConfig, LiteLlmConfig } from "../../lib/types"; +import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, LiteLlmConfig } from "../../lib/types"; import { useProjects } from "../../hooks/useProjects"; import { useMcpServers } from "../../hooks/useMcpServers"; import { useTerminal } from "../../hooks/useTerminal"; @@ -202,16 +202,16 @@ export default function ProjectCard({ project }: Props) { model_id: null, }; - const handleAuthModeChange = async (mode: AuthMode) => { + const handleBackendChange = async (mode: Backend) => { try { - const updates: Partial = { auth_mode: mode }; + const updates: Partial = { backend: mode }; if (mode === "bedrock" && !project.bedrock_config) { updates.bedrock_config = defaultBedrockConfig; } if (mode === "ollama" && !project.ollama_config) { updates.ollama_config = defaultOllamaConfig; } - if (mode === "lit_llm" && !project.litellm_config) { + if (mode === "lite_llm" && !project.litellm_config) { updates.litellm_config = defaultLiteLlmConfig; } await update({ ...project, ...updates }); @@ -446,12 +446,12 @@ export default function ProjectCard({ project }: Props) { {isSelected && (
- {/* Auth mode selector */} + {/* Backend selector */}
- Auth: + Backend:
@@ -794,7 +794,7 @@ export default function ProjectCard({ project }: Props) { )} {/* Bedrock config */} - {project.auth_mode === "bedrock" && (() => { + {project.backend === "bedrock" && (() => { const bc = project.bedrock_config ?? defaultBedrockConfig; const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"; return ( @@ -916,7 +916,7 @@ export default function ProjectCard({ project }: Props) { })()} {/* Ollama config */} - {project.auth_mode === "ollama" && (() => { + {project.backend === "ollama" && (() => { const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"; return (
@@ -956,7 +956,7 @@ export default function ProjectCard({ project }: Props) { })()} {/* LiteLLM config */} - {project.auth_mode === "lit_llm" && (() => { + {project.backend === "lite_llm" && (() => { const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"; return (
diff --git a/app/src/components/settings/ApiKeyInput.tsx b/app/src/components/settings/ApiKeyInput.tsx index 7cff023..9ae7dee 100644 --- a/app/src/components/settings/ApiKeyInput.tsx +++ b/app/src/components/settings/ApiKeyInput.tsx @@ -1,9 +1,9 @@ export default function ApiKeyInput() { return (
- +

- Each project can use claude login (OAuth, run inside the terminal) or AWS Bedrock. Set auth mode per-project. + Each project can use claude login (OAuth, run inside the terminal) or AWS Bedrock. Set backend per-project.

); diff --git a/app/src/components/settings/DockerSettings.tsx b/app/src/components/settings/DockerSettings.tsx index 7a43bd1..ac3c217 100644 --- a/app/src/components/settings/DockerSettings.tsx +++ b/app/src/components/settings/DockerSettings.tsx @@ -121,9 +121,9 @@ export default function DockerSettings() { )} {/* Resolved image display */} -
+
Image - + {resolvedImageName}
diff --git a/app/src/components/settings/ImageUpdateDialog.tsx b/app/src/components/settings/ImageUpdateDialog.tsx new file mode 100644 index 0000000..dea67de --- /dev/null +++ b/app/src/components/settings/ImageUpdateDialog.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, useCallback } from "react"; +import type { ImageUpdateInfo } from "../../lib/types"; + +interface Props { + imageUpdateInfo: ImageUpdateInfo; + onDismiss: () => void; + onClose: () => void; +} + +export default function ImageUpdateDialog({ + imageUpdateInfo, + onDismiss, + onClose, +}: Props) { + const overlayRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }, + [onClose], + ); + + const shortDigest = (digest: string) => { + // Show first 16 chars of the hash part (after "sha256:") + const hash = digest.startsWith("sha256:") ? digest.slice(7) : digest; + return hash.slice(0, 16); + }; + + return ( +
+
+

Container Image Update

+ +

+ A newer version of the container image is available in the registry. + Re-pull the image in Docker settings to get the latest tools and fixes. +

+ +
+ {imageUpdateInfo.local_digest && ( +
+ Local digest + + {shortDigest(imageUpdateInfo.local_digest)}... + +
+ )} +
+ Remote digest + + {shortDigest(imageUpdateInfo.remote_digest)}... + +
+
+ +

+ Go to Settings > Docker and click "Re-pull Image" to update. + Running containers will not be affected until restarted. +

+ +
+ + +
+
+
+ ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 347e9f0..b6651b5 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -1,5 +1,4 @@ import { useState, useEffect } from "react"; -import ApiKeyInput from "./ApiKeyInput"; import DockerSettings from "./DockerSettings"; import AwsSettings from "./AwsSettings"; import { useSettings } from "../../hooks/useSettings"; @@ -11,7 +10,7 @@ import type { EnvVar } from "../../lib/types"; export default function SettingsPanel() { const { appSettings, saveSettings } = useSettings(); - const { appVersion, checkForUpdates } = useUpdates(); + const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates(); const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? ""); const [globalEnvVars, setGlobalEnvVars] = useState(appSettings?.global_custom_env_vars ?? []); const [checkingUpdates, setCheckingUpdates] = useState(false); @@ -39,7 +38,7 @@ export default function SettingsPanel() { const handleCheckNow = async () => { setCheckingUpdates(true); try { - await checkForUpdates(); + await Promise.all([checkForUpdates(), checkImageUpdate()]); } finally { setCheckingUpdates(false); } @@ -55,7 +54,6 @@ export default function SettingsPanel() {

Settings

- @@ -146,6 +144,12 @@ export default function SettingsPanel() { > {checkingUpdates ? "Checking..." : "Check now"} + {imageUpdateInfo && ( +
+ + A newer container image is available. Re-pull the image in Docker settings above to update. +
+ )}
diff --git a/app/src/hooks/useUpdates.ts b/app/src/hooks/useUpdates.ts index 4e83f30..f788e24 100644 --- a/app/src/hooks/useUpdates.ts +++ b/app/src/hooks/useUpdates.ts @@ -6,16 +6,25 @@ import * as commands from "../lib/tauri-commands"; const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours export function useUpdates() { - const { updateInfo, setUpdateInfo, appVersion, setAppVersion, appSettings } = - useAppState( - useShallow((s) => ({ - updateInfo: s.updateInfo, - setUpdateInfo: s.setUpdateInfo, - appVersion: s.appVersion, - setAppVersion: s.setAppVersion, - appSettings: s.appSettings, - })), - ); + const { + updateInfo, + setUpdateInfo, + imageUpdateInfo, + setImageUpdateInfo, + appVersion, + setAppVersion, + appSettings, + } = useAppState( + useShallow((s) => ({ + updateInfo: s.updateInfo, + setUpdateInfo: s.setUpdateInfo, + imageUpdateInfo: s.imageUpdateInfo, + setImageUpdateInfo: s.setImageUpdateInfo, + appVersion: s.appVersion, + setAppVersion: s.setAppVersion, + appSettings: s.appSettings, + })), + ); const intervalRef = useRef | null>(null); @@ -47,11 +56,31 @@ export function useUpdates() { } }, [setUpdateInfo, appSettings?.dismissed_update_version]); + const checkImageUpdate = useCallback(async () => { + try { + const info = await commands.checkImageUpdate(); + if (info) { + // Respect dismissed image digest + const dismissed = appSettings?.dismissed_image_digest; + if (dismissed && dismissed === info.remote_digest) { + setImageUpdateInfo(null); + return null; + } + } + setImageUpdateInfo(info); + return info; + } catch (e) { + console.error("Failed to check for image updates:", e); + return null; + } + }, [setImageUpdateInfo, appSettings?.dismissed_image_digest]); + const startPeriodicCheck = useCallback(() => { if (intervalRef.current) return; intervalRef.current = setInterval(() => { if (appSettings?.auto_check_updates !== false) { checkForUpdates(); + checkImageUpdate(); } }, CHECK_INTERVAL_MS); return () => { @@ -60,13 +89,15 @@ export function useUpdates() { intervalRef.current = null; } }; - }, [checkForUpdates, appSettings?.auto_check_updates]); + }, [checkForUpdates, checkImageUpdate, appSettings?.auto_check_updates]); return { updateInfo, + imageUpdateInfo, appVersion, loadVersion, checkForUpdates, + checkImageUpdate, startPeriodicCheck, }; } diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index eaa5809..d62e046 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer, FileEntry } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -83,3 +83,5 @@ export const uploadFileToContainer = (projectId: string, hostPath: string, conta export const getAppVersion = () => invoke("get_app_version"); export const checkForUpdates = () => invoke("check_for_updates"); +export const checkImageUpdate = () => + invoke("check_image_update"); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 2ec1621..44360ab 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -20,7 +20,7 @@ export interface Project { paths: ProjectPath[]; container_id: string | null; status: ProjectStatus; - auth_mode: AuthMode; + backend: Backend; bedrock_config: BedrockConfig | null; ollama_config: OllamaConfig | null; litellm_config: LiteLlmConfig | null; @@ -45,7 +45,7 @@ export type ProjectStatus = | "stopping" | "error"; -export type AuthMode = "anthropic" | "bedrock" | "ollama" | "lit_llm"; +export type Backend = "anthropic" | "bedrock" | "ollama" | "lite_llm"; export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token"; @@ -116,6 +116,7 @@ export interface AppSettings { dismissed_update_version: string | null; timezone: string | null; default_microphone: string | null; + dismissed_image_digest: string | null; } export interface UpdateInfo { @@ -133,6 +134,12 @@ export interface ReleaseAsset { size: number; } +export interface ImageUpdateInfo { + remote_digest: string; + local_digest: string | null; + remote_updated_at: string | null; +} + export type McpTransportType = "stdio" | "http"; export interface McpServer { diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts index 8b52fd3..757c11c 100644 --- a/app/src/store/appState.ts +++ b/app/src/store/appState.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types"; +import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types"; interface AppState { // Projects @@ -39,6 +39,10 @@ interface AppState { setUpdateInfo: (info: UpdateInfo | null) => void; appVersion: string; setAppVersion: (version: string) => void; + + // Image update info + imageUpdateInfo: ImageUpdateInfo | null; + setImageUpdateInfo: (info: ImageUpdateInfo | null) => void; } export const useAppState = create((set) => ({ @@ -111,4 +115,8 @@ export const useAppState = create((set) => ({ setUpdateInfo: (info) => set({ updateInfo: info }), appVersion: "", setAppVersion: (version) => set({ appVersion: version }), + + // Image update info + imageUpdateInfo: null, + setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }), }));