Compare commits
9 Commits
v0.2.1
...
v0.2.10-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| ecaa42fa77 | |||
| 280358166a | |||
| 4732feb33e | |||
| 5977024953 | |||
| 27007b90e3 | |||
| 38e65619e9 | |||
| d2c1c2108a | |||
| cc163e6650 | |||
| 38082059a5 |
@@ -72,7 +72,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||||
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
|
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
|
||||||
- `image.rs` — Image build/pull with progress 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`
|
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||||
|
|
||||||
### Container (`container/`)
|
### Container (`container/`)
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [First Launch](#first-launch)
|
||||||
|
- [The Interface](#the-interface)
|
||||||
|
- [Project Management](#project-management)
|
||||||
|
- [Project Configuration](#project-configuration)
|
||||||
|
- [MCP Servers (Beta)](#mcp-servers-beta)
|
||||||
|
- [AWS Bedrock Configuration](#aws-bedrock-configuration)
|
||||||
|
- [Ollama Configuration](#ollama-configuration)
|
||||||
|
- [LiteLLM Configuration](#litellm-configuration)
|
||||||
|
- [Settings](#settings)
|
||||||
|
- [Terminal Features](#terminal-features)
|
||||||
|
- [Scheduled Tasks (Inside the Container)](#scheduled-tasks-inside-the-container)
|
||||||
|
- [What's Inside the Container](#whats-inside-the-container)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -86,21 +105,22 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
|
|||||||
**AWS Bedrock:**
|
**AWS Bedrock:**
|
||||||
|
|
||||||
1. Stop the container first (settings can only be changed while stopped).
|
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).
|
3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below).
|
||||||
4. Start the container again.
|
4. Start the container again.
|
||||||
|
|
||||||
**Ollama:**
|
**Ollama:**
|
||||||
|
|
||||||
1. Stop the container first (settings can only be changed while stopped).
|
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.
|
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). Set the **Model ID** to the model you want to use (required).
|
||||||
4. Start the container again.
|
4. Make sure the model has been pulled in Ollama (e.g., `ollama pull qwen3.5:27b`) or used via Ollama cloud before starting.
|
||||||
|
5. Start the container again.
|
||||||
|
|
||||||
**LiteLLM:**
|
**LiteLLM:**
|
||||||
|
|
||||||
1. Stop the container first (settings can only be changed while stopped).
|
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.
|
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.
|
4. Start the container again.
|
||||||
|
|
||||||
@@ -361,7 +381,7 @@ MCP server configuration is tracked via SHA-256 fingerprints stored as Docker la
|
|||||||
|
|
||||||
## AWS Bedrock Configuration
|
## 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
|
### Authentication Methods
|
||||||
|
|
||||||
@@ -390,12 +410,12 @@ Per-project settings always override these global defaults.
|
|||||||
|
|
||||||
## Ollama Configuration
|
## 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
|
### Settings
|
||||||
|
|
||||||
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
|
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
|
||||||
- **Model ID** — Optional. Override the model to use (e.g., `qwen3.5:27b`).
|
- **Model ID** — **Required.** The model to use (e.g., `qwen3.5:27b`). The model must be pulled in Ollama before use — run `ollama pull <model>` or use it via Ollama cloud so it is available when the container starts.
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
@@ -403,11 +423,13 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in
|
|||||||
|
|
||||||
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
|
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
|
||||||
|
|
||||||
|
> **Important:** The model must already be available in Ollama before starting the container. If using a local Ollama instance, pull the model first with `ollama pull <model-name>`. If using Ollama's cloud service, ensure the model has been used at least once so it is cached.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## LiteLLM Configuration
|
## 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
|
### Settings
|
||||||
|
|
||||||
@@ -622,3 +644,13 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
|
|||||||
- Ensure the Docker image for the MCP server exists (pull it first if needed).
|
- Ensure the Docker image for the MCP server exists (pull it first if needed).
|
||||||
- Check that Docker socket access is available (stdio + Docker MCP servers auto-enable this).
|
- Check that Docker socket access is available (stdio + Docker MCP servers auto-enable this).
|
||||||
- Try resetting the project container to force a clean recreation.
|
- Try resetting the project container to force a clean recreation.
|
||||||
|
|
||||||
|
### "Failed to install Anthropic marketplace" Error
|
||||||
|
|
||||||
|
If Claude Code shows **"Failed to install Anthropic marketplace - Will retry on next startup"** repeatedly, the marketplace metadata in `~/.claude.json` may be corrupted. To fix this, open a **Shell** session in the project and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswith("officialMarketplace") | not))' ~/.claude.json.bak > ~/.claude.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Each project can independently use one of:
|
|||||||
|
|
||||||
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
||||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Optional model override.
|
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||||
- **LiteLLM**: Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers. API key stored securely in OS keychain.
|
- **LiteLLM**: Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers. API key stored securely in OS keychain.
|
||||||
|
|
||||||
> **Note:** Ollama and LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
> **Note:** Ollama and LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||||
@@ -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/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/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
|
||||||
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
| `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/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
|
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
|
||||||
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
|
| `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/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/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/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/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/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) |
|
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ triple-c/
|
|||||||
│ ├── image.rs # Build from Dockerfile, pull from registry
|
│ ├── image.rs # Build from Dockerfile, pull from registry
|
||||||
│ └── network.rs # Per-project bridge networks for MCP
|
│ └── network.rs # Per-project bridge networks for MCP
|
||||||
├── models/ # Data structures
|
├── models/ # Data structures
|
||||||
│ ├── project.rs # Project, AuthMode, BedrockConfig
|
│ ├── project.rs # Project, Backend, BedrockConfig
|
||||||
│ ├── mcp_server.rs # MCP server configuration
|
│ ├── mcp_server.rs # MCP server configuration
|
||||||
│ ├── app_settings.rs # Global settings (image source, AWS, etc.)
|
│ ├── app_settings.rs # Global settings (image source, AWS, etc.)
|
||||||
│ ├── container_config.rs # Image name resolution
|
│ ├── container_config.rs # Image name resolution
|
||||||
|
|||||||
60
app/src-tauri/src/commands/help_commands.rs
Normal file
60
app/src-tauri/src/commands/help_commands.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
const HELP_URL: &str =
|
||||||
|
"https://repo.anhonesthost.net/cybercovellc/triple-c/raw/branch/main/HOW-TO-USE.md";
|
||||||
|
|
||||||
|
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");
|
||||||
|
|
||||||
|
/// Cached help content fetched from the remote repo (or `None` if not yet fetched).
|
||||||
|
static CACHED_HELP: OnceLock<Mutex<Option<String>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Return the help markdown content.
|
||||||
|
///
|
||||||
|
/// On the first call, tries to fetch the latest version from the gitea repo.
|
||||||
|
/// If that fails (network error, timeout, etc.), falls back to the version
|
||||||
|
/// embedded at compile time. The result is cached for the rest of the session.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_help_content() -> Result<String, String> {
|
||||||
|
let mutex = CACHED_HELP.get_or_init(|| Mutex::new(None));
|
||||||
|
let mut guard = mutex.lock().await;
|
||||||
|
|
||||||
|
if let Some(ref cached) = *guard {
|
||||||
|
return Ok(cached.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = match fetch_remote_help().await {
|
||||||
|
Ok(md) => {
|
||||||
|
log::info!("Loaded help content from remote repo");
|
||||||
|
md
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::info!("Using embedded help content (remote fetch failed: {})", e);
|
||||||
|
EMBEDDED_HELP.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
*guard = Some(content.clone());
|
||||||
|
Ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_remote_help() -> Result<String, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(HELP_URL)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch help content: {}", e))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("Remote returned status {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response body: {}", e))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod aws_commands;
|
pub mod aws_commands;
|
||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
pub mod file_commands;
|
pub mod file_commands;
|
||||||
|
pub mod help_commands;
|
||||||
pub mod mcp_commands;
|
pub mod mcp_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use tauri::{Emitter, State};
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
use crate::docker;
|
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::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -179,27 +179,27 @@ pub async fn start_project_container(
|
|||||||
// Resolve enabled MCP servers for this project
|
// Resolve enabled MCP servers for this project
|
||||||
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
||||||
|
|
||||||
// Validate auth mode requirements
|
// Validate backend requirements
|
||||||
if project.auth_mode == AuthMode::Bedrock {
|
if project.backend == Backend::Bedrock {
|
||||||
let bedrock = project.bedrock_config.as_ref()
|
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
|
// Region can come from per-project or global
|
||||||
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
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()
|
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() {
|
if ollama.base_url.is_empty() {
|
||||||
return Err("Ollama base URL is required.".to_string());
|
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()
|
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() {
|
if litellm.base_url.is_empty() {
|
||||||
return Err("LiteLLM base URL is required.".to_string());
|
return Err("LiteLLM base URL is required.".to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
use crate::models::{AuthMode, BedrockAuthMethod, Project};
|
use crate::models::{Backend, BedrockAuthMethod, Project};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Build the command to run in the container terminal.
|
/// 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`
|
/// 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).
|
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||||
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
||||||
let is_bedrock_profile = project.auth_mode == AuthMode::Bedrock
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
&& project
|
&& project
|
||||||
.bedrock_config
|
.bedrock_config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -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 =
|
const RELEASES_URL: &str =
|
||||||
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
|
"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]
|
#[tauri::command]
|
||||||
pub fn get_app_version() -> String {
|
pub fn get_app_version() -> String {
|
||||||
env!("CARGO_PKG_VERSION").to_string()
|
env!("CARGO_PKG_VERSION").to_string()
|
||||||
@@ -26,30 +34,37 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
.map_err(|e| format!("Failed to parse releases: {}", e))?;
|
.map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||||
|
|
||||||
let current_version = env!("CARGO_PKG_VERSION");
|
let current_version = env!("CARGO_PKG_VERSION");
|
||||||
let is_windows = cfg!(target_os = "windows");
|
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
|
||||||
|
|
||||||
|
// Determine platform suffix for tag filtering
|
||||||
|
let platform_suffix: &str = if cfg!(target_os = "windows") {
|
||||||
|
"-win"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"-mac"
|
||||||
|
} else {
|
||||||
|
"" // Linux uses bare tags (no suffix)
|
||||||
|
};
|
||||||
|
|
||||||
// Filter releases by platform tag suffix
|
// Filter releases by platform tag suffix
|
||||||
let platform_releases: Vec<&GiteaRelease> = releases
|
let platform_releases: Vec<&GiteaRelease> = releases
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
if is_windows {
|
if platform_suffix.is_empty() {
|
||||||
r.tag_name.ends_with("-win")
|
// Linux: bare tag only (no -win, no -mac)
|
||||||
|
!r.tag_name.ends_with("-win") && !r.tag_name.ends_with("-mac")
|
||||||
} else {
|
} else {
|
||||||
!r.tag_name.ends_with("-win")
|
r.tag_name.ends_with(platform_suffix)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Find the latest release with a higher patch version
|
// Find the latest release with a higher semver version
|
||||||
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
|
let mut best: Option<(&GiteaRelease, (u32, u32, u32))> = None;
|
||||||
let current_patch = parse_patch_version(current_version).unwrap_or(0);
|
|
||||||
|
|
||||||
let mut best: Option<(&GiteaRelease, u32)> = None;
|
|
||||||
for release in &platform_releases {
|
for release in &platform_releases {
|
||||||
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
|
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||||
if patch > current_patch {
|
if ver > current_semver {
|
||||||
if best.is_none() || patch > best.unwrap().1 {
|
if best.is_none() || ver > best.unwrap().1 {
|
||||||
best = Some((release, patch));
|
best = Some((release, ver));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,34 +99,125 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse patch version from a semver string like "0.1.5" -> 5
|
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||||
fn parse_patch_version(version: &str) -> Option<u32> {
|
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = version.trim_start_matches('v');
|
let clean = version.trim_start_matches('v');
|
||||||
let parts: Vec<&str> = clean.split('.').collect();
|
let parts: Vec<&str> = clean.split('.').collect();
|
||||||
if parts.len() >= 3 {
|
if parts.len() >= 3 {
|
||||||
parts[2].parse().ok()
|
let major = parts[0].parse().ok()?;
|
||||||
|
let minor = parts[1].parse().ok()?;
|
||||||
|
let patch = parts[2].parse().ok()?;
|
||||||
|
Some((major, minor, patch))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
|
/// Parse semver from a tag like "v0.2.5", "v0.2.5-win", "v0.2.5-mac" -> (0, 2, 5)
|
||||||
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
|
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = tag.trim_start_matches('v');
|
let clean = tag.trim_start_matches('v');
|
||||||
// Remove platform suffix
|
// Remove platform suffix
|
||||||
let clean = clean.strip_suffix("-win").unwrap_or(clean);
|
let clean = clean.strip_suffix("-win")
|
||||||
parse_patch_version(clean)
|
.or_else(|| clean.strip_suffix("-mac"))
|
||||||
|
.unwrap_or(clean);
|
||||||
|
parse_semver(clean)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5"
|
/// Extract a clean version string from a tag like "v0.2.5-win" -> "0.2.5"
|
||||||
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||||
let clean = tag.trim_start_matches('v');
|
let (major, minor, patch) = parse_semver_from_tag(tag)?;
|
||||||
let clean = clean.strip_suffix("-win").unwrap_or(clean);
|
Some(format!("{}.{}.{}", major, minor, patch))
|
||||||
// Validate it looks like a version
|
}
|
||||||
let parts: Vec<&str> = clean.split('.').collect();
|
|
||||||
if parts.len() >= 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
|
/// Check whether a newer container image is available in the registry.
|
||||||
Some(clean.to_string())
|
///
|
||||||
} else {
|
/// Compares the local image digest with the remote registry digest using the
|
||||||
None
|
/// 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<Option<ImageUpdateInfo>, 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/<repo>/manifests/<tag> and read the
|
||||||
|
/// `Docker-Content-Digest` header that the registry returns.
|
||||||
|
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
|||||||
use sha2::{Sha256, Digest};
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
use super::client::get_docker;
|
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
|
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||||
|
|
||||||
@@ -453,7 +453,7 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bedrock configuration
|
// Bedrock configuration
|
||||||
if project.auth_mode == AuthMode::Bedrock {
|
if project.backend == Backend::Bedrock {
|
||||||
if let Some(ref bedrock) = project.bedrock_config {
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
|
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
|
||||||
|
|
||||||
@@ -506,7 +506,7 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ollama configuration
|
// Ollama configuration
|
||||||
if project.auth_mode == AuthMode::Ollama {
|
if project.backend == Backend::Ollama {
|
||||||
if let Some(ref ollama) = project.ollama_config {
|
if let Some(ref ollama) = project.ollama_config {
|
||||||
env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url));
|
env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url));
|
||||||
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
|
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
|
||||||
@@ -517,7 +517,7 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LiteLLM configuration
|
// LiteLLM configuration
|
||||||
if project.auth_mode == AuthMode::LiteLlm {
|
if project.backend == Backend::LiteLlm {
|
||||||
if let Some(ref litellm) = project.litellm_config {
|
if let Some(ref litellm) = project.litellm_config {
|
||||||
env_vars.push(format!("ANTHROPIC_BASE_URL={}", litellm.base_url));
|
env_vars.push(format!("ANTHROPIC_BASE_URL={}", litellm.base_url));
|
||||||
if let Some(ref key) = litellm.api_key {
|
if let Some(ref key) = litellm.api_key {
|
||||||
@@ -624,7 +624,7 @@ pub async fn create_container(
|
|||||||
|
|
||||||
// AWS config mount (read-only)
|
// AWS config mount (read-only)
|
||||||
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
|
// 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 {
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
bedrock.auth_method == BedrockAuthMethod::Profile
|
bedrock.auth_method == BedrockAuthMethod::Profile
|
||||||
} else {
|
} else {
|
||||||
@@ -694,7 +694,7 @@ pub async fn create_container(
|
|||||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
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-id".to_string(), project.id.clone());
|
||||||
labels.insert("triple-c.project-name".to_string(), project.name.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.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.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
||||||
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_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
|
// Code settings stored in the named volume). The change takes effect
|
||||||
// on the next explicit rebuild instead.
|
// on the next explicit rebuild instead.
|
||||||
|
|
||||||
// ── Auth mode ────────────────────────────────────────────────────────
|
// ── Backend ──────────────────────────────────────────────────────────
|
||||||
let current_auth_mode = format!("{:?}", project.auth_mode);
|
let current_backend = format!("{:?}", project.backend);
|
||||||
if let Some(container_auth_mode) = get_label("triple-c.auth-mode") {
|
// Check new label name, falling back to old "triple-c.auth-mode" for pre-rename containers
|
||||||
if container_auth_mode != current_auth_mode {
|
let container_backend = get_label("triple-c.backend").or_else(|| get_label("triple-c.auth-mode"));
|
||||||
log::info!("Auth mode mismatch (container={:?}, project={:?})", container_auth_mode, current_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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,38 @@ pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
|||||||
Ok(!images.is_empty())
|
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<Option<String>, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"reference".to_string(),
|
||||||
|
vec![image_name.to_string()],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let images: Vec<ImageSummary> = 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<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
||||||
where
|
where
|
||||||
F: Fn(String) + Send + 'static,
|
F: Fn(String) + Send + 'static,
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ pub fn run() {
|
|||||||
// Updates
|
// Updates
|
||||||
commands::update_commands::get_app_version,
|
commands::update_commands::get_app_version,
|
||||||
commands::update_commands::check_for_updates,
|
commands::update_commands::check_for_updates,
|
||||||
|
commands::update_commands::check_image_update,
|
||||||
|
// Help
|
||||||
|
commands::help_commands::get_help_content,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ pub struct AppSettings {
|
|||||||
pub timezone: Option<String>,
|
pub timezone: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_microphone: Option<String>,
|
pub default_microphone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dismissed_image_digest: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -90,6 +92,7 @@ impl Default for AppSettings {
|
|||||||
dismissed_update_version: None,
|
dismissed_update_version: None,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
default_microphone: None,
|
default_microphone: None,
|
||||||
|
dismissed_image_digest: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ pub struct Project {
|
|||||||
pub paths: Vec<ProjectPath>,
|
pub paths: Vec<ProjectPath>,
|
||||||
pub container_id: Option<String>,
|
pub container_id: Option<String>,
|
||||||
pub status: ProjectStatus,
|
pub status: ProjectStatus,
|
||||||
pub auth_mode: AuthMode,
|
#[serde(alias = "auth_mode")]
|
||||||
|
pub backend: Backend,
|
||||||
pub bedrock_config: Option<BedrockConfig>,
|
pub bedrock_config: Option<BedrockConfig>,
|
||||||
pub ollama_config: Option<OllamaConfig>,
|
pub ollama_config: Option<OllamaConfig>,
|
||||||
pub litellm_config: Option<LiteLlmConfig>,
|
pub litellm_config: Option<LiteLlmConfig>,
|
||||||
@@ -65,13 +66,14 @@ pub enum ProjectStatus {
|
|||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the project authenticates with Claude.
|
/// Which AI model backend/provider the project uses.
|
||||||
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
|
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||||
/// persisted in the config volume)
|
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||||
/// - `Bedrock`: Uses 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AuthMode {
|
pub enum Backend {
|
||||||
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||||
#[serde(alias = "login", alias = "api_key")]
|
#[serde(alias = "login", alias = "api_key")]
|
||||||
Anthropic,
|
Anthropic,
|
||||||
@@ -81,7 +83,7 @@ pub enum AuthMode {
|
|||||||
LiteLlm,
|
LiteLlm,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthMode {
|
impl Default for Backend {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Anthropic
|
Self::Anthropic
|
||||||
}
|
}
|
||||||
@@ -152,7 +154,7 @@ impl Project {
|
|||||||
paths,
|
paths,
|
||||||
container_id: None,
|
container_id: None,
|
||||||
status: ProjectStatus::Stopped,
|
status: ProjectStatus::Stopped,
|
||||||
auth_mode: AuthMode::default(),
|
backend: Backend::default(),
|
||||||
bedrock_config: None,
|
bedrock_config: None,
|
||||||
ollama_config: None,
|
ollama_config: None,
|
||||||
litellm_config: None,
|
litellm_config: None,
|
||||||
|
|||||||
@@ -35,3 +35,14 @@ pub struct GiteaAsset {
|
|||||||
pub browser_download_url: String,
|
pub browser_download_url: String,
|
||||||
pub size: u64,
|
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<String>,
|
||||||
|
/// When the remote image was last updated (if known)
|
||||||
|
pub remote_updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function App() {
|
|||||||
const { loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { refresh: refreshMcp } = useMcpServers();
|
const { refresh: refreshMcp } = useMcpServers();
|
||||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId, setProjects } = useAppState(
|
const { sessions, activeSessionId, setProjects } = useAppState(
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
||||||
);
|
);
|
||||||
@@ -46,7 +46,10 @@ export default function App() {
|
|||||||
|
|
||||||
// Update detection
|
// Update detection
|
||||||
loadVersion();
|
loadVersion();
|
||||||
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
|
const updateTimer = setTimeout(() => {
|
||||||
|
checkForUpdates();
|
||||||
|
checkImageUpdate();
|
||||||
|
}, 3000);
|
||||||
const cleanup = startPeriodicCheck();
|
const cleanup = startPeriodicCheck();
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(updateTimer);
|
clearTimeout(updateTimer);
|
||||||
|
|||||||
218
app/src/components/layout/HelpDialog.tsx
Normal file
218
app/src/components/layout/HelpDialog.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
|
import { getHelpContent } from "../../lib/tauri-commands";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert header text to a URL-friendly slug for anchor links. */
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
||||||
|
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
||||||
|
.replace(/\s+/g, "-") // spaces to dashes
|
||||||
|
.replace(/-+/g, "-") // collapse consecutive dashes
|
||||||
|
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple markdown-to-HTML converter for the help content. */
|
||||||
|
function renderMarkdown(md: string): string {
|
||||||
|
let html = md;
|
||||||
|
|
||||||
|
// Normalize line endings
|
||||||
|
html = html.replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
|
// Escape HTML entities (but we'll re-introduce tags below)
|
||||||
|
html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
// Fenced code blocks (```...```)
|
||||||
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||||
|
return `<pre class="help-code-block"><code>${code.trimEnd()}</code></pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline code (`...`)
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code class="help-inline-code">$1</code>');
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
html = html.replace(
|
||||||
|
/(?:^|\n)(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/g,
|
||||||
|
(_m, headerRow: string, _sep: string, bodyRows: string) => {
|
||||||
|
const headers = headerRow
|
||||||
|
.split("|")
|
||||||
|
.slice(1, -1)
|
||||||
|
.map((c: string) => `<th>${c.trim()}</th>`)
|
||||||
|
.join("");
|
||||||
|
const rows = bodyRows
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((row: string) => {
|
||||||
|
const cells = row
|
||||||
|
.split("|")
|
||||||
|
.slice(1, -1)
|
||||||
|
.map((c: string) => `<td>${c.trim()}</td>`)
|
||||||
|
.join("");
|
||||||
|
return `<tr>${cells}</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `<table class="help-table"><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blockquotes (> ...)
|
||||||
|
html = html.replace(/(?:^|\n)> (.+)/g, '<blockquote class="help-blockquote">$1</blockquote>');
|
||||||
|
// Merge adjacent blockquotes
|
||||||
|
html = html.replace(/<\/blockquote>\s*<blockquote class="help-blockquote">/g, "<br/>");
|
||||||
|
|
||||||
|
// Horizontal rules
|
||||||
|
html = html.replace(/\n---\n/g, '<hr class="help-hr"/>');
|
||||||
|
|
||||||
|
// Headers with id attributes for anchor navigation (process from h4 down to h1)
|
||||||
|
html = html.replace(/^#### (.+)$/gm, (_m, title) => `<h4 class="help-h4" id="${slugify(title)}">${title}</h4>`);
|
||||||
|
html = html.replace(/^### (.+)$/gm, (_m, title) => `<h3 class="help-h3" id="${slugify(title)}">${title}</h3>`);
|
||||||
|
html = html.replace(/^## (.+)$/gm, (_m, title) => `<h2 class="help-h2" id="${slugify(title)}">${title}</h2>`);
|
||||||
|
html = html.replace(/^# (.+)$/gm, (_m, title) => `<h1 class="help-h1" id="${slugify(title)}">${title}</h1>`);
|
||||||
|
|
||||||
|
// Bold (**...**)
|
||||||
|
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||||
|
|
||||||
|
// Italic (*...*)
|
||||||
|
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||||
|
|
||||||
|
// Markdown-style anchor links [text](#anchor)
|
||||||
|
html = html.replace(
|
||||||
|
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
||||||
|
'<a class="help-link" href="#$2">$1</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Markdown-style external links [text](url)
|
||||||
|
html = html.replace(
|
||||||
|
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
||||||
|
'<a class="help-link" href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unordered list items (- ...)
|
||||||
|
// Group consecutive list items
|
||||||
|
html = html.replace(/((?:^|\n)- .+(?:\n- .+)*)/g, (block) => {
|
||||||
|
const items = block
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `<li>${line.replace(/^- /, "")}</li>`)
|
||||||
|
.join("");
|
||||||
|
return `<ul class="help-ul">${items}</ul>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordered list items (1. ...)
|
||||||
|
html = html.replace(/((?:^|\n)\d+\. .+(?:\n\d+\. .+)*)/g, (block) => {
|
||||||
|
const items = block
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `<li>${line.replace(/^\d+\. /, "")}</li>`)
|
||||||
|
.join("");
|
||||||
|
return `<ol class="help-ol">${items}</ol>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
||||||
|
html = html.replace(
|
||||||
|
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
||||||
|
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap remaining loose text lines in paragraphs
|
||||||
|
// Split by double newlines for paragraph breaks
|
||||||
|
const blocks = html.split(/\n\n+/);
|
||||||
|
html = blocks
|
||||||
|
.map((block) => {
|
||||||
|
const trimmed = block.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
// Don't wrap blocks that are already HTML elements
|
||||||
|
if (
|
||||||
|
/^<(h[1-4]|ul|ol|pre|table|blockquote|hr)/.test(trimmed)
|
||||||
|
) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
// Wrap in paragraph, replacing single newlines with <br/>
|
||||||
|
return `<p class="help-p">${trimmed.replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HelpDialog({ onClose }: Props) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getHelpContent()
|
||||||
|
.then(setMarkdown)
|
||||||
|
.catch((e) => setError(String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current) onClose();
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle anchor link clicks to scroll within the dialog
|
||||||
|
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const anchor = target.closest("a");
|
||||||
|
if (!anchor) return;
|
||||||
|
const href = anchor.getAttribute("href");
|
||||||
|
if (!href || !href.startsWith("#")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const el = contentRef.current?.querySelector(href);
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[48rem] max-w-[90vw] max-h-[85vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] flex-shrink-0">
|
||||||
|
<h2 className="text-lg font-semibold">How to Use Triple-C</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
onClick={handleContentClick}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-4 help-content"
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<p className="text-[var(--error)] text-sm">Failed to load help content: {error}</p>
|
||||||
|
)}
|
||||||
|
{!markdown && !error && (
|
||||||
|
<p className="text-[var(--text-secondary)] text-sm">Loading...</p>
|
||||||
|
)}
|
||||||
|
{markdown && (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import { useShallow } from "zustand/react/shallow";
|
|||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
|
||||||
export default function StatusBar() {
|
export default function StatusBar() {
|
||||||
const { projects, sessions } = useAppState(
|
const { projects, sessions, terminalHasSelection } = useAppState(
|
||||||
useShallow(s => ({ projects: s.projects, sessions: s.sessions }))
|
useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection }))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
|
||||||
@@ -20,6 +20,12 @@ export default function StatusBar() {
|
|||||||
<span>
|
<span>
|
||||||
{sessions.length} terminal{sessions.length !== 1 ? "s" : ""}
|
{sessions.length} terminal{sessions.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
{terminalHasSelection && (
|
||||||
|
<>
|
||||||
|
<span className="mx-2">|</span>
|
||||||
|
<span className="text-[var(--accent)]">Ctrl+Shift+C to copy</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,25 @@ import TerminalTabs from "../terminal/TerminalTabs";
|
|||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import UpdateDialog from "../settings/UpdateDialog";
|
import UpdateDialog from "../settings/UpdateDialog";
|
||||||
|
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
|
||||||
|
import HelpDialog from "./HelpDialog";
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
|
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
dockerAvailable: s.dockerAvailable,
|
dockerAvailable: s.dockerAvailable,
|
||||||
imageExists: s.imageExists,
|
imageExists: s.imageExists,
|
||||||
updateInfo: s.updateInfo,
|
updateInfo: s.updateInfo,
|
||||||
|
imageUpdateInfo: s.imageUpdateInfo,
|
||||||
appVersion: s.appVersion,
|
appVersion: s.appVersion,
|
||||||
setUpdateInfo: s.setUpdateInfo,
|
setUpdateInfo: s.setUpdateInfo,
|
||||||
|
setImageUpdateInfo: s.setImageUpdateInfo,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||||
|
const [showImageUpdateDialog, setShowImageUpdateDialog] = useState(false);
|
||||||
|
const [showHelpDialog, setShowHelpDialog] = useState(false);
|
||||||
|
|
||||||
const handleDismiss = async () => {
|
const handleDismiss = async () => {
|
||||||
if (appSettings && updateInfo) {
|
if (appSettings && updateInfo) {
|
||||||
@@ -29,6 +35,17 @@ export default function TopBar() {
|
|||||||
setShowUpdateDialog(false);
|
setShowUpdateDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageUpdateDismiss = async () => {
|
||||||
|
if (appSettings && imageUpdateInfo) {
|
||||||
|
await saveSettings({
|
||||||
|
...appSettings,
|
||||||
|
dismissed_image_digest: imageUpdateInfo.remote_digest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setImageUpdateInfo(null);
|
||||||
|
setShowImageUpdateDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
@@ -44,8 +61,24 @@ export default function TopBar() {
|
|||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{imageUpdateInfo && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImageUpdateDialog(true)}
|
||||||
|
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--warning,#f59e0b)] text-white hover:opacity-80 transition-colors"
|
||||||
|
title="A newer container image is available"
|
||||||
|
>
|
||||||
|
Image Update
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
||||||
<StatusDot ok={imageExists === true} label="Image" />
|
<StatusDot ok={imageExists === true} label="Image" />
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHelpDialog(true)}
|
||||||
|
title="Help"
|
||||||
|
className="ml-1 w-5 h-5 flex items-center justify-center rounded-full border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showUpdateDialog && updateInfo && (
|
{showUpdateDialog && updateInfo && (
|
||||||
@@ -56,6 +89,16 @@ export default function TopBar() {
|
|||||||
onClose={() => setShowUpdateDialog(false)}
|
onClose={() => setShowUpdateDialog(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showImageUpdateDialog && imageUpdateInfo && (
|
||||||
|
<ImageUpdateDialog
|
||||||
|
imageUpdateInfo={imageUpdateInfo}
|
||||||
|
onDismiss={handleImageUpdateDismiss}
|
||||||
|
onClose={() => setShowImageUpdateDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showHelpDialog && (
|
||||||
|
<HelpDialog onClose={() => setShowHelpDialog(false)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const mockProject: Project = {
|
|||||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||||
container_id: null,
|
container_id: null,
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
auth_mode: "anthropic",
|
backend: "anthropic",
|
||||||
bedrock_config: null,
|
bedrock_config: null,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
ssh_key_path: null,
|
ssh_key_path: null,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
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 { useProjects } from "../../hooks/useProjects";
|
||||||
import { useMcpServers } from "../../hooks/useMcpServers";
|
import { useMcpServers } from "../../hooks/useMcpServers";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
@@ -12,6 +12,7 @@ import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
|||||||
import ContainerProgressModal from "./ContainerProgressModal";
|
import ContainerProgressModal from "./ContainerProgressModal";
|
||||||
import FileManagerModal from "./FileManagerModal";
|
import FileManagerModal from "./FileManagerModal";
|
||||||
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
||||||
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -202,16 +203,16 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
model_id: null,
|
model_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAuthModeChange = async (mode: AuthMode) => {
|
const handleBackendChange = async (mode: Backend) => {
|
||||||
try {
|
try {
|
||||||
const updates: Partial<Project> = { auth_mode: mode };
|
const updates: Partial<Project> = { backend: mode };
|
||||||
if (mode === "bedrock" && !project.bedrock_config) {
|
if (mode === "bedrock" && !project.bedrock_config) {
|
||||||
updates.bedrock_config = defaultBedrockConfig;
|
updates.bedrock_config = defaultBedrockConfig;
|
||||||
}
|
}
|
||||||
if (mode === "ollama" && !project.ollama_config) {
|
if (mode === "ollama" && !project.ollama_config) {
|
||||||
updates.ollama_config = defaultOllamaConfig;
|
updates.ollama_config = defaultOllamaConfig;
|
||||||
}
|
}
|
||||||
if (mode === "lit_llm" && !project.litellm_config) {
|
if (mode === "lite_llm" && !project.litellm_config) {
|
||||||
updates.litellm_config = defaultLiteLlmConfig;
|
updates.litellm_config = defaultLiteLlmConfig;
|
||||||
}
|
}
|
||||||
await update({ ...project, ...updates });
|
await update({ ...project, ...updates });
|
||||||
@@ -446,12 +447,12 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
|
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
|
||||||
{/* Auth mode selector */}
|
{/* Backend selector */}
|
||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
<span className="text-[var(--text-secondary)] mr-1">Backend:<Tooltip text="Choose the AI model provider for this project. Anthropic: Connect directly to Claude via OAuth login (run 'claude login' in terminal). Bedrock: Route through AWS Bedrock using your AWS credentials. Ollama: Use locally-hosted open-source models (Llama, Mistral, etc.) via an Ollama server. LiteLLM: Connect through a LiteLLM proxy gateway to access 100+ model providers (OpenAI, Azure, Gemini, etc.)." /></span>
|
||||||
<select
|
<select
|
||||||
value={project.auth_mode}
|
value={project.backend}
|
||||||
onChange={(e) => { e.stopPropagation(); handleAuthModeChange(e.target.value as AuthMode); }}
|
onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||||
@@ -459,7 +460,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<option value="anthropic">Anthropic</option>
|
<option value="anthropic">Anthropic</option>
|
||||||
<option value="bedrock">Bedrock</option>
|
<option value="bedrock">Bedrock</option>
|
||||||
<option value="ollama">Ollama</option>
|
<option value="ollama">Ollama</option>
|
||||||
<option value="lit_llm">LiteLLM</option>
|
<option value="lite_llm">LiteLLM</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -609,7 +610,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* SSH Key */}
|
{/* SSH Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory<Tooltip text="Path to your .ssh directory. Mounted into the container so Claude can authenticate with Git remotes over SSH." /></label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<input
|
<input
|
||||||
value={sshKeyPath}
|
value={sshKeyPath}
|
||||||
@@ -631,7 +632,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* Git Name */}
|
{/* Git Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name<Tooltip text="Sets git user.name inside the container for commit authorship." /></label>
|
||||||
<input
|
<input
|
||||||
value={gitName}
|
value={gitName}
|
||||||
onChange={(e) => setGitName(e.target.value)}
|
onChange={(e) => setGitName(e.target.value)}
|
||||||
@@ -644,7 +645,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* Git Email */}
|
{/* Git Email */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email<Tooltip text="Sets git user.email inside the container for commit authorship." /></label>
|
||||||
<input
|
<input
|
||||||
value={gitEmail}
|
value={gitEmail}
|
||||||
onChange={(e) => setGitEmail(e.target.value)}
|
onChange={(e) => setGitEmail(e.target.value)}
|
||||||
@@ -657,7 +658,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* Git Token (HTTPS) */}
|
{/* Git Token (HTTPS) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token<Tooltip text="A personal access token (e.g. GitHub PAT) for HTTPS git operations inside the container." /></label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={gitToken}
|
value={gitToken}
|
||||||
@@ -671,7 +672,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* Docker access toggle */}
|
{/* Docker access toggle */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
|
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning<Tooltip text="Mounts the Docker socket so Claude can build and run Docker containers from inside the sandbox." /></label>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
|
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
|
||||||
@@ -691,7 +692,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* Mission Control toggle */}
|
{/* Mission Control toggle */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-[var(--text-secondary)]">Mission Control</label>
|
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
@@ -714,7 +715,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{/* Environment Variables */}
|
{/* Environment Variables */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-[var(--text-secondary)]">
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
|
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}<Tooltip text="Custom env vars injected into this project's container. Useful for API keys or tool configuration." />
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEnvVarsModal(true)}
|
onClick={() => setShowEnvVarsModal(true)}
|
||||||
@@ -727,7 +728,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{/* Port Mappings */}
|
{/* Port Mappings */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-[var(--text-secondary)]">
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
|
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}<Tooltip text="Map container ports to host ports so you can access dev servers running inside the container." />
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPortMappingsModal(true)}
|
onClick={() => setShowPortMappingsModal(true)}
|
||||||
@@ -740,7 +741,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{/* Claude Instructions */}
|
{/* Claude Instructions */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-[var(--text-secondary)]">
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
Claude Instructions{claudeInstructions ? " (set)" : ""}
|
Claude Instructions{claudeInstructions ? " (set)" : ""}<Tooltip text="Project-specific instructions written to CLAUDE.md. Guides Claude's behavior for this project." />
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowClaudeInstructionsModal(true)}
|
onClick={() => setShowClaudeInstructionsModal(true)}
|
||||||
@@ -753,7 +754,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{/* MCP Servers */}
|
{/* MCP Servers */}
|
||||||
{mcpServers.length > 0 && (
|
{mcpServers.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers<Tooltip text="Model Context Protocol servers give Claude access to external tools and data sources." /></label>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{mcpServers.map((server) => {
|
{mcpServers.map((server) => {
|
||||||
const enabled = project.enabled_mcp_servers.includes(server.id);
|
const enabled = project.enabled_mcp_servers.includes(server.id);
|
||||||
@@ -794,7 +795,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bedrock config */}
|
{/* Bedrock config */}
|
||||||
{project.auth_mode === "bedrock" && (() => {
|
{project.backend === "bedrock" && (() => {
|
||||||
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
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";
|
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 (
|
return (
|
||||||
@@ -819,7 +820,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* AWS Region (always shown) */}
|
{/* AWS Region (always shown) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region<Tooltip text="The AWS region where your Bedrock endpoint is available (e.g. us-east-1)." /></label>
|
||||||
<input
|
<input
|
||||||
value={bedrockRegion}
|
value={bedrockRegion}
|
||||||
onChange={(e) => setBedrockRegion(e.target.value)}
|
onChange={(e) => setBedrockRegion(e.target.value)}
|
||||||
@@ -834,7 +835,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{bc.auth_method === "static_credentials" && (
|
{bc.auth_method === "static_credentials" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID<Tooltip text="Your AWS IAM access key ID for Bedrock API authentication." /></label>
|
||||||
<input
|
<input
|
||||||
value={bedrockAccessKeyId}
|
value={bedrockAccessKeyId}
|
||||||
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
|
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
|
||||||
@@ -845,7 +846,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key<Tooltip text="Your AWS IAM secret key. Stored locally and injected as an env var into the container." /></label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={bedrockSecretKey}
|
value={bedrockSecretKey}
|
||||||
@@ -856,7 +857,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)<Tooltip text="Temporary session token for assumed-role or MFA-based AWS credentials." /></label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={bedrockSessionToken}
|
value={bedrockSessionToken}
|
||||||
@@ -872,7 +873,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{/* Profile field */}
|
{/* Profile field */}
|
||||||
{bc.auth_method === "profile" && (
|
{bc.auth_method === "profile" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile<Tooltip text="Named profile from your AWS config/credentials files (e.g. 'default' or 'prod')." /></label>
|
||||||
<input
|
<input
|
||||||
value={bedrockProfile}
|
value={bedrockProfile}
|
||||||
onChange={(e) => setBedrockProfile(e.target.value)}
|
onChange={(e) => setBedrockProfile(e.target.value)}
|
||||||
@@ -887,7 +888,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{/* Bearer token field */}
|
{/* Bearer token field */}
|
||||||
{bc.auth_method === "bearer_token" && (
|
{bc.auth_method === "bearer_token" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token<Tooltip text="An SSO or identity-center bearer token for Bedrock authentication." /></label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={bedrockBearerToken}
|
value={bedrockBearerToken}
|
||||||
@@ -901,7 +902,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* Model override */}
|
{/* Model override */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)<Tooltip text="Override the default Bedrock model. Leave blank to use Claude's default." /></label>
|
||||||
<input
|
<input
|
||||||
value={bedrockModelId}
|
value={bedrockModelId}
|
||||||
onChange={(e) => setBedrockModelId(e.target.value)}
|
onChange={(e) => setBedrockModelId(e.target.value)}
|
||||||
@@ -916,7 +917,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Ollama config */}
|
{/* 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";
|
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 (
|
return (
|
||||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
||||||
@@ -926,7 +927,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your Ollama server. Use host.docker.internal to reach the host machine from inside the container." /></label>
|
||||||
<input
|
<input
|
||||||
value={ollamaBaseUrl}
|
value={ollamaBaseUrl}
|
||||||
onChange={(e) => setOllamaBaseUrl(e.target.value)}
|
onChange={(e) => setOllamaBaseUrl(e.target.value)}
|
||||||
@@ -941,7 +942,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (required)<Tooltip text="Ollama model name to use (e.g. qwen3.5:27b). The model must be pulled in Ollama before starting the container." /></label>
|
||||||
<input
|
<input
|
||||||
value={ollamaModelId}
|
value={ollamaModelId}
|
||||||
onChange={(e) => setOllamaModelId(e.target.value)}
|
onChange={(e) => setOllamaModelId(e.target.value)}
|
||||||
@@ -956,7 +957,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* LiteLLM config */}
|
{/* 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";
|
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 (
|
return (
|
||||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
||||||
@@ -966,7 +967,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your LiteLLM proxy server. Use host.docker.internal for a locally running proxy." /></label>
|
||||||
<input
|
<input
|
||||||
value={litellmBaseUrl}
|
value={litellmBaseUrl}
|
||||||
onChange={(e) => setLitellmBaseUrl(e.target.value)}
|
onChange={(e) => setLitellmBaseUrl(e.target.value)}
|
||||||
@@ -981,7 +982,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">API Key</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">API Key<Tooltip text="Authentication key for your LiteLLM proxy, if required." /></label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={litellmApiKey}
|
value={litellmApiKey}
|
||||||
@@ -994,7 +995,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)<Tooltip text="Model identifier as configured in your LiteLLM proxy (e.g. gpt-4o, gemini-pro)." /></label>
|
||||||
<input
|
<input
|
||||||
value={litellmModelId}
|
value={litellmModelId}
|
||||||
onChange={(e) => setLitellmModelId(e.target.value)}
|
onChange={(e) => setLitellmModelId(e.target.value)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export default function ApiKeyInput() {
|
export default function ApiKeyInput() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
<label className="block text-sm font-medium mb-1">Backend</label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set backend per-project.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import * as commands from "../../lib/tauri-commands";
|
import * as commands from "../../lib/tauri-commands";
|
||||||
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
|
||||||
export default function AwsSettings() {
|
export default function AwsSettings() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
@@ -56,7 +57,7 @@ export default function AwsSettings() {
|
|||||||
|
|
||||||
{/* AWS Config Path */}
|
{/* AWS Config Path */}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path</span>
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path<Tooltip text="Path to your AWS config/credentials directory. Mounted into containers for Bedrock auth." /></span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -80,7 +81,7 @@ export default function AwsSettings() {
|
|||||||
|
|
||||||
{/* AWS Profile */}
|
{/* AWS Profile */}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile</span>
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile<Tooltip text="AWS named profile to use by default. Per-project settings can override this." /></span>
|
||||||
<select
|
<select
|
||||||
value={globalAws.aws_profile ?? ""}
|
value={globalAws.aws_profile ?? ""}
|
||||||
onChange={(e) => handleChange("aws_profile", e.target.value)}
|
onChange={(e) => handleChange("aws_profile", e.target.value)}
|
||||||
@@ -95,7 +96,7 @@ export default function AwsSettings() {
|
|||||||
|
|
||||||
{/* AWS Region */}
|
{/* AWS Region */}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region</span>
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region<Tooltip text="Default AWS region for Bedrock API calls (e.g. us-east-1). Can be overridden per project." /></span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={globalAws.aws_region ?? ""}
|
value={globalAws.aws_region ?? ""}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { useDocker } from "../../hooks/useDocker";
|
import { useDocker } from "../../hooks/useDocker";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import type { ImageSource } from "../../lib/types";
|
import type { ImageSource } from "../../lib/types";
|
||||||
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
|
||||||
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export default function DockerSettings() {
|
|||||||
|
|
||||||
{/* Image Source Selector */}
|
{/* Image Source Selector */}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source</span>
|
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source<Tooltip text="Registry pulls the pre-built image. Local Build compiles from the bundled Dockerfile. Custom lets you specify any image." /></span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{IMAGE_SOURCE_OPTIONS.map((opt) => (
|
{IMAGE_SOURCE_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -109,7 +110,7 @@ export default function DockerSettings() {
|
|||||||
{/* Custom image input */}
|
{/* Custom image input */}
|
||||||
{imageSource === "custom" && (
|
{imageSource === "custom" && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image</span>
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image<Tooltip text="Full image name including registry and tag (e.g. myregistry.com/image:tag)." /></span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customInput}
|
value={customInput}
|
||||||
@@ -121,9 +122,9 @@ export default function DockerSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resolved image display */}
|
{/* Resolved image display */}
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<span className="text-[var(--text-secondary)]">Image</span>
|
<span className="text-[var(--text-secondary)]">Image</span>
|
||||||
<span className="text-xs text-[var(--text-secondary)] truncate max-w-[200px]" title={resolvedImageName}>
|
<span className="block text-xs text-[var(--text-secondary)] font-mono mt-0.5 truncate" title={resolvedImageName}>
|
||||||
{resolvedImageName}
|
{resolvedImageName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
91
app/src/components/settings/ImageUpdateDialog.tsx
Normal file
91
app/src/components/settings/ImageUpdateDialog.tsx
Normal file
@@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] max-h-[80vh] overflow-y-auto shadow-xl">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Container Image Update</h2>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4 text-xs bg-[var(--bg-primary)] rounded p-3 border border-[var(--border-color)]">
|
||||||
|
{imageUpdateInfo.local_digest && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[var(--text-secondary)]">Local digest</span>
|
||||||
|
<span className="font-mono text-[var(--text-primary)]">
|
||||||
|
{shortDigest(imageUpdateInfo.local_digest)}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[var(--text-secondary)]">Remote digest</span>
|
||||||
|
<span className="font-mono text-[var(--accent)]">
|
||||||
|
{shortDigest(imageUpdateInfo.remote_digest)}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||||
|
Go to Settings > Docker and click "Re-pull Image" to update.
|
||||||
|
Running containers will not be affected until restarted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import ApiKeyInput from "./ApiKeyInput";
|
|
||||||
import DockerSettings from "./DockerSettings";
|
import DockerSettings from "./DockerSettings";
|
||||||
import AwsSettings from "./AwsSettings";
|
import AwsSettings from "./AwsSettings";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
@@ -8,10 +7,11 @@ import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
|||||||
import EnvVarsModal from "../projects/EnvVarsModal";
|
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||||
import { detectHostTimezone } from "../../lib/tauri-commands";
|
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||||
import type { EnvVar } from "../../lib/types";
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
const { appVersion, checkForUpdates } = useUpdates();
|
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
|
||||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||||
@@ -39,7 +39,7 @@ export default function SettingsPanel() {
|
|||||||
const handleCheckNow = async () => {
|
const handleCheckNow = async () => {
|
||||||
setCheckingUpdates(true);
|
setCheckingUpdates(true);
|
||||||
try {
|
try {
|
||||||
await checkForUpdates();
|
await Promise.all([checkForUpdates(), checkImageUpdate()]);
|
||||||
} finally {
|
} finally {
|
||||||
setCheckingUpdates(false);
|
setCheckingUpdates(false);
|
||||||
}
|
}
|
||||||
@@ -55,13 +55,12 @@ export default function SettingsPanel() {
|
|||||||
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||||
Settings
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
<ApiKeyInput />
|
|
||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
|
|
||||||
{/* Container Timezone */}
|
{/* Container Timezone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
<label className="block text-sm font-medium mb-1">Container Timezone<Tooltip text="Sets the timezone inside containers. Affects scheduled task timing and log timestamps." /></label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York)
|
Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York)
|
||||||
</p>
|
</p>
|
||||||
@@ -81,7 +80,7 @@ export default function SettingsPanel() {
|
|||||||
|
|
||||||
{/* Global Claude Instructions */}
|
{/* Global Claude Instructions */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
<label className="block text-sm font-medium mb-1">Claude Instructions<Tooltip text="Global instructions applied to all projects. Written to ~/.claude/CLAUDE.md in every container." /></label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
||||||
</p>
|
</p>
|
||||||
@@ -100,7 +99,7 @@ export default function SettingsPanel() {
|
|||||||
|
|
||||||
{/* Global Environment Variables */}
|
{/* Global Environment Variables */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
|
<label className="block text-sm font-medium mb-1">Global Environment Variables<Tooltip text="Env vars injected into all containers. Per-project vars with the same key take precedence." /></label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
Applied to all project containers. Per-project variables override global ones with the same key.
|
Applied to all project containers. Per-project variables override global ones with the same key.
|
||||||
</p>
|
</p>
|
||||||
@@ -119,7 +118,7 @@ export default function SettingsPanel() {
|
|||||||
|
|
||||||
{/* Updates section */}
|
{/* Updates section */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Updates</label>
|
<label className="block text-sm font-medium mb-2">Updates<Tooltip text="Check for new versions of the Triple-C app and container image." /></label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{appVersion && (
|
{appVersion && (
|
||||||
<p className="text-xs text-[var(--text-secondary)]">
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
@@ -146,6 +145,12 @@ export default function SettingsPanel() {
|
|||||||
>
|
>
|
||||||
{checkingUpdates ? "Checking..." : "Check now"}
|
{checkingUpdates ? "Checking..." : "Check now"}
|
||||||
</button>
|
</button>
|
||||||
|
{imageUpdateInfo && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning,#f59e0b)] rounded">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-[var(--warning,#f59e0b)]" />
|
||||||
|
<span>A newer container image is available. Re-pull the image in Docker settings above to update.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
const detectorRef = useRef<UrlDetector | null>(null);
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
|
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||||
|
|
||||||
const ssoBufferRef = useRef("");
|
const ssoBufferRef = useRef("");
|
||||||
const ssoTriggeredRef = useRef(false);
|
const ssoTriggeredRef = useRef(false);
|
||||||
@@ -80,6 +81,22 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
term.open(containerRef.current);
|
term.open(containerRef.current);
|
||||||
|
|
||||||
|
// Ctrl+Shift+C copies selected terminal text to clipboard.
|
||||||
|
// This prevents the keystroke from reaching the container (where
|
||||||
|
// Ctrl+C would send SIGINT and cancel running work).
|
||||||
|
term.attachCustomKeyEventHandler((event) => {
|
||||||
|
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
|
||||||
|
const sel = term.getSelection();
|
||||||
|
if (sel) {
|
||||||
|
navigator.clipboard.writeText(sel).catch((e) =>
|
||||||
|
console.error("Ctrl+Shift+C clipboard write failed:", e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false; // prevent xterm from processing this key
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// WebGL addon is loaded/disposed dynamically in the active effect
|
// WebGL addon is loaded/disposed dynamically in the active effect
|
||||||
// to avoid exhausting the browser's limited WebGL context pool.
|
// to avoid exhausting the browser's limited WebGL context pool.
|
||||||
|
|
||||||
@@ -120,6 +137,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
setIsAtBottom(buf.viewportY >= buf.baseY);
|
setIsAtBottom(buf.viewportY >= buf.baseY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track text selection to show copy hint in status bar
|
||||||
|
const selectionDisposable = term.onSelectionChange(() => {
|
||||||
|
setTerminalHasSelection(term.hasSelection());
|
||||||
|
});
|
||||||
|
|
||||||
// Handle image paste: intercept paste events with image data,
|
// Handle image paste: intercept paste events with image data,
|
||||||
// upload to the container, and inject the file path into terminal input.
|
// upload to the container, and inject the file path into terminal input.
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
@@ -222,6 +244,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
osc52Disposable.dispose();
|
osc52Disposable.dispose();
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
scrollDisposable.dispose();
|
scrollDisposable.dispose();
|
||||||
|
selectionDisposable.dispose();
|
||||||
|
setTerminalHasSelection(false);
|
||||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
|
|||||||
78
app/src/components/ui/Tooltip.tsx
Normal file
78
app/src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useRef, useLayoutEffect, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
text: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small circled question-mark icon that shows a tooltip on hover.
|
||||||
|
* Uses a portal to render at `document.body` so the tooltip is never
|
||||||
|
* clipped by ancestor `overflow: hidden` containers.
|
||||||
|
*/
|
||||||
|
export default function Tooltip({ text, children }: TooltipProps) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||||
|
const [, setPlacement] = useState<"top" | "bottom">("top");
|
||||||
|
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!visible || !triggerRef.current || !tooltipRef.current) return;
|
||||||
|
|
||||||
|
const trigger = triggerRef.current.getBoundingClientRect();
|
||||||
|
const tooltip = tooltipRef.current.getBoundingClientRect();
|
||||||
|
const gap = 6;
|
||||||
|
|
||||||
|
// Vertical: prefer above, fall back to below
|
||||||
|
const above = trigger.top - tooltip.height - gap >= 4;
|
||||||
|
const pos = above ? "top" : "bottom";
|
||||||
|
setPlacement(pos);
|
||||||
|
|
||||||
|
const top =
|
||||||
|
pos === "top"
|
||||||
|
? trigger.top - tooltip.height - gap
|
||||||
|
: trigger.bottom + gap;
|
||||||
|
|
||||||
|
// Horizontal: center on trigger, clamp to viewport
|
||||||
|
let left = trigger.left + trigger.width / 2 - tooltip.width / 2;
|
||||||
|
left = Math.max(4, Math.min(left, window.innerWidth - tooltip.width - 4));
|
||||||
|
|
||||||
|
setCoords({ top, left });
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={triggerRef}
|
||||||
|
className="inline-flex items-center ml-1"
|
||||||
|
onMouseEnter={() => setVisible(true)}
|
||||||
|
onMouseLeave={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[9px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||||
|
aria-label="Help"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{visible &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: coords.top,
|
||||||
|
left: coords.left,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
className={`px-2.5 py-1.5 text-[11px] leading-snug text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded shadow-lg whitespace-normal max-w-[280px] w-max pointer-events-none`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,16 +6,25 @@ import * as commands from "../lib/tauri-commands";
|
|||||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
export function useUpdates() {
|
export function useUpdates() {
|
||||||
const { updateInfo, setUpdateInfo, appVersion, setAppVersion, appSettings } =
|
const {
|
||||||
useAppState(
|
updateInfo,
|
||||||
useShallow((s) => ({
|
setUpdateInfo,
|
||||||
updateInfo: s.updateInfo,
|
imageUpdateInfo,
|
||||||
setUpdateInfo: s.setUpdateInfo,
|
setImageUpdateInfo,
|
||||||
appVersion: s.appVersion,
|
appVersion,
|
||||||
setAppVersion: s.setAppVersion,
|
setAppVersion,
|
||||||
appSettings: s.appSettings,
|
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<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -47,11 +56,31 @@ export function useUpdates() {
|
|||||||
}
|
}
|
||||||
}, [setUpdateInfo, appSettings?.dismissed_update_version]);
|
}, [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(() => {
|
const startPeriodicCheck = useCallback(() => {
|
||||||
if (intervalRef.current) return;
|
if (intervalRef.current) return;
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
if (appSettings?.auto_check_updates !== false) {
|
if (appSettings?.auto_check_updates !== false) {
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
|
checkImageUpdate();
|
||||||
}
|
}
|
||||||
}, CHECK_INTERVAL_MS);
|
}, CHECK_INTERVAL_MS);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -60,13 +89,15 @@ export function useUpdates() {
|
|||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [checkForUpdates, appSettings?.auto_check_updates]);
|
}, [checkForUpdates, checkImageUpdate, appSettings?.auto_check_updates]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateInfo,
|
updateInfo,
|
||||||
|
imageUpdateInfo,
|
||||||
appVersion,
|
appVersion,
|
||||||
loadVersion,
|
loadVersion,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
|
checkImageUpdate,
|
||||||
startPeriodicCheck,
|
startPeriodicCheck,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,3 +53,135 @@ body {
|
|||||||
to { opacity: 1; transform: translate(-50%, 0); }
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
}
|
}
|
||||||
.animate-slide-down { animation: slide-down 0.2s ease-out; }
|
.animate-slide-down { animation: slide-down 0.2s ease-out; }
|
||||||
|
|
||||||
|
/* Help dialog content styles */
|
||||||
|
.help-content {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-h2 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1.5rem 0 0.75rem 0;
|
||||||
|
padding-bottom: 0.375rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1.25rem 0 0.5rem 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-h4 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1rem 0 0.375rem 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-ul,
|
||||||
|
.help-content .help-ol {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-ul li,
|
||||||
|
.help-content .help-ol li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-code-block {
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-inline-code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-table th,
|
||||||
|
.help-content .help-table td {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-table th {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-table td {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content .help-link:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -83,3 +83,8 @@ export const uploadFileToContainer = (projectId: string, hostPath: string, conta
|
|||||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
export const checkForUpdates = () =>
|
export const checkForUpdates = () =>
|
||||||
invoke<UpdateInfo | null>("check_for_updates");
|
invoke<UpdateInfo | null>("check_for_updates");
|
||||||
|
export const checkImageUpdate = () =>
|
||||||
|
invoke<ImageUpdateInfo | null>("check_image_update");
|
||||||
|
|
||||||
|
// Help
|
||||||
|
export const getHelpContent = () => invoke<string>("get_help_content");
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface Project {
|
|||||||
paths: ProjectPath[];
|
paths: ProjectPath[];
|
||||||
container_id: string | null;
|
container_id: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
auth_mode: AuthMode;
|
backend: Backend;
|
||||||
bedrock_config: BedrockConfig | null;
|
bedrock_config: BedrockConfig | null;
|
||||||
ollama_config: OllamaConfig | null;
|
ollama_config: OllamaConfig | null;
|
||||||
litellm_config: LiteLlmConfig | null;
|
litellm_config: LiteLlmConfig | null;
|
||||||
@@ -45,7 +45,7 @@ export type ProjectStatus =
|
|||||||
| "stopping"
|
| "stopping"
|
||||||
| "error";
|
| "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";
|
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||||
|
|
||||||
@@ -116,6 +116,7 @@ export interface AppSettings {
|
|||||||
dismissed_update_version: string | null;
|
dismissed_update_version: string | null;
|
||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
default_microphone: string | null;
|
default_microphone: string | null;
|
||||||
|
dismissed_image_digest: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
@@ -133,6 +134,12 @@ export interface ReleaseAsset {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageUpdateInfo {
|
||||||
|
remote_digest: string;
|
||||||
|
local_digest: string | null;
|
||||||
|
remote_updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type McpTransportType = "stdio" | "http";
|
export type McpTransportType = "stdio" | "http";
|
||||||
|
|
||||||
export interface McpServer {
|
export interface McpServer {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
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 {
|
interface AppState {
|
||||||
// Projects
|
// Projects
|
||||||
@@ -24,6 +24,8 @@ interface AppState {
|
|||||||
removeMcpServerFromList: (id: string) => void;
|
removeMcpServerFromList: (id: string) => void;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
|
terminalHasSelection: boolean;
|
||||||
|
setTerminalHasSelection: (has: boolean) => void;
|
||||||
sidebarView: "projects" | "mcp" | "settings";
|
sidebarView: "projects" | "mcp" | "settings";
|
||||||
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
||||||
dockerAvailable: boolean | null;
|
dockerAvailable: boolean | null;
|
||||||
@@ -39,6 +41,10 @@ interface AppState {
|
|||||||
setUpdateInfo: (info: UpdateInfo | null) => void;
|
setUpdateInfo: (info: UpdateInfo | null) => void;
|
||||||
appVersion: string;
|
appVersion: string;
|
||||||
setAppVersion: (version: string) => void;
|
setAppVersion: (version: string) => void;
|
||||||
|
|
||||||
|
// Image update info
|
||||||
|
imageUpdateInfo: ImageUpdateInfo | null;
|
||||||
|
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppState = create<AppState>((set) => ({
|
export const useAppState = create<AppState>((set) => ({
|
||||||
@@ -96,6 +102,8 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
|
terminalHasSelection: false,
|
||||||
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: (view) => set({ sidebarView: view }),
|
setSidebarView: (view) => set({ sidebarView: view }),
|
||||||
dockerAvailable: null,
|
dockerAvailable: null,
|
||||||
@@ -111,4 +119,8 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
setUpdateInfo: (info) => set({ updateInfo: info }),
|
setUpdateInfo: (info) => set({ updateInfo: info }),
|
||||||
appVersion: "",
|
appVersion: "",
|
||||||
setAppVersion: (version) => set({ appVersion: version }),
|
setAppVersion: (version) => set({ appVersion: version }),
|
||||||
|
|
||||||
|
// Image update info
|
||||||
|
imageUpdateInfo: null,
|
||||||
|
setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user