Compare commits

...

81 Commits

Author SHA1 Message Date
alex dfbd3e60a8 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 11:14:06 +00:00
alex 075eb6a76b fix: ensure cloned repo is mounted in compose for clone mode
- Add safety check after rendering compose template to ensure REPO_PATH is mounted
- If compose template lacks volume mount, auto-add default mount to /workspace
- Add cloned repo verification to catch empty clone directories

Refs: clone mode repo not appearing in container workspace
2026-05-24 11:13:53 +00:00
Fusion 4571bebf8e feat: add font size controls to mobile terminal header and fix auto-hide space reclamation
- TerminalComponent: expose changeFontSize via onTerminalReady callback
- MobileTerminalWrapper: pass changeFontSize to header
- MobileTerminalHeader: add A- and A+ font size buttons
- CSS: collapse header height/padding/margin/border when hidden to reclaim space
2026-05-24 13:10:07 +02:00
Fusion ec45283257 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 13:02:24 +02:00
Fusion cce3fa773a feat: hide tunnel UI for terminal sessions and show session metadata
Backend:
- Add created_at to get_user_sessions response

Frontend:
- Hide tunnel error badges, probe output, and 'Recreate Tunnel' button for terminal-only sessions
- Show session start time (created_at) in active sessions list
- Show repository configuration (clone_mode, branch) for each session
- Skip health check polling for terminal-only sessions
- Update Session type to include created_at field
2026-05-24 13:02:06 +02:00
alex 50474f7b13 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 11:00:18 +00:00
alex 9c7043bab1 fix: add merge migration for alembic heads and make remove_is_builtin idempotent
- Create merge migration f3d2dc90ba3a to merge single_interface and clone_mode heads
- Make remove_is_builtin migration idempotent with IF EXISTS clause

Refs: alembic migration fix for dev branch
2026-05-24 11:00:03 +00:00
Fusion 2dd2f2ab06 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:45:21 +02:00
Fusion e555561a2d fix: lift modifier state to MobileTerminalWrapper for virtual keyboard integration
- Remove useSpecialKeys hook state, export pure utility functions instead
- MobileTerminalWrapper now owns activeModifier state
- SpecialKeysStrip and SpecialKeysPanel receive modifier via props
- TerminalComponent applies modifier to virtual keyboard input via activeModifier prop
- Modifier now works with both special keys AND virtual keyboard input
- Modifier clears after any key press (special or virtual keyboard)
2026-05-24 12:45:07 +02:00
alex 4ac3d593aa Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 10:32:58 +00:00
alex 1a3860a4d8 fix: mount /data/instances as host bind mount for API
When API runs in Docker with named volume instance_data:/data/instances,
generated docker-compose.yml files use bind mounts like
/data/instances/.../repo-clone:/workspace. Docker resolves bind mounts
on the host filesystem, not in named volumes, so containers see empty
 directories.

By mounting /data/instances as a host bind mount, both the API and
generated tool containers access the same host path.
2026-05-24 10:32:43 +00:00
Fusion 3d9ff44d1a Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:28:51 +02:00
Fusion 12378def4d feat: implement one-shot modifier keys for mobile terminal
- Redesign useSpecialKeys hook with modifier state tracking
- Add one-shot activation for Ctrl and Alt keys
- Visual feedback: active modifiers shown with yellow highlight
- Fix focusInput to use term.focus() instead of hidden input
- Always refocus terminal after sending any special key
- Add requestAnimationFrame for reliable focus restoration
2026-05-24 12:28:36 +02:00
alex f6f7853aa4 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 10:25:15 +00:00
alex 802d8f1e8c fix: strip remote prefix from branch names in list_branches
Git branch -a --format=%(refname:short) returns remote branches as
'origin/branch-name', not 'remotes/origin/branch-name'. The code was
only filtering 'remotes/' prefix, causing clone to fail with branch
names like 'origin/feat/foo'.

Now properly detects remote names using 'git remote' and strips the
remote prefix (e.g., 'origin/') from branch names.
2026-05-24 10:25:01 +00:00
Fusion 4f9aa7e3c2 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:17:50 +02:00
Fusion cb25b94cb7 fix: prevent special key buttons from stealing focus
- Add tabIndex={-1} to all special key buttons to prevent focus
- Add onFocus handler to immediately blur if focused
- Terminal focus stays intact when tapping special keys
2026-05-24 12:17:35 +02:00
alex 01aaf4c78f Merge remote dev and resolve conflicts in CreateSessionForm 2026-05-24 10:16:27 +00:00
alex 96c8dd7402 feat: make session creation a sequential workflow
- Refactor CreateSessionForm into step-by-step workflow
- Steps unlock sequentially: Project → Repository → Tool → Clone Mode → Branch
- Add visual step indicators with numbered badges
- Disable controls until prerequisites are met
- Add CSS for workflow step styling
2026-05-24 10:14:39 +00:00
Fusion f5c2c95af0 fix: focus xterm terminal on tap instead of hidden input
- Use term.focus() instead of hidden input focus
- This ensures keyboard opens properly when tapping anywhere on terminal
2026-05-24 12:14:11 +02:00
Fusion 06fe8623bc fix: move hidden input off-screen and fix branch loading
- Move terminal hidden input to off-screen position (-9999px) to prevent
  text selection/caret visibility on mobile
- Add user-select: none to prevent any selection UI
- Fix create-session-form to use new listRepositoryBranches API signature
  (projectId, repoId) and access response.branches/default_branch
2026-05-24 12:11:53 +02:00
Fusion f2fed518f0 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:06:38 +02:00
Fusion cc2a638c76 feat: always show special keys strip on mobile terminal
- Remove auto-hide behavior for special keys strip
- Keep header auto-hide functionality
- Special keys are now always visible for quick access
2026-05-24 12:06:22 +02:00
alex 5cec4a7a6f Merge branch 'feat/session-branch-selection' into dev
Resolved conflicts:
- Moved branch selection UI from inline sessions.tsx to CreateSessionForm component
- Integrated branch dropdown and new branch creation into CreateSessionForm
- Removed duplicate branch state management from sessions.tsx

All branch selection tests pass (7/7).
2026-05-24 10:06:14 +00:00
Fusion 7e0df57f8c fix: keep virtual keyboard open when tapping special keys
- Use onPointerDown with preventDefault() instead of onClick
- Add onKeepFocus callback to SpecialKeysStrip and SpecialKeysPanel
- Expose focusInput via onTerminalReady in TerminalComponent
- MobileTerminalWrapper passes focus callback to keep keyboard open
2026-05-24 11:57:55 +02:00
Fusion aea1ff95f6 fix: prevent infinite terminal re-initialization loop
- Remove status from TerminalComponent useEffect dependencies to prevent recreation on WebSocket status changes
- Use ref for onTerminalReady callback to avoid parent re-renders triggering terminal recreation
- Wrap MobileTerminalWrapper onTerminalReady with useCallback for stable reference
2026-05-24 11:35:32 +02:00
alex c8fdca7f60 Merge branch 'feat/session-branch-selection' into dev 2026-05-24 09:33:19 +00:00
alex 014b88ee56 test: add unit tests for session branch selection
- Test CreateInstanceRequest model with new_branch field
- Test local branch creation via git checkout -b
- Test instance branch storage logic
2026-05-24 09:31:09 +00:00
Fusion b6bda3d692 feat: implement mobile terminal UX
- Add mobile viewport detection hook
- Add virtual keyboard detection with fallback
- Add auto-hide hook for header/keys strip
- Add special keys mapping hook
- Create MobileTerminalHeader, SpecialKeysStrip, SpecialKeysPanel components
- Create MobileTerminalWrapper component
- Update TerminalComponent with mobile support, font scaling, copy/paste, reconnection
- Update AppShell to hide chrome on mobile terminal pages
- Update TerminalPage to use MobileTerminalWrapper
- Add comprehensive mobile terminal styles
- TypeScript check passes
- Build succeeds
2026-05-24 11:30:04 +02:00
alex d7fb51f427 feat: add branch dropdown and new branch creation UI
- Replace free-text branch input with dropdown of available branches
- Add 'Create new branch...' option with name and base branch inputs
- Load branches from API when repository is selected in clone mode
- Pass newBranch parameter to createInstance API
2026-05-24 09:25:48 +00:00
alex 0d57e3501a feat: add newBranch parameter to createInstance 2026-05-24 09:21:50 +00:00
alex 3672312028 feat: support creating local branch during session creation
- Add new_branch field to CreateInstanceRequest
- Run git checkout -b after cloning when new_branch is provided
- Store new branch name in ToolInstance record
2026-05-24 09:21:29 +00:00
alex c5f117e5b1 feat: add branch listing API function 2026-05-24 09:20:31 +00:00
alex 10a5c29702 docs: add session branch selection design spec
- Design for branch dropdown in session creation
- New local branch creation at clone time
- Frontend/backend changes overview
2026-05-24 09:18:29 +00:00
Fusion 312a646b89 feat: remove built-in tool types distinction
- Drop is_builtin column from tool_types table
- Remove built-in tool seeding from startup
- Remove is_builtin from API schemas and frontend types
- Update tool-types spec to reflect removal of built-in concept
- Add Alembic migration for column removal
- Update tests to work without built-in distinction
2026-05-23 20:02:19 +02:00
Fusion 01adc9a00f refactor: unify create session forms - show clone mode everywhere and display fixed fields as read-only 2026-05-23 19:53:12 +02:00
Fusion 2e9ca52cdb refactor: unify session creation form into CreateSessionForm component 2026-05-23 16:26:35 +02:00
Fusion cd4eba9803 fix: restore loading overlay for delete/stop operations on active sessions 2026-05-23 08:12:15 +02:00
Fusion 18646e3d1b fix: move creation loading indicator to create session form
Move the loading overlay from the active sessions section to the create
session section so it dims the form itself during creation, providing
better visual feedback to the user.
2026-05-23 08:08:24 +02:00
Fusion 8c5e1b931e fix: move creation loading indicator outside active sessions grid
The loading overlay for instance creation was inside the active sessions
grid, which doesn't render when there are no active sessions. Moved the
overlay to the parent container so it's always visible during creation
regardless of existing sessions.
2026-05-23 08:04:48 +02:00
Fusion d1be2e4951 feat: add loading indicators for long-running operations
Add loading overlay to sessions list during create, stop, delete,
and recreate tunnel operations. Show progress messages like
'Creating instance...' and 'Starting container...' during creation.
Dim the sessions grid while operations are in progress to prevent
user confusion and accidental duplicate actions.
2026-05-23 07:54:52 +02:00
Fusion 953ea05756 fix: show all probe attempts including successful ones
Remove 500-character truncation on probe output so users can see
all attempts including the final successful one. Add probe status
indicator (passed/failed/pending) that's always visible when probe
data exists.
2026-05-23 07:43:51 +02:00
Fusion 507b71c586 fix: React crash when opening terminal sessions
The backend was returning 'tool_type_interface_type' (string) but the
frontend expected 'tool_type_interfaces' (array). This caused
undefined.includes() crash when clicking Open on terminal sessions.

Changed both list_instances and get_user_sessions to return
tool_type_interfaces as an array. Also added clone_mode and branch
to get_user_sessions response.
2026-05-23 07:36:46 +02:00
Fusion aebcf25bf4 fix: OpenCode instances fail with 'no port configured' error
For terminal-only tools like OpenCode, default_port is 0 which is falsy
in Python. The code incorrectly treated port 0 as 'not configured' and
marked the instance as error. Now we only check if tool_type exists,
and default to port 0. Terminal tools skip tunnel creation anyway.
2026-05-23 07:29:56 +02:00
Fusion ae42cac61e fix: terminal tools always showing as unhealthy
For terminal-only tools (no URL), only check container status for
overall health instead of requiring tunnel health. Terminal tools
do not have tunnels, so tunnel_status stays as 'not_applicable'
which was failing the healthy check.
2026-05-22 23:55:51 +02:00
alex e2ad7d7fb6 fix: prevent null default_port for terminal tools 2026-05-22 21:49:31 +00:00
alex 9e1334eb6d fix: remove port exposure from terminal tool (opencode) 2026-05-22 21:47:02 +00:00
alex c41993310b Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:33:29 +00:00
alex cb25f21c44 feat: add SSH key signing and verification UI 2026-05-22 21:33:21 +00:00
Fusion 392e85ead4 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 23:30:54 +02:00
Fusion a4bf8afac9 fix: make full URL the default for repository cloning
When cloning a repository, the Full URL input is now shown by default
instead of the Owner/Repo Name fields.
2026-05-22 23:30:41 +02:00
alex a559470369 fix: add openssh-client to API Dockerfile for SSH git clone support 2026-05-22 21:29:28 +00:00
alex 8cab17472e Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:27:25 +00:00
alex e9d404b1ff feat: add SSH key payload signing and verification endpoints
- POST /ssh-keys/{id}/sign - sign payload with Ed25519 private key
- POST /ssh-keys/{id}/verify - verify signature with public key
- Returns base64-encoded signatures
2026-05-22 21:27:10 +00:00
Fusion dab6c74046 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 23:23:36 +02:00
alex ca8b255148 fix: log detailed error messages in repository preflight and clone 2026-05-22 21:21:45 +00:00
Fusion 02a2ad6df5 feat: allow modification of built-in tool types
Remove restrictions on updating and deleting built-in tool types.
Show delete button for all tool types in Tool Workshop.
2026-05-22 23:20:59 +02:00
alex 4ef0f108ea fix: use SSH key during repository preflight and clone 2026-05-22 21:19:15 +00:00
alex dc8ef0e463 fix: chain clone_mode migration after single_interface migration 2026-05-22 21:12:46 +00:00
Fusion 6a7657aeda chore: remove unused tool-types and tool-configs pages
These pages are superseded by the Tool Workshop page.
No functional changes.
2026-05-22 23:04:35 +02:00
alex eca8b8815b Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:02:13 +00:00
Fusion d0f7a97f92 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 23:01:59 +02:00
alex 765cb965e6 fix: sanitize template variables before YAML validation 2026-05-22 21:01:59 +00:00
Fusion 063a839790 feat: implement repository clone mode with SSH key support
- Add clone_mode and branch fields to tool_instances
- Add ssh_key_id to git_repositories for per-repo SSH key assignment
- Implement host-side git cloning with branch selection (default: main)
- Mount SSH keys into containers for git operations in clone mode
- Add dirty state check on clone-mode instance deletion with confirmation
- Update SessionsPage with mount/clone selector, branch input, SSH key display
- Add SSH key selector to repository creation form
- Add dirty delete confirmation modal with changed files list
- Update API schemas and endpoints for new fields
- Sync delta specs to main specs (git-repo, tool-instances, repo-clone-mode)
- Archive completed OpenSpec change: repo-clone-mode-with-ssh
- Document git requirement for custom tool types

Quality gates: Frontend typecheck and build passed
OpenSpec: repo-clone-mode-with-ssh archived with all tasks complete
2026-05-22 22:56:35 +02:00
alex ae41a64e66 fix: shorten migration revision ID to fit alembic_version column 2026-05-22 20:50:52 +00:00
alex 0901b1e832 fix: make migration database-agnostic for SQLite and PostgreSQL 2026-05-22 20:48:21 +00:00
alex 7cc720786e Merge branch 'feat/enforce-single-tool-type-with-port-config' into dev 2026-05-22 20:44:23 +00:00
alex e167a6be12 feat: enforce single tool type with port configuration
- Replace interfaces array with single interface_type string (web/terminal)
- Add requires_port boolean to indicate port/tunnel needs
- Create Alembic migration for database schema change
- Update backend model, API validation, and seed data
- Update frontend types and tool workshop UI with dropdown
- Add conditional port field rendering based on interface type
- Update all frontend and backend tests

OpenSpec change: enforce-single-tool-type-with-port-config
Quality gates: frontend typecheck PASS, lint PASS, tests 37/37 PASS
2026-05-22 20:44:17 +00:00
alex 0fa926284c feat: enforce single tool type with port config
- Replace interfaces array with interface_type string and requires_port boolean
- Add database migration for schema change
- Update backend model, API schemas, and validation
- Update frontend types and tool workshop UI
- Add dropdown for interface type selection
- Conditionally show/hide port fields based on requires_port
- Update tests and mock data
- All frontend tests pass (37/37)
- Frontend typecheck and lint pass
2026-05-22 20:32:10 +00:00
alex 5c17de0c3c fix: handle FastAPI validation error objects in tool workshop
- Add extractErrorMessage helper to safely stringify validation error arrays
- Apply to tool type, config, and folder save handlers
- Fixes React error #31 when rendering error objects directly in JSX

Closes: redesign-tool-workshop
2026-05-22 20:04:42 +00:00
alex 8efadc4432 fix: add defensive null checks to prevent filter crash
- Add fallback to empty arrays for toolTypes, configs, and folders
- Handle undefined API responses gracefully
- Prevent Cannot read properties of undefined (reading 'filter') error

Quality gates: npm run build passed
2026-05-22 19:54:40 +00:00
miguel 1e40540ef4 feat: show banner for bare mirror repositories
- Add isMirror prop to GitToolbar\n- Show warning banner when repo is a bare mirror\n- Explain that editing/committing/pulling/merging are unavailable\n- Suggest deleting and recreating to enable full features\n\nQuality gates: vitest (43 passed)
2026-05-22 21:51:17 +02:00
alex 7cbbb41661 feat: redesign tool workshop with split-pane layout
- Replace tabbed interface with split-pane layout
- Left sidebar: scrollable tool type list with selection and create button
- Right panel: editable tool type details with tabs for configs and folders
- Add dirty state tracking with unsaved changes warning
- Improve mobile responsiveness

Quality gates: npm run build passed
2026-05-22 19:49:54 +00:00
Fusion 952a9f3234 fix: correct merge migration to use down_revision tuple
The merge migration was using depends_on instead of down_revision,
which prevented Alembic from recognizing it as a merge point.
2026-05-22 21:47:42 +02:00
Fusion ab8872f79e fix: add merge migration to resolve multiple alembic heads
Resolves conflict between numeric migration branch (0013) and
tool-workshop migration branch (8ed7dd80973d) both depending on
0012_default_port_req.
2026-05-22 21:44:43 +02:00
Fusion be4893e2a7 fix: add missing migration for probe_result column
Adds probe_result JSON column to tool_instances table.
This column stores readiness probe results and was added to the
model but the migration was missing.
2026-05-22 21:42:10 +02:00
Fusion b3c6a5fdc9 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:39:59 +02:00
Fusion b7d17cea78 fix: complete in-progress OpenSpec changes
- git-repo-working-clones: Complete remaining test task
- opencode-web-terminal: Add port validation tests, fix model validator
- session-management-fixes: Mark frontend tasks as complete (already implemented)

All in-progress changes now complete.
2026-05-22 21:39:43 +02:00
miguel 36d6448f5f merge: integrate session management fixes and sessions hub 2026-05-22 21:39:37 +02:00
miguel 20a5f6a9a1 feat: session management fixes and sessions hub
- Add confirmation dialogs for stop/delete on dashboard
- Filter deleted sessions immediately without reload
- Add tunnel health polling with error badges
- Add Sessions nav item with active count badge
- Route /sessions to SessionsPage component

Quality gates: 43/43 tests pass, typecheck pass, lint pass

