Compare commits

..

7 Commits

Author SHA1 Message Date
27007b90e3 Fetch help content from repo, add TOC and marketplace troubleshooting
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m57s
Build App / build-linux (push) Successful in 5m2s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 10s
Help dialog now fetches HOW-TO-USE.md live from the gitea repo on open,
falling back to the compile-time embedded copy when offline. Content is
cached for the session. Removes the ~600-line hardcoded markdown constant
from HelpDialog.tsx in favor of a single source of truth.

Adds a Table of Contents with anchor links for quick navigation and a new
troubleshooting entry for the "Failed to install Anthropic marketplace"
error with the jq fix. Markdown renderer updated to support anchor links
and header id attributes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:00:59 -07:00
38e65619e9 Fix tooltips clipped by overflow containers, improve Backend tooltip text
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m44s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 12s
Rewrite Tooltip to use React portal (createPortal to document.body) so
tooltips render above all UI elements regardless of ancestor overflow:hidden.
Also increased max-width from 220px to 280px for longer descriptions.

Expanded Backend tooltip to explain each option (Anthropic, Bedrock,
Ollama, LiteLLM) with practical context for new users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:56:50 -07:00
d2c1c2108a Fix update checker to use full semver comparison and correct platform filtering
Some checks failed
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m21s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Has been cancelled
The version comparison was only comparing the patch number, ignoring major
and minor versions. This meant 0.1.75 (patch=75) appeared "newer" than
0.2.1 (patch=1), and updates within 0.2.x were missed entirely.

Also fixed platform filtering to handle -mac suffix (previously only
filtered -win, so Linux users would see macOS releases too).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:45:43 -07:00
cc163e6650 Add help dialog and tooltip indicators throughout the UI
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m29s
Build App / build-linux (push) Successful in 5m43s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 13s
- Add circled ? help button in TopBar that opens a dialog with HOW-TO-USE.md content
- Create reusable Tooltip component with viewport-aware positioning
- Add 32 tooltip indicators across project config and settings panels
- Covers backend selection, Bedrock/Ollama/LiteLLM fields, Docker, AWS, MCP, and more

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:35:04 -07:00
38082059a5 Rename AuthMode to Backend, fix LiteLLM variant typo, add image update alerts, clean up Settings
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m14s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
- Fix serde deserialization error: TypeScript sent "lit_llm" but Rust expected "lite_llm"
- Rename AuthMode enum to Backend across Rust and TypeScript (with serde alias for backward compat)
- Add container image update checking via registry digest comparison
- Improve Settings page: fix image address display spacing, remove per-project auth section
- Update UI labels from "Auth" to "Backend" throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:26:58 -07:00
beae0942a1 Add dynamic versioning with VERSION file and tag-based patch counting
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 6m14s
Build App / create-tag (push) Successful in 8s
Build App / sync-to-github (push) Successful in 10s
- Create VERSION file (currently `0.2`) as the single source of truth for major.minor
- Add compute-version job that reads VERSION and counts commits since the last
  matching v{major.minor}.N tag to derive the patch number
- Patch resets automatically when VERSION is bumped (no matching tags exist yet)
- Add create-tag job that tags the repo after all platform builds succeed,
  so subsequent builds count from the new tag
- All platform jobs now consume the shared version instead of computing their own
- VERSION file changes trigger the build workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:11:04 -07:00
6b49981b3a Update build workflow version from 0.1.x to 0.2.x
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m19s
Build App / build-linux (push) Successful in 6m37s
Build App / sync-to-github (push) Successful in 13s
The computed build version was hardcoded as 0.1.${COMMIT_COUNT} across all
three platform jobs (Linux, macOS, Windows), producing 0.1.x releases even
though the source files were bumped to 0.2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:03:53 -07:00
33 changed files with 1090 additions and 176 deletions

View File