Refs: openspec/changes/session-management-fixes
Refs: openspec/changes/sessions-hub
2026-05-22 21:39:28 +02:00
miguel 95a7454bee fix: handle bare repos in branch creation and checkout
- Fall back to symbolic-ref when checkout --orphan fails on bare repos\n- Fall back to symbolic-ref when checkout fails on bare repos\n- Make get_current_branch handle bare repos with unborn branches\n- Add integration tests for bare repo branch operations\n\nQuality gates: pytest integration tests (12 passed)
2026-05-22 21:32:51 +02:00
168 changed files with 7956 additions and 2555 deletions
+195
View File
@@ -0,0 +1,195 @@
---
name: sift-backlog
description: Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open.
---
# Sift Backlog
Triage backlog tasks: prioritize, group into plans, set dependencies, and activate.
## Overview
1. List backlog tasks (`sf task backlog`)
2. Clarify and enrich each task (titles, descriptions)
3. Identify groupings and create draft plans
4. Add tasks to plans and set dependencies
5. Activate plans
6. Set task status to open
## Workflow
### Step 1: List Backlog Tasks
```bash
sf task backlog
```
### Step 2: Clarify and Enrich Tasks
Backlog tasks often have only a brief title with no description. Before organizing, ensure each task is well-defined.
**For each task, evaluate:**
- Is the title clear and actionable?
- Is there a description? Check with `sf task describe <task-id> --show`
- Is the scope unambiguous?
**If the title is unclear**, update it:
```bash
sf update <task-id> --title "Clear, actionable title"
```
**Add a description** with context, scope, and acceptance criteria:
```bash
sf task describe <task-id> --content "Description with:
- What needs to be done
- Why it matters
- Acceptance criteria
- Any relevant context"
```
**Use your best judgment** to interpret tasks and make reasonable decisions about scope, grouping, and priority. You have context about the codebase, project patterns, and typical development practices—leverage this knowledge rather than deferring to the user for routine decisions.
**Only ask the user for clarity when absolutely necessary:**
- The task is fundamentally ambiguous (multiple mutually exclusive interpretations)
- Critical business logic or user-facing behavior that could go wrong in meaningful ways
- External dependencies or integrations you cannot verify
**Do NOT ask about:**
- Implementation details you can reasonably infer
- Priority or grouping decisions—use your judgment
- Standard development practices (testing, code style, etc.)
- Tasks where a reasonable interpretation exists
### Step 3: Create Draft Plans
Group related tasks into plans using your best judgment. Plans start as drafts (tasks won't be dispatched until activated).
**Grouping guidance:**
- Group tasks that share a common theme, feature area, or goal
- Consider technical dependencies when grouping (tasks that touch the same files/modules)
- Separate unrelated work into distinct plans for parallel execution
- Don't over-group—if tasks are truly independent, separate plans enable better parallelism
- Don't under-group—related tasks benefit from shared context and coordinated execution
```bash
sf plan create --title "Plan Name"
```
**Example:**
```bash
sf plan create --title "Authentication Improvements"
# Output: Created plan el-abc123
```
### Step 4: Add Tasks to Plans
```bash
sf plan add-task <plan-id> <task-id>
```
**Example:**
```bash
sf plan add-task el-abc123 el-task1
sf plan add-task el-abc123 el-task2
```
### Step 5: Set Dependencies Between Tasks
Use `blocks` dependency when one task must complete before another can start.
```bash
sf dependency add <blocked-id> <blocker-id> --type blocks
```
**Semantics:** The first ID is blocked BY the second ID. The blocker must complete first.
**Example:** Task 2 can't start until Task 1 completes:
```bash
sf dependency add el-task2 el-task1 --type blocks
```
### Step 6: Update Priorities
Set priorities based on your assessment of impact, urgency, and dependencies. Use your judgment—you don't need user confirmation for routine prioritization.
**Priority guidance:**
- **Critical (1):** Blocking issues, security vulnerabilities, production bugs
- **High (2):** Important features with deadlines, significant user impact
- **Medium (3):** Standard feature work, most tasks default here
- **Low (4):** Nice-to-haves, minor improvements, tech debt
- **Minimal (5):** Backlog cleanup, documentation, exploratory work
```bash
sf update <task-id> --priority <1-5>
```
| Value | Level |
| ----- | -------- |
| 1 | Critical |
| 2 | High |
| 3 | Medium |
| 4 | Low |
| 5 | Minimal |
### Step 7: Activate Plans
Once tasks are organized with dependencies set, activate plans to enable dispatch.
```bash
sf plan activate <plan-id>
```
### Step 8: Set Task Status to Open
Move tasks from backlog to open so they become ready for work.
```bash
sf update <id> --status open
```
## Other Actions
**Close obsolete tasks:**
```bash
sf task close <id> --reason "Won't do: <reason>"
```
**Defer tasks:**
```bash
sf task defer <id> --until <date>
```
**View existing plans:**
```bash
sf plan list
```
**View tasks in a plan:**
```bash
sf plan tasks <plan-id>
```
## Tips
- **Use your best judgment** for grouping, prioritization, and task interpretation—don't defer routine decisions to the user
- **Only escalate to the user** when ambiguity is fundamental and could lead to wasted work (mutually exclusive interpretations, critical business decisions)
- Make reasonable inferences about implementation details, scope, and priority based on codebase context
- Create plans before setting dependencies to avoid dispatch race conditions
- Always activate plans after dependencies are set
- Focus on oldest backlog items first (sorted by creation date)
- Every task should have a clear title and description before activation
- When uncertain about a minor detail, make a reasonable choice and document it in the task description—workers can ask if needed
+1
View File
@@ -48,3 +48,4 @@ apps/web/dist/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
/.stoneforge/.worktrees/
+2
View File
@@ -0,0 +1,2 @@
130078
1779618546909
+6
View File
@@ -0,0 +1,6 @@
# Runtime data
*.db
*.db-journal
*.db-wal
*.db-shm
daemon-state.json
+20
View File
@@ -0,0 +1,20 @@
# Stoneforge Configuration
database: stoneforge.db
sync:
auto_export: true
elements_file: elements.jsonl
dependencies_file: dependencies.jsonl
playbooks:
paths:
- playbooks
identity:
mode: soft
merge:
auto_merge: true
target_branch: null
require_approval: false
workflow:
preset: auto
agents:
permission_model: unrestricted
+1
View File
@@ -0,0 +1 @@
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
File diff suppressed because one or more lines are too long
+1
View File
@@ -27,6 +27,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \ libpq5 \
git \ git \
openssh-client \
netcat-openbsd \ netcat-openbsd \
ca-certificates \ ca-certificates \
curl \ curl \
@@ -0,0 +1,29 @@
"""add probe_result to tool_instances
Revision ID: 0013_add_probe_result
Revises: 0012_default_port_req
Create Date: 2026-05-22 21:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_probe_result"
down_revision: Union[str, None] = "0012_default_port_req"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_instances",
sa.Column("probe_result", postgresql.JSON, nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_instances", "probe_result")
@@ -0,0 +1,25 @@
"""merge migration heads
Revision ID: 0014_merge_heads
Revises: 0013_add_probe_result, 8ed7dd80973d
Create Date: 2026-05-22 21:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0014_merge_heads"
down_revision: Union[str, Sequence[str], None] = ("0013_add_probe_result", "8ed7dd80973d")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,110 @@
"""replace interfaces with interface_type and add requires_port
Revision ID: 0015_single_interface
Revises: 0014_merge_heads
Create Date: 2026-05-22 22:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = "0015_single_interface"
down_revision: Union[str, Sequence[str], None] = "0014_merge_heads"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _get_dialect() -> str:
"""Get the current database dialect name."""
conn = op.get_bind()
return conn.dialect.name
def upgrade() -> None:
dialect = _get_dialect()
# Add new columns
op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True))
op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true'))
# Migrate data: take first element from interfaces JSON array
if dialect == 'postgresql':
op.execute("""
UPDATE tool_types
SET interface_type = COALESCE(
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
'web'
),
requires_port = CASE
WHEN COALESCE(
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
'web'
) = 'web' THEN true
ELSE false
END
""")
else:
# SQLite: interfaces is stored as JSON text, extract first array element
op.execute("""
UPDATE tool_types
SET interface_type = COALESCE(
(SELECT json_extract(value, '$[0]')
FROM json_each(interfaces) AS value
WHERE json_valid(interfaces)
LIMIT 1),
'web'
),
requires_port = CASE
WHEN COALESCE(
(SELECT json_extract(value, '$[0]')
FROM json_each(interfaces) AS value
WHERE json_valid(interfaces)
LIMIT 1),
'web'
) = 'web' THEN true
ELSE false
END
""")
# Make interface_type non-nullable after data migration
op.alter_column('tool_types', 'interface_type', nullable=False)
# Drop old interfaces column
op.drop_column('tool_types', 'interfaces')
# Add CHECK constraint for interface_type (only on PostgreSQL; SQLite supports it too)
op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')"))
def downgrade() -> None:
dialect = _get_dialect()
# Drop CHECK constraint
op.drop_constraint('chk_interface_type', 'tool_types', type_='check')
# Add back interfaces column
if dialect == 'postgresql':
op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]'))
# Migrate data back: wrap interface_type in array
op.execute("""
UPDATE tool_types
SET interfaces = jsonb_build_array(interface_type)
""")
else:
op.add_column('tool_types', sa.Column('interfaces', sa.JSON(), nullable=False, server_default='["web"]'))
# Migrate data back: wrap interface_type in array for SQLite
op.execute("""
UPDATE tool_types
SET interfaces = json_array(interface_type)
""")
# Drop new columns
op.drop_column('tool_types', 'requires_port')
op.drop_column('tool_types', 'interface_type')
@@ -0,0 +1,36 @@
"""add_clone_mode_and_ssh_key_id
Revision ID: 2026_05_22_add_clone_mode
Revises: 0014_merge_heads
Create Date: 2026-05-22 20:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '2026_05_22_add_clone_mode'
down_revision = '0015_single_interface'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add ssh_key_id to git_repositories
op.add_column('git_repositories', sa.Column('ssh_key_id', postgresql.UUID(), nullable=True))
op.create_foreign_key('fk_git_repositories_ssh_key', 'git_repositories', 'ssh_keys', ['ssh_key_id'], ['id'])
# Add clone_mode and branch to tool_instances
op.add_column('tool_instances', sa.Column('clone_mode', sa.String(20), nullable=False, server_default='mount'))
op.add_column('tool_instances', sa.Column('branch', sa.String(255), nullable=True, server_default='main'))
def downgrade() -> None:
# Drop columns from tool_instances
op.drop_column('tool_instances', 'branch')
op.drop_column('tool_instances', 'clone_mode')
# Drop ssh_key_id from git_repositories
op.drop_constraint('fk_git_repositories_ssh_key', 'git_repositories', type_='foreignkey')
op.drop_column('git_repositories', 'ssh_key_id')
@@ -0,0 +1,25 @@
"""remove_is_builtin_from_tool_types
Revision ID: 2026_05_23_remove_is_builtin
Revises: 2026_05_22_add_clone_mode
Create Date: 2026-05-23 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2026_05_23_remove_is_builtin'
down_revision = 'f3d2dc90ba3a'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Drop the is_builtin column from tool_types
op.execute("ALTER TABLE tool_types DROP COLUMN IF EXISTS is_builtin")
def downgrade() -> None:
# Add the is_builtin column back to tool_types
op.add_column('tool_types', sa.Column('is_builtin', sa.Boolean(), nullable=False, server_default='false'))
@@ -0,0 +1,25 @@
"""merge_single_interface_and_clone_mode
Revision ID: f3d2dc90ba3a
Revises: 0015_single_interface, 2026_05_22_add_clone_mode
Create Date: 2026-05-24 10:43:14.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f3d2dc90ba3a"
down_revision: Union[str, Sequence[str], None] = ("0015_single_interface", "2026_05_22_add_clone_mode")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+139 -5
View File
@@ -14,6 +14,7 @@ from src.auth.dependencies import get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User from src.models.user import User
from src.utils.git_files import ( from src.utils.git_files import (
commit_file, commit_file,
@@ -34,6 +35,7 @@ from src.utils.git_control import (
) )
from src.utils.git_history import get_commit_detail, get_commit_history from src.utils.git_history import get_commit_detail, get_commit_history
from src.utils.git_url_parser import parse_git_url from src.utils.git_url_parser import parse_git_url
from src.services.ssh_keys import _get_fernet
router = APIRouter(prefix="/projects", tags=["git-repositories"]) router = APIRouter(prefix="/projects", tags=["git-repositories"])
@@ -94,41 +96,97 @@ def _build_provider_clone_url(owner: str, repo: str) -> str:
return f"git@git.commumedia.org:{owner}/{repo}.git" return f"git@git.commumedia.org:{owner}/{repo}.git"
def _preflight_remote_repository(remote_url: str) -> None: def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
"""Prepare environment variables for git commands with SSH authentication.
Returns a dict of extra env vars, or None if no SSH key provided.
The caller is responsible for cleaning up the temporary key file.
"""
if ssh_key is None:
return None
import tempfile
# Decrypt private key
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
# Write to temp file with restricted permissions
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
# Return env vars and the key path for cleanup
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
return env, key_path
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
"""Verify a remote repository is reachable before cloning.""" """Verify a remote repository is reachable before cloning."""
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try: try:
result = subprocess.run( result = subprocess.run(
["git", "ls-remote", remote_url], ["git", "ls-remote", remote_url],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60, timeout=60,
env={**os.environ, **env} if env else None,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0: if result.returncode != 0:
logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="repository not found or inaccessible", detail=f"repository not found or inaccessible: {result.stderr}",
) )
def _clone_working_repository(remote_url: str, repo_path: str) -> None: def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try: try:
result = subprocess.run( result = subprocess.run(
["git", "clone", remote_url, repo_path], ["git", "clone", remote_url, repo_path],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=300, timeout=300,
env={**os.environ, **env} if env else None,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0: if result.returncode != 0:
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}", detail=f"failed to clone repository: {result.stderr}",
@@ -175,6 +233,7 @@ class GitRepositoryCreate(BaseModel):
name: str name: str
remote_url: str | None = None remote_url: str | None = None
force_original_url: bool = False force_original_url: bool = False
ssh_key_id: str | None = None
class URLParseRequest(BaseModel): class URLParseRequest(BaseModel):
@@ -202,6 +261,7 @@ class GitRepositoryResponse(BaseModel):
is_mirror: bool is_mirror: bool
remote_url: str | None remote_url: str | None
last_push: datetime | None last_push: datetime | None
ssh_key_id: uuid.UUID | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -349,8 +409,23 @@ async def create_repository(
if parse_result["base_url"]: if parse_result["base_url"]:
remote_url = parse_result["base_url"] remote_url = parse_result["base_url"]
# Validate SSH key if provided
ssh_key_id = None
ssh_key = None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
if remote_url: if remote_url:
_preflight_remote_repository(remote_url) _preflight_remote_repository(remote_url, ssh_key)
repo_path = _get_repo_path(user_id, project_id, data.name) repo_path = _get_repo_path(user_id, project_id, data.name)
@@ -358,7 +433,7 @@ async def create_repository(
os.makedirs(os.path.dirname(repo_path), exist_ok=True) os.makedirs(os.path.dirname(repo_path), exist_ok=True)
if remote_url: if remote_url:
_clone_working_repository(remote_url, repo_path) _clone_working_repository(remote_url, repo_path, ssh_key)
else: else:
_init_working_repository(repo_path) _init_working_repository(repo_path)
@@ -369,6 +444,7 @@ async def create_repository(
owner_id=user_id, owner_id=user_id,
is_mirror=False, is_mirror=False,
remote_url=remote_url, remote_url=remote_url,
ssh_key_id=ssh_key_id,
) )
session.add(repo) session.add(repo)
await session.commit() await session.commit()
@@ -376,6 +452,64 @@ async def create_repository(
return repo return repo
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
@router.patch(
"/{project_id}/repositories/{repo_id}/ssh-key",
response_model=GitRepositoryResponse,
summary="Update repository SSH key",
description="Update the SSH key associated with a repository.",
)
async def update_repository_ssh_key(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: UpdateSSHKeyRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Update the SSH key for a repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Update data containing the new SSH key ID.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated repository.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
# Validate SSH key if provided
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
repo.ssh_key_id = ssh_key_id
else:
repo.ssh_key_id = None
await session.commit()
await session.refresh(repo)
return repo
@router.get( @router.get(
"/{project_id}/repositories/{repo_id}/history", "/{project_id}/repositories/{repo_id}/history",
summary="Get repository history", summary="Get repository history",
+95
View File
@@ -1,3 +1,4 @@
import base64
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -74,6 +75,23 @@ class SSHKeyResponse(BaseModel):
created_at: datetime created_at: datetime
class SignPayloadRequest(BaseModel):
payload: str
class SignatureResponse(BaseModel):
signature: str
class VerifySignatureRequest(BaseModel):
payload: str
signature: str
class VerifySignatureResponse(BaseModel):
valid: bool
@router.post( @router.post(
"", "",
response_model=SSHKeyResponse, response_model=SSHKeyResponse,
@@ -166,3 +184,80 @@ async def delete_ssh_key(
await session.delete(ssh_key) await session.delete(ssh_key)
await session.commit() await session.commit()
@router.post(
"/{key_id}/sign",
response_model=SignatureResponse,
summary="Sign payload",
description="Sign a payload using the SSH private key.",
)
async def sign_payload(
key_id: uuid.UUID,
data: SignPayloadRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> SignatureResponse:
"""Sign a payload with an SSH key.
Args:
key_id: UUID of the SSH key to use for signing.
data: Sign request containing the payload string.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Base64-encoded Ed25519 signature.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
fernet = _get_fernet()
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
private_key = serialization.load_ssh_private_key(
private_key_pem.encode(), password=None
)
signature = private_key.sign(data.payload.encode())
return SignatureResponse(signature=base64.b64encode(signature).decode())
@router.post(
"/{key_id}/verify",
response_model=VerifySignatureResponse,
summary="Verify signature",
description="Verify a signature against a payload using the SSH public key.",
)
async def verify_signature(
key_id: uuid.UUID,
data: VerifySignatureRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> VerifySignatureResponse:
"""Verify a signature with an SSH key's public key.
Args:
key_id: UUID of the SSH key to use for verification.
data: Verify request containing payload and base64-encoded signature.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Whether the signature is valid.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
try:
signature = base64.b64decode(data.signature)
public_key.verify(signature, data.payload.encode())
return VerifySignatureResponse(valid=True)
except Exception:
return VerifySignatureResponse(valid=False)
+187 -19
View File
@@ -2,6 +2,7 @@
import logging import logging
import os import os
import subprocess
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -18,6 +19,7 @@ from src.auth.dependencies import get_current_user_id
from src.auth.dependencies import get_db_session from src.auth.dependencies import get_db_session
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.tool_config import ToolConfig from src.models.tool_config import ToolConfig
from src.models.tool_instance import ToolInstance from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
@@ -43,8 +45,10 @@ from src.services.docker import (
write_env_file, write_env_file,
write_config_folder_files, write_config_folder_files,
) )
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
from src.services.docker_build import build_image from src.services.docker_build import build_image
from src.services.readiness_probe import execute_probe from src.services.readiness_probe import execute_probe
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files
router = APIRouter(prefix="/projects", tags=["tool-instances"]) router = APIRouter(prefix="/projects", tags=["tool-instances"])
@@ -56,6 +60,9 @@ class CreateInstanceRequest(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type to instantiate") tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name for the instance") display_name: str | None = Field(default=None, description="Optional display name for the instance")
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
def _modify_compose_file( def _modify_compose_file(
@@ -193,6 +200,19 @@ async def create_instance(
) )
try: try:
# Validate clone mode requirements
if data.clone_mode == "clone":
if not repo.remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository does not have a remote URL for cloning"
)
if not repo.ssh_key_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository must have an SSH key assigned for clone mode"
)
# Generate unique name # Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}" instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
@@ -204,6 +224,74 @@ async def create_instance(
# Find free port # Find free port
tool_port = find_free_port() tool_port = find_free_port()
# Determine repo path based on clone mode
if data.clone_mode == "clone":
# Get SSH key for cloning
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="repository SSH key not found"
)
# Prepare SSH key for clone operation
ssh_key_path = None
try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
ssh_key_path = os.path.join(ssh_dir, "id_ed25519")
# Clone repository
clone_path = clone_repository(
remote_url=repo.remote_url,
ssh_key_path=ssh_key_path,
instance_dir=instance_dir,
branch=data.branch or "main",
)
repo_path = clone_path
except Exception as exc:
logger.exception("Failed to clone repository: %s", exc)
cleanup_ssh_key_files(instance_dir)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clone repository: {exc}"
)
else:
repo_path = repo.path
# Verify cloned repo has files
if data.clone_mode == "clone" and repo_path:
try:
repo_contents = os.listdir(repo_path)
if not repo_contents or (len(repo_contents) == 1 and repo_contents[0] == ".git"):
logger.error("Cloned repository at %s appears empty", repo_path)
raise RuntimeError("Cloned repository is empty")
logger.info("Verified cloned repo at %s has %d items", repo_path, len(repo_contents))
except Exception as exc:
logger.exception("Failed to verify cloned repository: %s", exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Cloned repository verification failed: {exc}"
)
# Create new local branch if requested
if data.clone_mode == "clone" and data.new_branch:
try:
result = subprocess.run(
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr)
raise RuntimeError(f"Failed to create branch: {result.stderr}")
logger.info("Created local branch %s in cloned repository", data.new_branch)
except Exception as exc:
logger.exception("Failed to create local branch: %s", exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create local branch: {exc}"
)
# Handle based on definition type # Handle based on definition type
if tool_type.definition_type == "dockerfile": if tool_type.definition_type == "dockerfile":
# Build image from Dockerfile # Build image from Dockerfile
@@ -235,7 +323,7 @@ services:
ports: ports:
- "{tool_port}:{tool_type.default_port}" - "{tool_port}:{tool_type.default_port}"
volumes: volumes:
- {repo.path}:/workspace - {repo_path}:/workspace
restart: unless-stopped restart: unless-stopped
""" """
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
@@ -243,7 +331,7 @@ services:
else: else:
# Render compose template # Render compose template
variables = { variables = {
"REPO_PATH": repo.path, "REPO_PATH": repo_path,
"INSTANCE_NAME": instance_name, "INSTANCE_NAME": instance_name,
"INSTANCE_ID": instance_name, "INSTANCE_ID": instance_name,
"TOOL_NAME": instance_name, "TOOL_NAME": instance_name,
@@ -252,6 +340,37 @@ services:
"PROJECT_ID": str(project_id), "PROJECT_ID": str(project_id),
} }
compose_content = render_compose_template(tool_type.compose_template, variables) compose_content = render_compose_template(tool_type.compose_template, variables)
# Safety check: for clone mode, ensure repo is mounted in compose file
if data.clone_mode == "clone" and repo_path:
import yaml
compose_data = yaml.safe_load(compose_content)
repo_mounted = False
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
volumes = svc.get("volumes", [])
for vol in volumes:
vol_str = str(vol)
if repo_path in vol_str:
repo_mounted = True
break
if repo_mounted:
break
if not repo_mounted:
logger.warning(
"Compose template for tool type %s does not mount repo path; adding default mount",
tool_type.name,
)
# Add default mount to first service
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
if "volumes" not in svc:
svc["volumes"] = []
svc["volumes"].append(f"{repo_path}:/workspace")
break
compose_content = yaml.dump(compose_data, default_flow_style=False)
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
# Create database record # Create database record
@@ -265,6 +384,8 @@ services:
status="pending", status="pending",
compose_path=compose_path, compose_path=compose_path,
port=tool_port, port=tool_port,
clone_mode=data.clone_mode,
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
) )
session.add(instance) session.add(instance)
await session.commit() await session.commit()
@@ -276,6 +397,8 @@ services:
"display_name": instance.display_name, "display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id), "tool_type_id": str(instance.tool_type_id),
"status": instance.status, "status": instance.status,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"created_at": instance.created_at.isoformat(), "created_at": instance.created_at.isoformat(),
} }
except Exception as exc: except Exception as exc:
@@ -334,10 +457,12 @@ async def list_instances(
"display_name": i.display_name, "display_name": i.display_name,
"tool_type_id": str(i.tool_type_id), "tool_type_id": str(i.tool_type_id),
"tool_type_name": tool_type.name if tool_type else "unknown", "tool_type_name": tool_type.name if tool_type else "unknown",
"tool_type_interfaces": tool_type.interfaces if tool_type else [], "tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"status": i.status, "status": i.status,
"url": i.url, "url": i.url,
"port": i.port, "port": i.port,
"clone_mode": i.clone_mode,
"branch": i.branch,
"created_at": i.created_at.isoformat(), "created_at": i.created_at.isoformat(),
}) })
@@ -398,6 +523,8 @@ async def get_instance(
"compose_path": instance.compose_path, "compose_path": instance.compose_path,
"url": instance.url, "url": instance.url,
"port": instance.port, "port": instance.port,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, "last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None, "last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
"created_at": instance.created_at.isoformat(), "created_at": instance.created_at.isoformat(),
@@ -514,6 +641,23 @@ async def start_instance(
extra_volumes.extend(folder_volumes) extra_volumes.extend(folder_volumes)
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id) logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
# Mount SSH key for clone-mode instances
if instance.clone_mode == "clone":
repo = await session.get(GitRepository, instance.repository_id)
if repo and repo.ssh_key_id:
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key:
try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
extra_volumes.append({
"source": ssh_dir,
"target": "/root/.ssh",
"type": "ro",
})
logger.info("Mounted SSH key for clone-mode instance %s", instance.id)
except Exception as exc:
logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc)
# Modify compose file if needed (port override, start command, working dir, volumes) # Modify compose file if needed (port override, start command, working dir, volumes)
if port_override or start_command or working_directory or extra_volumes: if port_override or start_command or working_directory or extra_volumes:
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes) _modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes)
@@ -607,7 +751,7 @@ async def start_instance(
probe_command = probe_config.get("command", "") probe_command = probe_config.get("command", "")
probe_timeout = probe_config.get("timeout", 30) probe_timeout = probe_config.get("timeout", 30)
probe_interval = probe_config.get("interval", 2) probe_interval = probe_config.get("interval", 2)
elif "web" in (tool_type.interfaces or []): elif tool_type.interface_type == "web":
# Default probe for web tools # Default probe for web tools
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}" probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
probe_timeout = 30 probe_timeout = 30
@@ -659,22 +803,21 @@ async def start_instance(
# Get tool type for default port # Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
if not tool_type or not tool_type.default_port: if not tool_type:
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.", logger.error("Tool type %s not found", instance.tool_type_id)
instance.tool_type_id)
instance.status = "error" instance.status = "error"
await session.commit() await session.commit()
return { return {
"status": "error", "status": "error",
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", "error": f"Tool type '{instance.tool_type_id}' not found",
} }
instance_port = tool_type.default_port instance_port = tool_type.default_port or 0
logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s", logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
instance.id, tool_type.name, instance_port, tool_type.interfaces) instance.id, tool_type.name, instance_port, tool_type.interface_type)
# Only create Cloudflare tunnel for web-enabled tools # Only create Cloudflare tunnel for web-enabled tools
if "web" in tool_type.interfaces: if tool_type.interface_type == "web":
# Create temporary Cloudflare tunnel for public access # Create temporary Cloudflare tunnel for public access
try: try:
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)", logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
@@ -839,7 +982,7 @@ async def restart_instance(
instance_port = tool_type.default_port instance_port = tool_type.default_port
# Only create tunnel for web-enabled tools # Only create tunnel for web-enabled tools
if "web" in tool_type.interfaces: if tool_type.interface_type == "web":
# Create new temporary tunnel # Create new temporary tunnel
try: try:
tunnel_info = start_cloudflared_tunnel( tunnel_info = start_cloudflared_tunnel(
@@ -889,6 +1032,7 @@ async def delete_instance(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
instance_id: uuid.UUID, instance_id: uuid.UUID,
force: bool = False,
user_id: uuid.UUID = Depends(get_current_user_id), user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> None: ) -> None:
@@ -913,6 +1057,23 @@ async def delete_instance(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
) )
# Check dirty state for clone-mode instances
if instance.clone_mode == "clone" and not force:
instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None
if instance_dir:
clone_path = os.path.join(instance_dir, "repo-clone")
if os.path.exists(clone_path):
is_dirty, changed_files = check_dirty_state(clone_path)
if is_dirty:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Repository has uncommitted changes",
"changed_files": changed_files,
"force_required": True,
},
)
# Stop Cloudflare tunnel if exists # Stop Cloudflare tunnel if exists
if instance.tunnel_id: if instance.tunnel_id:
try: try:
@@ -925,7 +1086,7 @@ async def delete_instance(
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
execute_compose_command(instance.compose_path, "down") execute_compose_command(instance.compose_path, "down")
# Remove instance directory # Remove instance directory (includes clone and SSH keys)
if instance.compose_path: if instance.compose_path:
instance_dir = os.path.dirname(instance.compose_path) instance_dir = os.path.dirname(instance.compose_path)
if os.path.exists(instance_dir): if os.path.exists(instance_dir):
@@ -1112,7 +1273,7 @@ async def check_instance_tunnel_health(
response["probe_status"] = "pending" response["probe_status"] = "pending"
elif instance.probe_result: elif instance.probe_result:
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed" response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))[:500] response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))
# Check tunnel health if instance has a URL and is web-enabled # Check tunnel health if instance has a URL and is web-enabled
if instance.url and instance.status in ("running", "unhealthy"): if instance.url and instance.status in ("running", "unhealthy"):
@@ -1122,10 +1283,14 @@ async def check_instance_tunnel_health(
if tunnel_health.get("error"): if tunnel_health.get("error"):
response["error"] = tunnel_health["error"] response["error"] = tunnel_health["error"]
# Overall healthy only if container is running AND tunnel is healthy # Overall healthy: web tools need running container + healthy tunnel;
# terminal tools only need running container
container_healthy = container_info["status"] == "running" container_healthy = container_info["status"] == "running"
tunnel_healthy = response["tunnel_status"] == "healthy" if instance.url:
response["healthy"] = container_healthy and tunnel_healthy tunnel_healthy = response["tunnel_status"] == "healthy"
response["healthy"] = container_healthy and tunnel_healthy
else:
response["healthy"] = container_healthy
# If container is not running, override error message # If container is not running, override error message
if not container_healthy: if not container_healthy:
@@ -1309,13 +1474,16 @@ async def get_user_sessions(
"display_name": instance.display_name, "display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown", "tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code", "tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": tool_type.interfaces if tool_type else [], "tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"repository_name": repo.name if repo else "unknown", "repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id), "repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown", "project_name": project.name if project else "unknown",
"project_id": str(instance.project_id), "project_id": str(instance.project_id),
"status": instance.status, "status": instance.status,
"url": instance.url, "url": instance.url,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"created_at": instance.created_at.isoformat() if instance.created_at else None,
}) })
return {"sessions": sessions} return {"sessions": sessions}
+83 -54
View File
@@ -1,3 +1,4 @@
import re
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -7,6 +8,11 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
def _sanitize_template_vars(template: str) -> str:
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
@@ -37,7 +43,7 @@ class ToolTypeCreate(BaseModel):
name: str name: str
display_name: str display_name: str
description: str | None = None description: str | None = None
default_port: int default_port: int = 0
definition_type: str = "compose" definition_type: str = "compose"
compose_template: str | None = None compose_template: str | None = None
dockerfile_template: str | None = None dockerfile_template: str | None = None
@@ -45,7 +51,8 @@ class ToolTypeCreate(BaseModel):
readiness_probe: dict | None = None readiness_probe: dict | None = None
required_variables: list[str] = [] required_variables: list[str] = []
category: str = "other" category: str = "other"
interfaces: list[str] = ["web"] interface_type: str = "web"
requires_port: bool = True
@field_validator("definition_type") @field_validator("definition_type")
@classmethod @classmethod
@@ -64,8 +71,12 @@ class ToolTypeCreate(BaseModel):
if v is None: if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'") raise ValueError("compose_template is required when definition_type is 'compose'")
# Replace template variables with dummy values before YAML validation
# to avoid YAML parsing errors with {{VAR}} syntax
sanitized = _sanitize_template_vars(v)
try: try:
parsed = yaml.safe_load(v) parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e: except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}") raise ValueError(f"Invalid YAML: {e}")
@@ -95,48 +106,22 @@ class ToolTypeCreate(BaseModel):
return v return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str) -> str:
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("default_port") @field_validator("default_port")
@classmethod @classmethod
def validate_default_port(cls, v: int, info) -> int: def validate_default_port(cls, v: int, info) -> int:
data = info.data
requires_port = data.get("requires_port", True)
if not requires_port:
return v
if v <= 0 or v > 65535: if v <= 0 or v > 65535:
raise ValueError("Port must be between 1 and 65535") raise ValueError("Port must be between 1 and 65535")
# Get compose_template from the model data
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
try:
parsed = yaml.safe_load(template)
except yaml.YAMLError:
return v
# Check if the port is exposed in any service
port_str = str(v)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_name, service_config in parsed["services"].items():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
# Format: "8443:8443" or "8443"
if port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == v:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
return v return v
@field_validator("required_variables") @field_validator("required_variables")
@@ -166,6 +151,35 @@ class ToolTypeCreate(BaseModel):
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'") raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if self.definition_type == "compose" and self.compose_template is None: if self.definition_type == "compose" and self.compose_template is None:
raise ValueError("compose_template is required when definition_type is 'compose'") raise ValueError("compose_template is required when definition_type is 'compose'")
# Validate that default_port is exposed in compose template (only if requires_port)
if self.requires_port and self.definition_type == "compose" and self.compose_template:
try:
sanitized = _sanitize_template_vars(self.compose_template)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError:
return self
port_str = str(self.default_port)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_name, service_config in parsed["services"].items():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
if port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == self.default_port:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
return self return self
@@ -180,7 +194,8 @@ class ToolTypeUpdate(BaseModel):
readiness_probe: dict | None = None readiness_probe: dict | None = None
required_variables: list[str] | None = None required_variables: list[str] | None = None
category: str | None = None category: str | None = None
interfaces: list[str] | None = None interface_type: str | None = None
requires_port: bool | None = None
@field_validator("definition_type") @field_validator("definition_type")
@classmethod @classmethod
@@ -191,6 +206,15 @@ class ToolTypeUpdate(BaseModel):
raise ValueError("definition_type must be 'compose' or 'dockerfile'") raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("compose_template") @field_validator("compose_template")
@classmethod @classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None: def validate_compose_template(cls, v: str | None, info) -> str | None:
@@ -202,8 +226,11 @@ class ToolTypeUpdate(BaseModel):
if definition_type and definition_type != "compose": if definition_type and definition_type != "compose":
return v return v
# Replace template variables with dummy values before YAML validation
sanitized = _sanitize_template_vars(v)
try: try:
parsed = yaml.safe_load(v) parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e: except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}") raise ValueError(f"Invalid YAML: {e}")
@@ -243,7 +270,8 @@ class ToolTypeResponse(BaseModel):
display_name: str display_name: str
description: str | None description: str | None
category: str category: str
interfaces: list[str] interface_type: str
requires_port: bool
default_port: int default_port: int
definition_type: str definition_type: str
compose_template: str | None compose_template: str | None
@@ -251,7 +279,6 @@ class ToolTypeResponse(BaseModel):
build_context: dict | None build_context: dict | None
readiness_probe: dict | None readiness_probe: dict | None
required_variables: list[str] required_variables: list[str]
is_builtin: bool
created_by_id: uuid.UUID | None created_by_id: uuid.UUID | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -299,8 +326,8 @@ async def create_tool_type(
readiness_probe=data.readiness_probe, readiness_probe=data.readiness_probe,
required_variables=data.required_variables, required_variables=data.required_variables,
category=data.category, category=data.category,
interfaces=data.interfaces, interface_type=data.interface_type,
is_builtin=False, requires_port=data.requires_port,
created_by_id=user.id, created_by_id=user.id,
) )
session.add(tool_type) session.add(tool_type)
@@ -391,13 +418,13 @@ async def update_tool_type(
if tool_type is None: if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
if tool_type.is_builtin: # Built-in tool types can now be modified
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
# Validate port if being updated # Validate port if being updated
if "default_port" in update_data: requires_port = update_data.get("requires_port", tool_type.requires_port)
if "default_port" in update_data and requires_port:
new_port = update_data["default_port"] new_port = update_data["default_port"]
if new_port <= 0 or new_port > 65535: if new_port <= 0 or new_port > 65535:
raise HTTPException( raise HTTPException(
@@ -411,7 +438,8 @@ async def update_tool_type(
template = update_data.get("compose_template", tool_type.compose_template) template = update_data.get("compose_template", tool_type.compose_template)
if template: if template:
try: try:
parsed = yaml.safe_load(template) sanitized = _sanitize_template_vars(template)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError: except yaml.YAMLError:
parsed = None parsed = None
@@ -502,7 +530,8 @@ async def validate_tool_type_template(
errors.append("Compose template is required") errors.append("Compose template is required")
else: else:
try: try:
parsed = yaml.safe_load(data.compose_template) sanitized = _sanitize_template_vars(data.compose_template)
parsed = yaml.safe_load(sanitized)
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping") errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed: elif "services" not in parsed:
@@ -559,7 +588,8 @@ async def validate_tool_type(
errors.append("Compose template is empty") errors.append("Compose template is empty")
else: else:
try: try:
parsed = yaml.safe_load(tool_type.compose_template) sanitized = _sanitize_template_vars(tool_type.compose_template)
parsed = yaml.safe_load(sanitized)
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping") errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed: elif "services" not in parsed:
@@ -609,8 +639,7 @@ async def delete_tool_type(
if tool_type is None: if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
if tool_type.is_builtin: # Built-in tool types can now be deleted
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
await session.delete(tool_type) await session.delete(tool_type)
await session.commit() await session.commit()
+2 -154
View File
@@ -7,7 +7,7 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import select, text from sqlalchemy import text
from src.api.auth import router as auth_router from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router from src.api.dashboard import router as dashboard_router
@@ -25,13 +25,12 @@ from src.api.tool_types import router as tool_types_router
from src.api.user_config import router as user_config_router from src.api.user_config import router as user_config_router
from src.api.users import router as users_router from src.api.users import router as users_router
from src.config import Settings from src.config import Settings
from src.database import SessionLocal, init_database from src.database import init_database
from src.logging_config import ( from src.logging_config import (
ExceptionLoggingMiddleware, ExceptionLoggingMiddleware,
RequestLoggingMiddleware, RequestLoggingMiddleware,
configure_logging, configure_logging,
) )
from src.models.tool_type import ToolType
# Configure logging early # Configure logging early
log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -103,155 +102,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
) )
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
try:
result = await session.execute(
text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
builtin_types = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interfaces": ["web"],
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: unless-stopped""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interfaces": ["web"],
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interfaces": ["terminal"],
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /workspace
environment:
- HOME=/tmp
volumes:
- {{REPO_PATH}}:/workspace
- opencode_home:/tmp
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
echo 'cd /workspace' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
cd /workspace &&
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: unless-stopped
volumes:
opencode_home:""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
for tool_data in builtin_types:
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
if not existing:
tool_type = ToolType(
name=tool_data["name"],
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interfaces=tool_data["interfaces"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data.get("default_port"),
is_builtin=True,
)
session.add(tool_type)
logger.info("Created built-in tool type: %s", tool_data["name"])
else:
# Update existing built-in tool types to reflect code changes
existing.display_name = tool_data["display_name"]
existing.description = tool_data["description"]
existing.category = tool_data["category"]
existing.interfaces = tool_data["interfaces"]
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
existing.default_port = tool_data.get("default_port")
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
logger.info("Built-in tool types seeded successfully.")
@app.on_event("startup") @app.on_event("startup")
async def on_startup(): async def on_startup():
logger.info("Starting up Headquarter API...") logger.info("Starting up Headquarter API...")
@@ -263,8 +113,6 @@ async def on_startup():
import sys import sys
sys.exit(1) sys.exit(1)
# Seed built-in data
await seed_builtin_tool_types()
logger.info("Startup complete.") logger.info("Startup complete.")
app.include_router(health_router) app.include_router(health_router)
+5
View File
@@ -10,6 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User from src.models.user import User
@@ -23,6 +24,10 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("ssh_keys.id"), nullable=True
)
project: Mapped["Project"] = relationship(back_populates="repositories") project: Mapped["Project"] = relationship(back_populates="repositories")
owner: Mapped["User"] = relationship() owner: Mapped["User"] = relationship()
ssh_key: Mapped["SSHKey | None"] = relationship()
+6
View File
@@ -65,6 +65,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
probe_result: Mapped[dict | None] = mapped_column( probe_result: Mapped[dict | None] = mapped_column(
JSON, nullable=True JSON, nullable=True
) )
clone_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="mount"
)
branch: Mapped[str | None] = mapped_column(
String(255), nullable=True, default="main"
)
tool_type: Mapped["ToolType"] = relationship() tool_type: Mapped["ToolType"] = relationship()
repository: Mapped["GitRepository"] = relationship() repository: Mapped["GitRepository"] = relationship()
+2 -2
View File
@@ -18,7 +18,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
display_name: Mapped[str] = mapped_column(String(255), nullable=False) display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other") category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
default_port: Mapped[int] = mapped_column(nullable=False) default_port: Mapped[int] = mapped_column(nullable=False)
definition_type: Mapped[str] = mapped_column( definition_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="compose" String(20), nullable=False, default="compose"
@@ -30,7 +31,6 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
) )
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True) readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_by_id: Mapped[uuid.UUID | None] = mapped_column( created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), UUID(),
ForeignKey("users.id"), ForeignKey("users.id"),
+97
View File
@@ -0,0 +1,97 @@
"""Clone service for repository cloning and dirty state checking."""
import logging
import os
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
def clone_repository(
remote_url: str,
ssh_key_path: str | None,
instance_dir: str,
branch: str = "main",
) -> str:
"""Clone a git repository into the instance directory.
Args:
remote_url: Git remote URL (SSH or HTTPS)
ssh_key_path: Path to SSH private key for authentication (optional)
instance_dir: Path to instance directory
branch: Branch to clone (default: main)
Returns:
Path to the cloned repository
"""
clone_path = Path(instance_dir) / "repo-clone"
clone_path.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
if ssh_key_path:
# Use SSH key for cloning
env["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
cmd = [
"git",
"clone",
"--branch", branch,
"--single-branch",
remote_url,
str(clone_path),
]
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=300,
)
if result.returncode != 0:
logger.error("Git clone failed: %s", result.stderr)
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
logger.info("Successfully cloned repository into %s", clone_path)
return str(clone_path)
def check_dirty_state(clone_path: str) -> tuple[bool, list[str]]:
"""Check for uncommitted changes in a cloned repository.
Args:
clone_path: Path to the cloned repository
Returns:
Tuple of (is_dirty, list_of_changed_files)
"""
result = subprocess.run(
["git", "-C", clone_path, "status", "--short"],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.warning("Failed to check git status: %s", result.stderr)
return False, []
changed_files = [line.strip() for line in result.stdout.split("\n") if line.strip()]
is_dirty = len(changed_files) > 0
return is_dirty, changed_files
def remove_clone_directory(instance_dir: str) -> None:
"""Remove the cloned repository from the instance directory.
Args:
instance_dir: Path to instance directory
"""
clone_path = Path(instance_dir) / "repo-clone"
if clone_path.exists():
import shutil
shutil.rmtree(clone_path)
logger.info("Removed clone directory: %s", clone_path)
+73
View File
@@ -0,0 +1,73 @@
"""SSH key service utilities for preparing keys for container use."""
import os
from pathlib import Path
from cryptography.fernet import Fernet
from src.config import Settings
def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
import base64
import hashlib
settings = Settings()
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
key = base64.urlsafe_b64encode(key_bytes)
return Fernet(key)
def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str:
"""Decrypt and write SSH key files to instance directory for container mounting.
Args:
instance_dir: Path to instance directory
ssh_key: SSHKey model instance with encrypted private key
Returns:
Path to the .ssh directory
"""
ssh_dir = Path(instance_dir) / ".ssh"
ssh_dir.mkdir(parents=True, exist_ok=True)
# Decrypt private key
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
# Write private key with restricted permissions
private_key_path = ssh_dir / "id_ed25519"
private_key_path.write_text(private_key)
os.chmod(private_key_path, 0o600)
# Write public key
public_key_path = ssh_dir / "id_ed25519.pub"
public_key_path.write_text(ssh_key.public_key)
os.chmod(public_key_path, 0o644)
# Write SSH config
config_path = ssh_dir / "config"
config_content = """Host *
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
"""
config_path.write_text(config_content)
os.chmod(config_path, 0o644)
return str(ssh_dir)
def cleanup_ssh_key_files(instance_dir: str) -> None:
"""Remove temporary SSH key files from instance directory.
Args:
instance_dir: Path to instance directory
"""
ssh_dir = Path(instance_dir) / ".ssh"
if ssh_dir.exists():
for file_path in ssh_dir.iterdir():
file_path.unlink()
ssh_dir.rmdir()
+26 -8
View File
@@ -289,19 +289,37 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
branches: list[BranchInfo] = [] branches: list[BranchInfo] = []
default_branch = "main" default_branch = "main"
# Get list of remote names to properly filter remote tracking branches
try:
remote_output = _run_git_command(repo_path, "remote")
remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()}
except RuntimeError:
remote_names = set()
for line in output.strip().split("\n"): for line in output.strip().split("\n"):
if not line: if not line:
continue continue
branch_name = line.strip() branch_name = line.strip()
# Skip remote tracking branches (they start with remotes/)
if branch_name.startswith("remotes/"): # Skip detached HEAD pointer
# Extract just the branch name part if branch_name == "HEAD":
parts = branch_name.split("/", 2) continue
if len(parts) >= 3:
branch_name = parts[2] # Skip remote tracking branches - they appear as "origin/branch-name"
else: # Check if first part is a remote name
continue if "/" in branch_name:
first_part = branch_name.split("/", 1)[0]
if first_part in remote_names:
# Extract just the branch name part (after "origin/")
branch_name = branch_name.split("/", 1)[1]
elif branch_name.startswith("remotes/"):
# Handle "remotes/origin/branch-name" format
parts = branch_name.split("/", 2)
if len(parts) >= 3:
branch_name = parts[2]
else:
continue
# Skip duplicates # Skip duplicates
if any(b.name == branch_name for b in branches): if any(b.name == branch_name for b in branches):
@@ -98,7 +98,6 @@ def _insert_tool_type(
name: str, name: str,
display_name: str, display_name: str,
compose_template: str, compose_template: str,
is_builtin: bool = False,
created_by_id: str | None = None, created_by_id: str | None = None,
) -> None: ) -> None:
async def _run() -> None: async def _run() -> None:
@@ -120,7 +119,6 @@ def _insert_tool_type(
description="A test tool type", description="A test tool type",
compose_template=compose_template, compose_template=compose_template,
required_variables=["REPO_PATH", "TOOL_NAME"], required_variables=["REPO_PATH", "TOOL_NAME"],
is_builtin=is_builtin,
created_by_id=uuid.UUID(created_by_id) if created_by_id else None, created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
) )
await session.merge(tool_type) await session.merge(tool_type)
@@ -234,7 +232,6 @@ def test_create_tool_type_successfully() -> None:
assert data["name"] == "my-custom-tool" assert data["name"] == "my-custom-tool"
assert data["display_name"] == "My Custom Tool" assert data["display_name"] == "My Custom Tool"
assert data["description"] == "A custom development tool" assert data["description"] == "A custom development tool"
assert data["is_builtin"] == False
assert data["created_by_id"] == user_id assert data["created_by_id"] == user_id
assert "id" in data assert "id" in data
@@ -376,28 +373,7 @@ def test_update_tool_type_not_found() -> None:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.integration
def test_update_builtin_tool_type_fails() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(
tool_type_id,
"builtin-tool",
"Built-in Tool",
"version: '3.8'\nservices:\n app:\n image: builtin",
is_builtin=True,
)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
payload = {"display_name": "Updated"}
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
assert response.status_code == 403
@pytest.mark.integration @pytest.mark.integration
@@ -442,53 +418,4 @@ def test_delete_tool_type_not_found() -> None:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.integration
def test_delete_builtin_tool_type_fails() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(
tool_type_id,
"builtin-tool",
"Built-in Tool",
"version: '3.8'\nservices:\n app:\n image: builtin",
is_builtin=True,
)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
response = client.delete(f"/tool-types/{tool_type_id}")
assert response.status_code == 403
@pytest.mark.integration
def test_builtin_tool_types_seeded_on_startup() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
# Load app triggers startup event which seeds built-in types
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
response = client.get("/tool-types")
assert response.status_code == 200
data = response.json()
# Check that built-in types exist
builtin_names = [t["name"] for t in data if t["is_builtin"]]
assert "code-server" in builtin_names
assert "jupyter-notebook" in builtin_names
# Verify built-in types have correct attributes
code_server = next((t for t in data if t["name"] == "code-server"), None)
assert code_server is not None
assert code_server["display_name"] == "VS Code Server"
assert "services" in code_server["compose_template"]
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
@@ -39,7 +39,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web"], "interfaces": ["web"],
"default_port": 8080, "default_port": 8080,
"definition_type": "compose", "definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"readiness_probe": { "readiness_probe": {
"command": "curl -f http://localhost:8080", "command": "curl -f http://localhost:8080",
"timeout": 30, "timeout": 30,
@@ -92,7 +92,7 @@ class TestToolTypesAPIExtended:
"display_name": "Update Test Tool", "display_name": "Update Test Tool",
"default_port": 8080, "default_port": 8080,
"definition_type": "compose", "definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"required_variables": [], "required_variables": [],
}, },
) )
@@ -167,7 +167,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web", "terminal"], "interfaces": ["web", "terminal"],
"default_port": 8443, "default_port": 8443,
"definition_type": "compose", "definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", "compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
"readiness_probe": { "readiness_probe": {
"command": "curl -f http://localhost:8443", "command": "curl -f http://localhost:8443",
"timeout": 30, "timeout": 30,
@@ -186,3 +186,40 @@ class TestToolTypesAPIExtended:
assert data["category"] == "editor" assert data["category"] == "editor"
assert data["interfaces"] == ["web", "terminal"] assert data["interfaces"] == ["web", "terminal"]
assert "readiness_probe" in data assert "readiness_probe" in data
def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None:
"""Test that creating a tool type without default_port fails validation."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "no-port-tool",
"display_name": "No Port Tool",
"category": "utility",
"interfaces": ["web"],
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"required_variables": [],
},
)
assert response.status_code == 422
data = response.json()
assert "default_port" in str(data)
def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None:
"""Test that port mismatch between default_port and compose template fails."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "port-mismatch-tool",
"display_name": "Port Mismatch Tool",
"category": "utility",
"interfaces": ["web"],
"default_port": 9999,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"required_variables": [],
},
)
assert response.status_code == 422
data = response.json()
assert "Port 9999 is not exposed" in str(data)
@@ -0,0 +1,182 @@
"""Tests for session creation with branch selection and new branch creation."""
import os
import subprocess
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from src.api.tool_instances import CreateInstanceRequest
class TestCreateInstanceRequest:
"""Tests for CreateInstanceRequest model."""
def test_default_values(self):
"""Test default values for CreateInstanceRequest."""
request = CreateInstanceRequest(tool_type_id="123")
assert request.clone_mode == "mount"
assert request.branch == "main"
assert request.new_branch is None
assert request.display_name is None
def test_clone_mode_with_branch(self):
"""Test CreateInstanceRequest with clone mode and branch."""
request = CreateInstanceRequest(
tool_type_id="123",
clone_mode="clone",
branch="dev",
)
assert request.clone_mode == "clone"
assert request.branch == "dev"
def test_new_branch_field(self):
"""Test CreateInstanceRequest with new_branch field."""
request = CreateInstanceRequest(
tool_type_id="123",
clone_mode="clone",
branch="main",
new_branch="feature/test",
)
assert request.new_branch == "feature/test"
class TestBranchCreationInClone:
"""Tests for branch creation logic in clone process."""
def test_create_local_branch_success(self):
"""Test successful local branch creation."""
with tempfile.TemporaryDirectory() as tmpdir:
# Initialize repo
subprocess.run(
["git", "init", tmpdir],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.name", "Test User"],
capture_output=True,
check=True,
)
# Create initial commit
readme = os.path.join(tmpdir, "README.md")
with open(readme, "w") as f:
f.write("# Test\n")
subprocess.run(
["git", "-C", tmpdir, "add", "README.md"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
capture_output=True,
check=True,
)
# Create new branch
result = subprocess.run(
["git", "-C", tmpdir, "checkout", "-b", "feature/new-branch"],
capture_output=True,
text=True,
)
assert result.returncode == 0
# Verify branch exists
branches_result = subprocess.run(
["git", "-C", tmpdir, "branch", "--show-current"],
capture_output=True,
text=True,
)
assert branches_result.stdout.strip() == "feature/new-branch"
def test_create_local_branch_invalid_name(self):
"""Test local branch creation with invalid name fails."""
with tempfile.TemporaryDirectory() as tmpdir:
# Initialize repo
subprocess.run(
["git", "init", tmpdir],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.name", "Test User"],
capture_output=True,
check=True,
)
# Create initial commit
readme = os.path.join(tmpdir, "README.md")
with open(readme, "w") as f:
f.write("# Test\n")
subprocess.run(
["git", "-C", tmpdir, "add", "README.md"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
capture_output=True,
check=True,
)
# Try to create branch with invalid name (contains spaces)
result = subprocess.run(
["git", "-C", tmpdir, "checkout", "-b", "invalid branch name"],
capture_output=True,
text=True,
)
# Git accepts branch names with spaces but it's not recommended
# This test verifies the command structure
assert result.returncode == 0 or "fatal" in result.stderr
class TestCreateInstanceAPI:
"""Tests for create instance API endpoint with branch options."""
def test_create_instance_request_validation(self):
"""Test that CreateInstanceRequest validates correctly."""
# Valid request with new_branch
request = CreateInstanceRequest(
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
clone_mode="clone",
branch="main",
new_branch="feature/test",
)
assert request.new_branch == "feature/test"
# Valid request without new_branch
request2 = CreateInstanceRequest(
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
clone_mode="clone",
branch="dev",
)
assert request2.new_branch is None
def test_create_instance_with_new_branch_sets_instance_branch(self):
"""Test that instance branch is set to new_branch when provided."""
# This tests the logic: data.new_branch if data.new_branch else data.branch
new_branch = "feature/test"
base_branch = "main"
# Simulate the logic from create_instance
stored_branch = new_branch if new_branch else base_branch
assert stored_branch == "feature/test"
# Without new_branch
stored_branch2 = None if None else base_branch
assert stored_branch2 == "main"
+35
View File
@@ -8,6 +8,7 @@ export interface GitRepository {
owner_id: string; owner_id: string;
is_mirror: boolean; is_mirror: boolean;
remote_url: string | null; remote_url: string | null;
ssh_key_id: string | null;
last_push: string | null; last_push: string | null;
created_at: string | null; created_at: string | null;
} }
@@ -16,6 +17,7 @@ export interface GitRepositoryCreate {
name: string; name: string;
remote_url?: string; remote_url?: string;
force_original_url?: boolean; force_original_url?: boolean;
ssh_key_id?: string;
} }
export interface URLParseResult { export interface URLParseResult {
@@ -50,6 +52,39 @@ export async function deleteRepository(projectId: string, repoId: string): Promi
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`); await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
} }
export async function updateRepositorySshKey(
projectId: string,
repoId: string,
sshKeyId: string | null
): Promise<GitRepository> {
const response = await apiClient.patch(
`/projects/${projectId}/repositories/${repoId}/ssh-key`,
{ ssh_key_id: sshKeyId }
);
return response.data;
}
export interface Branch {
name: string;
is_default: boolean;
last_commit: string | null;
}
export interface BranchesResponse {
branches: Branch[];
default_branch: string;
}
export async function listRepositoryBranches(
projectId: string,
repoId: string
): Promise<BranchesResponse> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/branches`
);
return response.data;
}
export interface CommitHistoryEntry { export interface CommitHistoryEntry {
hash: string; hash: string;
short_hash: string; short_hash: string;
+14 -3
View File
@@ -27,6 +27,9 @@ export interface Session {
url: string | null; url: string | null;
container_status?: string; container_status?: string;
probe_status?: string; probe_status?: string;
clone_mode?: string;
branch?: string | null;
created_at?: string;
} }
export async function listInstances( export async function listInstances(
@@ -43,13 +46,19 @@ export async function createInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
toolTypeId: string, toolTypeId: string,
displayName?: string displayName?: string,
cloneMode?: string,
branch?: string,
newBranch?: string
): Promise<ToolInstance> { ): Promise<ToolInstance> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`, `/projects/${projectId}/repositories/${repoId}/instances`,
{ {
tool_type_id: toolTypeId, tool_type_id: toolTypeId,
display_name: displayName, display_name: displayName,
clone_mode: cloneMode || "mount",
branch: branch || undefined,
new_branch: newBranch || undefined,
} }
); );
return response.data; return response.data;
@@ -91,10 +100,12 @@ export async function restartInstance(
export async function deleteInstance( export async function deleteInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
force?: boolean
): Promise<void> { ): Promise<void> {
await apiClient.delete( await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
{ params: { force } }
); );
} }
+27
View File
@@ -24,3 +24,30 @@ export async function createSSHKey(data: SSHKeyCreate): Promise<SSHKey> {
export async function deleteSSHKey(keyId: string): Promise<void> { export async function deleteSSHKey(keyId: string): Promise<void> {
await apiClient.delete(`/ssh-keys/${keyId}`); await apiClient.delete(`/ssh-keys/${keyId}`);
} }
export interface SignPayloadRequest {
payload: string;
}
export interface SignatureResponse {
signature: string;
}
export interface VerifySignatureRequest {
payload: string;
signature: string;
}
export interface VerifySignatureResponse {
valid: boolean;
}
export async function signPayload(keyId: string, data: SignPayloadRequest): Promise<SignatureResponse> {
const response = await apiClient.post<SignatureResponse>(`/ssh-keys/${keyId}/sign`, data);
return response.data;
}
export async function verifySignature(keyId: string, data: VerifySignatureRequest): Promise<VerifySignatureResponse> {
const response = await apiClient.post<VerifySignatureResponse>(`/ssh-keys/${keyId}/verify`, data);
return response.data;
}
+6 -4
View File
@@ -12,7 +12,8 @@ export interface ToolType {
display_name: string; display_name: string;
description: string | null; description: string | null;
category: string; category: string;
interfaces: string[]; interface_type: string;
requires_port: boolean;
default_port: number | null; default_port: number | null;
definition_type: 'compose' | 'dockerfile'; definition_type: 'compose' | 'dockerfile';
compose_template: string | null; compose_template: string | null;
@@ -20,7 +21,6 @@ export interface ToolType {
build_context: Record<string, string> | null; build_context: Record<string, string> | null;
readiness_probe: ReadinessProbe | null; readiness_probe: ReadinessProbe | null;
required_variables: string[]; required_variables: string[];
is_builtin: boolean;
created_by_id: string | null; created_by_id: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -31,7 +31,8 @@ export interface CreateToolTypeRequest {
display_name: string; display_name: string;
description?: string; description?: string;
category?: string; category?: string;
interfaces?: string[]; interface_type?: string;
requires_port?: boolean;
default_port: number; default_port: number;
definition_type?: 'compose' | 'dockerfile'; definition_type?: 'compose' | 'dockerfile';
compose_template?: string; compose_template?: string;
@@ -45,7 +46,8 @@ export interface UpdateToolTypeRequest {
display_name?: string; display_name?: string;
description?: string; description?: string;
category?: string; category?: string;
interfaces?: string[]; interface_type?: string;
requires_port?: boolean;
default_port?: number; default_port?: number;
definition_type?: 'compose' | 'dockerfile'; definition_type?: 'compose' | 'dockerfile';
compose_template?: string; compose_template?: string;
+42 -6
View File
@@ -1,16 +1,18 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, NavLink, Outlet } from "react-router-dom"; import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { getUserSessions } from "../api/sessions"; import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions"; import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme"; import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth"; import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions"; import { useSessions } from "../state/sessions";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Icon } from "./icon"; import { Icon } from "./icon";
import type { IconName } from "../utils/icons"; import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [ const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
{ to: "/", label: "Home", icon: "dashboard" }, { to: "/", label: "Home", icon: "dashboard" },
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
{ to: "/projects", label: "Projects", icon: "projects" }, { to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" }, { to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/settings", label: "Settings", icon: "settings" } { to: "/settings", label: "Settings", icon: "settings" }
@@ -38,6 +40,11 @@ export const AppShell = () => {
useTheme(); useTheme();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions(); const { sessions, setAllSessions } = useSessions();
const location = useLocation();
const isMobile = useMobileViewport();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
try { try {
@@ -57,6 +64,19 @@ export const AppShell = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadSessions]); }, [loadSessions]);
// Close mobile menu on route change
useEffect(() => {
setMobileMenuOpen(false);
}, [location.pathname]);
if (isMobileTerminal) {
return (
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
);
}
return ( return (
<div className="shell"> <div className="shell">
<header className="shell-header"> <header className="shell-header">
@@ -81,9 +101,18 @@ export const AppShell = () => {
</header> </header>
<div className="shell-body"> <div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation"> <aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
{isMobile && (
<button
className="mobile-menu-close"
onClick={() => setMobileMenuOpen(false)}
type="button"
aria-label="Close menu"
>
<Icon name="close" size="sm" />
</button>
)}
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length; const activeCount = sessions.filter((s) => s.status === "running").length;
return ( return (
<NavLink <NavLink
@@ -94,7 +123,7 @@ export const AppShell = () => {
> >
<Icon name={item.icon} size="sm" /> <Icon name={item.icon} size="sm" />
{item.label} {item.label}
{isHome && activeCount > 0 && ( {item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span> <span className="nav-badge">{activeCount}</span>
)} )}
</NavLink> </NavLink>
@@ -112,6 +141,13 @@ export const AppShell = () => {
)} )}
</aside> </aside>
{isMobile && mobileMenuOpen && (
<div
className="mobile-menu-overlay"
onClick={() => setMobileMenuOpen(false)}
/>
)}
<main className="shell-content"> <main className="shell-content">
<Outlet /> <Outlet />
</main> </main>
@@ -0,0 +1,475 @@
import { useState, useEffect } from "react";
import { Icon } from "./icon";
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
import type { Project } from "../types";
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
import type { ToolType } from "../api/tool_types";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
interface CreateSessionFormProps {
projects: Project[];
repositories: GitRepository[];
toolTypes: ToolType[];
fixedProjectId?: string;
fixedRepoId?: string;
projectName?: string;
repoName?: string;
showCloneMode?: boolean;
showFixedFields?: boolean;
onProjectChange?: (projectId: string) => void;
onSuccess?: (instance: ToolInstance) => void;
onCancel?: () => void;
submitLabel?: string;
className?: string;
}
export const CreateSessionForm = ({
projects,
repositories,
toolTypes,
fixedProjectId,
fixedRepoId,
projectName,
repoName,
showCloneMode = true,
showFixedFields = true,
onProjectChange,
onSuccess,
onCancel,
submitLabel = "Create Session",
className = "",
}: CreateSessionFormProps) => {
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
const [branch, setBranch] = useState("main");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [baseBranch, setBaseBranch] = useState("");
const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
const [progress, setProgress] = useState("");
const [error, setError] = useState<string | null>(null);
// Load SSH keys when clone mode is shown
useEffect(() => {
if (!showCloneMode) return;
const loadKeys = async () => {
try {
const keys = await listSSHKeys();
setSshKeys(keys);
} catch {
// ignore
}
};
void loadKeys();
}, [showCloneMode]);
// Load branches when selected repo changes
useEffect(() => {
const projectId = fixedProjectId || selectedProject;
if (!selectedRepo || !projectId || !showCloneMode) {
setBranches([]);
return;
}
const loadBranches = async () => {
setIsLoadingBranches(true);
try {
const response = await listRepositoryBranches(projectId, selectedRepo);
setBranches(response.branches);
if (response.default_branch) {
setBranch(response.default_branch);
setBaseBranch(response.default_branch);
}
} catch {
// ignore
} finally {
setIsLoadingBranches(false);
}
};
void loadBranches();
}, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]);
// Filter repositories by selected project
const availableRepos = selectedProject
? repositories.filter((r) => r.project_id === selectedProject)
: [];
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
const projectId = fixedProjectId || selectedProject;
const repoId = fixedRepoId || selectedRepo;
if (!projectId || !repoId || !selectedToolType) {
setError("Project, repository, and tool type are required");
return;
}
if (showCloneMode && cloneMode === "clone") {
const repo = repositories.find((r) => r.id === repoId);
if (!repo?.ssh_key_id) {
setError("Repository must have an SSH key assigned for clone mode");
return;
}
}
setStatus("creating");
setProgress("Creating instance...");
try {
const instance = await createInstance(
projectId,
repoId,
selectedToolType,
displayName || undefined,
showCloneMode ? cloneMode : undefined,
showCloneMode && cloneMode === "clone"
? isCreatingNewBranch
? baseBranch
: branch
: undefined,
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
? newBranchName
: undefined
);
setProgress("Starting container...");
await startInstance(projectId, repoId, instance.id);
// Reset form
if (!fixedProjectId) setSelectedProject("");
if (!fixedRepoId) setSelectedRepo("");
setSelectedToolType("");
setDisplayName("");
setCloneMode("mount");
setBranch("main");
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
setBranches([]);
setStatus("idle");
onSuccess?.(instance);
} catch {
setStatus("error");
setError("Failed to create session");
setProgress("");
}
};
const isSubmitting = status === "creating";
// Determine which steps are active/unlocked
const hasProject = !!(fixedProjectId || selectedProject);
const hasRepo = !!(fixedRepoId || selectedRepo);
const hasToolType = !!selectedToolType;
const renderStep = (
label: string,
number: number,
isActive: boolean,
isComplete: boolean,
children: React.ReactNode
) => {
const stepClass = `workflow-step ${isActive ? "active" : ""} ${isComplete ? "complete" : ""}`;
return (
<div className={stepClass}>
<div className="workflow-step-header">
<span className="workflow-step-number">{number}</span>
<span className="workflow-step-label">{label}</span>
</div>
<div className="workflow-step-content">
{children}
</div>
</div>
);
};
return (
<div className={`create-session-form-wrapper ${className}`}>
{isSubmitting && (
<div className="loading-overlay">
<div className="loading-content">
<Icon name="loading" size="lg" />
<p>{progress || "Creating session..."}</p>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="stack create-session-form workflow-form">
{/* Step 1: Project */}
{renderStep("Select Project", 1, true, hasProject,
fixedProjectId && showFixedFields ? (
<label className="form-field">
<input
type="text"
value={projectName || projects.find((p) => p.id === fixedProjectId)?.name || ""}
disabled
readOnly
/>
</label>
) : (
<label className="form-field">
<select
value={selectedProject}
onChange={(e) => {
const value = e.target.value;
setSelectedProject(value);
setSelectedRepo("");
setSelectedToolType("");
setCloneMode("mount");
setIsCreatingNewBranch(false);
onProjectChange?.(value);
}}
disabled={isSubmitting}
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
)
)}
{/* Step 2: Repository */}
{hasProject && renderStep("Select Repository", 2, true, hasRepo,
fixedRepoId && showFixedFields ? (
<label className="form-field">
<input
type="text"
value={repoName || repositories.find((r) => r.id === fixedRepoId)?.name || ""}
disabled
readOnly
/>
</label>
) : (
<label className="form-field">
<select
value={selectedRepo}
onChange={(e) => {
setSelectedRepo(e.target.value);
setSelectedToolType("");
setCloneMode("mount");
setIsCreatingNewBranch(false);
}}
disabled={!hasProject || isSubmitting}
>
<option value="">Select repository...</option>
{availableRepos.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
)
)}
{/* Step 3: Tool Type */}
{hasRepo && renderStep("Select Tool", 3, true, hasToolType,
<label className="form-field">
<select
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setCloneMode("mount");
setIsCreatingNewBranch(false);
}}
disabled={!hasRepo || isSubmitting}
>
<option value="">Select tool...</option>
{toolTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.display_name}
</option>
))}
</select>
</label>
)}
{/* Step 4: Clone Mode & Branch */}
{showCloneMode && hasToolType && renderStep("Repository Access", 4, true, false,
<div className="form-row">
<label className="form-field">
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="mount"
checked={cloneMode === "mount"}
onChange={(e) => {
setCloneMode(e.target.value as "mount" | "clone");
setIsCreatingNewBranch(false);
}}
disabled={isSubmitting}
/>
Mount (live sync)
</label>
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="clone"
checked={cloneMode === "clone"}
onChange={(e) => {
setCloneMode(e.target.value as "mount" | "clone");
setIsCreatingNewBranch(false);
}}
disabled={isSubmitting}
/>
Clone fresh copy
</label>
</div>
</label>
{cloneMode === "clone" && (
<>
<label className="form-field">
Branch
{isLoadingBranches ? (
<span className="muted">Loading branches...</span>
) : (
<select
value={isCreatingNewBranch ? "__new__" : branch}
onChange={(e) => {
const value = e.target.value;
if (value === "__new__") {
setIsCreatingNewBranch(true);
setNewBranchName("");
} else {
setIsCreatingNewBranch(false);
setBranch(value);
setBaseBranch(value);
}
}}
disabled={isSubmitting}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
<option value="__new__">Create new branch...</option>
</select>
)}
</label>
{isCreatingNewBranch && (
<>
<label className="form-field">
New Branch Name
<input
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="feature/my-new-branch"
required
disabled={isSubmitting}
/>
</label>
<label className="form-field">
Base Branch
<select
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
disabled={isSubmitting}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
</select>
</label>
</>
)}
{selectedRepo && (
<div className="form-field ssh-key-info">
{(() => {
const repo = repositories.find((r) => r.id === selectedRepo);
if (!repo) return null;
if (repo.ssh_key_id) {
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
return (
<span className="success-text">
SSH key: {key?.name || "Assigned"}
</span>
);
}
return (
<span className="warning-text">
No SSH key assigned to this repository. Clone mode requires an SSH key.
</span>
);
})()}
</div>
)}
</>
)}
</div>
)}
{/* Step 5: Display Name */}
{hasToolType && renderStep("Display Name (optional)", 5, true, !!displayName,
<label className="form-field">
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
disabled={isSubmitting}
/>
</label>
)}
{/* Error & Submit */}
{error && <p className="error-text">{error}</p>}
{hasToolType && (
<div className="form-actions">
{onCancel && (
<button
className="secondary-button"
type="button"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</button>
)}
<button
className="primary-button"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Icon name="loading" size="sm" />
Creating...
</>
) : (
<>
<Icon name="add" size="sm" />
{submitLabel}
</>
)}
</button>
</div>
)}
</form>
</div>
);
};
+9 -1
View File
@@ -18,6 +18,7 @@ interface GitToolbarProps {
currentBranch: string; currentBranch: string;
branches: string[]; branches: string[];
hasRemote: boolean; hasRemote: boolean;
isMirror: boolean;
onBranchChange: (branch: string) => void; onBranchChange: (branch: string) => void;
onRefresh: () => void; onRefresh: () => void;
} }
@@ -28,6 +29,7 @@ export const GitToolbar = ({
currentBranch, currentBranch,
branches, branches,
hasRemote, hasRemote,
isMirror,
onBranchChange, onBranchChange,
onRefresh, onRefresh,
}: GitToolbarProps) => { }: GitToolbarProps) => {
@@ -136,7 +138,13 @@ export const GitToolbar = ({
return ( return (
<div className="git-toolbar"> <div className="git-toolbar">
{error && <div className="toolbar-error">{error}</div>} {error && <div className="toolbar-error">{error}</div>}
{isMirror && (
<div className="warning-message">
<Icon name="warning" size="sm" /> This repository is a bare mirror.
Editing, committing, pulling, and merging are not available.
Delete and recreate it to enable full workspace features.
</div>
)}
<div className="toolbar-row"> <div className="toolbar-row">
<div className="toolbar-group"> <div className="toolbar-group">
<select <select
+19 -58
View File
@@ -4,7 +4,6 @@ import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions"; import type { ToolInstance } from "../api/sessions";
import { import {
checkInstanceHealth, checkInstanceHealth,
createInstance,
deleteInstance, deleteInstance,
listInstances, listInstances,
recreateInstanceTunnel, recreateInstanceTunnel,
@@ -13,22 +12,23 @@ import {
stopInstance, stopInstance,
} from "../api/sessions"; } from "../api/sessions";
import type { ToolType } from "../api/tool_types"; import type { ToolType } from "../api/tool_types";
import { CreateSessionForm } from "./create-session-form";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps { interface InstanceListProps {
projectId: string; projectId: string;
repoId: string; repoId: string;
projectName?: string;
repoName?: string;
toolTypes: ToolType[]; toolTypes: ToolType[];
} }
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => { export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTypes }: InstanceListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]); const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Stop confirmation // Stop confirmation
@@ -83,18 +83,9 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
return () => clearInterval(interval); return () => clearInterval(interval);
}, [instances, projectId, repoId]); }, [instances, projectId, repoId]);
const handleCreate = async () => { const handleCreateSuccess = async () => {
if (!selectedToolType) return; setShowCreate(false);
setError(null); await loadInstances();
try {
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
setShowCreate(false);
setSelectedToolType("");
setDisplayName("");
await loadInstances();
} catch {
setError("Failed to create instance");
}
}; };
const handleStart = async (instanceId: string) => { const handleStart = async (instanceId: string) => {
@@ -309,48 +300,18 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
<div className="dialog-overlay" role="dialog" aria-modal="true"> <div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog"> <div className="dialog">
<h2>Launch Tool</h2> <h2>Launch Tool</h2>
<div className="stack"> <CreateSessionForm
<label className="form-field"> projects={[]}
Tool Type repositories={[]}
<select toolTypes={toolTypes}
value={selectedToolType} fixedProjectId={projectId}
onChange={(e) => setSelectedToolType(e.target.value)} fixedRepoId={repoId}
> projectName={projectName}
<option value="">Select a tool...</option> repoName={repoName}
{toolTypes.map((tool) => ( onSuccess={handleCreateSuccess}
<option key={tool.id} value={tool.id}> onCancel={() => setShowCreate(false)}
{tool.display_name} submitLabel="Launch"
</option> />
))}
</select>
</label>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="dialog-actions">
<button
className="secondary-button"
onClick={() => setShowCreate(false)}
type="button"
>
Cancel
</button>
<button
className="primary-button"
onClick={() => void handleCreate()}
disabled={!selectedToolType}
type="button"
>
Launch
</button>
</div>
</div>
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,94 @@
import React from "react";
import { Icon } from "./icon";
interface MobileTerminalHeaderProps {
instanceName?: string;
onBack?: () => void;
onMenuToggle?: () => void;
onClose?: () => void;
onFontSizeChange?: (delta: number) => void;
isVisible: boolean;
connectionStatus?: "connecting" | "connected" | "disconnected" | "error";
}
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
instanceName,
onBack,
onMenuToggle,
onClose,
onFontSizeChange,
isVisible,
connectionStatus = "connecting",
}) => {
return (
<div
className={`mobile-terminal-header ${isVisible ? "visible" : "hidden"}`}
>
<div className="mobile-terminal-header-left">
{onBack && (
<button
className="mobile-terminal-header-button"
onClick={onBack}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="sm" />
</button>
)}
{onMenuToggle && (
<button
className="mobile-terminal-header-button"
onClick={onMenuToggle}
type="button"
aria-label="Toggle menu"
>
<Icon name="menu" size="sm" />
</button>
)}
</div>
<div className="mobile-terminal-header-center">
<span className="mobile-terminal-header-title">
{instanceName || "Terminal"}
</span>
<span
className={`mobile-terminal-header-status ${connectionStatus}`}
aria-label={`Connection status: ${connectionStatus}`}
/>
</div>
<div className="mobile-terminal-header-right">
{onFontSizeChange && (
<>
<button
className="mobile-terminal-header-button"
onClick={() => onFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button
className="mobile-terminal-header-button"
onClick={() => onFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
</>
)}
{onClose && (
<button
className="mobile-terminal-header-button"
onClick={onClose}
type="button"
aria-label="Close terminal"
>
<Icon name="close" size="sm" />
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,116 @@
import React, { useState, useCallback } from "react";
import { TerminalComponent } from "./terminal";
import { MobileTerminalHeader } from "./mobile-terminal-header";
import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useAutoHide } from "../hooks/use-auto-hide";
import type { ModifierKey } from "../hooks/use-special-keys";
interface MobileTerminalWrapperProps {
instanceId: string;
instanceName?: string;
onClose?: () => void;
onBack?: () => void;
onMenuToggle?: () => void;
}
export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
instanceId,
instanceName,
onClose,
onBack,
onMenuToggle,
}) => {
const isMobile = useMobileViewport();
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [showPanel, setShowPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected" | "error";
focusInput: () => void;
changeFontSize: (delta: number) => void;
} | null>(null);
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const handleTerminalTap = useCallback(() => {
headerAutoHide.toggle();
}, [headerAutoHide]);
const handleTerminalReady = useCallback(
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error", focusInput: () => void, changeFontSize: (delta: number) => void) => {
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
},
[]
);
const handleSendKey = useCallback(
(data: string) => {
terminalRef?.sendData(data);
},
[terminalRef]
);
if (!isMobile) {
return (
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={false}
/>
);
}
return (
<div className="mobile-terminal-wrapper">
<MobileTerminalHeader
instanceName={instanceName}
onBack={onBack}
onMenuToggle={onMenuToggle}
onClose={onClose}
onFontSizeChange={(delta) => terminalRef?.changeFontSize(delta)}
isVisible={headerAutoHide.isVisible}
connectionStatus={terminalRef?.connectionStatus}
/>
<div
className="mobile-terminal-content"
style={{
paddingBottom: isKeyboardOpen ? keyboardHeight : 0,
}}
onClick={handleTerminalTap}
>
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={true}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady}
/>
</div>
<SpecialKeysStrip
onSend={handleSendKey}
isVisible={!showPanel}
onMoreClick={() => setShowPanel(true)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
<SpecialKeysPanel
onSend={handleSendKey}
isOpen={showPanel}
onClose={() => setShowPanel(false)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
</div>
);
};
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories"; import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { Icon } from "./icon"; import { Icon } from "./icon";
type CreateMode = "clone" | "blank"; type CreateMode = "clone" | "blank";
@@ -20,12 +21,14 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
const [owner, setOwner] = useState(""); const [owner, setOwner] = useState("");
const [repoName, setRepoName] = useState(""); const [repoName, setRepoName] = useState("");
const [advancedUrl, setAdvancedUrl] = useState(""); const [advancedUrl, setAdvancedUrl] = useState("");
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false); const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [urlValidation, setUrlValidation] = useState<{ const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus; status: UrlValidationStatus;
result: URLParseResult | null; result: URLParseResult | null;
}>({ status: "idle", result: null }); }>({ status: "idle", result: null });
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@@ -35,6 +38,19 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
} }
}, [open]); }, [open]);
useEffect(() => {
if (!open) return;
const loadKeys = async () => {
try {
const data = await listSSHKeys();
setSshKeys(data);
} catch {
// ignore
}
};
void loadKeys();
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (!useAdvancedUrl) { if (!useAdvancedUrl) {
@@ -81,9 +97,10 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
setOwner(""); setOwner("");
setRepoName(""); setRepoName("");
setAdvancedUrl(""); setAdvancedUrl("");
setUseAdvancedUrl(false); setUseAdvancedUrl(true);
setFormError(null); setFormError(null);
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
setSelectedSshKey("");
}; };
const handleClose = () => { const handleClose = () => {
@@ -120,6 +137,9 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
} }
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`; input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
} }
if (selectedSshKey) {
input.ssh_key_id = selectedSshKey;
}
} }
await createRepository(projectId, input); await createRepository(projectId, input);
@@ -212,6 +232,20 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
placeholder="repo-name" placeholder="repo-name"
/> />
</label> </label>
<label className="form-field">
SSH Key
<select
value={selectedSshKey}
onChange={(event) => setSelectedSshKey(event.target.value)}
>
<option value="">Select SSH key (optional)...</option>
{sshKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</label>
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p> <p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
<button <button
type="button" type="button"
@@ -223,45 +257,61 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
</> </>
)} )}
{createMode === "clone" && useAdvancedUrl && ( {createMode === "clone" && useAdvancedUrl && (
<label className="form-field"> <>
Remote URL <label className="form-field">
<input Remote URL
type="text" <input
value={advancedUrl} type="text"
onChange={(event) => setAdvancedUrl(event.target.value)} value={advancedUrl}
placeholder="https://github.com/user/repo.git" onChange={(event) => setAdvancedUrl(event.target.value)}
className={getUrlInputClass()} placeholder="https://github.com/user/repo.git"
/> className={getUrlInputClass()}
{urlValidation.status === "validating" && ( />
<span className="validation-status validating">Validating...</span> {urlValidation.status === "validating" && (
)} <span className="validation-status validating">Validating...</span>
{urlValidation.status === "valid" && ( )}
<span className="validation-status valid"> {urlValidation.status === "valid" && (
<Icon name="success" size="sm" /> Valid git URL <span className="validation-status valid">
</span> <Icon name="success" size="sm" /> Valid git URL
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
</span> </span>
<div className="suggestion-actions"> )}
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span> {urlValidation.status === "needs-parsing" && urlValidation.result && (
<button <div className="url-suggestion">
type="button" <span className="validation-status warning">
className="secondary-button small" <Icon name="warning" size="sm" /> This looks like a browser URL
onClick={handleUseSuggestedUrl} </span>
> <div className="suggestion-actions">
Use Suggested <span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
</button> <button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div> </div>
</div> )}
)} {urlValidation.status === "invalid" && (
{urlValidation.status === "invalid" && ( <span className="validation-status invalid">
<span className="validation-status invalid"> <Icon name="error" size="sm" /> Invalid URL
<Icon name="error" size="sm" /> Invalid URL </span>
</span> )}
)} </label>
<label className="form-field">
SSH Key
<select
value={selectedSshKey}
onChange={(event) => setSelectedSshKey(event.target.value)}
>
<option value="">Select SSH key (optional)...</option>
{sshKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</label>
<button <button
type="button" type="button"
className="secondary-button small" className="secondary-button small"
@@ -269,7 +319,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
> >
Use owner/repo instead Use owner/repo instead
</button> </button>
</label> </>
)} )}
{formError && ( {formError && (
<div className="error-message"> <div className="error-message">
@@ -0,0 +1,114 @@
import React from "react";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
interface SpecialKeysPanelProps {
onSend: (data: string) => void;
isOpen: boolean;
onClose: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "home", label: "Home" },
{ key: "end", label: "End" },
{ key: "pageup", label: "PgUp" },
{ key: "pagedown", label: "PgDn" },
{ key: "ctrlc", label: "Ctrl+C" },
{ key: "ctrld", label: "Ctrl+D" },
{ key: "ctrlz", label: "Ctrl+Z" },
];
const F_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "f1", label: "F1" },
{ key: "f2", label: "F2" },
{ key: "f3", label: "F3" },
{ key: "f4", label: "F4" },
{ key: "f5", label: "F5" },
{ key: "f6", label: "F6" },
{ key: "f7", label: "F7" },
{ key: "f8", label: "F8" },
{ key: "f9", label: "F9" },
{ key: "f10", label: "F10" },
{ key: "f11", label: "F11" },
{ key: "f12", label: "F12" },
];
export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
onSend,
isOpen,
onClose,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
if (!isOpen) return null;
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
onClose();
// Always refocus terminal after sending
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
const handleOverlayPointerDown = (e: React.PointerEvent) => {
e.preventDefault();
onModifierChange(null);
onClose();
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
return (
<div
className="special-keys-panel-overlay"
onPointerDown={handleOverlayPointerDown}
>
<div
className="special-keys-panel"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="special-keys-panel-section">
{EXPANDED_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
>
{label}
</button>
))}
</div>
<div className="special-keys-panel-divider" />
<div className="special-keys-panel-section">
{F_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
>
{label}
</button>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,96 @@
import React from "react";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey, KEY_SEQUENCES } from "../hooks/use-special-keys";
interface SpecialKeysStripProps {
onSend: (data: string) => void;
isVisible: boolean;
onMoreClick?: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
{ key: "escape", label: "Esc" },
{ key: "tab", label: "Tab" },
{ key: "ctrl", label: "Ctrl", isModifier: true },
{ key: "alt", label: "Alt", isModifier: true },
{ key: "up", label: "↑" },
{ key: "down", label: "↓" },
{ key: "left", label: "←" },
{ key: "right", label: "→" },
];
export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
onSend,
isVisible,
onMoreClick,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
onModifierChange(activeModifier === key ? null : key);
requestAnimationFrame(() => {
onKeepFocus?.();
});
return;
}
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
// Always refocus terminal after sending
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
const handleMorePointerDown = (e: React.PointerEvent) => {
e.preventDefault();
onMoreClick?.();
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
return (
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
{PRIMARY_KEYS.map(({ key, label, isModifier }) => (
<button
key={key}
className={`special-key-button ${
isModifier && activeModifier === key ? "active-modifier" : ""
}`}
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
aria-label={`Send ${label}`}
aria-pressed={isModifier && activeModifier === key}
>
{label}
</button>
))}
{onMoreClick && (
<button
className="special-key-button special-key-more"
onPointerDown={handleMorePointerDown}
type="button"
tabIndex={-1}
aria-label="More special keys"
>
More
</button>
)}
</div>
);
};
+327 -78
View File
@@ -1,29 +1,135 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState, useCallback } from "react";
import { Terminal } from "xterm"; import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit"; import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links"; import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css"; import "xterm/css/xterm.css";
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
interface TerminalProps { interface TerminalProps {
instanceId: string; instanceId: string;
onClose?: () => void; onClose?: () => void;
isMobile?: boolean;
activeModifier?: ModifierKey | null;
onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: (
sendData: (data: string) => void,
connectionStatus: "connecting" | "connected" | "disconnected" | "error",
focusInput: () => void,
changeFontSize: (delta: number) => void
) => void;
} }
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => { const FONT_SIZE_KEY = "terminal-font-size";
const MIN_FONT_SIZE = 16;
const MAX_FONT_SIZE = 24;
const RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY_BASE = 1000;
export const TerminalComponent: React.FC<TerminalProps> = ({
instanceId,
onClose,
isMobile = false,
activeModifier,
onModifierChange,
onTerminalReady,
}) => {
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const hiddenInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">( const termRef = useRef<Terminal | null>(null);
"connecting", const fitAddonRef = useRef<FitAddon | null>(null);
); const reconnectAttemptsRef = useRef(0);
const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady;
const [status, setStatus] = useState<
"connecting" | "connected" | "disconnected" | "error"
>("connecting");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
if (typeof window === "undefined") return isMobile ? 16 : 14;
const stored = localStorage.getItem(FONT_SIZE_KEY);
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
});
const calculateFontSize = useCallback(() => {
if (!isMobile) return fontSize;
const vw = window.innerWidth;
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
return Math.round(calculated);
}, [isMobile, fontSize]);
const connectWebSocket = useCallback(() => {
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
};
ws.onmessage = (event) => {
if (!termRef.current) return;
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
termRef.current?.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
}
} catch {
termRef.current?.write(event.data);
}
}
};
ws.onclose = (event) => {
setStatus("disconnected");
if (event.code !== 1000) {
setError(`Connection closed (code: ${event.code})`);
// Attempt reconnection
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
reconnectAttemptsRef.current++;
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (document.visibilityState !== "hidden") {
connectWebSocket();
}
}, delay);
}
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
return ws;
}, [instanceId]);
useEffect(() => { useEffect(() => {
if (!terminalRef.current) return; if (!terminalRef.current) return;
// Initialize terminal // Initialize terminal
const currentFontSize = calculateFontSize();
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 14, fontSize: currentFontSize,
fontFamily: 'Menlo, Monaco, "Courier New", monospace', fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: { theme: {
background: "#1e1e1e", background: "#1e1e1e",
@@ -49,78 +155,54 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
}, },
}); });
termRef.current = term;
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon()); term.loadAddon(new WebLinksAddon());
term.open(terminalRef.current); term.open(terminalRef.current);
fitAddon.fit(); fitAddon.fit();
// Build WebSocket URL
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
// Connect WebSocket // Connect WebSocket
const ws = new WebSocket(wsUrl); const ws = connectWebSocket();
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
};
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
term.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
}
} catch {
term.write(event.data);
}
}
};
ws.onclose = (event) => {
setStatus("disconnected");
if (event.code !== 1000) {
setError(`Connection closed (code: ${event.code})`);
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
// Handle terminal input // Handle terminal input
term.onData((data) => { term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) return;
ws.send(data);
// Apply active modifier to single-character input
const modifier = activeModifierRef.current;
if (modifier && data.length === 1) {
const modified = applyModifierToChar(data, modifier);
if (modified) {
ws.send(modified);
onModifierChange?.(null);
return;
}
} }
ws.send(data);
}); });
// Handle resize // Handle resize with debounce
let resizeTimeout: ReturnType<typeof setTimeout>;
const handleResize = () => { const handleResize = () => {
fitAddon.fit(); clearTimeout(resizeTimeout);
const { cols, rows } = term; resizeTimeout = setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) { fitAddon.fit();
ws.send( const { cols, rows } = term;
JSON.stringify({ if (ws.readyState === WebSocket.OPEN) {
type: "resize", ws.send(
cols, JSON.stringify({
rows, type: "resize",
}), cols,
); rows,
} })
);
}
}, 250);
}; };
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
@@ -128,31 +210,198 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
// Initial resize // Initial resize
setTimeout(handleResize, 100); setTimeout(handleResize, 100);
// Notify parent about terminal readiness
if (onTerminalReadyRef.current) {
const sendData = (data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChange(delta);
};
onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize);
}
// Visibility API for reconnection
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) {
reconnectAttemptsRef.current = 0;
connectWebSocket();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => { return () => {
clearTimeout(resizeTimeout);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
document.removeEventListener("visibilitychange", handleVisibilityChange);
ws.close(); ws.close();
term.dispose(); term.dispose();
}; };
}, [instanceId]); }, [instanceId, connectWebSocket, calculateFontSize]);
// Update parent about status changes
useEffect(() => {
if (onTerminalReady && termRef.current) {
const sendData = (data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChange(delta);
};
onTerminalReady(sendData, status, focusInput, changeFontSize);
}
}, [status, onTerminalReady]);
const handleFontSizeChange = (delta: number) => {
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
setFontSize(newSize);
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
if (termRef.current) {
termRef.current.options.fontSize = newSize;
fitAddonRef.current?.fit();
}
};
const handleCopy = async () => {
if (!termRef.current) return;
const selection = termRef.current.getSelection();
if (selection) {
try {
await navigator.clipboard.writeText(selection);
} catch {
// Fallback for older browsers
const textarea = document.createElement("textarea");
textarea.value = selection;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(text);
}
} catch {
// Clipboard API not available
}
};
// Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => {
if (isMobile && termRef.current) {
termRef.current.focus();
}
};
return ( return (
<div className="terminal-wrapper"> <div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
<div className="terminal-header"> <div className="terminal-header">
<div className="terminal-status"> <div className="terminal-header-left">
<span <div className="terminal-status">
className={`status-dot ${status}`} <span
aria-label={`Terminal status: ${status}`} className={`status-dot ${status}`}
/> aria-label={`Terminal status: ${status}`}
<span className="status-text">{status}</span> />
<span className="status-text">
{reconnectAttemptsRef.current > 0 && status !== "connected"
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
: status}
</span>
</div>
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={handleCopy}
type="button"
aria-label="Copy selection"
>
Copy
</button>
<button
className="terminal-header-button"
onClick={handlePaste}
type="button"
aria-label="Paste from clipboard"
>
Paste
</button>
</>
)}
</div>
<div className="terminal-header-right">
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
</>
)}
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div> </div>
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div> </div>
{error && <div className="terminal-error">{error}</div>} {error && (
<div ref={terminalRef} className="terminal-container" /> <div className="terminal-error">
{error}
{status === "error" && (
<button
className="terminal-reconnect"
onClick={() => {
reconnectAttemptsRef.current = 0;
connectWebSocket();
}}
type="button"
>
Reconnect
</button>
)}
</div>
)}
<div
ref={terminalRef}
className="terminal-container"
onClick={handleTerminalClick}
/>
{isMobile && (
<input
ref={hiddenInputRef}
type="text"
className="terminal-hidden-input"
aria-hidden="true"
/>
)}
</div> </div>
); );
}; };
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback, useRef } from "react";
interface AutoHideOptions {
timeout?: number;
enabled?: boolean;
}
export function useAutoHide(options: AutoHideOptions = {}) {
const { timeout = 3000, enabled = true } = options;
const [isVisible, setIsVisible] = useState(true);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastInteractionRef = useRef(Date.now());
const show = useCallback(() => {
if (!enabled) return;
setIsVisible(true);
lastInteractionRef.current = Date.now();
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setIsVisible(false);
}, timeout);
}, [enabled, timeout]);
const hide = useCallback(() => {
if (!enabled) return;
setIsVisible(false);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, [enabled]);
const toggle = useCallback(() => {
if (!enabled) return;
if (isVisible) {
hide();
} else {
show();
}
}, [enabled, isVisible, show, hide]);
useEffect(() => {
if (!enabled) {
setIsVisible(true);
return;
}
// Start the timer initially
show();
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [enabled, show]);
return {
isVisible,
show,
hide,
toggle,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { useState, useEffect } from "react";
const MOBILE_BREAKPOINT = 768;
export function useMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") return false;
return window.innerWidth < MOBILE_BREAKPOINT;
});
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return isMobile;
}
+140
View File
@@ -0,0 +1,140 @@
export type SpecialKey =
| "escape"
| "tab"
| "ctrl"
| "alt"
| "up"
| "down"
| "left"
| "right"
| "home"
| "end"
| "pageup"
| "pagedown"
| "ctrlc"
| "ctrld"
| "ctrlz"
| "f1"
| "f2"
| "f3"
| "f4"
| "f5"
| "f6"
| "f7"
| "f8"
| "f9"
| "f10"
| "f11"
| "f12";
export type ModifierKey = "ctrl" | "alt";
const KEY_SEQUENCES: Record<SpecialKey, string> = {
escape: "\x1B",
tab: "\t",
ctrl: "",
alt: "",
up: "\x1B[A",
down: "\x1B[B",
right: "\x1B[C",
left: "\x1B[D",
home: "\x1B[H",
end: "\x1B[F",
pageup: "\x1B[5~",
pagedown: "\x1B[6~",
ctrlc: "\x03",
ctrld: "\x04",
ctrlz: "\x1A",
f1: "\x1BOP",
f2: "\x1BOQ",
f3: "\x1BOR",
f4: "\x1BOS",
f5: "\x1B[15~",
f6: "\x1B[17~",
f7: "\x1B[18~",
f8: "\x1B[19~",
f9: "\x1B[20~",
f10: "\x1B[21~",
f11: "\x1B[23~",
f12: "\x1B[24~",
};
// Single character sequences with modifier prefixes
const MODIFIER_PREFIXES: Record<string, { ctrl: string; alt: string; ctrlAlt: string }> = {
// Letters
a: { ctrl: "\x01", alt: "\x1Ba", ctrlAlt: "\x1B\x01" },
b: { ctrl: "\x02", alt: "\x1Bb", ctrlAlt: "\x1B\x02" },
c: { ctrl: "\x03", alt: "\x1Bc", ctrlAlt: "\x1B\x03" },
d: { ctrl: "\x04", alt: "\x1Bd", ctrlAlt: "\x1B\x04" },
e: { ctrl: "\x05", alt: "\x1Be", ctrlAlt: "\x1B\x05" },
f: { ctrl: "\x06", alt: "\x1Bf", ctrlAlt: "\x1B\x06" },
g: { ctrl: "\x07", alt: "\x1Bg", ctrlAlt: "\x1B\x07" },
h: { ctrl: "\x08", alt: "\x1Bh", ctrlAlt: "\x1B\x08" },
i: { ctrl: "\x09", alt: "\x1Bi", ctrlAlt: "\x1B\x09" },
j: { ctrl: "\x0A", alt: "\x1Bj", ctrlAlt: "\x1B\x0A" },
k: { ctrl: "\x0B", alt: "\x1Bk", ctrlAlt: "\x1B\x0B" },
l: { ctrl: "\x0C", alt: "\x1Bl", ctrlAlt: "\x1B\x0C" },
m: { ctrl: "\x0D", alt: "\x1Bm", ctrlAlt: "\x1B\x0D" },
n: { ctrl: "\x0E", alt: "\x1Bn", ctrlAlt: "\x1B\x0E" },
o: { ctrl: "\x0F", alt: "\x1Bo", ctrlAlt: "\x1B\x0F" },
p: { ctrl: "\x10", alt: "\x1Bp", ctrlAlt: "\x1B\x10" },
q: { ctrl: "\x11", alt: "\x1Bq", ctrlAlt: "\x1B\x11" },
r: { ctrl: "\x12", alt: "\x1Br", ctrlAlt: "\x1B\x12" },
s: { ctrl: "\x13", alt: "\x1Bs", ctrlAlt: "\x1B\x13" },
t: { ctrl: "\x14", alt: "\x1Bt", ctrlAlt: "\x1B\x14" },
u: { ctrl: "\x15", alt: "\x1Bu", ctrlAlt: "\x1B\x15" },
v: { ctrl: "\x16", alt: "\x1Bv", ctrlAlt: "\x1B\x16" },
w: { ctrl: "\x17", alt: "\x1Bw", ctrlAlt: "\x1B\x17" },
x: { ctrl: "\x18", alt: "\x1Bx", ctrlAlt: "\x1B\x18" },
y: { ctrl: "\x19", alt: "\x1By", ctrlAlt: "\x1B\x19" },
z: { ctrl: "\x1A", alt: "\x1Bz", ctrlAlt: "\x1B\x1A" },
// Numbers
"0": { ctrl: "0", alt: "\x1B0", ctrlAlt: "\x1B0" },
"1": { ctrl: "1", alt: "\x1B1", ctrlAlt: "\x1B1" },
"2": { ctrl: "\x00", alt: "\x1B2", ctrlAlt: "\x1B\x00" },
"3": { ctrl: "\x1B", alt: "\x1B3", ctrlAlt: "\x1B\x1B" },
"4": { ctrl: "\x1C", alt: "\x1B4", ctrlAlt: "\x1B\x1C" },
"5": { ctrl: "\x1D", alt: "\x1B5", ctrlAlt: "\x1B\x1D" },
"6": { ctrl: "\x1E", alt: "\x1B6", ctrlAlt: "\x1B\x1E" },
"7": { ctrl: "\x1F", alt: "\x1B7", ctrlAlt: "\x1B\x1F" },
"8": { ctrl: "\x7F", alt: "\x1B8", ctrlAlt: "\x1B\x7F" },
"9": { ctrl: "9", alt: "\x1B9", ctrlAlt: "\x1B9" },
};
export function getSequenceWithModifier(
key: SpecialKey,
activeModifier: ModifierKey | null
): { sequence: string; clearModifier: boolean } | null {
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
return null; // Modifiers don't send anything themselves
}
const sequence = KEY_SEQUENCES[key];
if (!sequence) return null;
// Check if we have an active modifier and the key is a single character
if (activeModifier && sequence.length === 1) {
const char = sequence;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (mapping) {
return { sequence: mapping[activeModifier], clearModifier: true };
}
}
return { sequence, clearModifier: !!activeModifier };
}
export function applyModifierToChar(
char: string,
modifier: ModifierKey
): string | null {
if (char.length !== 1) return null;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (!mapping) return null;
return mapping[modifier];
}
export { KEY_SEQUENCES };
@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback } from "react";
interface VirtualKeyboardState {
isOpen: boolean;
height: number;
viewportHeight: number;
}
export function useVirtualKeyboard() {
const [state, setState] = useState<VirtualKeyboardState>({
isOpen: false,
height: 0,
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
});
const updateKeyboardState = useCallback(() => {
const visualViewport = window.visualViewport;
const windowHeight = window.innerHeight;
if (visualViewport) {
const viewportHeight = visualViewport.height;
const keyboardHeight = windowHeight - viewportHeight;
const isOpen = keyboardHeight > 100; // Threshold to avoid false positives
setState({
isOpen,
height: keyboardHeight,
viewportHeight,
});
} else {
// Fallback: compare window height to a stored reference
// This is less reliable but works on older browsers
const currentHeight = windowHeight;
const isOpen = currentHeight < state.viewportHeight - 100;
setState((prev) => ({
isOpen,
height: isOpen ? prev.viewportHeight - currentHeight : 0,
viewportHeight: isOpen ? prev.viewportHeight : currentHeight,
}));
}
}, [state.viewportHeight]);
useEffect(() => {
const visualViewport = window.visualViewport;
if (visualViewport) {
visualViewport.addEventListener("resize", updateKeyboardState);
visualViewport.addEventListener("scroll", updateKeyboardState);
} else {
window.addEventListener("resize", updateKeyboardState);
}
// Initial check
updateKeyboardState();
return () => {
if (visualViewport) {
visualViewport.removeEventListener("resize", updateKeyboardState);
visualViewport.removeEventListener("scroll", updateKeyboardState);
} else {
window.removeEventListener("resize", updateKeyboardState);
}
};
}, [updateKeyboardState]);
return state;
}
+130 -88
View File
@@ -2,13 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard"; import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions"; import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
import { listProjects } from "../api/projects"; import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories"; import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types"; import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings"; import { updateUserConfig } from "../api/settings";
import type { Project } from "../types"; import type { Project } from "../types";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
type HomeStatus = "loading" | "ready" | "error"; type HomeStatus = "loading" | "ready" | "error";
@@ -29,11 +30,10 @@ export const HomePage = () => {
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState(""); const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
const [actionBusy, setActionBusy] = useState<string | null>(null); const [actionBusy, setActionBusy] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const safeSessions = Array.isArray(sessions) ? sessions : []; const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadHome = useCallback(async () => { const loadHome = useCallback(async () => {
@@ -59,6 +59,49 @@ export const HomePage = () => {
void loadHome(); void loadHome();
}, [loadHome]); }, [loadHome]);
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
const checkHealth = async () => {
const runningSessions = safeSessions.filter(
(s) => s.status === "running" && s.url
);
for (const session of runningSessions) {
try {
const health = await checkInstanceHealth(
session.project_id,
session.repository_id,
session.id
);
setTunnelHealth((prev) => ({
...prev,
[session.id]: health,
}));
} catch {
setTunnelHealth((prev) => ({
...prev,
[session.id]: {
healthy: false,
container_status: "unknown",
container_health: null,
container_exit_code: null,
tunnel_status: "error",
tunnel_status_code: null,
probe_status: "error",
last_probe_output: null,
error: "check failed",
},
}));
}
}
};
void checkHealth();
const interval = setInterval(() => {
void checkHealth();
}, 30000);
return () => clearInterval(interval);
}, [safeSessions]);
useEffect(() => { useEffect(() => {
if (!selectedProject) { if (!selectedProject) {
setRepositories([]); setRepositories([]);
@@ -87,24 +130,10 @@ export const HomePage = () => {
[safeSessions] [safeSessions]
); );
const handleCreate = async (event: React.FormEvent) => { const handleCreateSuccess = async (instance: { id: string }) => {
event.preventDefault(); await updateUserConfig({ last_session_id: instance.id });
if (!selectedProject || !selectedRepo || !selectedToolType) return; setSelectedProject("");
await loadHome();
setSaveState("saving");
try {
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setDisplayName("");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setSaveState("idle");
await loadHome();
} catch {
setSaveState("error");
}
}; };
const handleOpen = (session: SessionView) => { const handleOpen = (session: SessionView) => {
@@ -120,7 +149,12 @@ export const HomePage = () => {
}; };
const handleStop = async (session: SessionView) => { const handleStop = async (session: SessionView) => {
if (stopConfirmId !== session.id) {
setStopConfirmId(session.id);
return;
}
setActionBusy(session.id); setActionBusy(session.id);
setStopConfirmId(null);
try { try {
await stopInstance(session.project_id, session.repository_id, session.id); await stopInstance(session.project_id, session.repository_id, session.id);
await loadHome(); await loadHome();
@@ -130,10 +164,17 @@ export const HomePage = () => {
}; };
const handleDelete = async (session: SessionView) => { const handleDelete = async (session: SessionView) => {
if (deleteConfirmId !== session.id) {
setDeleteConfirmId(session.id);
return;
}
setActionBusy(session.id); setActionBusy(session.id);
setDeleteConfirmId(null);
try { try {
await deleteInstance(session.project_id, session.repository_id, session.id); await deleteInstance(session.project_id, session.repository_id, session.id);
await loadHome(); setSessions((prev) => prev.filter((s) => s.id !== session.id));
} catch {
// error - session remains in state
} finally { } finally {
setActionBusy(null); setActionBusy(null);
} }
@@ -203,36 +244,65 @@ export const HomePage = () => {
<p className="muted">No active sessions right now.</p> <p className="muted">No active sessions right now.</p>
) : ( ) : (
<div className="home-session-grid"> <div className="home-session-grid">
{activeSessions.map((session) => ( {activeSessions.map((session) => {
<article className="card session-card" key={session.id}> const health = tunnelHealth[session.id];
<div className="stack-sm"> const isUnhealthy = health && !health.healthy;
<div className="row row-tight"> return (
<h3>{session.display_name}</h3> <article className="card session-card" key={session.id}>
<span className={`status-badge ${session.status}`}>{session.status}</span> <div className="stack-sm">
<div className="row row-tight">
<h3>{session.display_name}</h3>
<div className="row row-tight">
{isUnhealthy && (
<span className="status-badge error" title={health.error || "unhealthy"}>!</span>
)}
<span className={`status-badge ${session.status}`}>{session.status}</span>
</div>
</div>
<p className="muted">{session.project_name} · {session.repository_name}</p>
<p className="muted">{session.tool_type_name}</p>
</div> </div>
<p className="muted">{session.project_name} · {session.repository_name}</p> <div className="session-actions">
<p className="muted">{session.tool_type_name}</p> <button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
</div> <Icon name="external" size="sm" />
<div className="session-actions"> Open
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}> </button>
<Icon name="external" size="sm" /> <button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
Open <Icon name="refresh" size="sm" />
</button> Tunnel
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}> </button>
<Icon name="refresh" size="sm" /> {stopConfirmId === session.id ? (
Tunnel <div className="stop-confirm-inline">
</button> <span className="confirm-text">Stop?</span>
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}> <button className="ghost-button small danger-text" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" /> <Icon name="stop" size="sm" /> Stop
Stop </button>
</button> <button className="ghost-button small" type="button" onClick={() => setStopConfirmId(null)}>Cancel</button>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}> </div>
<Icon name="delete" size="sm" /> ) : (
Delete <button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
</button> <Icon name="stop" size="sm" />
</div> Stop
</article> </button>
))} )}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<span className="confirm-text">Delete?</span>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" /> Delete
</button>
<button className="ghost-button small" type="button" onClick={() => setDeleteConfirmId(null)}>Cancel</button>
</div>
) : (
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" />
Delete
</button>
)}
</div>
</article>
);
})}
</div> </div>
)} )}
</section> </section>
@@ -271,41 +341,13 @@ export const HomePage = () => {
<h2>Start a session</h2> <h2>Start a session</h2>
</div> </div>
</div> </div>
<form className="stack create-session-form" onSubmit={handleCreate}> <CreateSessionForm
<div className="form-row"> projects={projects}
<label className="form-field"> repositories={repositories}
Project toolTypes={toolTypes}
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}> onProjectChange={(projectId) => setSelectedProject(projectId)}
<option value="">Select project...</option> onSuccess={handleCreateSuccess}
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)} />
</select>
</label>
<label className="form-field">
Repository
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
<option value="">Select repository...</option>
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
</select>
</label>
<label className="form-field">
Tool type
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
<option value="">Select tool...</option>
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
</select>
</label>
</div>
<label className="form-field">
Display name
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
</label>
<div className="form-actions">
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
</button>
{saveState === "error" && <span className="error-text">Failed to create session</span>}
</div>
</form>
</section> </section>
{recentSessions.length > 0 && ( {recentSessions.length > 0 && (
+3
View File
@@ -197,6 +197,7 @@ export const RepoWorkspace = () => {
currentBranch={currentBranch} currentBranch={currentBranch}
branches={branches} branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)} hasRemote={Boolean(selectedRepo?.remote_url)}
isMirror={Boolean(selectedRepo?.is_mirror)}
onBranchChange={(branch) => { onBranchChange={(branch) => {
setCurrentBranch(branch); setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
@@ -253,6 +254,8 @@ export const RepoWorkspace = () => {
<InstanceList <InstanceList
projectId={projectId!} projectId={projectId!}
repoId={selectedRepoId} repoId={selectedRepoId}
projectName={project?.name}
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
toolTypes={toolTypes} toolTypes={toolTypes}
/> />
)} )}
+128 -141
View File
@@ -9,17 +9,15 @@ import {
type Session, type Session,
deleteInstance, deleteInstance,
stopInstance, stopInstance,
startInstance,
checkInstanceHealth, checkInstanceHealth,
recreateInstanceTunnel, recreateInstanceTunnel,
} from "../api/sessions"; } from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types"; import { listToolTypes, type ToolType } from "../api/tool_types";
import { createInstance } from "../api/sessions";
import { getUserConfig, updateUserConfig } from "../api/settings"; import { getUserConfig, updateUserConfig } from "../api/settings";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
type SessionsStatus = "loading" | "ready" | "error"; type SessionsStatus = "loading" | "ready" | "error";
type CreateStatus = "idle" | "creating" | "error";
export const SessionsPage = () => { export const SessionsPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,13 +28,10 @@ export const SessionsPage = () => {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState<string>(""); const [selectedProject, setSelectedProject] = useState<string>("");
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [selectedToolType, setSelectedToolType] = useState<string>(""); const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
const [displayName, setDisplayName] = useState(""); const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
const [createError, setCreateError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null); const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
@@ -52,6 +47,8 @@ export const SessionsPage = () => {
}>>({}); }>>({});
const [recreatingId, setRecreatingId] = useState<string | null>(null); const [recreatingId, setRecreatingId] = useState<string | null>(null);
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null); const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [loadingAction, setLoadingAction] = useState<string>("");
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
setStatus("loading"); setStatus("loading");
@@ -96,11 +93,14 @@ export const SessionsPage = () => {
void loadToolTypes(); void loadToolTypes();
}, []); }, []);
// Poll health every 30 seconds for active instances
// Poll health every 30 seconds for active web-enabled instances
useEffect(() => { useEffect(() => {
const checkHealth = async () => { const checkHealth = async () => {
const activeSessions = sessions.filter( const activeSessions = sessions.filter(
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status) (s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
&& s.tool_type_interfaces?.includes("web")
); );
for (const session of activeSessions) { for (const session of activeSessions) {
try { try {
@@ -168,63 +168,58 @@ export const SessionsPage = () => {
[sessions, lastSessionId] [sessions, lastSessionId]
); );
const handleCreate = async (e: React.FormEvent) => { const handleCreateSuccess = async (instance: { id: string }) => {
e.preventDefault(); await updateUserConfig({ last_session_id: instance.id });
setCreateError(null); setSelectedProject("");
await loadSessions();
if (!selectedProject || !selectedRepo || !selectedToolType) {
setCreateError("Project, repository, and tool type are required");
return;
}
setCreateStatus("creating");
try {
const instance = await createInstance(
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined
);
// Auto-start the instance
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setCreateStatus("idle");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setDisplayName("");
await loadSessions();
} catch {
setCreateStatus("error");
setCreateError("Failed to create session");
}
}; };
const handleStop = async (sessionId: string, projectId: string, repoId: string) => { const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
setLoadingSessionId(sessionId);
setLoadingAction("Stopping...");
try { try {
await stopInstance(projectId, repoId, sessionId); await stopInstance(projectId, repoId, sessionId);
setStopConfirmId(null); setStopConfirmId(null);
await loadSessions(); await loadSessions();
} catch { } catch {
setStopConfirmId(null); setStopConfirmId(null);
} finally {
setLoadingSessionId(null);
setLoadingAction("");
} }
}; };
const handleDelete = async (sessionId: string, projectId: string, repoId: string) => { const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => {
setLoadingSessionId(sessionId);
setLoadingAction("Deleting...");
try { try {
await deleteInstance(projectId, repoId, sessionId); await deleteInstance(projectId, repoId, sessionId, force);
setDeleteConfirmId(null); setDeleteConfirmId(null);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
// Remove from local state immediately // Remove from local state immediately
setSessions((prev) => prev.filter((s) => s.id !== sessionId)); setSessions((prev) => prev.filter((s) => s.id !== sessionId));
} catch { } catch (error) {
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
if (axiosError.response?.status === 409) {
const detail = axiosError.response.data?.detail;
if (detail?.changed_files) {
setDirtyDeleteSession(sessions.find((s) => s.id === sessionId) ?? null);
setDirtyDeleteFiles(detail.changed_files);
setDeleteConfirmId(null);
return;
}
}
setDeleteConfirmId(null); setDeleteConfirmId(null);
} finally {
setLoadingSessionId(null);
setLoadingAction("");
} }
}; };
const handleRecreateTunnel = async (session: Session) => { const handleRecreateTunnel = async (session: Session) => {
setRecreatingId(session.id); setLoadingSessionId(session.id);
setLoadingAction("Recreating tunnel...");
try { try {
await recreateInstanceTunnel( await recreateInstanceTunnel(
session.project_id, session.project_id,
@@ -236,7 +231,8 @@ export const SessionsPage = () => {
} catch { } catch {
// ignore // ignore
} finally { } finally {
setRecreatingId(null); setLoadingSessionId(null);
setLoadingAction("");
} }
}; };
@@ -321,7 +317,15 @@ export const SessionsPage = () => {
)} )}
{/* Active Sessions */} {/* Active Sessions */}
<div className="active-sessions-section"> <div className={`active-sessions-section ${loadingSessionId ? "dimmed" : ""}`}>
{loadingSessionId && (
<div className="loading-overlay">
<div className="loading-content">
<Icon name="loading" size="lg" />
<p>{loadingAction}</p>
</div>
</div>
)}
<h2> <h2>
Active Sessions Active Sessions
{activeSessions.length > 0 && ( {activeSessions.length > 0 && (
@@ -332,13 +336,25 @@ export const SessionsPage = () => {
<p className="muted">No active sessions</p> <p className="muted">No active sessions</p>
) : ( ) : (
<div className="sessions-grid"> <div className="sessions-grid">
{activeSessions.map((session) => ( {activeSessions.map((session) => {
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
return (
<div className="card session-card" key={session.id}> <div className="card session-card" key={session.id}>
<div className="session-info"> <div className="session-info">
<h4>{session.display_name}</h4> <h4>{session.display_name}</h4>
<p className="muted"> <p className="muted">
{session.tool_type_name} · {session.project_name} {session.tool_type_name} · {session.project_name}
</p> </p>
{session.created_at && (
<p className="session-meta">
Started: {new Date(session.created_at).toLocaleString()}
</p>
)}
{session.clone_mode && (
<p className="session-meta">
Repo: {session.clone_mode === "clone" ? `clone (${session.branch || "main"})` : "mount"}
</p>
)}
{session.url && ( {session.url && (
<p className="session-url"> <p className="session-url">
<a href={session.url} target="_blank" rel="noopener noreferrer"> <a href={session.url} target="_blank" rel="noopener noreferrer">
@@ -353,23 +369,24 @@ export const SessionsPage = () => {
{session.status === "probing" && ( {session.status === "probing" && (
<span className="status-badge probing">checking...</span> <span className="status-badge probing">checking...</span>
)} )}
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && ( {!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
<span className="status-badge error">tunnel error</span> <span className="status-badge error">tunnel error</span>
)} )}
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && ( {!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span> <span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
)} )}
{tunnelHealth[session.id]?.last_probe_output && ( {!isTerminalOnly && tunnelHealth[session.id]?.probe_status && tunnelHealth[session.id]?.probe_status !== "not_applicable" && (
<div className="probe-output-section"> <div className="probe-output-section">
<button <button
className="probe-toggle" className={`probe-toggle probe-${tunnelHealth[session.id].probe_status}`}
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)} onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
type="button" type="button"
> >
<Icon name="info" size="sm" /> <Icon name="info" size="sm" />
{expandedProbeId === session.id ? "Hide probe output" : "Show probe output"} Probe: {tunnelHealth[session.id].probe_status}
{expandedProbeId === session.id ? " (hide)" : " (show)"}
</button> </button>
{expandedProbeId === session.id && ( {expandedProbeId === session.id && tunnelHealth[session.id]?.last_probe_output && (
<pre className="probe-output"> <pre className="probe-output">
{tunnelHealth[session.id].last_probe_output} {tunnelHealth[session.id].last_probe_output}
</pre> </pre>
@@ -398,7 +415,7 @@ export const SessionsPage = () => {
Open Open
</button> </button>
)} )}
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && ( {!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
<button <button
className="secondary-button small" className="secondary-button small"
onClick={() => void handleRecreateTunnel(session)} onClick={() => void handleRecreateTunnel(session)}
@@ -477,7 +494,7 @@ export const SessionsPage = () => {
)} )}
</div> </div>
</div> </div>
))} )})}
</div> </div>
)} )}
</div> </div>
@@ -556,91 +573,61 @@ export const SessionsPage = () => {
{/* Create Session */} {/* Create Session */}
<div className="create-session-section"> <div className="create-session-section">
<h2>Create New Session</h2> <h2>Create New Session</h2>
<form onSubmit={handleCreate} className="card stack create-session-form"> <CreateSessionForm
<div className="form-row"> projects={projects}
<label className="form-field"> repositories={repositories}
Project toolTypes={toolTypes}
<select onProjectChange={(projectId) => {
value={selectedProject} setSelectedProject(projectId);
onChange={(e) => { }}
setSelectedProject(e.target.value); onSuccess={handleCreateSuccess}
setSelectedRepo(""); />
}}
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="form-field">
Repository
<select
value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)}
disabled={!selectedProject}
>
<option value="">Select repository...</option>
{repositories.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select tool...</option>
{toolTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.display_name}
</option>
))}
</select>
</label>
</div>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
{createError && <p className="error-text">{createError}</p>}
<div className="form-actions">
<button
className="primary-button"
type="submit"
disabled={createStatus === "creating"}
>
{createStatus === "creating" ? (
<>
<Icon name="loading" size="sm" />
Creating...
</>
) : (
<>
<Icon name="add" size="sm" />
Create Session
</>
)}
</button>
</div>
</form>
</div> </div>
{/* Dirty Delete Confirmation Modal */}
{dirtyDeleteSession && (
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Uncommitted Changes</h3>
<p>
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
uncommitted changes. Deleting this session will permanently lose these
changes.
</p>
<div className="changed-files-list">
<h4>Changed files:</h4>
<ul>
{dirtyDeleteFiles.map((file, idx) => (
<li key={idx}>{file}</li>
))}
</ul>
</div>
<div className="modal-actions">
<button
className="secondary-button"
onClick={() => setDirtyDeleteSession(null)}
type="button"
>
Cancel
</button>
<button
className="danger-button"
onClick={() =>
void handleDelete(
dirtyDeleteSession.id,
dirtyDeleteSession.project_id,
dirtyDeleteSession.repository_id,
true
)
}
type="button"
>
Force Delete
</button>
</div>
</div>
</div>
)}
</> </>
)} )}
</section> </section>
+1 -3
View File
@@ -9,8 +9,6 @@ type SettingsStatus = "loading" | "ready" | "error";
const TABS = [ const TABS = [
{ label: "General", path: "general" }, { label: "General", path: "general" },
{ label: "SSH Keys", path: "ssh-keys" }, { label: "SSH Keys", path: "ssh-keys" },
{ label: "Tool Types", path: "tool-types" },
{ label: "Tool Configs", path: "tool-configs" },
] as const; ] as const;
const THEME_OPTIONS = [ const THEME_OPTIONS = [
@@ -106,7 +104,7 @@ export const SettingsPage = () => {
<p className="eyebrow">Configuration</p> <p className="eyebrow">Configuration</p>
<h1>Settings</h1> <h1>Settings</h1>
</div> </div>
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p> <p className="muted">General preferences and SSH keys.</p>
</header> </header>
<nav className="settings-tabs" aria-label="Settings sections"> <nav className="settings-tabs" aria-label="Settings sections">
+148 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys"; import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
export const SSHKeysPage = () => { export const SSHKeysPage = () => {
@@ -10,6 +10,13 @@ export const SSHKeysPage = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [newKeyName, setNewKeyName] = useState(""); const [newKeyName, setNewKeyName] = useState("");
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
const [signatures, setSignatures] = useState<Record<string, string>>({});
const [signing, setSigning] = useState<Record<string, boolean>>({});
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
useEffect(() => { useEffect(() => {
loadKeys(); loadKeys();
@@ -59,6 +66,42 @@ export const SSHKeysPage = () => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
} }
async function handleSign(keyId: string) {
const payload = signPayloads[keyId];
if (!payload?.trim()) return;
try {
setSigning((prev) => ({ ...prev, [keyId]: true }));
const result = await signPayload(keyId, { payload: payload.trim() });
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
setError(null);
} catch {
setError("Failed to sign payload");
} finally {
setSigning((prev) => ({ ...prev, [keyId]: false }));
}
}
async function handleVerify(keyId: string) {
const payload = verifyPayloads[keyId];
const signature = verifySignatures[keyId];
if (!payload?.trim() || !signature?.trim()) return;
try {
setVerifying((prev) => ({ ...prev, [keyId]: true }));
const result = await verifySignature(keyId, {
payload: payload.trim(),
signature: signature.trim(),
});
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
setError(null);
} catch {
setError("Failed to verify signature");
} finally {
setVerifying((prev) => ({ ...prev, [keyId]: false }));
}
}
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
return ( return (
@@ -133,6 +176,110 @@ export const SSHKeysPage = () => {
Copy Full Key Copy Full Key
</button> </button>
</div> </div>
<div className="key-signing">
<h4>Sign Payload</h4>
<div className="form-group">
<textarea
value={signPayloads[key.id] || ""}
onChange={(e) =>
setSignPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
}
placeholder="Enter payload to sign..."
rows={3}
/>
</div>
<button
onClick={() => handleSign(key.id)}
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
className="primary-button"
>
{signing[key.id] ? (
<>
<Icon name="loading" size="sm" />
Signing...
</>
) : (
<>
<Icon name="edit" size="sm" />
Sign
</>
)}
</button>
{signatures[key.id] && (
<div className="signature-result">
<label>Signature (base64):</label>
<code>{signatures[key.id]}</code>
<button
onClick={() => copyToClipboard(signatures[key.id])}
className="secondary-button"
>
<Icon name="copy" size="sm" />
Copy Signature
</button>
</div>
)}
</div>
<div className="key-verification">
<h4>Verify Signature</h4>
<div className="form-group">
<textarea
value={verifyPayloads[key.id] || ""}
onChange={(e) =>
setVerifyPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
}
placeholder="Enter payload..."
rows={2}
/>
</div>
<div className="form-group">
<textarea
value={verifySignatures[key.id] || ""}
onChange={(e) =>
setVerifySignatures((prev) => ({ ...prev, [key.id]: e.target.value }))
}
placeholder="Enter base64 signature..."
rows={2}
/>
</div>
<button
onClick={() => handleVerify(key.id)}
disabled={
verifying[key.id] ||
!verifyPayloads[key.id]?.trim() ||
!verifySignatures[key.id]?.trim()
}
className="primary-button"
>
{verifying[key.id] ? (
<>
<Icon name="loading" size="sm" />
Verifying...
</>
) : (
<>
<Icon name="success" size="sm" />
Verify
</>
)}
</button>
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
{verifyResults[key.id] ? (
<>
<Icon name="success" size="sm" />
Signature is valid
</>
) : (
<>
<Icon name="error" size="sm" />
Signature is invalid
</>
)}
</div>
)}
</div>
</div> </div>
)) ))
)} )}
+14 -2
View File
@@ -1,11 +1,13 @@
import React from "react"; import React from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal"; import { TerminalComponent } from "../components/terminal";
import { Icon } from "../components/icon"; import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
export const TerminalPage: React.FC = () => { export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>(); const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useMobileViewport();
if (!instanceId) { if (!instanceId) {
return ( return (
@@ -16,6 +18,16 @@ export const TerminalPage: React.FC = () => {
); );
} }
if (isMobile) {
return (
<MobileTerminalWrapper
instanceId={instanceId}
onBack={() => navigate(-1)}
onClose={() => navigate(-1)}
/>
);
}
return ( return (
<section className="terminal-page"> <section className="terminal-page">
<div className="terminal-page-header"> <div className="terminal-page-header">
@@ -24,7 +36,6 @@ export const TerminalPage: React.FC = () => {
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
type="button" type="button"
> >
<Icon name="arrow-left" size="sm" />
Back Back
</button> </button>
<h1>Terminal</h1> <h1>Terminal</h1>
@@ -32,6 +43,7 @@ export const TerminalPage: React.FC = () => {
<TerminalComponent <TerminalComponent
instanceId={instanceId} instanceId={instanceId}
onClose={() => navigate(-1)} onClose={() => navigate(-1)}
isMobile={false}
/> />
</section> </section>
); );
-354
View File
@@ -1,354 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "../components/icon";
import { listToolTypes, type ToolType } from "../api/tool_types";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
type ToolConfig,
} from "../api/tool_configs";
type ConfigStatus = "loading" | "ready" | "error";
export const ToolConfigsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ConfigStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [showForm, setShowForm] = useState(false);
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
const [formData, setFormData] = useState({
key: "",
value: "",
config_type: "env",
file_path: "",
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const loadData = useCallback(async () => {
try {
const [typesData, configsData] = await Promise.all([
listToolTypes(),
listToolConfigs(),
]);
setToolTypes(typesData);
setConfigs(configsData);
if (typesData.length > 0 && !selectedToolType) {
setSelectedToolType(typesData[0].id);
}
setStatus("ready");
} catch {
setStatus("error");
}
}, [selectedToolType]);
useEffect(() => {
void loadData();
}, [loadData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaveStatus("saving");
try {
const data = {
tool_type_id: selectedToolType,
key: formData.key,
value: formData.value,
config_type: formData.config_type,
file_path: formData.config_type === "file" ? formData.file_path : undefined,
};
if (editingConfig) {
await updateToolConfig(editingConfig.id, data);
} else {
await createToolConfig(data);
}
setSaveStatus("saved");
setShowForm(false);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
await loadData();
} catch {
setSaveStatus("error");
}
};
const handleEdit = (config: ToolConfig) => {
setEditingConfig(config);
setFormData({
key: config.key,
value: config.value,
config_type: config.config_type,
file_path: config.file_path || "",
});
setSelectedToolType(config.tool_type_id);
setShowForm(true);
};
const handleDelete = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
// Error handled by UI state
}
};
const filteredConfigs = configs.filter(
(c) => c.tool_type_id === selectedToolType
);
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
if (status === "loading") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<p className="muted">Loading...</p>
</section>
);
}
if (status === "error") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<div className="card stack">
<p>Failed to load configurations</p>
<button className="secondary-button" onClick={() => void loadData()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
</section>
);
}
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Configurations</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
<p className="muted">
Manage environment variables and configuration files for your tools
</p>
</div>
{/* Tool Type Selector */}
<div className="card">
<label htmlFor="tool-type-select">Select Tool</label>
<select
id="tool-type-select"
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setShowForm(false);
setEditingConfig(null);
}}
className="form-input"
>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
{selectedTool && (
<p className="muted" style={{ marginTop: "0.5rem" }}>
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
</p>
)}
</div>
{/* Config List */}
<div className="card stack">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h2>Configuration Variables</h2>
<button
className="primary-button small"
onClick={() => {
setShowForm(true);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
}}
type="button"
>
<Icon name="add" size="sm" />
Add Config
</button>
</div>
{filteredConfigs.length === 0 ? (
<p className="muted">No configurations for this tool yet.</p>
) : (
<div className="stack" style={{ gap: "0.5rem" }}>
{filteredConfigs.map((config) => (
<div
key={config.id}
className="card"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 1rem",
}}
>
<div>
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
<code style={{ fontWeight: 600 }}>{config.key}</code>
<span
className="badge"
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
color: "white",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
}}
>
{config.config_type}
</span>
</div>
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
{config.config_type === "file" && config.file_path
? `File: ${config.file_path}`
: "Environment variable"}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => handleEdit(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => void handleDelete(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Add/Edit Form */}
{showForm && (
<div className="card stack">
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
<form onSubmit={handleSubmit} className="stack">
<div>
<label htmlFor="config-key">Key</label>
<input
id="config-key"
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div>
<label htmlFor="config-type">Type</label>
<select
id="config-type"
value={formData.config_type}
onChange={(e) =>
setFormData({ ...formData, config_type: e.target.value })
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{formData.config_type === "file" && (
<div>
<label htmlFor="config-file-path">File Path</label>
<input
id="config-file-path"
type="text"
value={formData.file_path}
onChange={(e) =>
setFormData({ ...formData, file_path: e.target.value })
}
placeholder="e.g., /app/config.json"
className="form-input"
required
/>
</div>
)}
<div>
<label htmlFor="config-value">Value</label>
<textarea
id="config-value"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
placeholder={
formData.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={formData.config_type === "file" ? 8 : 2}
required
/>
</div>
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
<button
type="button"
className="secondary-button"
onClick={() => {
setShowForm(false);
setEditingConfig(null);
}}
>
Cancel
</button>
<button type="submit" className="primary-button">
{editingConfig ? "Update" : "Add"} Config
</button>
</div>
{saveStatus === "saved" && (
<p className="text-success" style={{ textAlign: "right" }}>
Saved successfully!
</p>
)}
{saveStatus === "error" && (
<p className="text-error" style={{ textAlign: "right" }}>
Failed to save. Please try again.
</p>
)}
</form>
</div>
)}
</section>
);
};
-380
View File
@@ -1,380 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
type CreateToolTypeRequest,
type UpdateToolTypeRequest,
} from "../api/tool_types";
import { Icon } from "../components/icon";
import type { ToolType } from "../api/tool_types";
type ToolTypesStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ToolTypesPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ToolTypesStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingToolType, setEditingToolType] = useState<ToolType | null>(null);
const [formName, setFormName] = useState("");
const [formDisplayName, setFormDisplayName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formCategory, setFormCategory] = useState("");
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
const [formPort, setFormPort] = useState("");
const [formTemplate, setFormTemplate] = useState("");
const [formVariables, setFormVariables] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadToolTypes = useCallback(async () => {
setStatus("loading");
try {
const data = await listToolTypes();
setToolTypes(data);
setStatus("ready");
} catch {
setToolTypes([]);
setStatus("error");
}
}, []);
useEffect(() => {
void loadToolTypes();
}, [loadToolTypes]);
const openCreate = () => {
setFormName("");
setFormDisplayName("");
setFormDescription("");
setFormCategory("");
setFormInterfaces([]);
setFormPort("");
setFormTemplate("");
setFormVariables("");
setFormError(null);
setEditingToolType(null);
setDialogMode("create");
};
const openEdit = (toolType: ToolType) => {
setFormName(toolType.name);
setFormDisplayName(toolType.display_name);
setFormDescription(toolType.description ?? "");
setFormCategory(toolType.category ?? "");
setFormInterfaces(toolType.interfaces ?? []);
setFormPort(toolType.default_port?.toString() ?? "");
setFormTemplate(toolType.compose_template ?? "");
setFormVariables(toolType.required_variables.join(", "));
setFormError(null);
setEditingToolType(toolType);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingToolType(null);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
setFormError("Name, display name, and compose template are required");
return;
}
if (!formPort.trim() || isNaN(Number(formPort))) {
setFormError("Default port is required and must be a number");
return;
}
const variables = formVariables
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
try {
if (dialogMode === "create") {
const input: CreateToolTypeRequest = {
name: formName.trim(),
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
default_port: Number(formPort),
compose_template: formTemplate.trim(),
required_variables: variables,
};
await createToolType(input);
} else if (dialogMode === "edit" && editingToolType) {
const input: UpdateToolTypeRequest = {
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
default_port: Number(formPort),
compose_template: formTemplate.trim(),
required_variables: variables,
};
await updateToolType(editingToolType.id, input);
}
closeDialog();
await loadToolTypes();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
const detail = axiosError?.response?.data?.detail || "Failed to save tool type";
setFormError(detail);
}
};
const handleDelete = async (id: string) => {
try {
await deleteToolType(id);
setDeleteConfirmId(null);
await loadToolTypes();
} catch {
alert("Failed to delete tool type");
}
};
if (status === "loading") {
return (
<div className="container">
<p>Loading tool types...</p>
</div>
);
}
if (status === "error") {
return (
<div className="container">
<p className="text-error">Failed to load tool types.</p>
<button onClick={loadToolTypes}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
);
}
return (
<div className="container">
<div className="page-header" style={{ marginBottom: "1rem" }}>
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Types</h1>
</div>
<div className="row">
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
</button>
</div>
</div>
{toolTypes.length === 0 ? (
<p>No tool types found.</p>
) : (
<div className="card-grid">
{toolTypes.map((toolType) => (
<div key={toolType.id} className="card">
<div className="card-header">
<h3>{toolType.display_name}</h3>
{toolType.is_builtin && <span className="badge">Built-in</span>}
</div>
<p className="text-secondary">{toolType.description || "No description"}</p>
<div className="tool-type-meta">
<span>Port: {toolType.default_port || "N/A"}</span>
{toolType.interfaces?.length > 0 && (
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
)}
{toolType.category && <span>Category: {toolType.category}</span>}
</div>
<div className="card-actions">
{!toolType.is_builtin && (
<>
<button onClick={() => openEdit(toolType)} className="button-secondary">
<Icon name="edit" size="sm" />
Edit
</button>
<button
onClick={() => setDeleteConfirmId(toolType.id)}
className="button-danger"
>
<Icon name="delete" size="sm" />
Delete
</button>
</>
)}
</div>
{deleteConfirmId === toolType.id && (
<div className="dialog-overlay">
<div className="dialog">
<p>Delete tool type "{toolType.display_name}"?</p>
<div className="dialog-actions">
<button onClick={() => handleDelete(toolType.id)} className="button-danger">
<Icon name="delete" size="sm" />
Delete
</button>
<button onClick={() => setDeleteConfirmId(null)}>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{dialogMode !== "none" && (
<div className="dialog-overlay">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name (unique identifier)</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
disabled={dialogMode === "edit"}
placeholder="e.g., code-server"
/>
</div>
<div className="form-group">
<label>Display Name</label>
<input
type="text"
value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)}
placeholder="e.g., VS Code Server"
/>
</div>
<div className="form-group">
<label>Description</label>
<input
type="text"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="form-group">
<label>Category</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g., editor, notebook, ai-assistant"
/>
</div>
<div className="form-group">
<label>Interfaces</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("web")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "web"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "web"));
}
}}
/>
Web
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("terminal")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "terminal"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal"));
}
}}
/>
Terminal
</label>
</div>
</div>
<div className="form-group">
<label>Default Port *</label>
<input
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
placeholder="e.g., 8443"
required
/>
</div>
<div className="form-group">
<label>Compose Template (YAML)</label>
<textarea
value={formTemplate}
onChange={(e) => setFormTemplate(e.target.value)}
rows={10}
placeholder="version: '3.8'&#10;services:&#10; app:&#10; image: ..."
/>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={formVariables}
onChange={(e) => setFormVariables(e.target.value)}
placeholder="REPO_PATH, TOOL_NAME"
/>
</div>
{formError && <p className="text-error">{formError}</p>}
<div className="dialog-actions">
<button type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Update
</>
)}
</button>
<button type="button" onClick={closeDialog} className="button-secondary">
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+40 -168
View File
@@ -13,7 +13,8 @@ const mockToolTypes = [
display_name: "VS Code Server", display_name: "VS Code Server",
description: "VS Code in browser", description: "VS Code in browser",
category: "editor", category: "editor",
interfaces: ["web"], interface_type: "web",
requires_port: true,
default_port: 8443, default_port: 8443,
definition_type: "compose", definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server", compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
@@ -32,7 +33,8 @@ const mockToolTypes = [
display_name: "Custom Tool", display_name: "Custom Tool",
description: "My custom tool", description: "My custom tool",
category: "utility", category: "utility",
interfaces: ["terminal"], interface_type: "terminal",
requires_port: false,
default_port: 8080, default_port: 8080,
definition_type: "dockerfile", definition_type: "dockerfile",
compose_template: null, compose_template: null,
@@ -153,164 +155,10 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole("button", { name: /configs/i })); fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
});
expect(screen.getByText("advanced-config")).toBeInTheDocument();
});
it("switches to folders tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
expect(screen.getByText("project-configs")).toBeInTheDocument();
});
it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
});
it("creates tool type with compose definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "New Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "8080" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-tool",
display_name: "New Tool",
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
})
);
});
});
it("creates tool type with dockerfile definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "docker-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "Docker Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "3000" },
});
// Switch to dockerfile
fireEvent.change(screen.getByLabelText("Definition Type"), {
target: { value: "dockerfile" },
});
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
target: { value: "FROM python:3.11\\nRUN pip install flask" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
})
);
});
});
it("shows readiness probe fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
expect(screen.getByText(/interval/i)).toBeInTheDocument();
});
it("opens config creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole("button", { name: /configs/i })); fireEvent.click(screen.getByRole("button", { name: /configs/i }));
@@ -321,8 +169,8 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /add config/i })); fireEvent.click(screen.getByRole("button", { name: /add config/i }));
expect(screen.getByLabelText(/key/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument();
expect(screen.getByLabelText(/value/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument();
}); });
it("creates config with advanced fields", async () => { it("creates config with advanced fields", async () => {
@@ -337,6 +185,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i })); fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => { await waitFor(() => {
@@ -345,16 +199,16 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /add config/i })); fireEvent.click(screen.getByRole("button", { name: /add config/i }));
fireEvent.change(screen.getByLabelText(/key/i), { fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), {
target: { value: "MY_CONFIG" }, target: { value: "MY_CONFIG" },
}); });
fireEvent.change(screen.getByLabelText(/value/i), { fireEvent.change(screen.getByPlaceholderText(/Enter value/i), {
target: { value: "my-value" }, target: { value: "my-value" },
}); });
fireEvent.change(screen.getByLabelText(/port override/i), { fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), {
target: { value: "9090" }, target: { value: "9090" },
}); });
fireEvent.change(screen.getByLabelText(/start command/i), { fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), {
target: { value: "python app.py" }, target: { value: "python app.py" },
}); });
@@ -384,6 +238,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i })); fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => { await waitFor(() => {
@@ -392,8 +252,8 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create folder/i })); fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument(); expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument();
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument(); expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument();
}); });
it("creates config folder successfully", async () => { it("creates config folder successfully", async () => {
@@ -408,6 +268,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i })); fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => { await waitFor(() => {
@@ -416,10 +282,10 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create folder/i })); fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByLabelText("Name *"), { fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), {
target: { value: "new-folder" }, target: { value: "new-folder" },
}); });
fireEvent.change(screen.getByLabelText("Mount Path *"), { fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), {
target: { value: "/home/dev" }, target: { value: "/home/dev" },
}); });
@@ -447,6 +313,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i })); fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => { await waitFor(() => {
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -14,17 +14,13 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
import { TerminalPage } from "./pages/terminal"; import { TerminalPage } from "./pages/terminal";
import { ToolWorkshopPage } from "./pages/tool-workshop"; import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys"; import { SSHKeysPage } from "./pages/ssh-keys";
import { ToolConfigsPage } from "./pages/tool-configs"; import { SessionsPage } from "./pages/sessions";
import { ToolTypesPage } from "./pages/tool-types";
export const AppRouter = () => { export const AppRouter = () => {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginRedirectPage />} /> <Route path="/login" element={<LoginRedirectPage />} />
<Route path="/sessions" element={<Navigate to="/" replace />} />
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} /> <Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
<Route <Route
path="/" path="/"
element={ element={
@@ -44,10 +40,9 @@ export const AppRouter = () => {
<Route index element={<Navigate to="general" replace />} /> <Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettingsTab />} /> <Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} /> <Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="*" element={<Navigate to="general" replace />} /> <Route path="*" element={<Navigate to="general" replace />} />
</Route> </Route>
<Route path="sessions" element={<SessionsPage />} />
<Route path="tool-workshop" element={<ToolWorkshopPage />} /> <Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} /> <Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route> </Route>
+514
View File
@@ -2652,9 +2652,15 @@ a.nav-item,
} }
.active-sessions-section { .active-sessions-section {
position: relative;
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.active-sessions-section.dimmed {
opacity: 0.5;
pointer-events: none;
}
.active-sessions-section h2 { .active-sessions-section h2 {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2717,6 +2723,62 @@ a.nav-item,
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
/* Loading overlay for sessions */
.sessions-grid {
position: relative;
}
.sessions-grid.dimmed {
opacity: 0.5;
pointer-events: none;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background: rgba(255, 254, 249, 0.7);
border-radius: var(--space-2);
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-6);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--space-2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.loading-content .icon {
animation: spin 1s linear infinite;
color: var(--brand);
}
.loading-content p {
margin: 0;
font-size: var(--font-size-base);
color: var(--muted);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.recent-sessions-list { .recent-sessions-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2763,9 +2825,15 @@ a.nav-item,
} }
.create-session-section { .create-session-section {
position: relative;
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.create-session-section.dimmed {
opacity: 0.5;
pointer-events: none;
}
.create-session-form { .create-session-form {
max-width: 600px; max-width: 600px;
} }
@@ -2776,6 +2844,65 @@ a.nav-item,
gap: var(--space-4); gap: var(--space-4);
} }
/* Workflow Step Styles */
.workflow-form {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.workflow-step {
opacity: 0.4;
pointer-events: none;
transition: opacity 0.2s ease;
}
.workflow-step.active {
opacity: 1;
pointer-events: auto;
}
.workflow-step.complete {
opacity: 0.7;
pointer-events: auto;
}
.workflow-step-header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
font-weight: 600;
color: var(--text-muted);
}
.workflow-step.active .workflow-step-header {
color: var(--text-primary);
}
.workflow-step-number {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-muted);
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.workflow-step.active .workflow-step-number {
background: var(--primary);
color: white;
}
.workflow-step.complete .workflow-step-number {
background: var(--success);
color: white;
}
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2805,3 +2932,390 @@ a.nav-item,
background: var(--danger-light, #fee2e2); background: var(--danger-light, #fee2e2);
color: var(--danger, #dc2626); color: var(--danger, #dc2626);
} }
/* ============================================
Mobile Terminal Styles
============================================ */
.mobile-terminal-shell {
height: 100vh;
overflow: hidden;
}
.mobile-terminal-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
background: #1e1e1e;
position: relative;
overflow: hidden;
}
/* Mobile Terminal Header */
.mobile-terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
transition: transform 0.3s ease, opacity 0.3s ease, height 0.3s ease, padding 0.3s ease, margin 0.3s ease;
z-index: 100;
overflow: hidden;
}
.mobile-terminal-header.hidden {
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
border-width: 0;
flex-shrink: 1;
}
.mobile-terminal-header.visible {
transform: translateY(0);
opacity: 1;
height: auto;
}
.mobile-terminal-header-left,
.mobile-terminal-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 0 0 auto;
}
.mobile-terminal-header-center {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
justify-content: center;
min-width: 0;
}
.mobile-terminal-header-title {
font-size: 0.875rem;
font-weight: 500;
color: #d4d4d4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-terminal-header-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: transparent;
border: 1px solid #3e3e3e;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.875rem;
transition: background 0.2s ease;
}
.mobile-terminal-header-button:hover {
background: #3e3e3e;
}
.mobile-terminal-header-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
flex-shrink: 0;
}
.mobile-terminal-header-status.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.mobile-terminal-header-status.connected {
background: #0dbc79;
}
.mobile-terminal-header-status.disconnected,
.mobile-terminal-header-status.error {
background: #cd3131;
}
/* Mobile Terminal Content */
.mobile-terminal-content {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
/* Special Keys Strip */
.special-keys-strip {
display: flex;
align-items: center;
gap: 2px;
padding: var(--space-1) var(--space-2);
background: #2d2d2d;
border-top: 1px solid #3e3e3e;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
flex-shrink: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
z-index: 100;
}
.special-keys-strip.hidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
}
.special-keys-strip.visible {
transform: translateY(0);
opacity: 1;
}
.special-keys-strip::-webkit-scrollbar {
display: none;
}
.special-key-button {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 44px;
padding: 0 var(--space-2);
background: #3e3e3e;
border: 1px solid #4e4e4e;
border-radius: 6px;
color: #d4d4d4;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background 0.15s ease, transform 0.1s ease;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
.special-key-button:active {
background: #4e4e4e;
transform: scale(0.95);
}
.special-key-more {
background: #2472c8;
border-color: #2472c8;
color: white;
}
.special-key-more:active {
background: #1e5fa8;
}
.special-key-button.active-modifier {
background: #f5f543;
color: #1e1e1e;
border-color: #f5f543;
font-weight: 700;
box-shadow: 0 0 8px rgba(245, 245, 67, 0.5);
}
/* Special Keys Panel */
.special-keys-panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
}
.special-keys-panel {
background: #2d2d2d;
border-top: 1px solid #3e3e3e;
border-radius: 12px 12px 0 0;
padding: var(--space-4);
width: 100%;
max-height: 70vh;
overflow-y: auto;
animation: slideUp 0.2s ease;
}
.special-keys-panel-section {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.special-keys-panel-section:last-child {
margin-bottom: 0;
}
.special-keys-panel-divider {
height: 1px;
background: #3e3e3e;
margin: var(--space-3) 0;
}
/* Terminal Component Updates */
.terminal-wrapper.mobile {
border: none;
border-radius: 0;
height: 100%;
}
.terminal-wrapper.mobile .terminal-header {
display: none;
}
.terminal-header-left {
display: flex;
align-items: center;
gap: var(--space-2);
}
.terminal-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.terminal-header-button {
padding: var(--space-1) var(--space-2);
background: transparent;
border: 1px solid #666;
border-radius: 4px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.75rem;
transition: background 0.2s ease;
}
.terminal-header-button:hover {
background: #3e3e3e;
}
.terminal-reconnect {
margin-left: var(--space-2);
padding: var(--space-1) var(--space-2);
background: #2472c8;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 0.75rem;
}
.terminal-hidden-input {
position: fixed;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
/* Disable zoom on mobile terminal */
@media (max-width: 767px) {
.mobile-terminal-wrapper {
touch-action: none;
-webkit-text-size-adjust: none;
}
.mobile-terminal-wrapper * {
touch-action: manipulation;
}
.terminal-container {
touch-action: none;
}
}
/* Animations */
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Mobile Menu Overlay */
.mobile-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
}
.mobile-menu-close {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: none;
color: var(--text);
cursor: pointer;
}
/* AppShell mobile menu */
@media (max-width: 767px) {
.shell-nav {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 260px;
background: var(--bg);
z-index: 100;
transform: translateX(-100%);
transition: transform 0.3s ease;
padding-top: var(--space-8);
}
.shell-nav.mobile-open {
transform: translateX(0);
}
}
+1 -2
View File
@@ -58,7 +58,7 @@ services:
INSTANCE_BASE_PATH: /data/instances INSTANCE_BASE_PATH: /data/instances
volumes: volumes:
- repo_data:/data/repos - repo_data:/data/repos
- instance_data:/data/instances - /data/instances:/data/instances
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
@@ -92,7 +92,6 @@ volumes:
postgres_data: postgres_data:
redis_data: redis_data:
repo_data: repo_data:
instance_data:
networks: networks:
backend: backend:
+11
View File
@@ -62,6 +62,17 @@ services:
- `{{TOOL_NAME}}` - Unique name for the container - `{{TOOL_NAME}}` - Unique name for the container
- `{{REPO_PATH}}` - Path to the repository - `{{REPO_PATH}}` - Path to the repository
#### Git Requirement for Clone Mode
When users create instances in **clone mode** (fresh repository copy instead of bind mount), the container image must have `git` installed. This enables git operations (push, pull, branch) inside the container.
**Built-in types with git:**
- VS Code Server: Includes git
- Jupyter Notebook: Includes git
- OpenCode: Installs git during startup
**Custom tool types:** Ensure your base image includes git (e.g., `apt-get install -y git` in Dockerfile).
#### Validating Templates #### Validating Templates
The system validates templates: The system validates templates:
@@ -0,0 +1,415 @@
# Session Branch Selection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace free-text branch input with a dropdown of available branches and add "Create new branch" functionality in the session creation form.
**Architecture:** Frontend fetches branches from existing API, displays them in a dropdown with a "Create new branch..." option. When creating a new branch, frontend sends both base branch and new branch name to backend. Backend clones the base branch then creates a local branch in the cloned workspace.
**Tech Stack:** React + TypeScript (frontend), FastAPI + Python (backend), Git via subprocess
---
## File Structure
- `apps/web/src/api/git_repositories.ts` — Add `listRepositoryBranches` API function
- `apps/web/src/pages/sessions.tsx` — Replace branch input with dropdown + new branch form
- `apps/api/src/api/tool_instances.py` — Extend `CreateInstanceRequest`, add local branch creation
- `apps/web/src/api/sessions.ts` — Update `createInstance` signature to accept `newBranch`
---
### Task 1: Add Branch Listing API to Frontend
**Files:**
- Modify: `apps/web/src/api/git_repositories.ts`
- [ ] **Step 1: Add Branch types and listRepositoryBranches function**
Add after the existing imports and before `export interface CommitHistoryEntry`:
```typescript
export interface Branch {
name: string;
is_default: boolean;
last_commit: string | null;
}
export interface BranchesResponse {
branches: Branch[];
default_branch: string;
}
export async function listRepositoryBranches(
projectId: string,
repoId: string
): Promise<BranchesResponse> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/branches`
);
return response.data;
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/src/api/git_repositories.ts
git commit -m "feat: add branch listing API function"
```
---
### Task 2: Update Backend Request Model and Clone Logic
**Files:**
- Modify: `apps/api/src/api/tool_instances.py`
- [ ] **Step 1: Extend CreateInstanceRequest with new_branch field**
Change the `CreateInstanceRequest` class (around line 55-64):
```python
class CreateInstanceRequest(BaseModel):
"""Request body for creating a tool instance."""
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name for the instance")
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
```
- [ ] **Step 2: Add local branch creation after clone**
After the clone block (around line 248), add:
```python
# Create new local branch if requested
if data.clone_mode == "clone" and data.new_branch:
try:
result = subprocess.run(
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr)
raise RuntimeError(f"Failed to create branch: {result.stderr}")
logger.info("Created local branch %s in cloned repository", data.new_branch)
except Exception as exc:
logger.exception("Failed to create local branch: %s", exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create local branch: {exc}"
)
```
- [ ] **Step 3: Update ToolInstance record to store new branch name**
Change the instance creation (around line 321):
```python
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
```
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/api/tool_instances.py
git commit -m "feat: support creating local branch during session creation
- Add new_branch field to CreateInstanceRequest
- Run git checkout -b after cloning when new_branch is provided
- Store new branch name in ToolInstance record"
```
---
### Task 3: Update Frontend API to Support newBranch
**Files:**
- Modify: `apps/web/src/api/sessions.ts`
- [ ] **Step 1: Update createInstance signature**
Change the function signature (around line 44):
```typescript
export async function createInstance(
projectId: string,
repoId: string,
toolTypeId: string,
displayName?: string,
cloneMode?: string,
branch?: string,
newBranch?: string
): Promise<ToolInstance> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`,
{
tool_type_id: toolTypeId,
display_name: displayName,
clone_mode: cloneMode || "mount",
branch: branch || undefined,
new_branch: newBranch || undefined,
}
);
return response.data;
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/src/api/sessions.ts
git commit -m "feat: add newBranch parameter to createInstance"
```
---
### Task 4: Update Session Creation UI
**Files:**
- Modify: `apps/web/src/pages/sessions.tsx`
- [ ] **Step 1: Add new imports**
Add to existing imports:
```typescript
import { listRepositoryBranches, type Branch } from "../api/git_repositories";
```
- [ ] **Step 2: Add state variables**
Add after `const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);` (around line 44):
```typescript
const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [baseBranch, setBaseBranch] = useState("");
```
- [ ] **Step 3: Add branch loading effect**
Add after the `loadSshKeys` effect (around line 117):
```typescript
useEffect(() => {
const loadBranches = async () => {
if (!selectedRepo || !selectedProject || cloneMode !== "clone") {
setBranches([]);
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
return;
}
setIsLoadingBranches(true);
try {
const data = await listRepositoryBranches(selectedProject, selectedRepo);
setBranches(data.branches);
const defaultBranch = data.default_branch;
setBaseBranch(defaultBranch);
if (!branch || !data.branches.find((b) => b.name === branch)) {
setBranch(defaultBranch);
}
} catch {
setBranches([]);
} finally {
setIsLoadingBranches(false);
}
};
void loadBranches();
}, [selectedRepo, selectedProject, cloneMode]);
```
- [ ] **Step 4: Replace branch input with dropdown**
Replace the branch input section (around lines 686-696):
```tsx
{cloneMode === "clone" && (
<>
<label className="form-field">
Branch
{isLoadingBranches ? (
<span className="muted">Loading branches...</span>
) : (
<select
value={isCreatingNewBranch ? "__new__" : branch}
onChange={(e) => {
const value = e.target.value;
if (value === "__new__") {
setIsCreatingNewBranch(true);
setNewBranchName("");
} else {
setIsCreatingNewBranch(false);
setBranch(value);
setBaseBranch(value);
}
}}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
<option value="__new__">Create new branch...</option>
</select>
)}
</label>
{isCreatingNewBranch && (
<>
<label className="form-field">
New Branch Name
<input
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="feature/my-new-branch"
required
/>
</label>
<label className="form-field">
Base Branch
<select
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
</select>
</label>
</>
)}
{selectedRepo && (
<div className="form-field ssh-key-info">
{(() => {
const repo = repositories.find((r) => r.id === selectedRepo);
if (!repo) return null;
if (repo.ssh_key_id) {
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
return (
<span className="success-text">
SSH key: {key?.name || "Assigned"}
</span>
);
}
return (
<span className="warning-text">
No SSH key assigned to this repository. Clone mode requires an SSH key.
</span>
);
})()}
</div>
)}
</>
)}
```
- [ ] **Step 5: Update handleCreate to pass newBranch**
Change the `createInstance` call (around line 210):
```typescript
const instance = await createInstance(
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined,
cloneMode,
isCreatingNewBranch ? baseBranch : branch,
isCreatingNewBranch ? newBranchName : undefined
);
```
- [ ] **Step 6: Reset new branch state on successful creation**
In the success handler (around line 227), add:
```typescript
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
setBranches([]);
```
- [ ] **Step 7: Commit**
```bash
git add apps/web/src/pages/sessions.tsx
git commit -m "feat: add branch dropdown and new branch creation UI
- Replace free-text branch input with dropdown of available branches
- Add 'Create new branch...' option with name and base branch inputs
- Load branches from API when repository is selected in clone mode
- Pass newBranch parameter to createInstance API"
```
---
### Task 5: Verify and Test
- [ ] **Step 1: Check TypeScript compilation**
```bash
cd apps/web && npm run typecheck
```
Expected: No errors
- [ ] **Step 2: Check Python syntax**
```bash
cd apps/api && python -m py_compile src/api/tool_instances.py
```
Expected: No errors
- [ ] **Step 3: Run backend tests if available**
```bash
cd apps/api && pytest src/tests/ -v -k "instance" || echo "No instance tests found"
```
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "test: verify branch selection implementation compiles"
```
---
## Spec Coverage Check
| Spec Requirement | Task |
|---|---|
| Branch dropdown with available branches | Task 4 |
| "Create new branch..." option | Task 4 |
| New branch name input | Task 4 |
| Base branch dropdown | Task 4 |
| Backend clone + local branch creation | Task 2 |
| Frontend API integration | Task 1, 3 |
## Placeholder Scan
- No TBD, TODO, or "implement later" references
- All code is complete and copy-paste ready
- No vague instructions like "add appropriate error handling"
## Type Consistency Check
- `Branch` interface used consistently in Task 1 and Task 4
- `new_branch` / `newBranch` naming consistent between frontend and backend
- `CreateInstanceRequest` fields match API call in `sessions.ts`
@@ -0,0 +1,155 @@
# Session Branch Selection with New Branch Creation
## Summary
Replace the free-text branch input in the session creation form with a dropdown of available branches from the repository. Add the ability to create a new local branch at clone time by selecting "Create new branch..." from the dropdown.
## Context
The current session creation UI (`apps/web/src/pages/sessions.tsx`) has a free-text input for the branch name when "Clone fresh copy" mode is selected. Users must manually type the branch name, which is error-prone and doesn't show what branches are available.
The backend already has:
- A `GET /projects/{project_id}/repositories/{repo_id}/branches` endpoint that returns all branches and the default branch
- A `clone_repository` service that clones a specific branch
- Branch creation APIs for the original repository
## Design
### Frontend Changes
#### 1. Branch API Integration
Add a new API function in `apps/web/src/api/git_repositories.ts`:
```typescript
export interface Branch {
name: string;
is_default: boolean;
last_commit: string | null;
}
export interface BranchesResponse {
branches: Branch[];
default_branch: string;
}
export async function listRepositoryBranches(
projectId: string,
repoId: string
): Promise<BranchesResponse> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/branches`
);
return response.data;
}
```
#### 2. UI State Management
In `apps/web/src/pages/sessions.tsx`, add state for:
- `branches`: `Branch[]` — loaded when a repository is selected and clone mode is active
- `isLoadingBranches`: `boolean`
- `isCreatingNewBranch`: `boolean` — toggled when "Create new branch..." is selected
- `newBranchName`: `string` — the name for the new branch
- `baseBranch`: `string` — the base branch for the new branch
#### 3. Branch Loading
When a repository is selected and clone mode is "clone", fetch branches:
- Call `listRepositoryBranches(selectedProject, selectedRepo)`
- Set `baseBranch` to `default_branch` from the response
- If the current `branch` state is not in the list, reset it to `default_branch`
#### 4. Branch Dropdown
Replace the free-text input with a `<select>`:
- Options populated from `branches` state
- Default branch marked visually: `"main (default)"`
- Last option: `"Create new branch..."` (disabled separator style or as a real option)
- When selected, set `isCreatingNewBranch = true`
#### 5. New Branch Form
When `isCreatingNewBranch` is true, show:
- **New branch name** input (required, validated for valid git branch name)
- **Base branch** dropdown (populated from `branches`, defaulting to `default_branch`)
#### 6. Form Submission
Update `handleCreate` to handle new branch creation:
- If `isCreatingNewBranch` is true, pass `newBranchName` and `baseBranch` to the API
- The `branch` parameter sent to the API should be:
- `newBranchName` if creating a new branch
- The selected existing branch otherwise
### Backend Changes
#### 1. Update `CreateInstanceRequest`
In `apps/api/src/api/tool_instances.py`, extend the request model:
```python
class CreateInstanceRequest(BaseModel):
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name")
clone_mode: str = Field(default="mount", description="'mount' or 'clone'")
branch: str | None = Field(default="main", description="Branch to clone")
new_branch: str | None = Field(default=None, description="Create a new branch from 'branch' after clone")
```
#### 2. Update Clone Logic
In `create_instance`, after cloning:
- If `data.new_branch` is provided:
1. Clone the `data.branch` (base branch) as usual
2. Run `git -C <clone_path> checkout -b <new_branch>` to create the local branch
3. Store `new_branch` in the `branch` field of the ToolInstance record
#### 3. Update `clone_repository` Service
No changes needed — it already clones a specific branch. The new branch creation happens after clone.
#### 4. Database Schema
No changes needed — the existing `branch` field on `ToolInstance` can store the new branch name.
### Data Flow
```
User selects repo + "Clone fresh copy"
→ Frontend fetches branches from GET /branches
→ User selects "Create new branch..."
→ User fills: newBranchName="feature-x", baseBranch="dev"
→ Frontend sends: { branch: "dev", new_branch: "feature-x", ... }
→ Backend clones "dev" branch
→ Backend runs: git checkout -b feature-x
→ Instance record stores branch="feature-x"
→ Container starts with the new branch checked out
```
### Error Handling
- **Branch fetch fails**: Show error, fallback to free-text input
- **Invalid branch name**: Frontend validation (regex for valid git branch names)
- **New branch creation fails**: Backend returns 400 with git error message
- **Branch already exists locally**: Backend handles gracefully (git checkout -b will fail if branch exists)
### Testing
1. **Frontend**: Test branch dropdown loads correctly, "Create new branch" toggle works, form submission sends correct payload
2. **Backend**: Test instance creation with `new_branch` parameter, verify git command runs correctly
3. **Integration**: End-to-end test creating a session with a new branch
## Files Changed
- `apps/web/src/api/git_repositories.ts` — Add `listRepositoryBranches` function
- `apps/web/src/pages/sessions.tsx` — Replace branch input with dropdown + new branch form
- `apps/api/src/api/tool_instances.py` — Extend `CreateInstanceRequest` and clone logic
- `apps/api/src/services/clone.py` — Add `create_local_branch` helper (optional)
## Trade-offs
- **Local branch only**: The new branch is created in the cloned workspace only, not pushed to the remote. This is intentional — it's a disposable work branch.
- **No branch deletion**: When the instance is deleted, the branch is lost with the clone. This matches the "disposable" mental model.
@@ -0,0 +1,69 @@
## Context
Currently, tool types use a JSON `interfaces` array (e.g., `["web"]`, `["terminal"]`, `["web", "terminal"]`) to define what interfaces a tool supports. This was designed for flexibility but in practice:
1. No tool needs both web and terminal simultaneously
2. Terminal tools don't expose ports or need tunneling
3. The UI shows checkboxes for both, allowing invalid multi-select combinations
The database migration `0008_tool_type_category` added the `interfaces` JSON column. All existing records use `["web"]` or `["terminal"]` as the first (and only) element.
## Goals / Non-Goals
**Goals:**
- Replace `interfaces` array with single `interface_type` string column
- Add `requires_port` boolean to indicate if port/tunnel config is relevant
- Update UI to use dropdown instead of checkboxes
- Conditionally hide port fields for terminal tools
- Migrate existing data safely
**Non-Goals:**
- No changes to tool instance runtime behavior
- No changes to tunnel/port infrastructure
- No changes to existing tool configs (port_override remains in schema)
## Decisions
**Decision: Replace interfaces array with single interface_type string**
- Rationale: Simplifies model, API, and UI. No legitimate use case for multiple interfaces.
- Alternative: Keep array but enforce single item — rejected because it keeps unnecessary complexity
**Decision: Add requires_port boolean instead of inferring from interface_type**
- Rationale: Explicit is better than implicit. Future interface types may have different port needs.
- Alternative: Infer from interface_type === "web" — rejected for flexibility
**Decision: Default requires_port = true for existing records, then update per actual type**
- Rationale: Most existing tools are web-based. Safer default.
- Migration will inspect existing interfaces[0] to set correct value.
**Decision: Keep port_override in tool_configs schema**
- Rationale: Even terminal tools might need port overrides in edge cases. The UI just hides it.
- Alternative: Remove column — rejected to avoid destructive migration
## Risks / Trade-offs
- **[Risk]** Existing API consumers expect `interfaces` array
- **Mitigation:** This is a **BREAKING** change. Update frontend simultaneously. Document in changelog.
- **[Risk]** Data migration fails for unexpected interfaces values
- **Mitigation:** Migration takes first array element. Add fallback to "web" with requires_port=true.
- **[Risk]** Tests break across backend and frontend
- **Mitigation:** Update all test fixtures and assertions in single commit.
## Migration Plan
1. Create Alembic migration to:
- Add `interface_type` string column (nullable temporarily)
- Add `requires_port` boolean column (default true)
- Migrate data: `interface_type = interfaces[0]`, `requires_port = (interfaces[0] == "web")`
- Drop `interfaces` column
- Make `interface_type` non-nullable
2. Update Pydantic schemas (Create/Update/Response)
3. Update SQLAlchemy model
4. Update frontend types and API client
5. Update tool workshop form (dropdown + conditional fields)
6. Update built-in seed data
7. Update tests
8. Run full test suite
## Open Questions
None
@@ -0,0 +1,31 @@
## Why
Currently, tool types support multiple interfaces (e.g., `web` and `terminal` simultaneously), but in practice each tool serves a single purpose and should have one clear interface type. Additionally, terminal tools don't need ports or tunneling capabilities, yet the UI always shows port configuration. This creates confusion and allows invalid configurations.
## What Changes
- **BREAKING**: Change `interfaces` from an array (`["web"]`) to a single string (`"web"` or `"terminal"`) in the ToolType model, API, and frontend
- Add `requires_port` boolean field to ToolType model — `true` for web tools, `false` for terminal tools
- Update frontend UI to use a dropdown for interface type selection (single choice)
- Conditionally show/hide port-related fields based on `requires_port`
- Add database migration to convert existing `interfaces` arrays to single values and set `requires_port`
- Update built-in tool types (code-server, jupyter-notebook) to use new schema
- Update tool workshop page to reflect the single-type dropdown and conditional port visibility
## Capabilities
### New Capabilities
- `tool-type-single-interface`: Enforce single interface type per tool with dropdown selection
- `tool-type-port-visibility`: Conditionally show port/tunnel config based on tool interface type
### Modified Capabilities
- `tool-types-definition`: Update model and API to replace `interfaces` array with single `interface_type` string and add `requires_port`
- `frontend-foundation`: Update tool workshop UI for single interface dropdown and conditional port fields
## Impact
- Database: Migration to change `interfaces` JSON column to `interface_type` string + add `requires_port` boolean
- Backend API: Update Pydantic schemas, SQLAlchemy model, validation logic
- Frontend: Update TypeScript types, tool workshop form, API client
- Existing tool configs: No direct impact, but port_override field becomes irrelevant for terminal tools
- Tests: Update test data and assertions for new schema
@@ -0,0 +1,32 @@
## ADDED Requirements
### Requirement: Tool Interface Type Dropdown
The tool workshop SHALL provide a dropdown for selecting a single interface type.
#### Scenario: Interface type dropdown
- GIVEN the tool workshop page
- WHEN a user creates or edits a tool type
- THEN the interface type field is a dropdown (not checkboxes)
- AND the options are "web" and "terminal"
- AND only one option can be selected
### Requirement: Conditional Port Fields
The tool workshop SHALL conditionally show or hide port-related fields based on the selected interface type.
#### Scenario: Web tool shows port fields
- GIVEN a tool type with interface type "web"
- WHEN the user views the tool editor
- THEN the Default Port field is visible and required
- AND port-related config fields are shown
#### Scenario: Terminal tool hides port fields
- GIVEN a tool type with interface type "terminal"
- WHEN the user views the tool editor
- THEN the Default Port field is hidden
- AND port-related config fields are hidden or disabled
#### Scenario: Changing interface type updates visibility
- GIVEN a user changes interface type from "web" to "terminal"
- WHEN the change is applied
- THEN port fields are immediately hidden
- AND any port value is preserved but not validated
@@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: Port Configuration Visibility
The system SHALL control whether port configuration is relevant for a tool type.
#### Scenario: Web tool requires port
- GIVEN a tool type with `requires_port` = true
- WHEN the tool type is displayed in the UI
- THEN port configuration fields are shown
- AND default_port is validated as required
#### Scenario: Terminal tool does not require port
- GIVEN a tool type with `requires_port` = false
- WHEN the tool type is displayed in the UI
- THEN port configuration fields are hidden
- AND default_port validation is skipped
- AND port_override in tool configs is not shown
### Requirement: Port Validation Based on requires_port
The API SHALL validate port fields conditionally based on requires_port.
#### Scenario: Validate port for web tools
- GIVEN a tool type with `requires_port` = true
- WHEN creating or updating without a default_port
- THEN the system returns 400 Bad Request
#### Scenario: Skip port validation for terminal tools
- GIVEN a tool type with `requires_port` = false
- WHEN creating or updating without a default_port
- THEN the request succeeds
- AND default_port defaults to 0 or null
### Requirement: UI Conditional Rendering
The frontend SHALL conditionally render port-related UI elements.
#### Scenario: Hide port in tool list
- GIVEN a terminal tool type
- WHEN displayed in the tool workshop list
- THEN port information is not shown
#### Scenario: Hide port in editor
- GIVEN a terminal tool type being edited
- WHEN the editor form is rendered
- THEN the Default Port field is hidden
- AND the readiness probe fields are shown (still relevant)
#### Scenario: Show port for web tools
- GIVEN a web tool type being edited
- WHEN the editor form is rendered
- THEN the Default Port field is visible and required
@@ -0,0 +1,39 @@
## ADDED Requirements
### Requirement: Single Interface Type Enforcement
The system SHALL enforce that each tool type has exactly one interface type.
#### Scenario: Create with single interface
- GIVEN a tool type creation request with `interface_type` = "web"
- WHEN the request is processed
- THEN the tool type is created successfully
- AND the interface type is stored as a single string
#### Scenario: Reject multiple interfaces
- GIVEN a legacy request with `interfaces` array
- WHEN the request is processed
- THEN the system returns 400 Bad Request
- AND the error message indicates that `interface_type` (string) should be used instead
### Requirement: Interface Type Validation
The system SHALL validate that interface_type is one of the allowed values.
#### Scenario: Valid interface types
- GIVEN interface_type values "web" or "terminal"
- WHEN a tool type is created or updated
- THEN the request is accepted
#### Scenario: Invalid interface type
- GIVEN interface_type value "ssh"
- WHEN a tool type is created or updated
- THEN the system returns 400 Bad Request
### Requirement: Data Migration
The system SHALL migrate existing tool types from interfaces array to single interface_type.
#### Scenario: Migrate existing records
- GIVEN existing tool types with interfaces = ["web"] or ["terminal"]
- WHEN the migration runs
- THEN each record gets interface_type = interfaces[0]
- AND requires_port is set based on the interface type
- AND the old interfaces column is removed
@@ -0,0 +1,80 @@
## MODIFIED Requirements
### Requirement: Tool Type Model
The system SHALL provide a `ToolType` model to store tool definitions.
#### Scenario: Model structure
- GIVEN a tool type definition
- THEN the model SHALL have:
- `id`: UUID primary key
- `name`: unique string (e.g., "code-server")
- `display_name`: human-readable string (e.g., "VS Code Server")
- `description`: optional text
- `category`: string (e.g., "editor", "notebook")
- `interface_type`: single string — "web" or "terminal"
- `requires_port`: boolean indicating if port/tunnel configuration is needed
- `compose_template`: Docker Compose YAML string
- `dockerfile_template`: Dockerfile string
- `definition_type`: string — "compose" or "dockerfile"
- `required_variables`: list of required template variables
- `is_builtin`: boolean flag for system-defined types
- `created_at`/`updated_at`: timestamps
### Requirement: CRUD API Endpoints
The system SHALL provide REST API endpoints for tool type management.
#### Scenario: Create tool type
- GIVEN an admin user
- WHEN they POST /api/tool-types with valid data
- THEN the system creates a new tool type
- AND validates `interface_type` is "web" or "terminal"
- AND validates `requires_port` is boolean
- AND validates the compose template YAML (if definition_type is "compose")
- AND validates all required variables are present in template
- AND returns 201 Created with the new tool type
#### Scenario: Update tool type
- GIVEN an admin user
- WHEN they PUT /api/tool-types/{id} with valid data
- THEN the system updates the tool type
- AND validates `interface_type` is "web" or "terminal" if provided
- AND re-validates the compose template
- AND returns 200 OK with updated tool type
## REMOVED Requirements
### Requirement: Multiple interfaces support
**Reason**: Tool types now use a single `interface_type` instead of an array of interfaces. No tool legitimately needs both web and terminal interfaces simultaneously.
**Migration**: Use `interface_type` field (string) instead of `interfaces` array. Set to "web" or "terminal".
## ADDED Requirements
### Requirement: Port requirement indication
The system SHALL allow tool types to indicate whether they require port configuration.
#### Scenario: Web tool requires port
- GIVEN a tool type with `interface_type` = "web"
- WHEN the tool type is created or updated
- THEN `requires_port` SHALL default to true
- AND port-related configuration is shown in the UI
#### Scenario: Terminal tool does not require port
- GIVEN a tool type with `interface_type` = "terminal"
- WHEN the tool type is created or updated
- THEN `requires_port` SHALL default to false
- AND port-related configuration is hidden in the UI
### Requirement: Single interface validation
The system SHALL enforce that each tool type has exactly one interface type.
#### Scenario: Invalid interface type
- GIVEN a tool type creation request with `interface_type` = "invalid"
- WHEN the request is processed
- THEN the system returns 400 Bad Request
- AND the error message indicates valid values are "web" or "terminal"
#### Scenario: Missing interface type
- GIVEN a tool type creation request without `interface_type`
- WHEN the request is processed
- THEN the system returns 400 Bad Request
- AND the error message indicates interface_type is required
@@ -0,0 +1,41 @@
## 1. Database Migration
- [x] 1.1 Create Alembic migration to add `interface_type` string column and `requires_port` boolean column to `tool_types` table
- [x] 1.2 Write migration logic to populate `interface_type` from `interfaces[0]` and set `requires_port` based on value
- [x] 1.3 Drop `interfaces` JSON column and make `interface_type` non-nullable
## 2. Backend Model & API Updates
- [x] 2.1 Update SQLAlchemy model (`apps/api/src/models/tool_type.py`) — replace `interfaces` list with `interface_type` string and add `requires_port` boolean
- [x] 2.2 Update Pydantic schemas (`apps/api/src/api/tool_types.py`) — `ToolTypeCreate`, `ToolTypeUpdate`, `ToolTypeResponse`
- [x] 2.3 Add validation for `interface_type` (must be "web" or "terminal")
- [x] 2.4 Update default values and built-in tool type seeding logic
- [x] 2.5 Update API tests for new schema
## 3. Frontend Type & API Updates
- [x] 3.1 Update TypeScript interfaces (`apps/web/src/api/tool_types.ts`) — replace `interfaces: string[]` with `interface_type: string` and add `requires_port: boolean`
- [x] 3.2 Update API request/response types (`CreateToolTypeRequest`, `UpdateToolTypeRequest`)
## 4. Tool Workshop UI Updates
- [x] 4.1 Replace interface checkboxes with dropdown (single-select) in tool editor
- [x] 4.2 Add conditional rendering for port field based on `requires_port` / `interface_type`
- [x] 4.3 Update tool list to show `interface_type` instead of interfaces array
- [x] 4.4 Update form state management for new fields
- [x] 4.5 Update dirty state tracking
## 5. Test Updates
- [x] 5.1 Update backend API tests (`test_tool_types_api.py`) for new schema
- [x] 5.2 Update frontend tests (`tool-workshop.test.tsx`) for dropdown and conditional fields
- [x] 5.3 Update mock data fixtures
## 6. Verification & Cleanup
- [x] 6.1 Run backend tests: `pytest apps/api/tests/`
- [x] 6.2 Run frontend typecheck: `npm run typecheck`
- [x] 6.3 Run frontend tests: `npm run test`
- [x] 6.4 Run lint: `npm run lint`
- [x] 6.5 Verify migration applies cleanly to existing database
- [x] 6.6 Update documentation if needed
@@ -0,0 +1,22 @@
## 1. Backend - SSH Existence Check
- [x] 1.1 Add `git ls-remote` preflight to repository creation in `git_repositories.py`
- [x] 1.2 Build SSH clone URL from `owner` and `repo` for `git.commumedia.org`
- [x] 1.3 Return a clear error when the repository is missing or inaccessible
## 2. Frontend - Structured Clone Form
- [x] 2.1 Update `RepositoryCreateDialog` clone mode to accept `owner` and `repo`
- [x] 2.2 Keep advanced full-URL paste flow and blank repository fallback
- [x] 2.3 Reuse the shared dialog from repository settings and repositories page
## 3. Validation and Docs
- [x] 3.1 Update repository docs to explain SSH-only owner/repo input
- [x] 3.2 Add tests for success, missing repo, and URL fallback behavior
## 4. Quality Gates
- [x] 4.1 Run backend and frontend targeted tests
- [x] 4.2 Run frontend typecheck and lint where applicable
- [x] 4.3 Commit and push changes
@@ -16,4 +16,4 @@
## 4. Quality Gates ## 4. Quality Gates
- [ ] 4.1 Run targeted API tests - [x] 4.1 Run targeted API tests
@@ -0,0 +1,26 @@
## 1. Backend - Proxy Endpoint
- [x] 1.1 Add `container_name` field to ToolInstance model and update start_instance to store it
- [x] 1.2 Create proxy endpoint `/instances/{id}/proxy/{path:path}` in tool_instances.py
- [x] 1.3 Implement HTTP forwarding using httpx with streaming support
- [x] 1.4 Add ownership check before proxying
- [x] 1.5 Add WebSocket upgrade support for the proxy endpoint
- [x] 1.6 Handle response header forwarding (Content-Type, cookies, etc.)
## 2. Backend - Instance URL Update
- [x] 2.1 Update start_instance to set instance URL to proxy path instead of localhost
- [x] 2.2 Ensure container_name is captured during start
## 3. Frontend - Update Instance Links
- [x] 3.1 Update InstanceList "Open" button to use proxy URL
- [x] 3.2 Update SessionsPage "Open" button to use proxy URL
- [x] 3.3 Ensure URLs open in new tab
## 4. Testing & Quality
- [x] 4.1 Test proxy with code-server instance
- [x] 4.2 Verify WebSocket features work (terminal inside code-server)
- [x] 4.3 Run quality gates (ruff, mypy, typecheck, lint, build)
- [x] 4.4 Deploy and test end-to-end
@@ -31,9 +31,9 @@
## 6. Testing & Quality Gates ## 6. Testing & Quality Gates
- [ ] 6.1 Test creating tool type without port fails validation - [x] 6.1 Test creating tool type without port fails validation
- [ ] 6.2 Test creating tool type with port mismatch fails validation - [x] 6.2 Test creating tool type with port mismatch fails validation
- [ ] 6.3 Test OpenCode instance creates tunnel on port 3000 - [x] 6.3 Test OpenCode instance creates tunnel on port 3000
- [ ] 6.4 Run backend quality gates (ruff, mypy) - [x] 6.4 Run backend quality gates (ruff, mypy) - skipped (not installed)
- [ ] 6.5 Run frontend quality gates (typecheck, lint, build) - [x] 6.5 Run frontend quality gates (typecheck, lint, build) - PASSED
- [ ] 6.6 Commit and push changes - [x] 6.6 Commit and push changes
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-22
@@ -0,0 +1,101 @@
## Context
Currently, all tool instances bind-mount the host repository path via `{{REPO_PATH}}` substitution in compose templates. The repository model (`GitRepository`) has no SSH key association. The instance model (`ToolInstance`) has no concept of repository access mode.
Users want two modes:
1. **Mount** (current): Live sync with working copy on host
2. **Clone** (new): Fresh isolated copy with full git history inside the container
The SSH key system already exists with encrypted private keys in the database. Keys can be project-scoped or user-scoped.
## Goals / Non-Goals
**Goals:**
- Allow per-instance choice between mount and clone mode
- Support branch selection for clone mode (default: main)
- Enable git operations inside containers via SSH key mounting
- Protect against accidental data loss with dirty check on clone deletion
- Allow SSH key assignment at repository creation and later modification
**Non-Goals:**
- Modifying existing tool type compose templates
- Installing git in containers (assumes tool images have git or install it)
- Multiple SSH keys per container
- Automatic push/pull/sync between host and container
- Shallow clones (full history only)
## Decisions
### 1. Host-Side Clone (not in-container)
**Decision**: Clone happens on the host before container start, not inside the container.
**Rationale**:
- No changes to compose templates required
- Works with all existing tool types immediately
- No need for git/SSH inside every container image
- Host has direct filesystem access to the clone
- Easier error handling and rollback
**Alternative considered**: In-container clone via command override. Rejected because it requires git in every image, SSH auth setup inside containers, and makes error handling fragile.
### 2. SSH Key Mounting via `_modify_compose_file`
**Decision**: Inject SSH key volume dynamically at container start time using the existing `_modify_compose_file` helper.
**Rationale**:
- Zero changes to tool type definitions
- Consistent with how other runtime overrides work (port, command, working_dir, extra_volumes)
- Mounts the `.ssh/` directory with key + config into container
**Implementation**:
```
instance_dir/.ssh/
id_ed25519 (decrypted private key, mode 600)
id_ed25519.pub (public key)
config (StrictHostKeyChecking no)
```
Mounted as: `instance_dir/.ssh:/root/.ssh:ro` (or appropriate home dir)
### 3. Single SSH Key per Repository
**Decision**: The SSH key is stored on `GitRepository` and used for both clone and container access.
**Rationale**:
- Natural association: a repository's clone URL determines which SSH key is needed
- Simpler UX: one key per repo, not per session
- Session creation can override (future enhancement) but defaults to repo key
### 4. Dirty Check via `git status --short`
**Decision**: Check for uncommitted changes using `git status --short` before allowing deletion of clone-mode instances.
**Rationale**:
- Simple and reliable
- Catches staged, unstaged, and untracked files
- Fast (local filesystem operation)
## Risks / Trade-offs
**[Risk] Disk space usage** → Each clone-mode instance duplicates the full repository. Mitigation: Instance deletion removes the clone directory.
**[Risk] Clone time for large repos** → Synchronous clone during instance creation may timeout. Mitigation: No timeout on clone operation; consider async clone in future.
**[Risk] SSH key permissions in containers** → Some containers run as non-root users. The `.ssh` directory mount needs correct ownership. Mitigation: Mount as read-only; container's entrypoint may need to copy to writable location if needed.
**[Risk] Git not installed in custom tool images** → User-defined tool types may not have git. Mitigation: Document requirement; built-in types already have or install git.
**[Risk] SSH host key checking** → Cloning from new hosts may fail. Mitigation: SSH config sets `StrictHostKeyChecking no` for clone operations.
## Migration Plan
1. Run Alembic migrations to add new columns
2. Existing instances default to `clone_mode='mount'` (no behavior change)
3. Existing repositories have `ssh_key_id=null` (no behavior change until assigned)
4. No data migration needed
## Open Questions
- Should the `.ssh` mount be read-only or writable? (Writable needed if container generates new keys, but we don't support that)
- Should we support submodules in cloned repos?

Some files were not shown because too many files have changed in this diff Show More