@@ -5,11 +5,13 @@ on:
branches: [main] branches: [main]
paths: paths:
- "app/**" - "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml" - ".gitea/workflows/build-app.yml"
pull_request: pull_request:
branches: [main] branches: [main]
paths: paths:
- "app/**" - "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml" - ".gitea/workflows/build-app.yml"
workflow_dispatch: workflow_dispatch:
@@ -18,10 +20,43 @@ env:
REPO: ${{ gitea.repository }} REPO: ${{ gitea.repository }}
jobs: jobs:
build-linux: compute-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
version: ${{ steps.version.outputs.VERSION }} version: ${{ steps.version.outputs.VERSION }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch all tags
run: git fetch --tags
- name: Compute version from VERSION file and tags
id: version
run: |
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
echo "Major.Minor: ${MAJOR_MINOR}"
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1)
if [ -n "$LATEST_TAG" ]; then
echo "Latest matching tag: ${LATEST_TAG}"
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
else
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
PATCH=$(git rev-list --count HEAD)
fi
VERSION="${MAJOR_MINOR}.${PATCH}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
build-linux:
runs-on: ubuntu-latest
needs: [compute-version]
steps: steps:
- name: Install Node.js 22 - name: Install Node.js 22
run: | run: |
@@ -54,17 +89,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.1.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version - name: Set app version
run: | run: |
VERSION="${{ steps.version.outputs.VERSION }}" VERSION="${{ needs.compute-version.outputs.version }}"
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
@@ -133,7 +160,7 @@ jobs:
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
TAG="v${{ steps.version.outputs.VERSION }}" TAG="v${{ needs.compute-version.outputs.version }}"
# Create release # Create release
curl -s -X POST \ curl -s -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
@@ -156,6 +183,7 @@ jobs:
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
needs: [compute-version]
steps: steps:
- name: Install Node.js 22 - name: Install Node.js 22
run: | run: |
@@ -183,17 +211,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.1.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version - name: Set app version
run: | run: |
VERSION="${{ steps.version.outputs.VERSION }}" VERSION="${{ needs.compute-version.outputs.version }}"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
@@ -243,12 +263,12 @@ jobs:
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
TAG="v${{ steps.version.outputs.VERSION }}-mac" TAG="v${{ needs.compute-version.outputs.version }}-mac"
# Create release # Create release
curl -s -X POST \ curl -s -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
echo "Release ID: ${RELEASE_ID}" echo "Release ID: ${RELEASE_ID}"
@@ -266,6 +286,7 @@ jobs:
build-windows: build-windows:
runs-on: windows-latest runs-on: windows-latest
needs: [compute-version]
defaults: defaults:
run: run:
shell: cmd shell: cmd
@@ -275,18 +296,10 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Compute version
id: version
run: |
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
set "VERSION=0.1.%COMMIT_COUNT%"
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
echo Computed version: %VERSION%
- name: Set app version - name: Set app version
shell: powershell shell: powershell
run: | run: |
$version = "${{ steps.version.outputs.VERSION }}" $version = "${{ needs.compute-version.outputs.version }}"
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json (Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json (Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml (Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
@@ -367,9 +380,9 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
run: | run: |
set "TAG=v${{ steps.version.outputs.VERSION }}-win" set "TAG=v${{ needs.compute-version.outputs.version }}-win"
echo Creating release %TAG%... echo Creating release %TAG%...
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
:found :found
echo Release ID: %RELEASE_ID% echo Release ID: %RELEASE_ID%
@@ -378,9 +391,36 @@ jobs:
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf" curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf"
) )
create-tag:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create version tag
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
VERSION="${{ needs.compute-version.outputs.version }}"
TAG="v${VERSION}"
echo "Creating tag ${TAG}..."
# Create annotated tag via Gitea API
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
echo "Tag ${TAG} created successfully"
sync-to-github: sync-to-github:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-linux, build-macos, build-windows] needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
env: env:
GH_PAT: ${{ secrets.GH_PAT }} GH_PAT: ${{ secrets.GH_PAT }}
@@ -389,7 +429,7 @@ jobs:
- name: Download artifacts from Gitea releases - name: Download artifacts from Gitea releases
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.build-linux.outputs.version }} VERSION: ${{ needs.compute-version.outputs.version }}
run: | run: |
set -e set -e
mkdir -p artifacts mkdir -p artifacts
@@ -418,7 +458,7 @@ jobs:
- name: Create GitHub release and upload artifacts - name: Create GitHub release and upload artifacts
env: env:
VERSION: ${{ needs.build-linux.outputs.version }} VERSION: ${{ needs.compute-version.outputs.version }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
run: | run: |
set -e set -e

View File

@@ -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/`)

View File

@@ -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,21 @@ 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). Optionally set a model ID.
4. Start the container again. 4. 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 +380,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,7 +409,7 @@ 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
@@ -407,7 +426,7 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in
## 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 +641,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.

View File

@@ -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) |

View File

@@ -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

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.2

View 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))
}

View File

@@ -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;

View File

@@ -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());
} }

View File

@@ -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()

View File

@@ -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)
}
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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,

View File

@@ -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");

View File

@@ -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,
} }
} }
} }

View File

@@ -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,

View File

@@ -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>,
}

View File

@@ -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);

View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// 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)&gt; (.+)/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>
);
}

View File

@@ -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)} />
)}
</> </>
); );
} }

View File

@@ -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,

View File

@@ -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 (optional)<Tooltip text="Ollama model name to use (e.g. qwen3.5:27b). Leave blank for the server default." /></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)}

View File

@@ -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>
); );

View File

@@ -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 ?? ""}

View File

@@ -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>

View 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 &gt; Docker and click &quot;Re-pull Image&quot; 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -6,11 +6,20 @@ 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,
setUpdateInfo,
imageUpdateInfo,
setImageUpdateInfo,
appVersion,
setAppVersion,
appSettings,
} = useAppState(
useShallow((s) => ({ useShallow((s) => ({
updateInfo: s.updateInfo, updateInfo: s.updateInfo,
setUpdateInfo: s.setUpdateInfo, setUpdateInfo: s.setUpdateInfo,
imageUpdateInfo: s.imageUpdateInfo,
setImageUpdateInfo: s.setImageUpdateInfo,
appVersion: s.appVersion, appVersion: s.appVersion,
setAppVersion: s.setAppVersion, setAppVersion: s.setAppVersion,
appSettings: s.appSettings, appSettings: s.appSettings,
@@ -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,
}; };
} }

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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
@@ -39,6 +39,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) => ({
@@ -111,4 +115,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 }),
})); }));