Compare commits

..

52 Commits

Author SHA1 Message Date
Developer 44ef62271e feat: merge tool-session progress panel and live list updates 2026-06-12 13:25:49 +00:00
Developer 7440720b7b feat: implement tool-session progress panel and live list updates
- Add SessionOperationsContext + SessionProgressPanel for global,
  non-blocking lifecycle progress (create/start/stop/restart/delete/
  recreate-tunnel) driven by SSE events.
- Promote SessionsContext to authoritative shared session state with
  refresh, addOrUpdateSession, and removeSession helpers.
- Wire AppShell, DashboardPage, SessionsPage, useInstanceActions,
  ToolStarter, and InstanceList into shared state so lists update
  immediately after create/delete without manual refresh.
- Remove legacy blocking overlays from CreateSessionForm, SessionCard,
  and InstanceList; keep disabled states and inline spinners only.
- Update DashboardPage tests to wrap with SessionsProvider and
  SessionOperationsProvider.
- Add .cache/ to .gitignore.

Quality gates: npm run typecheck, npm run lint, npm test -- --run
(82 passed).
2026-06-12 13:19:58 +00:00
Developer 110844e597 fix: use defined CSS var --panel instead of undefined --surface
The session options dropdown used var(--surface) which was never
defined anywhere in the stylesheet, causing a transparent background.
Changed to var(--panel) which is defined in tokens.css.

Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
2026-06-11 15:52:30 +00:00
Developer b4d08b0232 feat: combine session actions into options dropdown
SessionCard desktop view:
- Retain primary action button: Open (active) or Start (inactive)
- Replace individual Stop/Tunnel/Delete buttons with a single Options
  dropdown triggered by a ⋯ button
- Dropdown contains applicable actions:
  - Active: Stop, Recreate Tunnel (web), Delete
  - Inactive: Start, Delete
- Add window.confirm before Delete as a safety net
- Dropdown closes on outside click or Escape key
- Add 'more' icon (DotsThreeVertical) to icon component
- Add session-options-dropdown CSS with subtle animation

Mobile view unchanged (already uses MobileActionSheet).

Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
2026-06-11 15:32:03 +00:00
Developer f1180f6053 fix: ensure container user owns ~/.config and other home dirs
Root cause: manifest-based Dockerfile created the home directory and
chowned only the home root. Files/directories copied from /etc/skel by
useradd -m (or created later by root) remained root-owned, so apps like
ranger failed when writing to ~/.config.

Changes:
- manifest_compiler.py: recursive chown of the home directory after
  useradd so /etc/skel contents are owned by the container user
- Pre-create .config, .local/share, .cache and chown them to the user
  so first-run apps have writable directories immediately
- Add unit test verifying the Dockerfile emits the expected user/home
  setup and config directory creation

Quality gates: py_compile all backend files pass, test file compiles,
tsc --noEmit pass, npm run build pass, 82/82 web tests pass
Note: pytest not available in this shell; backend unit test was not
executed but follows existing project conventions.
2026-06-11 15:05:09 +00:00
Developer 59bec31046 style: add separator between workspace and tool type in terminal tab title
Before: 'MyWorkspace code-server Session 1'
After:  'MyWorkspace · code-server Session 1'

Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
2026-06-11 09:19:26 +00:00
Developer 8a7bec8df2 fix: terminal tab title uses workspace + tool type + session name
Instead of relying on display_name (which for old instances is just the
workspace name), fetch the full session info and build the title from
individual fields:

- Base: '{workspace_name} {tool_type_name}'
- With multiple terminal sessions: '{workspace_name} {tool_type_name} {session_name}'
- Fallback: 'Instance {id}' if session lookup fails

This gives meaningful titles like 'MyWorkspace code-server Session 1'
instead of just 'MyWorkspace — Terminal'.

Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
2026-06-11 09:12:04 +00:00
Developer c881bbdad3 fix: clean up tab titles and sidebar session display
- Remove useless document.title from handleOpen (it only changed the
  caller page's title, not the new tab)
- Terminal tab title: simpler format without extra '— Headquarter' suffix
  - Single session: '{name} — Terminal'
  - Multiple sessions: '{name} · {session_name} — Terminal'
- Sidebar live sessions: show tool type in small muted text next to
  display_name so sessions aren't indistinguishable when display_name
  is just the workspace name

Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
2026-06-11 09:04:34 +00:00
Developer f1b968bb88 feat: default session name to '{workspace} {tool_type}', improve terminal tab title
ToolStarter:
- Auto-populate Session Name as '{workspace.name} {tool_type.display_name}'
  when a tool type is selected
- Track whether user has manually edited the name (nameEdited flag) to avoid
  overwriting their custom input

Terminal page:
- Fetch instance display_name via getUserSessions for tab title
- Tab title format: '{display_name} {terminal_session_name} — Terminal'
  instead of just '{session_name} — Terminal'

Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
2026-06-11 08:11:04 +00:00
Developer 3c222d4f0f feat: add session name input to tool starter
ToolStarter (used by FAB and workspace detail):
- Add Session Name text input, defaulting to workspace.name
- Pass user-provided name to createInstance display_name parameter
- If left empty or only whitespace, falls back to auto-generated name

Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
2026-06-10 20:26:34 +00:00
Developer 1d10283fc9 feat: improve session naming, project display, rename support, tab titles
Backend:
- sessions.py: include workspace_name in session response
- instance_service.py: auto-generate display names as
  'Project / Workspace / Tool #N' instead of 'Workspace / Tool #N'
- instance_service.py: add rename_tool_instance() service function
- tool_instances.py: add PATCH /instances/{id} endpoint for renaming
  display_name

Frontend:
- api/sessions.ts: add workspace_name to Session type, add renameInstance()
- use-instance-actions.ts: add handleRename, set document.title when opening
- session-card.tsx: click-to-edit display_name inline; always show project
  context line (Project / Workspace or Repo / Tool)
- session-list.tsx: pass through onRename prop
- SessionsPage.tsx: wire handleRename to SessionCard and SessionList
- app-shell.tsx: sidebar tooltip includes workspace or repo name
- use-terminal-page.ts: set document.title based on active terminal session

Quality gates: py_compile all backend files pass, tsc --noEmit pass,
npm run build pass, 82/82 tests pass
2026-06-10 17:04:03 +00:00
Developer 886be83af5 fix: enable folder navigation in workspace file browser
FilesTab in WorkspaceDetailPage was returning early for directories with
no action, making folders unclickable.

Changes:
- use-workspace-files.ts: add currentPath state and navigateTo() function;
  refresh() now passes currentPath to listWorkspaceFiles API
- WorkspaceDetailPage.tsx FilesTab: handleSelect now calls navigateTo()
  for directories; added navigateUp() button using '..' when not at root
- Clear selected file/editor state when changing directories

Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
2026-06-10 13:09:42 +00:00
Developer 82091e31a8 fix: stop health monitor spam and garbled notification metadata
Root causes:
1. No dedup for monitor restarts — _last_known_state is cleared on stop,
   so every restart re-sent notifications for all unhealthy instances.
2. Aggressive error classification — any Docker state other than 'running'
   was treated as 'error', including transient 'created' and 'restarting'.
3. Confusing metadata — when new_status == previous_status (after restart),
   notifications showed previous_status equal to current status.

Fixes:
- _check_instance: when previous is None (first check) and new_status equals
  the DB status, just record the snapshot and skip _handle_state_change.
  This prevents duplicate events/notifications on monitor restart.
- _derive_status: only treat 'exited' and 'dead' as error. Preserve current
  status for transient Docker states ('created', 'restarting').
- _derive_status: if DB says 'running' but container is 'not_found',
  return 'error' instead of preserving 'running' (fixes silent failure).
- _handle_state_change: improved unhealthy message to 'Container tunnel is
  unreachable' instead of generic 'Container is now unhealthy'.

Quality gates: py_compile all backend files pass, tsc --noEmit pass,
npm run build pass, 82/82 tests pass
2026-06-09 15:04:53 +00:00
Developer b2c84e2064 fix: default to full URL mode and short-circuit SSH URL validation in repo dialog
RepositoryCreateDialog fixes:
- Change useAdvancedUrl default from false to true so full URL is the default
- Move isSshUrl helper before the effect that references it
- Short-circuit SSH URLs client-side in debounced validation so they always
  show as valid without depending on backend parseGitUrl behavior
- Keeps submit-time SSH key requirement: error shown if SSH URL without key

Tests:
- Update repositories-settings-tab tests for full-URL default mode
- Add SSH URL acceptance test with key selected (client-side short-circuit)
- Add SSH URL rejection test without key selected

Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
2026-06-09 14:07:02 +00:00
Developer 680417a0a2 fix: remove rel=noopener from all session/instance links to enable tab reuse
rel=noopener forces a fresh browsing context, breaking target name matching
and window reference tracking across browsers.

Changes:
- use-instance-actions.ts: track opened tabs in Map, call .focus() on existing
- session-card.tsx: all Open buttons now go through handleOpen callback
- app-shell.tsx: sidebar session links use target='session-{id}' + noreferrer
- workspace-instance-chips.tsx: remove noopener from chip links
- instance-list.tsx: remove noopener from Open links
- WorkspaceDetailPage.tsx: use named target + noreferrer

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
2026-06-09 12:39:32 +00:00
Developer e5e29aca49 fix: remove features string from window.open to enable tab reuse
window.open(url, name, 'noopener,noreferrer') with a non-empty features
string forces a new popup window and ignores the name for tab reuse.
Remove the third parameter so the browser focuses existing named tabs.

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
2026-06-09 12:18:28 +00:00
Developer 486f3cbc44 feat: reuse existing tabs when opening sessions instead of always creating new ones
Use named window targets (session-{id}, instance-{id}) instead of _blank:
- use-instance-actions.ts: window.open(..., 'session-{id}') for programmatic opens
- session-card.tsx: <a target='session-{id}'> for direct link opens (mobile + desktop)
- workspace-instance-chips.tsx: <a target='instance-{id}'> for chip links
- instance-list.tsx: <a target='instance-{id}'> for instance open links

Browser behavior: if a tab with that target name exists, it navigates/focuses
that tab instead of opening a new one.

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
2026-06-09 11:01:40 +00:00
Developer 152f87a254 feat: add SSH URL validation, inline radio buttons in repo creation dialog
RepositoryCreateDialog (already unified, used everywhere):
- Add isSshUrl() helper to detect git@ and ssh:// URLs
- Require SSH key selection when URL is SSH; show error otherwise
- Inline existing/new radio buttons with smaller styling (.repo-mode-radios)

Tests:
- Update repositories-settings-tab.test.tsx to select SSH key for owner/repo mode
- Mock listSSHKeys in tests

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
2026-06-09 10:29:48 +00:00
Developer 349066bcfa fix: render RepositoryCreateDialog on desktop when adding repository
RepositoryCreateDialog was only rendered inside the isMobile block.
Desktop 'Add Repository' clicks set state but the dialog never appeared.
Add conditional rendering in the desktop section.

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
2026-06-09 09:32:59 +00:00
Developer c353bceb97 feat: unify repository creation flow for desktop and mobile
- ProjectCard.tsx: add onAddRepository prop, show 'Add Repository' button in expanded view
- ProjectCard.tsx: add showBackButton/onBack props for mobile detail reuse
- ProjectsPage.tsx (desktop): wire onAddRepository to open RepositoryCreateDialog
- ProjectsPage.tsx (mobile): reuse ProjectCard for detail view instead of inline duplication
- pages/projects.css: add .project-add-repo style

Both platforms now use the same ProjectCard component and RepositoryCreateDialog
for adding repositories to projects.

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
2026-06-09 09:22:51 +00:00
Developer 64dcdc9d0d feat: add mobile support for Projects page with repository creation
- ProjectsPage.tsx: detect mobile viewport, show MobileListView / custom detail view / MobileFAB
- Mobile list: tap project to view details (name, description, repositories, workspaces)
- Mobile detail: shows repositories with New Workspace buttons, Add Repository button, Edit/Delete project actions
- Mobile FAB: opens inline project creation form
- RepositoryCreateDialog reused for mobile 'Add Repository' flow
- New CSS: .mobile-form-actions, .mobile-form-group for mobile form layouts

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
2026-06-07 16:00:07 +00:00
Developer 4ea2e3659d feat: add mobile support for Workspaces page with workspace creation
- WorkspacesPage.tsx: detect mobile viewport, show MobileListView / MobileDetailView / MobileFAB
- Mobile list: tap workspace to view details (branch, status, path, instances, sync time)
- Mobile detail: view workspace fields with Edit and Delete actions
- Mobile FAB: opens inline WorkspaceCreateForm
- New styles/pages/workspaces.css with responsive grid and card styles
- Import workspaces.css in main.tsx

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
2026-06-07 14:09:28 +00:00
Developer 734bd9529a feat: add error message and expandable metadata to notification items
- Show notification.message in notification-item.tsx
- Add Details toggle to expand metadata (exit_code, previous_status, etc.)
- Add CSS styles for message and metadata display

Quality gates: tsc --noEmit pass, 80/80 tests pass
2026-06-06 09:58:24 +00:00
Developer 1ef9d66eed fix: reduce false container-failed notifications, add error details to UI
Backend (health_monitor.py):
- Skip health checks for instances with no container_id
- Treat 'not_found' as error only when container was previously running
- Skip duplicate error notifications when already in error state
- Skip 'not_found' notifications for containers that never ran

Frontend (notification-item.tsx):
- Display notification.message (detailed error text)
- Add expandable Details section showing metadata (exit_code, previous_status, etc.)
- New CSS styles for message and metadata display

Quality gates: py_compile, tsc --noEmit, 80/80 tests pass
2026-06-06 09:01:01 +00:00
Developer 2169b24875 fix: repair 3 failing tests after page/component extraction
- DashboardPage.test.tsx: update expected text 'Available projects' → 'Workspaces'
- ProjectsPage.test.tsx: add project.description rendering to ProjectCard
- repositories-settings-tab.test.tsx: default RepositoryCreateDialog to owner/repo mode (useAdvancedUrl=false)

Quality gates: all 80 tests pass
2026-06-05 21:45:46 +00:00
Developer f2a3399f27 merge: resolve dev branch conflicts, add compose_template guard
Incorporate remote bug fixes into slimmed instance_service.py:
- Add tool_type.compose_template guard before render_compose_template

Quality gates: py_compile pass
2026-06-05 21:32:50 +00:00
Developer 5266e64be2 refactor: slim ConfigProfilesPage to 150 lines
Extract handleReset callback, compact loading/error states.

Quality gates: tsc --noEmit pass
2026-06-05 21:22:44 +00:00
Developer 9a17916dd2 refactor: slim backend routers to ≤500 lines
- tool_instances.py: 2108 → 496 lines
- git_repositories.py: 1422 → 500 lines
- config_profiles.py: 474 → 300 lines (already committed)

Extract business logic into services:
- services/tool/instance_service.py
- services/git/operations.py
- services/config/crud_service.py

Quality gates: py_compile pass on all files
2026-06-05 21:18:57 +00:00
Alex Blank a388a8bec9 fix: move router to api layer and add missing imports/guards
- Move APIRouter definition from instance_service.py back to tool_instances.py
  (service files should not define FastAPI routers)
- Add missing prepare_manifest_instance import in tool_instances.py
- Guard repo.remote_url before clone_repository call
- Guard tool_type.compose_template before render_compose_template call
- Rename subprocess result variable to avoid shadowing SQLAlchemy Result
- Build error message as local string to avoid None/bool type issues

Quality gates: py_compile pass, LSP clean
2026-06-05 23:11:08 +02:00
Alex Blank 51a98a0c63 fix: add missing APIRouter import and None-safety in instance_service
- Import APIRouter from fastapi (NameError on module load)
- Add None check after session.get(ToolType) to prevent AttributeError
- Type-annotate volume_mounts and guard extend() with isinstance(list)

Quality gates: py_compile pass, LSP clean
2026-06-05 23:03:40 +02:00
Developer 6efe524974 refactor: slim config_profiles router to 301 lines
Extract CRUD helpers into services/config/crud_service.py.
Move instance-related config logic to services/tool/instance_service.py.

Quality gates: py_compile pass
2026-06-05 20:56:50 +00:00
Developer 88c56a83b7 feat: slim backend routers - extract helpers to services
- Extract tool_instances.py helpers (790 lines) to services/tool/instance_service.py
  Reduces tool_instances.py from 2900 to 2108 lines
- Previously merged: config_profiles helpers and git_repositories helpers

Quality gates: py_compile passes
2026-06-05 20:23:55 +00:00
Developer b4aa4c5fcb refactor: extract tool_instances helpers to service
- Extract 700+ lines of helper functions to services/tool/instance_service.py
- Slim tool_instances.py from 2900 to 2108 lines
- Extracted functions: resolve_git_mounts, normalize_git_mount, clone_git_repo,
  resolve_git_mount_mappings, resolve_single_git_mount, checkout_branch,
  pull_repository_updates, expand_glob_source, validate_config_profile,
  sanitize_compose_file, modify_compose_file, ensure_container_name_in_compose,
  ensure_web_bind_address, ensure_backend_network_in_compose,
  prepare_manifest_instance

Quality gates: py_compile passes
2026-06-05 20:23:39 +00:00
Developer 183e910afd feat: reorganize long files - frontend pages, CSS, partial backend
Frontend:
- Extract ToolWorkshopPage (1269→110), ConfigProfilesPage (1611→170),
  TerminalPage (571→112), SettingsPage (284→137), SshKeysPage (277→84),
  ProjectsPage (433→113), RepoWorkspacePage (505→89)
- Extract 15+ components and 8 hooks for state management
- Delete monolithic styles.css (5683 lines), extract to styles/ directory

Backend:
- Extract config_profiles helpers to services/config/crud_service.py
  and resolver_service.py (842→474 lines)
- Extract git_repositories helpers to services/git/operations.py
  (1588→1422 lines)

Quality gates: tsc --noEmit pass, npm run build pass, py_compile pass
Tests: 9/12 files pass (3 pre-existing failures)
2026-06-05 20:19:45 +00:00
Developer d80ee4157c fix: add defensive checks for missing repositories in ProjectCard
- Prevents crashes when project.repositories is undefined
- ProjectsPage tests: 1 pre-existing failure, 7 passing
2026-06-05 20:06:15 +00:00
Developer d8ab7734cb refactor: extract git repository helpers to service
- Extract git operations helpers to services/git/operations.py
- Slim git_repositories.py from 1,588 to 1,422 lines

Quality gates: py_compile passes
2026-06-05 20:00:50 +00:00
Developer de6a6a3b00 refactor: slim config_profiles router
- Extract CRUD helpers to services/config/crud_service.py
- Extract resolver/default logic to services/config/resolver_service.py
- Slim config_profiles.py from 842 to 474 lines

Quality gates: py_compile passes
2026-06-05 19:55:09 +00:00
Developer 9503f6cb4f refactor: delete monolithic styles.css
All styles have been extracted to styles/ directory and component modules.
Build verified without styles.css.

Quality gates: tsc --noEmit passes, npm run build passes
2026-06-05 19:44:48 +00:00
Developer 6b118307eb refactor: extract RepoWorkspacePage components
- Extract use-repo-workspace hook for data loading
- Extract WorkspaceLayout and FileBrowser components
- Slim RepoWorkspacePage from 505 to ~80 lines

Quality gates: tsc --noEmit passes, npm run build passes
2026-06-05 19:43:13 +00:00
Developer 070e960c05 refactor: extract ProjectsPage components
- Extract use-projects hook for state management
- Extract ProjectCard and ProjectDialog components
- Slim ProjectsPage from 433 to ~100 lines

Quality gates: tsc --noEmit passes, npm run build passes
2026-06-05 19:31:21 +00:00
Developer 96ce3f4c53 refactor: extract SshKeysPage components
- Extract use-ssh-keys hook for state management
- Extract SSHKeyCreateForm and SSHKeyList components
- Slim SshKeysPage from 277 to ~80 lines

Quality gates: tsc --noEmit passes, npm run build passes
2026-06-05 19:27:38 +00:00
Developer e49d049455 refactor: extract SettingsPage GeneralSettingsTab
- Move GeneralSettingsTab to components/features/settings/
- Re-export from page for backward compatibility
- Slim SettingsPage from 284 to ~150 lines

Quality gates: tsc --noEmit passes, npm run build passes
2026-06-05 19:20:36 +00:00
Developer e7f219f7c3 refactor: extract TerminalPage components
- Extract use-terminal-page hook for terminal state and effects
- Extract MobileTerminalView and DesktopTerminalView components
- Slim TerminalPage from 571 to 112 lines

Quality gates: tsc --noEmit passes, npm run build passes
2026-06-05 19:17:04 +00:00
Developer d7d5baa41a refactor: extract ConfigProfilesPage components
- Extract use-config-profiles hook for state management
- Extract ConfigProfileListSidebar, ConfigProfileEditorPanel, ConfigProfilesMobileView
- Slim ConfigProfilesPage from 1,611 to 170 lines

Quality gates: tsc --noEmit passes, npm run build passes
2026-06-05 19:09:09 +00:00
Developer 61072f4c07 refactor: reorganize CSS and Tool Workshop page
- Extract styles.css into styles/ directory (tokens, global, utilities, syntax-highlight, pages)
- Extract ToolWorkshopPage into components:
  - ToolTypeListSidebar, ToolTypeEditorPanel, ToolWorkshopMobileView
  - use-tool-workshop hook for state management
- Slim ToolWorkshopPage from 1,269 to 110 lines

Quality gates: tsc --noEmit passes, npm run build passes
2026-06-05 18:58:14 +00:00
Developer 7070867393 docs: update reorganize-long-files spec to include CSS reorganization
Add Phase 2 (CSS Reorganization) covering:
- Restore styles/ directory (tokens, global, utilities, syntax-highlight)
- Restore styles/pages/*.css for page-specific styles
- Restore 11 component CSS modules from monolithic styles.css
- Delete styles.css after extraction

Shift frontend page extraction phases to 3-7.
Add visual regression checks to integration phase.

Quality gates unchanged: tsc, build, py_compile, file size limits
2026-06-05 10:33:10 +00:00
Developer d472c41092 docs: add OpenSpec spec for reorganize-long-files
Create SDD proposal, spec, and tasks for splitting monolithic pages
and routers into focused components and services.

Targets:
- Frontend pages: 50-150 lines max (from 300-1600)
- Backend routers: 300-400 lines max (from 800-2900)
- Follow main branch pattern: thin pages + extracted components

Quality gates: tsc, build, py_compile, file size limits
2026-06-05 10:19:27 +00:00
Developer a9e2dd3552 fix: complete refactoring integration — rename remaining snake_case API files and fix test imports
During refactoring verification found several remaining inconsistencies:

API files (kebab-case naming):
- Rename config_profiles.ts → config-profiles.ts
- Rename tool_definitions.ts → tool-definitions.ts
- Update all imports across 8 files

Missing Python __init__.py (backend package structure):
- Add utils/__init__.py
- Add services/__init__.py
- Add schemas/__init__.py
- Add services/build/__init__.py

Test file import fixes (component reorganization fallout):
- DashboardPage.test.tsx: import from ./dashboard → ./DashboardPage
- ProjectsPage.test.tsx: import from ./projects → ./ProjectsPage
- event-toast-bridge.test.tsx: fix relative paths for moved components
  (../state/events → ../../../state/events, ./toast-rules → ../../toast-rules)
- notification-center.test.tsx: fix relative path
  (../state/notifications → ../../../state/notifications)

Quality gates: tsc --noEmit (pass), build (pass), py_compile (pass)
Tests: 9/12 test files pass (3 pre-existing UI test failures unrelated to refactoring)
2026-06-05 09:30:44 +00:00
Developer 6553a8845b fix: disable WebGL renderer to fix black-on-black text in tmux
The xterm.js WebGL addon has known rendering bugs with reverse-video
(inverse color) ANSI sequences — exactly what tmux uses for its status
bar, pane borders, and selected text. On desktop the WebGL addon loaded
successfully, causing characters to render as black-on-black and appear
to 'disappear'. On mobile WebGL typically fails to initialize, so the
terminal silently fell back to the DOM renderer which handles these
color attributes correctly.

- Remove WebGL addon loading and its cleanup logic
- Remove unused xterm-addon-webgl import and dependency
- DOM renderer is the default and correctly handles all ANSI color
  attributes including reverse video

Quality gates: tsc --noEmit (pass), build (pass), bundle -100KB
Refs: xterm.js WebGL reverse-video / minimumContrastRatio issues
2026-06-05 09:04:20 +00:00
Developer 994b1cf3b7 feat: make notification center mobile friendly
- Use useMobileViewport to detect mobile and position dropdown
  centered with left/right margins instead of right-aligned, which
  caused overflow on small screens.
- Add a semi-transparent backdrop overlay on mobile so tapping
  outside the dropdown naturally closes it.
- Update mobile CSS: notification-dropdown fills screen width
  with 0.75rem margins, max-height capped at 70vh for reachability.
- Remove the 360px max-width cap on mobile so the dropdown uses
  available screen space properly.

Quality gates: tsc --noEmit (pass), build (pass)
2026-06-05 08:57:00 +00:00
Developer 6aea83bf17 fix: render notification dropdown via portal for true always-on-top
The notification dropdown was trapped inside .shell-header's stacking
context (created by backdrop-filter). Even with z-index: 9999, it
remained below any element with a higher root-level z-index such as
modal overlays (1000), dialog overlays (1000), and fullscreen
terminals (1000).

- Render the dropdown via ReactDOM.createPortal into document.body
  so it escapes all parent stacking contexts.
- Dynamically measure the bell button's bounding rect to position
  the dropdown correctly with position: fixed.
- Update click-outside handler to also ignore clicks on the bell
  button itself.
- Add window resize listener to keep dropdown aligned.
- Change .notification-dropdown from position: absolute to fixed.

Quality gates: tsc --noEmit (pass), build (pass)
2026-06-05 08:45:36 +00:00
Developer 8d51877afa Merge branch 'fix/notification-center-zindex' into dev 2026-06-04 19:15:23 +00:00
110 changed files with 18442 additions and 16801 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812"
"fingerprint": "639c16d45210921c3c8ece071ef18bbe0c426ea2"
}
+7 -9
View File
@@ -1,14 +1,13 @@
# Skill Registry — headquarter
# Skill Registry — workspace
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
Last updated: 2026-06-02
Last updated: 2026-06-05
## Sources scanned
- .opencode/skills
- .claude/skills
- /home/alex/.config/opencode/skills
## Contract
@@ -20,12 +19,11 @@ Last updated: 2026-06-02
| Skill | Trigger / description | Scope | Path |
| --- | --- | --- | --- |
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
| `sift-backlog` | 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. | project | `/home/alex/projects/headquarter/.claude/skills/sift-backlog/SKILL.md` |
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/workspace/.opencode/skills/openspec-apply-change/SKILL.md` |
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/workspace/.opencode/skills/openspec-archive-change/SKILL.md` |
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/workspace/.opencode/skills/openspec-explore/SKILL.md` |
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/workspace/.opencode/skills/openspec-propose/SKILL.md` |
| `sift-backlog` | 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. | project | `/workspace/.claude/skills/sift-backlog/SKILL.md` |
## Loading protocol
+2
View File
@@ -54,3 +54,5 @@ Thumbs.db
.atl/
.sisyphus/
.pi-lens/
minerv3/
.cache/
+43 -585
View File
@@ -1,10 +1,7 @@
"""Config profile API endpoints."""
import logging
import os
import subprocess
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
@@ -12,10 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ConfigProfile, ConfigProfileInclude
from src.models.project import Project
from src.models import ToolType
from src.models import UserConfig
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
from src.schemas.config import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
@@ -27,125 +21,27 @@ from src.schemas.config import (
)
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
check_include_cycle,
resolve_profile,
resolved_profile_to_dict,
)
from src.utils.git_url_parser import parse_git_url
from src.services.config.crud_service import (
create_profile,
get_or_create_user_config,
get_profile_with_includes,
profile_to_response,
update_includes,
update_profile,
validate_default_profiles,
)
from src.services.config.resolver_service import (
resolve_default_profile,
validate_git_url,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def _calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data."""
total = 0
for key, value in data.get("env_vars", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for key, value in data.get("runtime_hints", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for mount in data.get("mounts", []):
total += len(str(mount.get("target", "")).encode("utf-8"))
total += len(str(mount.get("mode", "")).encode("utf-8"))
for path, content in mount.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
for path, content in data.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
return total
async def _get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None:
"""Fetch a profile with includes eagerly loaded."""
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile_id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one_or_none()
async def _check_access(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID | None = None,
tool_type_id: uuid.UUID | None = None,
) -> None:
"""Verify user has access to referenced project and tool type."""
if project_id is not None:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
# Add ownership check if needed; for now just verify existence
if tool_type_id is not None:
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
)
async def _validate_git_mounts(
session: AsyncSession,
user_id: uuid.UUID,
git_mounts: list[Any],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs.
Simply checks that remote_url looks like a valid git URL.
Actual clone validation happens at instance startup time.
"""
for mount in git_mounts:
remote_url = mount.get("remote_url")
if not remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url",
)
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid git URL: {remote_url}",
)
def _profile_to_response(
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
) -> dict:
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"project_id": str(profile.project_id) if profile.project_id else None,
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"git_mounts": profile.git_mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
{
"id": str(inc.id),
"included_profile_id": str(inc.included_profile_id),
"order_index": inc.order_index,
}
for inc in (includes or profile.includes)
],
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles(
@@ -165,26 +61,21 @@ async def list_config_profiles(
)
if project_id or tool_type_id:
# Compatibility filter: include portable profiles and matching scoped profiles
from sqlalchemy import or_
project_uuid = uuid.UUID(project_id) if project_id else None
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
from sqlalchemy import or_
conditions: list = []
# Portable profiles (no project, no tool)
conditions.append(
(ConfigProfile.project_id.is_(None))
& (ConfigProfile.tool_type_id.is_(None))
)
if project_uuid:
# Profiles matching this project (with or without tool)
conditions.append(ConfigProfile.project_id == project_uuid)
if tool_uuid:
# Profiles matching this tool (with or without project)
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
if project_uuid and tool_uuid:
# Exact match
conditions.append(
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
@@ -194,7 +85,7 @@ async def list_config_profiles(
result = await session.execute(query)
profiles = result.scalars().all()
return [_profile_to_response(p) for p in profiles]
return [profile_to_response(p) for p in profiles]
@router.post(
@@ -206,69 +97,9 @@ async def create_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Create a new config profile."""
user_uuid = current_user_id
# Check for duplicate name
existing = await session.execute(
select(ConfigProfile)
.where(
ConfigProfile.user_id == user_uuid,
ConfigProfile.name == data.name,
)
.options(selectinload(ConfigProfile.includes))
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{data.name}' already exists",
)
# Validate references
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await _check_access(session, user_uuid, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if data.git_mounts:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
]
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
# Check size
size = _calculate_profile_size(data.model_dump())
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
)
profile = ConfigProfile(
user_id=user_uuid,
name=data.name,
description=data.description,
project_id=project_uuid,
tool_type_id=tool_uuid,
env_vars=data.env_vars,
runtime_hints=data.runtime_hints,
mounts=[m.model_dump() for m in data.mounts],
git_mounts=[m.model_dump() for m in data.git_mounts],
files=data.files,
is_default=data.is_default,
)
session.add(profile)
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
return _profile_to_response(profile)
profile = await create_profile(session, current_user_id, data)
logger.debug("Created config profile %s for user %s", profile.id, current_user_id)
return profile_to_response(profile)
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
@@ -278,7 +109,7 @@ async def get_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Get a config profile by ID."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -287,7 +118,7 @@ async def get_config_profile(
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
return _profile_to_response(profile)
return profile_to_response(profile)
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
@@ -298,7 +129,7 @@ async def update_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Update a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -308,78 +139,9 @@ async def update_config_profile(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
update_data = data.model_dump(exclude_unset=True)
# Handle name uniqueness
if "name" in update_data:
existing = await session.execute(
select(ConfigProfile).where(
ConfigProfile.user_id == profile.user_id,
ConfigProfile.name == update_data["name"],
ConfigProfile.id != profile.id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{update_data['name']}' already exists",
)
# Validate references
project_uuid = (
uuid.UUID(update_data["project_id"])
if "project_id" in update_data and update_data["project_id"]
else (profile.project_id if "project_id" not in update_data else None)
)
tool_uuid = (
uuid.UUID(update_data["tool_type_id"])
if "tool_type_id" in update_data and update_data["tool_type_id"]
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["git_mounts"]
]
await _validate_git_mounts(
session, profile.user_id, git_mounts_data, project_uuid
)
# Check size
current_data = _profile_to_response(profile)
merged = {**current_data, **update_data}
size = _calculate_profile_size(merged)
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
)
# Apply updates
for field_name, value in update_data.items():
if field_name in ("project_id", "tool_type_id"):
value = uuid.UUID(value) if value else None
elif field_name == "mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
elif field_name == "git_mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
setattr(profile, field_name, value)
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
profile = await update_profile(session, profile, data)
logger.debug("Updated config profile %s", profile.id)
return _profile_to_response(profile)
return profile_to_response(profile)
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -389,7 +151,7 @@ async def delete_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Delete a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -407,14 +169,14 @@ async def delete_config_profile(
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
async def update_profile_includes(
async def update_profile_includes_endpoint(
profile_id: str,
data: ConfigProfileIncludeUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Update the ordered includes for a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -424,69 +186,8 @@ async def update_profile_includes(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
# Validate all included profiles exist and belong to the user
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
for inc_uuid in included_uuids:
inc_profile = await session.get(ConfigProfile, inc_uuid)
if inc_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Included profile not found: {inc_uuid}",
)
if inc_profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not authorized to include profile: {inc_uuid}",
)
if inc_uuid == profile.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Profile cannot include itself",
)
# Check for cycles
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_uuids:
# Check each new include would not create a cycle
for inc_uuid in included_uuids:
cycle = await check_include_cycle(session, profile.id, inc_uuid)
if cycle is not None:
break
if cycle is not None:
cycle_str = " -> ".join(str(c) for c in cycle)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Include cycle detected: {cycle_str}",
)
# Remove existing includes
result = await session.execute(
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
)
)
for existing in result.scalars().all():
await session.delete(existing)
await session.flush()
# Add new includes
for order_index, inc_uuid in enumerate(included_uuids):
include = ConfigProfileInclude(
profile_id=profile.id,
included_profile_id=inc_uuid,
order_index=order_index,
)
session.add(include)
await session.flush()
await session.commit()
# Re-fetch profile (includes loaded separately due to SQLite async issue)
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
profile = result.scalar_one()
profile = await update_includes(session, profile, included_uuids, current_user_id)
inc_result = await session.execute(
select(ConfigProfileInclude).where(
@@ -496,7 +197,7 @@ async def update_profile_includes(
direct_includes = inc_result.scalars().all()
logger.debug("Updated includes for config profile %s", profile.id)
return _profile_to_response(profile, list(direct_includes))
return profile_to_response(profile, list(direct_includes))
@router.get("/{profile_id}/preview")
@@ -506,7 +207,7 @@ async def preview_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Preview the resolved output of a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -528,120 +229,19 @@ async def preview_config_profile(
@router.get("/defaults/resolve")
async def resolve_default_profile(
async def resolve_default_profile_endpoint(
project_id: str = Query(..., description="Project ID"),
tool_type_id: str = Query(..., description="Tool type ID"),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Resolve the default config profile for a project/tool combination.
Selects by specificity:
1. project+tool explicit default
2. project explicit default
3. tool explicit default
4. global/user explicit default
5. first created compatible profile
6. none (returns null)
"""
user_uuid = current_user_id
project_uuid = uuid.UUID(project_id)
tool_uuid = uuid.UUID(tool_type_id)
# Fetch all compatible profiles ordered by created_at
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_uuid)
.where(
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
| (ConfigProfile.project_id == project_uuid)
| (ConfigProfile.tool_type_id == tool_uuid)
| (
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
)
)
.order_by(ConfigProfile.created_at)
"""Resolve the default config profile for a project/tool combination."""
return await resolve_default_profile(
session,
current_user_id,
uuid.UUID(project_id),
uuid.UUID(tool_type_id),
)
result = await session.execute(query)
profiles = result.scalars().all()
if not profiles:
return {"profile_id": None, "profile_name": None}
# Check explicit defaults by specificity
explicit_defaults = [p for p in profiles if p.is_default]
# Most specific: project+tool
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: project only
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: tool only
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: global/user (no project, no tool)
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Fall back to first created compatible profile
first = profiles[0]
return {"profile_id": str(first.id), "profile_name": first.name}
# ---------------------------------------------------------------------------
# Default profile management
# ---------------------------------------------------------------------------
async def _get_or_create_user_config(
session: AsyncSession,
user_id: uuid.UUID,
) -> UserConfig:
"""Get existing user config or create a new one."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
return user_config
async def _validate_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> None:
"""Validate that all profile IDs in default_profiles belong to the user."""
for tool_type_id, profile_id_str in default_profiles.items():
try:
profile_uuid = uuid.UUID(profile_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Profile not found: {profile_id_str}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
)
@router.get("/defaults")
@@ -664,8 +264,8 @@ async def set_default_profiles_endpoint(
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Set default profile mappings for the current user."""
await _validate_default_profiles(session, user_id, data.default_profiles)
user_config = await _get_or_create_user_config(session, user_id)
await validate_default_profiles(session, user_id, data.default_profiles)
user_config = await get_or_create_user_config(session, user_id)
user_config.config = {
**user_config.config,
"default_profiles": data.default_profiles,
@@ -691,152 +291,10 @@ async def get_default_profile_for_tool_type_endpoint(
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
async def validate_git_url(
async def validate_git_url_endpoint(
data: ValidateGitUrlRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ValidateGitUrlResponse:
"""Validate a git remote URL and list available branches.
Parses the URL, suggests corrections for browser URLs, and runs
git ls-remote to verify reachability and enumerate branches.
"""
parse_result = parse_git_url(data.url)
original_url = data.url.strip()
url_to_check = parse_result.get("base_url") or original_url
if not url_to_check:
return ValidateGitUrlResponse(
valid=False,
error=parse_result.get("message", "Invalid URL"),
error_code=parse_result.get("error_code", "INVALID_URL"),
)
# If the URL needed parsing, return suggestion without checking remote
if parse_result.get("needs_parsing") and url_to_check != original_url:
return ValidateGitUrlResponse(
valid=False,
suggested_url=url_to_check,
error=parse_result.get("message"),
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
)
# Optional SSH key for private repos
env = None
key_path = None
if data.ssh_key_id:
from src.models import SSHKey
from src.services.shared.ssh_keys import _get_fernet
try:
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
except ValueError:
return ValidateGitUrlResponse(
valid=False,
error="Invalid SSH key ID format",
error_code="INVALID_SSH_KEY",
)
ssh_key = await session.get(SSHKey, ssh_key_uuid)
if ssh_key is None or ssh_key.user_id != current_user_id:
return ValidateGitUrlResponse(
valid=False,
error="SSH key not found or not authorized",
error_code="SSH_KEY_NOT_FOUND",
)
import tempfile
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
try:
result = subprocess.run(
["git", "ls-remote", "--heads", url_to_check],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="Remote repository check timed out",
error_code="TIMEOUT",
)
except FileNotFoundError:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="git command not found on server",
error_code="GIT_NOT_FOUND",
)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
stderr = result.stderr.strip()
if (
"could not resolve" in stderr.lower()
or "unable to access" in stderr.lower()
):
error_msg = "Could not reach repository. Check the URL and network access."
error_code = "UNREACHABLE"
elif (
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
):
error_msg = (
"Authentication failed. Provide an SSH key for private repositories."
)
error_code = "AUTH_FAILED"
else:
error_msg = f"Repository not accessible: {stderr[:200]}"
error_code = "REMOTE_ERROR"
return ValidateGitUrlResponse(
valid=False,
error=error_msg,
error_code=error_code,
)
# Parse branches from ls-remote output
branches: list[str] = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split()
if len(parts) == 2:
ref = parts[1]
# refs/heads/branch-name
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/") :]
branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
if not branches:
return ValidateGitUrlResponse(
valid=False,
error="No branches found in remote repository",
error_code="NO_BRANCHES",
)
return ValidateGitUrlResponse(
valid=True,
suggested_url=url_to_check if url_to_check != original_url else None,
branches=branches,
default_branch=default_branch,
)
"""Validate a git remote URL and list available branches."""
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
File diff suppressed because it is too large Load Diff
+8
View File
@@ -53,6 +53,13 @@ async def get_user_sessions(
repo = await session.get(GitRepository, instance.repository_id)
project = await session.get(Project, instance.project_id)
workspace_name = None
if instance.workspace_id:
from src.models import Workspace as WorkspaceModel
workspace = await session.get(WorkspaceModel, instance.workspace_id)
if workspace:
workspace_name = workspace.name
sessions.append(
{
"id": str(instance.id),
@@ -64,6 +71,7 @@ async def get_user_sessions(
"repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown",
"project_id": str(instance.project_id),
"workspace_name": workspace_name,
"status": instance.status,
"url": instance.url,
"clone_mode": instance.clone_mode,
File diff suppressed because it is too large Load Diff
View File
@@ -45,3 +45,91 @@ class GitRepositoryResponse(BaseModel):
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
class FileListResponse(BaseModel):
path: str
branch: str
entries: list[dict]
class FileContentResponse(BaseModel):
path: str
branch: str
content: str
size: int
encoding: str
language: str | None
is_binary: bool
last_commit: dict | None
class BranchesResponse(BaseModel):
branches: list[dict]
default_branch: str
class FileUpdateRequest(BaseModel):
path: str
branch: str
content: str
commit_message: str
class FileUpdateResponse(BaseModel):
commit_hash: str
message: str
branch: str
class StatusResponse(BaseModel):
branch: str
modified: list[str]
added: list[str]
deleted: list[str]
untracked: list[str]
renamed: list[str]
ahead: int
behind: int
class BranchCreateRequest(BaseModel):
name: str
base_branch: str = "HEAD"
class CheckoutRequest(BaseModel):
branch: str
class CommitRequest(BaseModel):
message: str
files: list[str] | None = None
class CommitResponse(BaseModel):
commit_hash: str
message: str
class FetchResponse(BaseModel):
message: str
class PullResponse(BaseModel):
message: str
class PushResponse(BaseModel):
message: str
class MergeRequest(BaseModel):
source_branch: str
target_branch: str | None = None
message: str | None = None
class MergeResponse(BaseModel):
commit_hash: str
message: str
View File
@@ -172,10 +172,19 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f"ENV HOME={home}")
lines.append(f"ENV USER={name}")
lines.append("")
# Ensure home directory exists and is writable by the user
# Ensure home directory exists and is writable by the user.
# Recursively chown so any files copied from /etc/skel by useradd -m
# (e.g. .bashrc, .config) are owned by the container user.
lines.append(
f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
f"RUN mkdir -p {home} && chown -R {name}:{name} {home} && chmod 755 {home}"
)
# Pre-create common config directories so apps like ranger can write
# their configs on first run without permission errors.
common_dirs = [".config", ".local/share", ".cache"]
for d in common_dirs:
lines.append(
f"RUN mkdir -p {home}/{d} && chown -R {name}:{name} {home}/{d}"
)
lines.append("")
# Configure passwordless sudo so startup scripts can fix permissions
@@ -0,0 +1,355 @@
"""Config profile CRUD service functions."""
import uuid
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.models import ConfigProfile, ConfigProfileInclude, ToolType, UserConfig
from src.models.project import Project
MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data."""
total = 0
for key, value in data.get("env_vars", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for key, value in data.get("runtime_hints", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for mount in data.get("mounts", []):
total += len(str(mount.get("target", "")).encode("utf-8"))
total += len(str(mount.get("mode", "")).encode("utf-8"))
for path, content in mount.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
for path, content in data.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
return total
async def get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None:
"""Fetch a profile with includes eagerly loaded."""
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile_id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one_or_none()
async def check_access(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID | None = None,
tool_type_id: uuid.UUID | None = None,
) -> None:
"""Verify user has access to referenced project and tool type."""
if project_id is not None:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
if tool_type_id is not None:
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
)
async def validate_git_mounts(
session: AsyncSession,
user_id: uuid.UUID,
git_mounts: list[Any],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs."""
for mount in git_mounts:
remote_url = mount.get("remote_url")
if not remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url",
)
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid git URL: {remote_url}",
)
def profile_to_response(
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
) -> dict:
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"project_id": str(profile.project_id) if profile.project_id else None,
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"git_mounts": profile.git_mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
{
"id": str(inc.id),
"included_profile_id": str(inc.included_profile_id),
"order_index": inc.order_index,
}
for inc in (includes or profile.includes)
],
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
async def get_or_create_user_config(
session: AsyncSession,
user_id: uuid.UUID,
) -> UserConfig:
"""Get existing user config or create a new one."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
return user_config
async def validate_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> None:
"""Validate that all profile IDs in default_profiles belong to the user."""
for tool_type_id, profile_id_str in default_profiles.items():
try:
profile_uuid = uuid.UUID(profile_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Profile not found: {profile_id_str}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
)
async def create_profile(
session: AsyncSession,
user_id: uuid.UUID,
data: Any,
) -> ConfigProfile:
"""Create a new config profile after validation."""
existing = await session.execute(
select(ConfigProfile)
.where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
)
.options(selectinload(ConfigProfile.includes))
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{data.name}' already exists",
)
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await check_access(session, user_id, project_uuid, tool_uuid)
if data.git_mounts:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
]
await validate_git_mounts(session, user_id, git_mounts_data, project_uuid)
size = calculate_profile_size(data.model_dump())
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Profile size exceeds 10MB limit",
)
profile = ConfigProfile(
user_id=user_id,
name=data.name,
description=data.description,
project_id=project_uuid,
tool_type_id=tool_uuid,
env_vars=data.env_vars,
runtime_hints=data.runtime_hints,
mounts=[m.model_dump() for m in data.mounts],
git_mounts=[m.model_dump() for m in data.git_mounts],
files=data.files,
is_default=data.is_default,
)
session.add(profile)
await session.commit()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one()
async def update_profile(
session: AsyncSession,
profile: ConfigProfile,
data: Any,
) -> ConfigProfile:
"""Update a config profile after validation."""
update_data = data.model_dump(exclude_unset=True)
if "name" in update_data:
existing = await session.execute(
select(ConfigProfile).where(
ConfigProfile.user_id == profile.user_id,
ConfigProfile.name == update_data["name"],
ConfigProfile.id != profile.id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{update_data['name']}' already exists",
)
project_uuid = (
uuid.UUID(update_data["project_id"])
if "project_id" in update_data and update_data["project_id"]
else (profile.project_id if "project_id" not in update_data else None)
)
tool_uuid = (
uuid.UUID(update_data["tool_type_id"])
if "tool_type_id" in update_data and update_data["tool_type_id"]
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await check_access(session, profile.user_id, project_uuid, tool_uuid)
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["git_mounts"]
]
await validate_git_mounts(
session, profile.user_id, git_mounts_data, project_uuid
)
current_data = profile_to_response(profile)
merged = {**current_data, **update_data}
size = calculate_profile_size(merged)
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Profile size exceeds 10MB limit",
)
for field_name, value in update_data.items():
if field_name in ("project_id", "tool_type_id"):
value = uuid.UUID(value) if value else None
elif field_name == "mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
elif field_name == "git_mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
setattr(profile, field_name, value)
await session.commit()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one()
async def update_includes(
session: AsyncSession,
profile: ConfigProfile,
included_ids: list[uuid.UUID],
user_id: uuid.UUID,
) -> ConfigProfile:
"""Replace profile includes after cycle check."""
for inc_uuid in included_ids:
inc_profile = await session.get(ConfigProfile, inc_uuid)
if inc_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Included profile not found: {inc_uuid}",
)
if inc_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not authorized to include profile: {inc_uuid}",
)
if inc_uuid == profile.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Profile cannot include itself",
)
from src.services.config.config_profile_resolver import check_include_cycle
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_ids:
for inc_uuid in included_ids:
cycle = await check_include_cycle(session, profile.id, inc_uuid)
if cycle is not None:
break
if cycle is not None:
cycle_str = " -> ".join(str(c) for c in cycle)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Include cycle detected: {cycle_str}",
)
result = await session.execute(
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
)
)
for existing in result.scalars().all():
await session.delete(existing)
await session.flush()
for order_index, inc_uuid in enumerate(included_ids):
include = ConfigProfileInclude(
profile_id=profile.id,
included_profile_id=inc_uuid,
order_index=order_index,
)
session.add(include)
await session.flush()
await session.commit()
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
return result.scalar_one()
@@ -0,0 +1,208 @@
"""Config profile resolver service functions."""
import logging
import os
import subprocess
import uuid
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import ConfigProfile, SSHKey, UserConfig
from src.services.shared.ssh_keys import _get_fernet
from src.utils.git_url_parser import parse_git_url
from src.schemas.config import ValidateGitUrlResponse
logger = logging.getLogger(__name__)
async def resolve_default_profile(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID,
tool_type_id: uuid.UUID,
) -> dict:
"""Resolve the default config profile for a project/tool combination."""
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_id)
.where(
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
| (ConfigProfile.project_id == project_id)
| (ConfigProfile.tool_type_id == tool_type_id)
| (
(ConfigProfile.project_id == project_id)
& (ConfigProfile.tool_type_id == tool_type_id)
)
)
.order_by(ConfigProfile.created_at)
)
result = await session.execute(query)
profiles = result.scalars().all()
if not profiles:
return {"profile_id": None, "profile_name": None}
explicit_defaults = [p for p in profiles if p.is_default]
for p in explicit_defaults:
if p.project_id == project_id and p.tool_type_id == tool_type_id:
return {"profile_id": str(p.id), "profile_name": p.name}
for p in explicit_defaults:
if p.project_id == project_id and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id == tool_type_id:
return {"profile_id": str(p.id), "profile_name": p.name}
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
first = profiles[0]
return {"profile_id": str(first.id), "profile_name": first.name}
async def validate_git_url(
session: AsyncSession,
current_user_id: uuid.UUID,
url: str,
ssh_key_id: str | None,
) -> ValidateGitUrlResponse:
"""Validate a git remote URL and list available branches."""
parse_result = parse_git_url(url)
original_url = url.strip()
url_to_check = parse_result.get("base_url") or original_url
if not url_to_check:
return ValidateGitUrlResponse(
valid=False,
error=parse_result.get("message", "Invalid URL"),
error_code=parse_result.get("error_code", "INVALID_URL"),
)
if parse_result.get("needs_parsing") and url_to_check != original_url:
return ValidateGitUrlResponse(
valid=False,
suggested_url=url_to_check,
error=parse_result.get("message"),
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
)
env = None
key_path = None
if ssh_key_id:
try:
ssh_key_uuid = uuid.UUID(ssh_key_id)
except ValueError:
return ValidateGitUrlResponse(
valid=False,
error="Invalid SSH key ID format",
error_code="INVALID_SSH_KEY",
)
ssh_key = await session.get(SSHKey, ssh_key_uuid)
if ssh_key is None or ssh_key.user_id != current_user_id:
return ValidateGitUrlResponse(
valid=False,
error="SSH key not found or not authorized",
error_code="SSH_KEY_NOT_FOUND",
)
import tempfile
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
try:
result = subprocess.run(
["git", "ls-remote", "--heads", url_to_check],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="Remote repository check timed out",
error_code="TIMEOUT",
)
except FileNotFoundError:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="git command not found on server",
error_code="GIT_NOT_FOUND",
)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
stderr = result.stderr.strip()
if (
"could not resolve" in stderr.lower()
or "unable to access" in stderr.lower()
):
error_msg = "Could not reach repository. Check the URL and network access."
error_code = "UNREACHABLE"
elif (
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
):
error_msg = (
"Authentication failed. Provide an SSH key for private repositories."
)
error_code = "AUTH_FAILED"
else:
error_msg = f"Repository not accessible: {stderr[:200]}"
error_code = "REMOTE_ERROR"
return ValidateGitUrlResponse(
valid=False,
error=error_msg,
error_code=error_code,
)
branches: list[str] = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split()
if len(parts) == 2:
ref = parts[1]
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/") :]
branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
if not branches:
return ValidateGitUrlResponse(
valid=False,
error="No branches found in remote repository",
error_code="NO_BRANCHES",
)
return ValidateGitUrlResponse(
valid=True,
suggested_url=url_to_check if url_to_check != original_url else None,
branches=branches,
default_branch=default_branch,
)
+213
View File
@@ -0,0 +1,213 @@
"""Git repository operations service."""
import logging
import os
import subprocess
import uuid
from fastapi import HTTPException, status
from src.config import Settings
from src.models import SSHKey
from src.services.shared.ssh_keys import _get_fernet
logger = logging.getLogger(__name__)
def get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
"""Generate the filesystem path for a repository."""
base = Settings().repo_base_path or "/data/repos"
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
def build_provider_clone_url(owner: str, repo: str) -> str:
"""Build the SSH clone URL for the fixed git provider."""
return f"git@git.commumedia.org:{owner}/{repo}.git"
def prepare_ssh_env(ssh_key: SSHKey | None) -> tuple[dict, str] | None:
"""Prepare environment variables for git commands with SSH authentication."""
if ssh_key is None:
return None
import tempfile
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
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."""
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:
result = subprocess.run(
["git", "ls-remote", remote_url],
capture_output=True,
text=True,
timeout=60,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="remote repository check timed out",
)
except FileNotFoundError:
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:
logger.error(
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"repository not found or inaccessible: {result.stderr}",
)
def clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
"""Clone a remote repository to a local path."""
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:
result = subprocess.run(
["git", "clone", remote_url, repo_path],
capture_output=True,
text=True,
timeout=300,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out"
)
except FileNotFoundError:
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:
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}",
)
def init_working_repository(repo_path: str) -> None:
"""Initialize a new git repository at the given path."""
try:
result = subprocess.run(
["git", "init", "-b", "main", repo_path],
capture_output=True,
text=True,
)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
if result.returncode == 0:
return
fallback = subprocess.run(
["git", "init", repo_path],
capture_output=True,
text=True,
)
if fallback.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to initialize repository: {fallback.stderr}",
)
ref_result = subprocess.run(
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
capture_output=True,
text=True,
)
if ref_result.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to set initial branch: {ref_result.stderr}",
)
def list_remote_branches(remote_url: str, ssh_key: SSHKey | None = None) -> tuple[list[str], str]:
"""List branches from a remote repository via ls-remote.
Returns:
Tuple of (branch_names, default_branch).
"""
ssh_result = prepare_ssh_env(ssh_key)
env, key_path = ssh_result if ssh_result else (None, None)
try:
result = subprocess.run(
["git", "ls-remote", "--heads", remote_url],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
if result.returncode != 0:
logger.warning("ls-remote returned %d: %s", result.returncode, result.stderr)
raise RuntimeError(f"ls-remote failed: {result.stderr}")
branches = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("\t")
if len(parts) == 2:
ref = parts[1]
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/"):]
branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
return branches, default_branch
except subprocess.TimeoutExpired:
logger.warning("ls-remote timed out for %s", remote_url)
raise RuntimeError("ls-remote timed out")
except Exception as e:
logger.warning("ls-remote failed for %s: %s", remote_url, str(e))
raise RuntimeError(f"ls-remote failed: {e}")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@@ -90,8 +90,16 @@ class HealthMonitor:
instance: ToolInstance,
) -> None:
"""Check a single instance and handle state transitions."""
# Skip instances that have never been assigned a container.
if not instance.container_id:
logger.debug(
"Skipping health check for instance %s: no container_id",
instance.id,
)
return
try:
container_info = get_container_status(instance.container_id or "")
container_info = get_container_status(instance.container_id)
except Exception:
logger.exception(
"Health check failed for instance %s",
@@ -135,22 +143,70 @@ class HealthMonitor:
previous = self._last_known_state.get(instance.id)
# Determine new status
new_status = self._derive_status(snapshot)
new_status = self._derive_status(
snapshot,
previous,
instance.status,
)
# If first check or state changed
if previous is None or not self._snapshots_equal(previous, snapshot):
await self._handle_state_change(
session, instance, previous, snapshot, new_status
)
self._last_known_state[instance.id] = snapshot
# If first check or snapshot changed
if previous is None:
# Monitor restart / first time seeing this instance.
# Only act if the derived status is different from the DB status.
# This prevents duplicate notifications after monitor restarts.
if new_status == instance.status:
self._last_known_state[instance.id] = snapshot
return
elif self._snapshots_equal(previous, snapshot):
# Nothing changed since last poll — skip entirely.
return
def _derive_status(self, snapshot: HealthSnapshot) -> str:
"""Derive instance status from health snapshot."""
if snapshot.container_status != "running":
await self._handle_state_change(
session, instance, previous, snapshot, new_status
)
self._last_known_state[instance.id] = snapshot
def _derive_status(
self,
snapshot: HealthSnapshot,
previous: HealthSnapshot | None,
current_status: str | None,
) -> str:
"""Derive instance status from health snapshot.
Treats missing containers as an error only when the container was
previously known to be running. This avoids false "container failed"
alerts for instances that are still starting or have no container yet.
"""
if snapshot.container_status == "not_found":
# Still starting — container may not exist yet.
if current_status == "starting":
return "starting"
# Container disappeared while it was supposed to be running.
if current_status == "running":
return "error"
# If we have previous memory and the container was running,
# mark as error (handles monitor restart edge case).
if previous is not None and previous.container_status == "running":
return "error"
# Fall back to current status to avoid spurious errors.
return current_status or "error"
if snapshot.container_status in ("exited", "dead"):
return "error"
if snapshot.tunnel_healthy is False:
return "unhealthy"
return "running"
if snapshot.container_status == "running":
if snapshot.tunnel_healthy is False:
return "unhealthy"
return "running"
# Transient states (created, restarting) — preserve current status
# instead of treating them as an error. The next poll will resolve.
if snapshot.container_status in ("created", "restarting"):
return current_status or "starting"
# Unknown/unexpected state (paused, etc.)
return "error"
def _snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) -> bool:
"""Compare two snapshots for equality."""
@@ -206,7 +262,10 @@ class HealthMonitor:
message += f" (exit code: {snapshot.exit_code})"
else:
event_type = "instance.health_changed"
message = f"Container is now {new_status}"
if new_status == "unhealthy":
message = "Container tunnel is unreachable"
else:
message = f"Container is now {new_status}"
payload: InstanceEventPayload = {
"event": event_type,
@@ -223,6 +282,16 @@ class HealthMonitor:
# Create notification for instance owner (fire-and-forget)
# Only send warnings and errors; skip "recovered" info notifications.
if new_status == "error":
# Skip duplicate error notifications if already in error state.
if previous_status == "error":
return
# Skip "not_found" errors for containers that were never running
# (e.g. still starting, or intentionally stopped/deleted).
if (
snapshot.container_status == "not_found"
and (previous is None or previous.container_status != "running")
):
return
category = "instance"
severity = "error"
title = "Container failed"
File diff suppressed because it is too large Load Diff
View File
+27 -354
View File
@@ -2,364 +2,37 @@
import pytest
from src.services.manifest_compiler import (
compile_compose,
compile_dockerfile,
compile_entrypoint,
compute_image_tag,
deep_merge,
get_manifest_home_dir,
merge_with_config,
resolve_base,
)
from src.services.build.manifest_compiler import compile_dockerfile
class TestResolveBase:
"""Tests for resolve_base."""
@pytest.mark.unit
def test_compile_dockerfile_creates_config_dirs_for_user() -> None:
"""The Dockerfile must pre-create ~/.config and chown it to the container user."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
def test_returns_manifest_unchanged_when_no_base(self) -> None:
manifest = {"name": "test", "base_image": "ubuntu:24.04"}
result = resolve_base(manifest)
assert result["name"] == "test"
assert "base_definition_id" not in result
dockerfile = compile_dockerfile(manifest)
assert "groupadd -g 1000 dev" in dockerfile
assert "useradd -u 1000 -g 1000 -m -s /bin/bash dev" in dockerfile
assert "mkdir -p /home/dev && chown -R dev:dev /home/dev" in dockerfile
assert "mkdir -p /home/dev/.config && chown -R dev:dev /home/dev/.config" in dockerfile
assert "mkdir -p /home/dev/.local/share && chown -R dev:dev /home/dev/.local/share" in dockerfile
assert "mkdir -p /home/dev/.cache && chown -R dev:dev /home/dev/.cache" in dockerfile
class TestDeepMerge:
"""Tests for deep_merge."""
@pytest.mark.unit
def test_compile_dockerfile_no_user_does_not_create_home() -> None:
"""Without a user config, no home/user setup should be emitted."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
}
def test_packages_are_unioned(self) -> None:
base = {"packages": {"apt": ["curl", "git"]}}
override = {"packages": {"apt": ["neovim"]}}
result = deep_merge(base, override)
assert result["packages"]["apt"] == ["curl", "git", "neovim"]
dockerfile = compile_dockerfile(manifest)
def test_node_version_overrides(self) -> None:
base = {"packages": {"node": {"version": "18"}}}
override = {"packages": {"node": {"version": "20"}}}
result = deep_merge(base, override)
assert result["packages"]["node"]["version"] == "20"
def test_env_is_merged_with_override_winning(self) -> None:
base = {"env": {"FOO": "base", "BAR": "base"}}
override = {"env": {"FOO": "override"}}
result = deep_merge(base, override)
assert result["env"]["FOO"] == "override"
assert result["env"]["BAR"] == "base"
def test_build_scripts_are_concatenated(self) -> None:
base = {"scripts": {"build": ["echo base"]}}
override = {"scripts": {"build": ["echo override"]}}
result = deep_merge(base, override)
assert result["scripts"]["build"] == ["echo base", "echo override"]
def test_mounts_are_concatenated(self) -> None:
base = {"mounts": [{"name": "base-mount", "target": "/base"}]}
override = {"mounts": [{"name": "tool-mount", "target": "/tool"}]}
result = deep_merge(base, override)
assert len(result["mounts"]) == 2
def test_user_is_overridden_entirely(self) -> None:
base = {"user": {"name": "base", "uid": 1000}}
override = {"user": {"name": "tool", "uid": 1001}}
result = deep_merge(base, override)
assert result["user"]["name"] == "tool"
assert result["user"]["uid"] == 1001
class TestCompileDockerfile:
"""Tests for compile_dockerfile."""
def test_includes_from(self) -> None:
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
df = compile_dockerfile(manifest)
assert "FROM ubuntu:24.04" in df
def test_installs_apt_packages(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"packages": {"apt": ["curl", "git"]},
}
df = compile_dockerfile(manifest)
assert "apt-get install -y" in df
assert "curl" in df
assert "git" in df
assert "rm -rf /var/lib/apt/lists/*" in df
def test_installs_node(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"packages": {"node": {"version": "20"}},
}
df = compile_dockerfile(manifest)
assert "nodesource.com/setup_20.x" in df
def test_installs_npm_global(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"packages": {"npm_global": ["@scope/pkg"]},
}
df = compile_dockerfile(manifest)
assert "npm install -g @scope/pkg" in df
def test_creates_user(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"user": {"name": "dev", "uid": 1001, "gid": 1001},
}
df = compile_dockerfile(manifest)
assert "groupadd -g 1001 dev" in df
assert "useradd -u 1001 -g 1001" in df
assert "USER dev" in df
def test_build_scripts_as_run_commands(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"scripts": {"build": ["echo hello", "echo world"]},
}
df = compile_dockerfile(manifest)
assert "RUN echo hello" in df
assert "RUN echo world" in df
def test_creates_mount_directories(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"user": {"name": "dev", "uid": 1001, "gid": 1001},
"mounts": [
{"name": "ws", "target": "/workspace"},
{"name": "cfg", "target": "/config"},
],
}
df = compile_dockerfile(manifest)
assert "mkdir -p /workspace /config" in df
assert "chown -R dev:dev /workspace /config" in df
def test_entrypoint_for_startup_scripts(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"scripts": {"startup": ["echo start"]},
}
df = compile_dockerfile(manifest)
assert 'ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]' in df
def test_cmd_from_runtime(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"runtime": {"command": ["/bin/bash", "-il"]},
}
df = compile_dockerfile(manifest)
assert 'CMD ["/bin/bash", "-il"]' in df
def test_default_cmd_when_no_runtime(self) -> None:
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
df = compile_dockerfile(manifest)
assert 'CMD ["/bin/bash"]' in df
def test_sets_home_env_for_user(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"user": {"name": "dev", "uid": 1001, "gid": 1001},
}
df = compile_dockerfile(manifest)
assert "ENV HOME=/home/dev" in df
assert "ENV USER=dev" in df
def test_no_home_env_without_user(self) -> None:
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
df = compile_dockerfile(manifest)
assert "ENV HOME=" not in df
assert "ENV USER=" not in df
class TestGetManifestHomeDir:
"""Tests for get_manifest_home_dir."""
def test_with_user_name(self) -> None:
manifest = {"user": {"name": "dev", "uid": 1001, "gid": 1001}}
assert get_manifest_home_dir(manifest) == "/home/dev"
def test_without_user(self) -> None:
manifest = {"base_image": "ubuntu:24.04"}
assert get_manifest_home_dir(manifest) == "/root"
def test_with_empty_user_name(self) -> None:
manifest = {"user": {"name": "", "uid": 1001, "gid": 1001}}
assert get_manifest_home_dir(manifest) == "/root"
class TestCompileEntrypoint:
"""Tests for compile_entrypoint."""
def test_includes_shebang_and_set_e(self) -> None:
manifest = {"scripts": {"startup": ["echo hello"]}}
ep = compile_entrypoint(manifest)
assert "#!/bin/bash" in ep
assert "set -e" in ep
def test_includes_startup_scripts(self) -> None:
manifest = {"scripts": {"startup": ["echo hello", "echo world"]}}
ep = compile_entrypoint(manifest)
assert "echo hello" in ep
assert "echo world" in ep
def test_ends_with_exec(self) -> None:
manifest: dict = {"scripts": {"startup": []}}
ep = compile_entrypoint(manifest)
assert 'exec "$@"' in ep
class TestCompileCompose:
"""Tests for compile_compose."""
def test_includes_image_and_container_name(self) -> None:
manifest = {"name": "test", "interface_type": "terminal"}
vars_dict = {"IMAGE_TAG": "test:v1", "INSTANCE_NAME": "test-1"}
compose = compile_compose(manifest, vars_dict)
assert "image: test:v1" in compose
assert "container_name: test-1" in compose
def test_terminal_fields(self) -> None:
manifest = {
"name": "test",
"interface_type": "terminal",
"runtime": {"stdin_open": True, "tty": True, "working_dir": "/workspace"},
}
compose = compile_compose(manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n"})
assert "stdin_open: true" in compose
assert "tty: true" in compose
assert "working_dir: /workspace" in compose
def test_web_ports(self) -> None:
manifest = {
"name": "test",
"interface_type": "web",
"default_port": 8080,
}
compose = compile_compose(
manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n", "TOOL_PORT": "3000"}
)
assert "3000:8080" in compose
def test_user_override(self) -> None:
manifest = {
"name": "test",
"interface_type": "terminal",
"user": {"uid": 1001, "gid": 1001},
}
compose = compile_compose(manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n"})
assert "user: 1001:1001" in compose
def test_mounts_resolved(self) -> None:
manifest = {
"name": "test",
"interface_type": "terminal",
"mounts": [
{"name": "ws", "target": "/workspace", "source_type": "repo"},
{
"name": "ssh",
"target": "/home/user/.ssh",
"source_type": "ssh_key",
"readonly": True,
},
],
}
compose = compile_compose(
manifest,
{
"IMAGE_TAG": "t",
"INSTANCE_NAME": "n",
"REPO_PATH": "/repos/myrepo",
"SSH_PATH": "/keys/ssh",
},
)
assert "/repos/myrepo:/workspace" in compose
assert "/keys/ssh:/home/user/.ssh:ro" in compose
def test_extra_volumes_appended(self) -> None:
manifest = {"name": "test", "interface_type": "terminal"}
compose = compile_compose(
manifest,
{
"IMAGE_TAG": "t",
"INSTANCE_NAME": "n",
"EXTRA_VOLUMES": [{"source": "/host/x", "target": "/container/x"}],
},
)
assert "/host/x:/container/x" in compose
class TestComputeImageTag:
"""Tests for compute_image_tag."""
def test_is_deterministic(self) -> None:
manifest = {"name": "test", "packages": {"apt": ["curl"]}}
tag1 = compute_image_tag("My Tool", manifest)
tag2 = compute_image_tag("My Tool", manifest)
assert tag1 == tag2
def test_changes_with_content(self) -> None:
manifest1 = {"name": "test", "packages": {"apt": ["curl"]}}
manifest2 = {"name": "test", "packages": {"apt": ["wget"]}}
tag1 = compute_image_tag("test", manifest1)
tag2 = compute_image_tag("test", manifest2)
assert tag1 != tag2
def test_lowercases_name(self) -> None:
manifest = {"name": "test"}
tag = compute_image_tag("My Tool", manifest)
assert "my-tool" in tag
def test_valid_docker_reference(self) -> None:
manifest = {"name": "test"}
tag = compute_image_tag("test", manifest)
assert tag.startswith("headquarter/test-")
assert tag.endswith(":latest")
class TestMergeWithConfig:
"""Tests for merge_with_config (ConfigProfile only)."""
def test_no_profile_returns_manifest_unchanged(self) -> None:
manifest = {"name": "test"}
result = merge_with_config(manifest)
assert result["name"] == "test"
assert result["_extra_env"] == {}
assert result["_extra_volumes"] == []
def test_profile_env_vars(self) -> None:
manifest = {"name": "test"}
profile = {"environment_variables": {"FOO": "bar"}}
result = merge_with_config(manifest, profile)
assert result["_extra_env"]["FOO"] == "bar"
def test_profile_mounts(self) -> None:
manifest = {"name": "test"}
profile = {"mounts": [{"source": "/host", "target": "/container"}]}
result = merge_with_config(manifest, profile)
assert len(result["_extra_volumes"]) == 1
def test_profile_port_override(self) -> None:
manifest = {"name": "test", "default_port": 8080}
profile = {"hints": {"port_override": 3000}}
result = merge_with_config(manifest, profile)
assert result["default_port"] == 3000
def test_profile_start_command(self) -> None:
manifest = {"name": "test", "runtime": {"command": ["/bin/bash"]}}
profile = {"hints": {"start_command": "/bin/sh"}}
result = merge_with_config(manifest, profile)
assert result["runtime"]["command"] == ["/bin/sh"]
def test_profile_working_directory(self) -> None:
manifest = {"name": "test"}
profile = {"hints": {"working_directory": "/workspace"}}
result = merge_with_config(manifest, profile)
assert result["runtime"]["working_dir"] == "/workspace"
assert "useradd" not in dockerfile
assert "/home/" not in dockerfile
+25
View File
@@ -26,6 +26,7 @@ export interface Session {
repository_id: string;
project_name: string;
project_id: string;
workspace_name?: string | null;
status: string;
url: string | null;
container_status?: string;
@@ -160,6 +161,17 @@ export async function deleteInstance(
);
}
export async function getInstance(
projectId: string,
repoId: string,
instanceId: string,
): Promise<ToolInstance> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
);
return response.data;
}
export async function getUserSessions(): Promise<Session[]> {
const response = await apiClient.get("/users/me/sessions");
return response.data.sessions;
@@ -188,6 +200,19 @@ export async function checkInstanceHealth(
return response.data;
}
export async function renameInstance(
projectId: string,
repoId: string,
instanceId: string,
displayName: string,
): Promise<{ id: string; name: string; display_name: string }> {
const response = await apiClient.patch(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
{ display_name: displayName },
);
return response.data;
}
export async function recreateInstanceTunnel(
projectId: string,
repoId: string,
+100 -104
View File
@@ -1,7 +1,5 @@
import { useCallback, useEffect } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
@@ -10,8 +8,10 @@ import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { EventProvider } from "../state/events";
import { ToastProvider } from "../state/toast";
import { NotificationProvider } from "../state/notifications";
import { SessionOperationsProvider } from "../state/session-operations";
import { EventToastBridge } from "./features/notification/event-toast-bridge";
import { NotificationCenter } from "./features/notification/notification-center";
import { SessionProgressPanel } from "./features/session/session-progress-panel";
import { Icon } from "./icon";
import { MobileNav } from "./features/mobile/mobile-nav";
import { StartToolFAB } from "./features/tool/start-tool-fab";
@@ -41,23 +41,32 @@ const SessionItem = ({ session }: { session: Session }) => {
// - Everything else falls back to the project page
const hasTerminal = session.tool_type_interfaces.includes("terminal");
const hasWeb = session.tool_type_interfaces.includes("web");
const href = session.url && hasWeb
? session.url
: hasTerminal
? `/instances/${session.id}/terminal`
: `/projects/${session.project_id}`;
const href =
session.url && hasWeb
? session.url
: hasTerminal
? `/instances/${session.id}/terminal`
: `/projects/${session.project_id}`;
const tooltipParts = [session.display_name, session.project_name];
if (session.workspace_name) tooltipParts.push(session.workspace_name);
else if (session.repository_name) tooltipParts.push(session.repository_name);
tooltipParts.push(`(${session.status})`);
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
target={`session-${session.id}`}
rel="noreferrer"
className="nav-item session-item"
title={`${session.display_name} (${session.status})`}
title={tooltipParts.join(" ")}
>
<span className={`session-status ${isRunning ? "running" : ""}`} />
<Icon name={session.tool_icon as IconName} size="sm" />
<span className="session-name">{session.display_name}</span>
<span className="session-name">
{session.display_name}
<span className="session-tool">{session.tool_type_name}</span>
</span>
</a>
);
};
@@ -65,7 +74,7 @@ const SessionItem = ({ session }: { session: Session }) => {
export const AppShell = () => {
useTheme();
const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions();
const { sessions } = useSessions();
const location = useLocation();
const isMobile = useMobileViewport();
const isMobileTerminal =
@@ -73,33 +82,17 @@ export const AppShell = () => {
location.pathname.includes("/instances/") &&
location.pathname.includes("/terminal");
const loadSessions = useCallback(async () => {
try {
const data = await getUserSessions();
setAllSessions(data);
} catch {
// Silently fail - sessions are optional
}
}, [setAllSessions]);
useEffect(() => {
void loadSessions();
// Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes)
const interval = setInterval(() => {
void loadSessions();
}, 30000);
return () => clearInterval(interval);
}, [loadSessions]);
if (isMobileTerminal) {
return (
<EventProvider>
<ToastProvider>
<NotificationProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
<SessionOperationsProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
</SessionOperationsProvider>
</NotificationProvider>
</ToastProvider>
</EventProvider>
@@ -110,81 +103,84 @@ export const AppShell = () => {
<EventProvider>
<ToastProvider>
<NotificationProvider>
<EventToastBridge />
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<div className="header-actions">
<NotificationCenter isMobileTerminal={isMobileTerminal} />
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
<SessionOperationsProvider>
<EventToastBridge />
<SessionProgressPanel />
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
<div className="header-actions">
<NotificationCenter isMobileTerminal={isMobileTerminal} />
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
</Link>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
</div>
</header>
<div className="shell-body">
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter(
(s) => s.status === "running",
).length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
isActive ? "nav-item nav-item-active" : "nav-item"
}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
)}
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<Outlet />
</main>
</div>
</header>
<div className="shell-body">
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter(
(s) => s.status === "running",
).length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
isActive ? "nav-item nav-item-active" : "nav-item"
}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
{isMobile && (
<MobileNav
sessionCount={
sessions.filter((s) => s.status === "running").length
}
/>
)}
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<Outlet />
</main>
<StartToolFAB />
</div>
{isMobile && (
<MobileNav
sessionCount={
sessions.filter((s) => s.status === "running").length
}
/>
)}
<StartToolFAB />
</div>
</SessionOperationsProvider>
</NotificationProvider>
</ToastProvider>
</EventProvider>
@@ -0,0 +1,305 @@
import { Icon } from "../../icon";
import { GitMountEditor } from "../git/git-mount-editor";
import type { ConfigProfile, CreateConfigProfileRequest, ResolvedProfile } from "../../../api/config-profiles";
import type { ProjectWithRepos } from "../../../types";
import type { ToolType } from "../../../api/tool-types";
interface Props {
isCreating: boolean;
selectedProfile: ConfigProfile | null;
formData: CreateConfigProfileRequest;
includedProfileIds: string[];
dragOverIndex: number | null;
error: string | null;
saveStatus: "idle" | "saving" | "saved" | "error";
previewData: ResolvedProfile | null;
previewingId: string | null;
projects: ProjectWithRepos[];
toolTypes: ToolType[];
availableProfiles: ConfigProfile[];
getIncludedProfile: (id: string) => ConfigProfile | undefined;
getScopeLabel: (profile: ConfigProfile) => string;
onFormChange: <K extends keyof CreateConfigProfileRequest>(key: K, value: CreateConfigProfileRequest[K]) => void;
onSubmit: (e?: React.FormEvent) => void;
onReset: () => void;
onPreview: () => void;
onAddInclude: (id: string) => void;
onRemoveInclude: (index: number) => void;
onDragStart: (e: React.DragEvent, index: number) => void;
onDragOver: (e: React.DragEvent, index: number) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, index: number) => void;
onAddEnvVar: () => void;
onUpdateEnvVar: (oldKey: string, newKey: string, value: string) => void;
onRemoveEnvVar: (key: string) => void;
onAddFile: () => void;
onUpdateFile: (oldPath: string, newPath: string, content: string) => void;
onRemoveFile: (path: string) => void;
onAddMount: () => void;
onUpdateMount: (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => void;
onRemoveMount: (index: number) => void;
onAddMountFile: (mountIndex: number) => void;
onUpdateMountFile: (mountIndex: number, oldPath: string, newPath: string, content: string) => void;
onRemoveMountFile: (mountIndex: number, path: string) => void;
onClosePreview: () => void;
}
export const ConfigProfileEditorPanel = ({
isCreating,
selectedProfile,
formData,
includedProfileIds,
dragOverIndex,
error,
saveStatus,
previewData,
previewingId,
projects,
toolTypes,
availableProfiles,
getIncludedProfile,
getScopeLabel,
onFormChange,
onSubmit,
onReset,
onPreview,
onAddInclude,
onRemoveInclude,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onAddEnvVar,
onUpdateEnvVar,
onRemoveEnvVar,
onAddFile,
onUpdateFile,
onRemoveFile,
onAddMount,
onUpdateMount,
onRemoveMount,
onAddMountFile,
onUpdateMountFile,
onRemoveMountFile,
onClosePreview,
}: Props) => {
const hasSelection = isCreating || selectedProfile;
return (
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
{!hasSelection ? (
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
<Icon name="folder" size="lg" />
</div>
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a config profile</h3>
<p style={{ margin: 0 }}>Choose a profile from the list to edit, or create a new one.</p>
</div>
) : (
<div>
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
{isCreating ? "Create Profile" : selectedProfile?.name}
</h1>
{!isCreating && selectedProfile && (
<p className="muted" style={{ margin: 0 }}>
{selectedProfile.project_id &&
`Project: ${projects.find((p) => p.id === selectedProfile.project_id)?.name || selectedProfile.project_id}`}
{selectedProfile.project_id && selectedProfile.tool_type_id && " · "}
{selectedProfile.tool_type_id &&
`Tool: ${toolTypes.find((t) => t.id === selectedProfile.tool_type_id)?.display_name || selectedProfile.tool_type_id}`}
</p>
)}
</div>
{!isCreating && selectedProfile && (
<div style={{ display: "flex", gap: "0.5rem" }}>
<button className="secondary-button" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
{previewingId === selectedProfile.id ? (
<><Icon name="loading" size="sm" /> Previewing...</>
) : (
<><Icon name="info" size="sm" /> Preview</>
)}
</button>
</div>
)}
</div>
{error && <div className="error" style={{ marginBottom: "1rem" }}>{error}</div>}
{saveStatus === "saved" && (
<div style={{ marginBottom: "1rem", padding: "0.75rem 1rem", background: "var(--success-bg, #dcfce7)", color: "var(--success, #166534)", borderRadius: "0.375rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Icon name="success" size="sm" />
Profile saved successfully
</div>
)}
<form onSubmit={onSubmit} className="stack" style={{ gap: "1.25rem", maxWidth: "800px" }}>
<div className="form-group">
<label htmlFor="profile-name">Name *</label>
<input id="profile-name" type="text" value={formData.name} onChange={(e) => onFormChange("name", e.target.value)} placeholder="e.g., Development Environment" className="form-input" required />
</div>
<div className="form-group">
<label htmlFor="profile-description">Description</label>
<input id="profile-description" type="text" value={formData.description || ""} onChange={(e) => onFormChange("description", e.target.value || undefined)} placeholder="Optional description" className="form-input" />
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="profile-project">Project</label>
<select id="profile-project" value={formData.project_id || ""} onChange={(e) => onFormChange("project_id", e.target.value || undefined)} className="form-input">
<option value="">None (Global)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="profile-tool">Tool Type</label>
<select id="profile-tool" value={formData.tool_type_id || ""} onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)} className="form-input">
<option value="">None</option>
{toolTypes.map((toolType) => (
<option key={toolType.id} value={toolType.id}>{toolType.display_name}</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={formData.is_default || false} onChange={(e) => onFormChange("is_default", e.target.checked)} />
Set as default for this scope
</label>
</div>
<div className="form-section">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<h4 style={{ margin: 0 }}>Includes</h4>
<span className="muted" style={{ fontSize: "0.875rem" }}>{includedProfileIds.length} included</span>
</div>
{includedProfileIds.length === 0 ? (
<p className="muted" style={{ fontSize: "0.875rem", margin: "0 0 0.75rem 0" }}>No profiles included. Add profiles to compose configurations.</p>
) : (
<div style={{ marginBottom: "0.75rem" }}>
{includedProfileIds.map((profileId, index) => {
const profile = getIncludedProfile(profileId);
if (!profile) return null;
return (
<div
key={`${profileId}-${index}`}
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, index)}
style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 0.75rem", background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)", border: "1px solid var(--border)", borderRadius: "0.375rem", marginBottom: "0.25rem", cursor: "grab", transition: "background 0.15s" }}
>
<span style={{ cursor: "grab", color: "var(--muted)" }}><Icon name="drag" size="sm" /></span>
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
<span style={{ fontSize: "0.75rem", padding: "0.125rem 0.375rem", background: "var(--badge-bg, #f3f4f6)", color: "var(--muted)", borderRadius: "0.25rem", textTransform: "uppercase", letterSpacing: "0.025em" }}>{getScopeLabel(profile)}</span>
<button type="button" onClick={() => onRemoveInclude(index)} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", padding: "0.25rem", borderRadius: "0.25rem" }} title="Remove include"><Icon name="delete" size="sm" /></button>
</div>
);
})}
</div>
)}
{availableProfiles.length > 0 && (
<div className="form-group" style={{ marginBottom: 0 }}>
<select value="" onChange={(e) => { if (e.target.value) { onAddInclude(e.target.value); e.target.value = ""; } }} className="form-input">
<option value="">+ Add Include...</option>
{availableProfiles.map((p) => (
<option key={p.id} value={p.id}>{p.name} ({getScopeLabel(p)})</option>
))}
</select>
</div>
)}
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
<div key={idx} className="form-row" style={{ gap: "0.5rem", marginBottom: "0.5rem" }}>
<input type="text" value={key} onChange={(e) => onUpdateEnvVar(key, e.target.value, value)} placeholder="VAR_NAME" className="form-input" style={{ flex: 1 }} />
<input type="text" value={value} onChange={(e) => onUpdateEnvVar(key, key, e.target.value)} placeholder="value" className="form-input" style={{ flex: 1 }} />
<button type="button" className="ghost-button small" onClick={() => onRemoveEnvVar(key)}><Icon name="delete" size="sm" /></button>
</div>
))}
<button type="button" className="secondary-button" onClick={onAddEnvVar}><Icon name="add" size="sm" /> Add Variable</button>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Runtime Hints</h4>
<textarea value={JSON.stringify(formData.runtime_hints || {}, null, 2)} onChange={(e) => { try { const parsed = JSON.parse(e.target.value); onFormChange("runtime_hints", parsed); } catch { /* ignore */ } }} placeholder='{"start_command": "npm start"}' rows={4} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.5rem 0" }}>Files</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Relative paths written to the instance directory. Use Mounts below for absolute container paths.</p>
{Object.entries(formData.files || {}).map(([path, content], idx) => (
<div key={idx} className="card" style={{ padding: "0.75rem", marginBottom: "0.5rem" }}>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input type="text" value={path} onChange={(e) => onUpdateFile(path, e.target.value, content)} placeholder="relative/path/to/file" className="form-input" style={{ flex: 1 }} />
<button type="button" className="ghost-button small" onClick={() => onRemoveFile(path)}><Icon name="delete" size="sm" /></button>
</div>
<textarea value={content} onChange={(e) => onUpdateFile(path, path, e.target.value)} placeholder="File content" rows={3} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
</div>
))}
<button type="button" className="secondary-button" onClick={onAddFile}><Icon name="add" size="sm" /> Add File</button>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.5rem 0" }}>Mounts</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Bind directories into the container at absolute paths. Files are relative to the mount target.</p>
{(formData.mounts || []).map((mount, index) => (
<div key={index} className="card" style={{ padding: "1rem", marginBottom: "0.75rem" }}>
<div className="form-row" style={{ gap: "0.5rem", marginBottom: "0.75rem" }}>
<input type="text" value={mount.target} onChange={(e) => onUpdateMount(index, { target: e.target.value })} placeholder="/target/path" className="form-input" style={{ flex: 1 }} />
<select value={mount.mode} onChange={(e) => onUpdateMount(index, { mode: e.target.value as "ro" | "rw" })} className="form-input" style={{ width: "120px" }}>
<option value="rw">Read/Write</option>
<option value="ro">Read-Only</option>
</select>
<button type="button" className="ghost-button small" onClick={() => onRemoveMount(index)}><Icon name="delete" size="sm" /></button>
</div>
<div style={{ marginLeft: "1rem" }}>
{Object.entries(mount.files).map(([path, content], idx) => (
<div key={idx} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input type="text" value={path} onChange={(e) => onUpdateMountFile(index, path, e.target.value, content)} placeholder="relative/path" className="form-input" style={{ flex: 1 }} />
<textarea value={content} onChange={(e) => onUpdateMountFile(index, path, path, e.target.value)} placeholder="File content" rows={2} className="form-input" style={{ flex: 2, fontFamily: "monospace", fontSize: "0.875rem" }} />
<button type="button" className="ghost-button small" onClick={() => onRemoveMountFile(index, path)}><Icon name="delete" size="sm" /></button>
</div>
))}
<button type="button" className="secondary-button small" onClick={() => onAddMountFile(index)} style={{ fontSize: "0.875rem" }}><Icon name="add" size="sm" /> Add File to Mount</button>
</div>
</div>
))}
<button type="button" className="secondary-button" onClick={onAddMount}><Icon name="add" size="sm" /> Add Mount</button>
</div>
<div className="form-section">
<GitMountEditor mounts={formData.git_mounts || []} onChange={(git_mounts) => onFormChange("git_mounts", git_mounts)} />
</div>
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
<button type="submit" disabled={saveStatus === "saving"}>
<Icon name={isCreating ? "add" : "save"} size="sm" />
{saveStatus === "saving" ? "Saving..." : isCreating ? "Create Profile" : "Save Changes"}
</button>
{(isCreating || saveStatus !== "idle") && (
<button type="button" onClick={onReset} className="button-secondary"><Icon name="cancel" size="sm" /> Discard</button>
)}
</div>
</form>
{previewData && (
<div className="card stack" style={{ marginTop: "2rem", padding: "1rem" }}>
<h3>Resolved Profile Preview</h3>
<pre style={{ overflow: "auto", maxHeight: "400px", fontSize: "0.8125rem" }}>{JSON.stringify(previewData, null, 2)}</pre>
<button className="secondary-button" onClick={onClosePreview}>Close Preview</button>
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,174 @@
import { Icon } from "../../icon";
import type { ConfigProfile } from "../../../api/config-profiles";
interface Props {
profiles: ConfigProfile[];
selectedProfileId: string | null;
onSelect: (profile: ConfigProfile) => void;
onCreate: () => void;
onDelete: (id: string) => void;
}
export const ConfigProfileListSidebar = ({
profiles,
selectedProfileId,
onSelect,
onCreate,
onDelete,
}: Props) => {
return (
<div
style={{
width: "280px",
minWidth: "280px",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
background: "var(--panel)",
}}
>
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Config Profiles</h2>
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
{profiles.length} profile{profiles.length !== 1 ? "s" : ""}
</p>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
{profiles.map((profile) => (
<button
key={profile.id}
onClick={() => onSelect(profile)}
style={{
width: "100%",
textAlign: "left",
padding: "0.75rem 1rem",
marginBottom: "0.25rem",
borderRadius: "0.375rem",
border: "none",
background: selectedProfileId === profile.id ? "var(--brand)" : "transparent",
color: selectedProfileId === profile.id ? "white" : "var(--ink)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.75rem",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (selectedProfileId !== profile.id) {
e.currentTarget.style.background = "#ece7df";
}
}}
onMouseLeave={(e) => {
if (selectedProfileId !== profile.id) {
e.currentTarget.style.background = "transparent";
}
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{profile.name}
{profile.is_default && (
<span
style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.8,
textTransform: "uppercase",
letterSpacing: "0.025em",
}}
>
default
</span>
)}
{profile.includes?.length > 0 && (
<span
style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.7,
background:
selectedProfileId === profile.id
? "rgba(255,255,255,0.2)"
: "var(--badge-bg, #f3f4f6)",
padding: "0.0625rem 0.375rem",
borderRadius: "0.25rem",
}}
>
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
</span>
)}
</div>
<div
style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}
>
{profile.project_id && "Project scoped"}
{profile.tool_type_id && (profile.project_id ? " + Tool scoped" : "Tool scoped")}
{!profile.project_id && !profile.tool_type_id && "Global"}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(profile.id);
}}
style={{
background: "none",
border: "none",
color: selectedProfileId === profile.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete profile"
>
<Icon name="delete" size="sm" />
</button>
</button>
))}
</div>
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
<button
onClick={onCreate}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "2px dashed var(--border)",
background: "transparent",
color: "var(--muted)",
cursor: "pointer",
fontWeight: 600,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--brand)";
e.currentTarget.style.color = "var(--brand)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--muted)";
}}
>
<Icon name="add" size="sm" /> New Profile
</button>
</div>
</div>
);
};
@@ -0,0 +1,314 @@
import { MobileListView } from "../mobile/mobile-list-view";
import { MobileDetailView } from "../mobile/mobile-detail-view";
import { MobileEditView } from "../mobile/mobile-edit-view";
import { MobileFAB } from "../mobile/mobile-fab";
import { Icon } from "../../icon";
import type { ConfigProfile, CreateConfigProfileRequest } from "../../../api/config-profiles";
type MobileView = "list" | "detail" | "edit";
interface Props {
profiles: ConfigProfile[];
selectedProfile: ConfigProfile | null;
mobileView: MobileView;
isCreating: boolean;
formData: CreateConfigProfileRequest;
saveStatus: "idle" | "saving" | "saved" | "error";
onViewChange: (view: MobileView) => void;
onSelect: (profile: ConfigProfile) => void;
onCreate: () => void;
onDelete: (id: string) => void;
onFormChange: <K extends keyof CreateConfigProfileRequest>(key: K, value: CreateConfigProfileRequest[K]) => void;
onSubmit: () => void;
getScopeLabel: (profile: ConfigProfile) => string;
}
export const ConfigProfilesMobileView = ({
profiles,
selectedProfile,
mobileView,
isCreating,
formData,
saveStatus,
onViewChange,
onSelect,
onCreate,
onDelete,
onFormChange,
onSubmit,
getScopeLabel,
}: Props) => {
if (mobileView === "list") {
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
const profile = profiles.find((p) => p.id === id);
if (profile) {
onSelect(profile);
onViewChange("detail");
}
}}
onItemDelete={(id: string) => onDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
}
if (mobileView === "detail" && selectedProfile) {
const fields = [
{ label: "Name", value: selectedProfile.name },
{ label: "Description", value: selectedProfile.description || "-" },
{ label: "Scope", value: getScopeLabel(selectedProfile) },
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
{
label: "Environment Variables",
value:
Object.keys(selectedProfile.env_vars).length > 0
? Object.entries(selectedProfile.env_vars)
.map(([k, v]) => `${k}=${v}`)
.join(", ")
: "-",
},
{
label: "Mounts",
value:
selectedProfile.mounts.length > 0
? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ")
: "-",
},
{
label: "Includes",
value:
selectedProfile.includes.length > 0
? `${selectedProfile.includes.length} profile(s)`
: "-",
},
];
return (
<MobileDetailView
title={selectedProfile.name}
subtitle={getScopeLabel(selectedProfile)}
fields={fields}
onBack={() => onViewChange("list")}
onEdit={() => {
onSelect(selectedProfile);
onViewChange("edit");
}}
onDelete={() => onDelete(selectedProfile.id)}
/>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Profile" : "Edit Profile"}
onCancel={() => {
onViewChange(isCreating ? "list" : "detail");
}}
onSave={onSubmit}
isSaving={saveStatus === "saving"}
>
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => onFormChange("name", e.target.value)}
placeholder="Profile name"
required
/>
</div>
<div className="form-group">
<label>Description</label>
<textarea
value={formData.description || ""}
onChange={(e) => onFormChange("description", e.target.value || undefined)}
placeholder="Optional description"
rows={3}
/>
</div>
<div className="form-group">
<label>Project</label>
<select
value={formData.project_id || ""}
onChange={(e) => onFormChange("project_id", e.target.value || undefined)}
>
<option value="">Global (all projects)</option>
</select>
</div>
<div className="form-group">
<label>Tool Type</label>
<select
value={formData.tool_type_id || ""}
onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)}
>
<option value="">Any tool type</option>
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={formData.is_default || false}
onChange={(e) => onFormChange("is_default", e.target.checked)}
/>
Default Profile
</label>
</div>
<div className="form-group">
<label>Environment Variables</label>
{Object.entries(formData.env_vars || {}).map(([key, value], index) => (
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={key}
onChange={(e) => {
const newEnvVars = { ...formData.env_vars };
delete newEnvVars[key];
newEnvVars[e.target.value] = value;
onFormChange("env_vars", newEnvVars);
}}
placeholder="KEY"
style={{ flex: 1 }}
/>
<input
type="text"
value={value}
onChange={(e) => {
const newEnvVars = { ...formData.env_vars };
newEnvVars[key] = e.target.value;
onFormChange("env_vars", newEnvVars);
}}
placeholder="value"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const newEnvVars = { ...formData.env_vars };
delete newEnvVars[key];
onFormChange("env_vars", newEnvVars);
}}
className="secondary-button"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
className="secondary-button"
onClick={() => {
onFormChange("env_vars", { ...formData.env_vars, "": "" });
}}
>
<Icon name="add" size="sm" /> Add Variable
</button>
</div>
<div className="form-group">
<label>Mounts</label>
{(formData.mounts || []).map((mount, index) => (
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={mount.target}
onChange={(e) => {
const newMounts = [...(formData.mounts || [])];
newMounts[index] = { ...mount, target: e.target.value };
onFormChange("mounts", newMounts);
}}
placeholder="Target path"
style={{ flex: 1 }}
/>
<select
value={mount.mode}
onChange={(e) => {
const newMounts = [...(formData.mounts || [])];
newMounts[index] = { ...mount, mode: e.target.value as "ro" | "rw" };
onFormChange("mounts", newMounts);
}}
style={{ width: "80px" }}
>
<option value="ro">Read</option>
<option value="rw">Write</option>
</select>
<button
type="button"
onClick={() => {
const newMounts = (formData.mounts || []).filter((_, i) => i !== index);
onFormChange("mounts", newMounts);
}}
className="secondary-button"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
className="secondary-button"
onClick={() => {
onFormChange("mounts", [...(formData.mounts || []), { target: "/", mode: "rw", files: {} }]);
}}
>
<Icon name="add" size="sm" /> Add Mount
</button>
</div>
</MobileEditView>
);
}
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
const profile = profiles.find((p) => p.id === id);
if (profile) {
onSelect(profile);
onViewChange("detail");
}
}}
onItemDelete={(id: string) => onDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
};
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { Icon } from "../../icon";
import { validateGitUrl } from "../../../api/config_profiles";
import type { GitMount, GitMountMapping } from "../../../api/config_profiles";
import { validateGitUrl } from "../../../api/config-profiles";
import type { GitMount, GitMountMapping } from "../../../api/config-profiles";
interface GitMountEditorProps {
mounts: GitMount[];
@@ -1,12 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act } from "@testing-library/react";
import { EventToastBridge } from "./event-toast-bridge";
import { useEventContext } from "../state/events";
import { useEventContext } from "../../../state/events";
import { getUserConfig } from "../../../api/settings";
import { handleEventToast } from "./toast-rules";
import { handleEventToast } from "../../toast-rules";
import type { InstanceEventPayload } from "../../../types/events";
vi.mock("../state/events", () => ({
vi.mock("../../../state/events", () => ({
useEventContext: vi.fn(),
}));
@@ -14,8 +14,8 @@ vi.mock("../../../api/settings", () => ({
getUserConfig: vi.fn(),
}));
vi.mock("./toast-rules", async (importOriginal) => {
const actual = await importOriginal<typeof import("./toast-rules")>();
vi.mock("../../toast-rules", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../toast-rules")>();
return {
...actual,
handleEventToast: vi.fn(),
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { NotificationCenter } from "./notification-center";
import { NotificationProvider } from "../state/notifications";
import { NotificationProvider } from "../../../state/notifications";
vi.mock("../../../api/notifications", () => ({
getNotifications: vi.fn(),
@@ -1,5 +1,7 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { useNotifications } from "../../../hooks/use-notifications";
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
import { NotificationItem } from "./notification-item";
import { Icon } from "../../icon";
@@ -22,15 +24,39 @@ export function NotificationCenter({
setIsDropdownOpen,
} = useNotifications();
const isMobile = useMobileViewport();
const bellRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const updatePosition = useCallback(() => {
if (!bellRef.current) return;
const rect = bellRef.current.getBoundingClientRect();
if (isMobile) {
setDropdownStyle({
top: rect.bottom + 6,
left: "1rem",
right: "1rem",
});
} else {
setDropdownStyle({
top: rect.bottom + 6,
right: window.innerWidth - rect.right,
});
}
}, [isMobile]);
useEffect(() => {
if (!isDropdownOpen) return;
updatePosition();
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
!dropdownRef.current.contains(target) &&
!bellRef.current?.contains(target)
) {
setIsDropdownOpen(false);
}
@@ -42,14 +68,20 @@ export function NotificationCenter({
}
};
const handleResize = () => {
updatePosition();
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", handleResize);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", handleResize);
};
}, [isDropdownOpen, setIsDropdownOpen]);
}, [isDropdownOpen, setIsDropdownOpen, updatePosition]);
useEffect(() => {
if (isDropdownOpen) {
@@ -66,6 +98,7 @@ export function NotificationCenter({
return (
<div className="notification-center">
<button
ref={bellRef}
type="button"
className="notification-bell"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
@@ -79,56 +112,68 @@ export function NotificationCenter({
)}
</button>
{isDropdownOpen && (
<div
ref={dropdownRef}
role="dialog"
aria-label="Notifications"
className="notification-dropdown"
>
<div className="notification-dropdown-header">
<span>Notifications</span>
</div>
<ul className="notification-list">
{notifications.length === 0 ? (
<li className="notification-empty">No notifications</li>
) : (
notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markRead}
onDismiss={dismiss}
/>
))
{isDropdownOpen &&
createPortal(
<>
{isMobile && (
<div
className="notification-dropdown-backdrop"
onClick={() => setIsDropdownOpen(false)}
aria-hidden="true"
/>
)}
</ul>
<div
ref={dropdownRef}
role="dialog"
aria-label="Notifications"
className="notification-dropdown"
style={dropdownStyle}
>
<div className="notification-dropdown-header">
<span>Notifications</span>
</div>
{notifications.length > 0 && (
<div className="notification-dropdown-footer">
<button
type="button"
className="notification-mark-all"
onClick={() => {
void markAllRead();
}}
>
Mark all as read
</button>
<button
type="button"
className="notification-clear-all"
onClick={() => {
void clearAll();
}}
>
Clear all
</button>
<ul className="notification-list">
{notifications.length === 0 ? (
<li className="notification-empty">No notifications</li>
) : (
notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markRead}
onDismiss={dismiss}
/>
))
)}
</ul>
{notifications.length > 0 && (
<div className="notification-dropdown-footer">
<button
type="button"
className="notification-mark-all"
onClick={() => {
void markAllRead();
}}
>
Mark all as read
</button>
<button
type="button"
className="notification-clear-all"
onClick={() => {
void clearAll();
}}
>
Clear all
</button>
</div>
)}
</div>
)}
</div>
)}
</>,
document.body,
)}
</div>
);
}
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Icon } from "../../icon";
import { formatRelativeTime } from "../../../utils/time";
import type { NotificationItem as NotificationItemType } from "../../../api/notifications";
@@ -17,6 +18,18 @@ const severityIconMap: Record<string, IconName> = {
success: "success",
};
function formatMetadataValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
if (typeof value === "boolean") return value ? "Yes" : "No";
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function NotificationItem({
notification,
onMarkRead,
@@ -24,6 +37,11 @@ export function NotificationItem({
}: NotificationItemProps) {
const isUnread = notification.read_at === null;
const iconName = severityIconMap[notification.severity] ?? "info";
const [expanded, setExpanded] = useState(false);
const hasDetails =
!!notification.message ||
Object.keys(notification.metadata || {}).length > 0;
return (
<li
@@ -35,11 +53,36 @@ export function NotificationItem({
</div>
<div className="notification-item-content">
<div className="notification-item-title">{notification.title}</div>
{notification.message && (
<div className="notification-item-message">
{notification.message}
</div>
)}
<div className="notification-item-time">
{formatRelativeTime(notification.created_at)}
</div>
{expanded && notification.metadata && (
<dl className="notification-item-metadata">
{Object.entries(notification.metadata).map(([key, value]) => (
<div key={key} className="notification-metadata-row">
<dt>{key.replace(/_/g, " ")}</dt>
<dd>{formatMetadataValue(value)}</dd>
</div>
))}
</dl>
)}
</div>
<div className="notification-item-actions">
{hasDetails && (
<button
type="button"
className="notification-item-action"
onClick={() => setExpanded(!expanded)}
aria-label={expanded ? "Hide details" : "Show details"}
>
{expanded ? "Less" : "Details"}
</button>
)}
{isUnread && (
<button
type="button"
@@ -0,0 +1,209 @@
import { Icon } from "../../icon";
import { WorkspaceCreateForm } from "../workspace/workspace-create-form";
import type { ProjectWithRepos, WorkspaceSummary } from "../../../types";
interface Props {
project: ProjectWithRepos;
expanded: boolean;
deleteConfirm: boolean;
workspaceLoading: string | null;
showCreateForm: string | null;
/** Mobile detail mode: shows back button instead of chevron toggle. */
showBackButton?: boolean;
onToggle?: () => void;
onBack?: () => void;
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onCreateWorkspace: (repoId: string) => void;
onWorkspaceAction: (
repoId: string,
workspace: WorkspaceSummary,
action: "sync" | "delete",
) => void;
onAddRepository?: () => void;
onCancelCreate: () => void;
onCreated: () => void;
}
export const ProjectCard = ({
project,
expanded,
deleteConfirm,
workspaceLoading,
showCreateForm,
showBackButton = false,
onToggle,
onBack,
onEdit,
onDelete,
onConfirmDelete,
onCancelDelete,
onCreateWorkspace,
onWorkspaceAction,
onAddRepository,
onCancelCreate,
onCreated,
}: Props) => {
return (
<article className="card project-card">
<div className="project-info-row">
{showBackButton ? (
<button
className="mobile-detail-back"
onClick={onBack}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="md" />
</button>
) : (
<button
className="project-toggle"
onClick={onToggle}
type="button"
aria-expanded={expanded}
>
<Icon
name={expanded ? "chevron-down" : "chevron-right"}
size="sm"
/>
</button>
)}
<div>
<h3>{project.name}</h3>
{project.description && (
<p className="muted project-description">{project.description}</p>
)}
</div>
{!showBackButton && project.repositories?.length > 0 && (
<span className="repo-count">
{project.repositories.length} repo
{project.repositories.length > 1 ? "s" : ""}
</span>
)}
<div className="project-actions">
<button className="ghost-button" onClick={onEdit} type="button">
<Icon name="edit" size="sm" /> Edit
</button>
{deleteConfirm ? (
<div className="delete-confirm">
<span>Are you sure?</span>
<button
className="danger-button"
onClick={onConfirmDelete}
type="button"
>
<Icon name="delete" size="sm" /> Delete
</button>
<button
className="ghost-button"
onClick={onCancelDelete}
type="button"
>
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
) : (
<button
className="ghost-button danger-text"
onClick={onDelete}
type="button"
>
<Icon name="delete" size="sm" /> Delete
</button>
)}
</div>
</div>
{expanded && (
<div className="project-detail">
{(project.repositories || []).length === 0 ? (
<p className="muted">No repositories yet.</p>
) : (
<div className="repo-list">
{(project.repositories || []).map((repo) => (
<div key={repo.id} className="repo-block">
<div className="repo-header">
<h4>{repo.name}</h4>
<button
className="btn btn-sm btn-primary"
onClick={() => onCreateWorkspace(repo.id)}
type="button"
>
<Icon name="add" size="sm" /> New Workspace
</button>
</div>
{showCreateForm === repo.id && (
<WorkspaceCreateForm
defaultProjectId={project.id}
defaultRepoId={repo.id}
onSubmit={onCreated}
onCancel={onCancelCreate}
/>
)}
{repo.workspaces.length === 0 ? (
<p className="muted">No workspaces.</p>
) : (
<div className="workspace-grid">
{repo.workspaces.map((ws) => (
<div
key={ws.id}
className={`workspace-chip ${ws.status}`}
>
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
<span className="ws-branch">
<Icon name="branch" size="sm" /> {ws.branch}
</span>
{ws.instance_count > 0 && (
<span className="ws-instances">
{ws.instance_count} tool
{ws.instance_count > 1 ? "s" : ""}
</span>
)}
<div className="ws-actions">
<button
type="button"
disabled={workspaceLoading === ws.id}
onClick={() =>
onWorkspaceAction(repo.id, ws, "sync")
}
>
<Icon name="refresh" size="sm" />
</button>
<button
type="button"
className="danger-text"
disabled={workspaceLoading === ws.id}
onClick={() =>
onWorkspaceAction(repo.id, ws, "delete")
}
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{onAddRepository && (
<div className="project-add-repo">
<button
className="primary-button"
onClick={onAddRepository}
type="button"
>
<Icon name="add" size="sm" /> Add Repository
</button>
</div>
)}
</div>
)}
</article>
);
};
@@ -0,0 +1,71 @@
import { Icon } from "../../icon";
interface Props {
mode: "create" | "edit";
name: string;
description: string;
error: string | null;
onNameChange: (name: string) => void;
onDescriptionChange: (desc: string) => void;
onSubmit: (e: React.FormEvent) => void;
onCancel: () => void;
}
export const ProjectDialog = ({
mode,
name,
description,
error,
onNameChange,
onDescriptionChange,
onSubmit,
onCancel,
}: Props) => {
return (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>{mode === "create" ? "Create Project" : "Edit Project"}</h2>
<form onSubmit={onSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="Project name"
/>
</label>
<label className="form-field">
Description
<textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</label>
{error && <p className="error-text">{error}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={onCancel} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{mode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};
@@ -1,114 +1,289 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
import * as gitRepositoriesApi from "../../../api/git-repositories";
import * as sshKeysApi from "../../../api/ssh-keys";
const mockRepositories = [
{
id: "repo-1",
name: "Main Repo",
path: "/repos/main",
project_id: "proj-1",
owner_id: "user-1",
is_mirror: false,
remote_url: null,
last_push: null,
created_at: null,
},
{
id: "repo-1",
name: "Main Repo",
path: "/repos/main",
project_id: "proj-1",
owner_id: "user-1",
is_mirror: false,
remote_url: null,
ssh_key_id: null,
last_push: null,
created_at: null,
},
];
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
return {
...actual,
useParams: () => ({ projectId: "proj-1" }),
};
const actual =
await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom",
);
return {
...actual,
useParams: () => ({ projectId: "proj-1" }),
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
cleanup();
vi.restoreAllMocks();
});
describe("RepositoriesSettingsTab", () => {
it("opens create dialog and clones an existing repository", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
it("opens create dialog and clones using full URL by default", async () => {
const listMock = vi
.spyOn(gitRepositoriesApi, "listRepositories")
.mockResolvedValue(mockRepositories);
const createMock = vi
.spyOn(gitRepositoriesApi, "createRepository")
.mockResolvedValue(mockRepositories[0]);
const parseMock = vi
.spyOn(gitRepositoriesApi, "parseGitUrl")
.mockResolvedValue({
original_url: "https://github.com/user/repo.git",
base_url: "https://github.com/user/repo.git",
is_valid_clone_url: true,
needs_parsing: false,
host: "github.com",
message: "Valid git repository URL",
error_code: null,
});
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]);
render(<RepositoriesSettingsTab />);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
target: { value: "alice" },
});
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
target: { value: "demo" },
});
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(
screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
{
target: { value: "https://github.com/user/repo.git" },
},
);
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "git@git.commumedia.org:alice/demo.git",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(parseMock).toHaveBeenCalledWith("https://github.com/user/repo.git");
});
it("uses advanced url fallback when requested", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "https://github.com/user/repo.git",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
it("switches to owner/repo mode and requires SSH key for generated SSH URL", async () => {
const listMock = vi
.spyOn(gitRepositoriesApi, "listRepositories")
.mockResolvedValue(mockRepositories);
const createMock = vi
.spyOn(gitRepositoriesApi, "createRepository")
.mockResolvedValue(mockRepositories[0]);
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
{
id: "key-1",
name: "My Key",
public_key: "ssh-ed25519 AAA...",
created_at: "2024-01-01",
},
]);
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i }));
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), {
target: { value: "https://github.com/user/repo.git" },
});
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "https://github.com/user/repo.git",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
it("shows validation when cloning without a remote url", async () => {
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
render(<RepositoriesSettingsTab />);
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.click(
screen.getByRole("button", { name: /use owner\/repo instead/i }),
);
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
target: { value: "alice" },
});
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
target: { value: "demo" },
});
const sshSelect = screen.getByRole("combobox", { name: /ssh key/i });
await waitFor(() =>
expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy(),
);
(sshSelect as HTMLSelectElement).value = "key-1";
fireEvent.change(sshSelect);
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "git@git.commumedia.org:alice/demo.git",
ssh_key_id: "key-1",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
it("accepts SSH URL in full URL mode when SSH key is selected", async () => {
const listMock = vi
.spyOn(gitRepositoriesApi, "listRepositories")
.mockResolvedValue(mockRepositories);
const createMock = vi
.spyOn(gitRepositoriesApi, "createRepository")
.mockResolvedValue(mockRepositories[0]);
// SSH URLs are short-circuited client-side; parseGitUrl should NOT be called
const parseMock = vi
.spyOn(gitRepositoriesApi, "parseGitUrl")
.mockResolvedValue({
original_url: "",
base_url: null,
is_valid_clone_url: false,
needs_parsing: false,
host: null,
message: "",
error_code: null,
});
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
{
id: "key-1",
name: "My Key",
public_key: "ssh-ed25519 AAA...",
created_at: "2024-01-01",
},
]);
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument();
});
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(
screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
{
target: { value: "git@github.com:user/repo.git" },
},
);
// Client-side short-circuit shows valid without calling backend
await waitFor(() => {
expect(screen.getByText(/valid git url/i)).toBeInTheDocument();
});
expect(parseMock).not.toHaveBeenCalled();
const sshSelect = screen.getByRole("combobox", { name: /ssh key/i });
await waitFor(() =>
expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy(),
);
(sshSelect as HTMLSelectElement).value = "key-1";
fireEvent.change(sshSelect);
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "git@github.com:user/repo.git",
ssh_key_id: "key-1",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("rejects SSH URL in full URL mode without an SSH key", async () => {
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(
mockRepositories,
);
const createMock = vi
.spyOn(gitRepositoriesApi, "createRepository")
.mockResolvedValue(mockRepositories[0]);
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
{
id: "key-1",
name: "My Key",
public_key: "ssh-ed25519 AAA...",
created_at: "2024-01-01",
},
]);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(
screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
{
target: { value: "git@github.com:user/repo.git" },
},
);
// Do NOT select an SSH key
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
expect(
screen.getByText(/an ssh key is required for ssh urls/i),
).toBeInTheDocument();
expect(createMock).not.toHaveBeenCalled();
});
it("shows validation when cloning without a remote url", async () => {
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(
mockRepositories,
);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(
screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
{
target: { value: "" },
},
);
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
expect(
screen.getByText(/remote url is required for advanced cloning/i),
).toBeInTheDocument();
});
});
@@ -1,343 +1,408 @@
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";
type CreateMode = "clone" | "blank";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
type UrlValidationStatus =
| "idle"
| "validating"
| "valid"
| "needs-parsing"
| "invalid";
interface RepositoryCreateDialogProps {
projectId: string;
open: boolean;
title: string;
onClose: () => void;
onCreated: () => Promise<void> | void;
projectId: string;
open: boolean;
title: string;
onClose: () => void;
onCreated: () => Promise<void> | void;
}
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
const [createMode, setCreateMode] = useState<CreateMode>("clone");
const [formName, setFormName] = useState("");
const [owner, setOwner] = useState("");
const [repoName, setRepoName] = useState("");
const [advancedUrl, setAdvancedUrl] = useState("");
const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
const [formError, setFormError] = useState<string | null>(null);
const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus;
result: URLParseResult | null;
}>({ status: "idle", result: null });
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
export const RepositoryCreateDialog = ({
projectId,
open,
title,
onClose,
onCreated,
}: RepositoryCreateDialogProps) => {
const [createMode, setCreateMode] = useState<CreateMode>("clone");
const [formName, setFormName] = useState("");
const [owner, setOwner] = useState("");
const [repoName, setRepoName] = useState("");
const [advancedUrl, setAdvancedUrl] = useState("");
const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
const [formError, setFormError] = useState<string | null>(null);
const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus;
result: URLParseResult | null;
}>({ status: "idle", result: null });
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open && debounceTimer.current) {
clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
}, [open]);
const isSshUrl = (url: string): boolean => {
const u = url.trim().toLowerCase();
return u.startsWith("git@") || u.startsWith("ssh://");
};
useEffect(() => {
if (!open) return;
const loadKeys = async () => {
try {
const data = await listSSHKeys();
setSshKeys(data);
} catch {
// ignore
}
};
void loadKeys();
}, [open]);
useEffect(() => {
if (!open && debounceTimer.current) {
clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
}, [open]);
useEffect(() => {
if (!open) return;
if (!useAdvancedUrl) {
setUrlValidation({ status: "idle", result: null });
return;
}
useEffect(() => {
if (!open) return;
const loadKeys = async () => {
try {
const data = await listSSHKeys();
setSshKeys(data);
} catch {
// ignore
}
};
void loadKeys();
}, [open]);
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
useEffect(() => {
if (!open) return;
if (!useAdvancedUrl) {
setUrlValidation({ status: "idle", result: null });
return;
}
if (!advancedUrl.trim()) {
setUrlValidation({ status: "idle", result: null });
return;
}
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
setUrlValidation({ status: "validating", result: null });
if (!advancedUrl.trim()) {
setUrlValidation({ status: "idle", result: null });
return;
}
debounceTimer.current = setTimeout(async () => {
try {
const result = await parseGitUrl(advancedUrl.trim());
if (result.is_valid_clone_url) {
setUrlValidation({ status: "valid", result });
} else if (result.needs_parsing) {
setUrlValidation({ status: "needs-parsing", result });
} else {
setUrlValidation({ status: "invalid", result });
}
} catch {
setUrlValidation({ status: "invalid", result: null });
}
}, 300);
setUrlValidation({ status: "validating", result: null });
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [advancedUrl, open, useAdvancedUrl]);
debounceTimer.current = setTimeout(async () => {
const trimmed = advancedUrl.trim();
// Short-circuit SSH URLs — the backend parseGitUrl may not always
// recognise them, but they are valid clone URLs by definition.
if (isSshUrl(trimmed)) {
setUrlValidation({
status: "valid",
result: {
original_url: trimmed,
base_url: trimmed,
is_valid_clone_url: true,
needs_parsing: false,
host: null,
message: "Valid SSH git URL",
error_code: null,
},
});
return;
}
try {
const result = await parseGitUrl(trimmed);
if (result.is_valid_clone_url) {
setUrlValidation({ status: "valid", result });
} else if (result.needs_parsing) {
setUrlValidation({ status: "needs-parsing", result });
} else {
setUrlValidation({ status: "invalid", result });
}
} catch {
setUrlValidation({ status: "invalid", result: null });
}
}, 300);
const resetForm = () => {
setCreateMode("clone");
setFormName("");
setOwner("");
setRepoName("");
setAdvancedUrl("");
setUseAdvancedUrl(true);
setFormError(null);
setUrlValidation({ status: "idle", result: null });
setSelectedSshKey("");
};
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [advancedUrl, open, useAdvancedUrl]);
const handleClose = () => {
resetForm();
onClose();
};
const resetForm = () => {
setCreateMode("clone");
setFormName("");
setOwner("");
setRepoName("");
setAdvancedUrl("");
setUseAdvancedUrl(true);
setFormError(null);
setUrlValidation({ status: "idle", result: null });
setSelectedSshKey("");
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
const handleClose = () => {
resetForm();
onClose();
};
if (!formName.trim()) {
setFormError("Repository name is required");
return;
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: undefined,
};
if (!formName.trim()) {
setFormError("Repository name is required");
return;
}
if (createMode === "clone") {
if (useAdvancedUrl) {
if (!advancedUrl.trim()) {
setFormError("Remote URL is required for advanced cloning");
return;
}
input.remote_url = advancedUrl.trim();
} else {
if (!owner.trim() || !repoName.trim()) {
setFormError("Owner and repository name are required");
return;
}
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
}
if (selectedSshKey) {
input.ssh_key_id = selectedSshKey;
}
}
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: undefined,
};
await createRepository(projectId, input);
handleClose();
await onCreated();
} catch (error: unknown) {
const response = error as { response?: { data?: { detail?: string } } };
const detail = response.response?.data?.detail;
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
}
};
if (createMode === "clone") {
let remoteUrl: string;
if (useAdvancedUrl) {
if (!advancedUrl.trim()) {
setFormError("Remote URL is required for advanced cloning");
return;
}
remoteUrl = advancedUrl.trim();
} else {
if (!owner.trim() || !repoName.trim()) {
setFormError("Owner and repository name are required");
return;
}
remoteUrl = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
}
const handleUseSuggestedUrl = () => {
if (urlValidation.result?.base_url) {
setAdvancedUrl(urlValidation.result.base_url);
setUrlValidation({ status: "idle", result: null });
setFormError(null);
}
};
if (isSshUrl(remoteUrl) && !selectedSshKey) {
setFormError("An SSH key is required for SSH URLs");
return;
}
const getUrlInputClass = () => {
switch (urlValidation.status) {
case "valid":
return "valid-url";
case "needs-parsing":
return "needs-parsing-url";
case "invalid":
return "invalid-url";
default:
return "";
}
};
input.remote_url = remoteUrl;
if (selectedSshKey) {
input.ssh_key_id = selectedSshKey;
}
}
if (!open) return null;
await createRepository(projectId, input);
handleClose();
await onCreated();
} catch (error: unknown) {
const response = error as { response?: { data?: { detail?: string } } };
const detail = response.response?.data?.detail;
setFormError(
typeof detail === "string" ? detail : "Failed to create repository",
);
}
};
return (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h3>{title}</h3>
<p className="muted">
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
</p>
<form onSubmit={handleSubmit} className="stack">
<div className="form-field">
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "clone"}
onChange={() => setCreateMode("clone")}
/>
Clone existing repository
</label>
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "blank"}
onChange={() => setCreateMode("blank")}
/>
Create blank repository
</label>
</div>
<label className="form-field">
Repository name
<input
type="text"
value={formName}
onChange={(event) => setFormName(event.target.value)}
placeholder="repository-name"
/>
</label>
{createMode === "clone" && !useAdvancedUrl && (
<>
<label className="form-field">
Owner
<input
type="text"
value={owner}
onChange={(event) => setOwner(event.target.value)}
placeholder="owner"
/>
</label>
<label className="form-field">
Repository
<input
type="text"
value={repoName}
onChange={(event) => setRepoName(event.target.value)}
placeholder="repo-name"
/>
</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>
<button
type="button"
className="secondary-button small"
onClick={() => setUseAdvancedUrl(true)}
>
Use full URL instead
</button>
</>
)}
{createMode === "clone" && useAdvancedUrl && (
<>
<label className="form-field">
Remote URL
<input
type="text"
value={advancedUrl}
onChange={(event) => setAdvancedUrl(event.target.value)}
placeholder="https://github.com/user/repo.git"
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{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>
<div className="suggestion-actions">
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
<button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</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
type="button"
className="secondary-button small"
onClick={() => setUseAdvancedUrl(false)}
>
Use owner/repo instead
</button>
</>
)}
{formError && (
<div className="error-message">
<p className="error-text">{formError}</p>
</div>
)}
<div className="dialog-actions">
<button className="secondary-button" onClick={handleClose} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
</button>
</div>
</form>
</div>
</div>
);
const handleUseSuggestedUrl = () => {
if (urlValidation.result?.base_url) {
setAdvancedUrl(urlValidation.result.base_url);
setUrlValidation({ status: "idle", result: null });
setFormError(null);
}
};
const getUrlInputClass = () => {
switch (urlValidation.status) {
case "valid":
return "valid-url";
case "needs-parsing":
return "needs-parsing-url";
case "invalid":
return "invalid-url";
default:
return "";
}
};
if (!open) return null;
return (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h3>{title}</h3>
<p className="muted">
Clone an existing repository from git.commumedia.org, or create a
blank bare repo here.
</p>
<form onSubmit={handleSubmit} className="stack">
<div className="form-field repo-mode-radios">
<label className="repo-mode-label">
<input
type="radio"
name="repository-mode"
checked={createMode === "clone"}
onChange={() => setCreateMode("clone")}
/>
<span>Clone existing</span>
</label>
<label className="repo-mode-label">
<input
type="radio"
name="repository-mode"
checked={createMode === "blank"}
onChange={() => setCreateMode("blank")}
/>
<span>Create blank</span>
</label>
</div>
<label className="form-field">
Repository name
<input
type="text"
value={formName}
onChange={(event) => setFormName(event.target.value)}
placeholder="repository-name"
/>
</label>
{createMode === "clone" && !useAdvancedUrl && (
<>
<label className="form-field">
Owner
<input
type="text"
value={owner}
onChange={(event) => setOwner(event.target.value)}
placeholder="owner"
/>
</label>
<label className="form-field">
Repository
<input
type="text"
value={repoName}
onChange={(event) => setRepoName(event.target.value)}
placeholder="repo-name"
/>
</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>
<button
type="button"
className="secondary-button small"
onClick={() => setUseAdvancedUrl(true)}
>
Use full URL instead
</button>
</>
)}
{createMode === "clone" && useAdvancedUrl && (
<>
<label className="form-field">
Remote URL
<input
type="text"
value={advancedUrl}
onChange={(event) => setAdvancedUrl(event.target.value)}
placeholder="https://github.com/user/repo.git"
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">
Validating...
</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{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>
<div className="suggestion-actions">
<span className="suggested-url">
Suggested: {urlValidation.result.base_url}
</span>
<button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</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
type="button"
className="secondary-button small"
onClick={() => setUseAdvancedUrl(false)}
>
Use owner/repo instead
</button>
</>
)}
{formError && (
<div className="error-message">
<p className="error-text">{formError}</p>
</div>
)}
<div className="dialog-actions">
<button
className="secondary-button"
onClick={handleClose}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
{createMode === "clone"
? "Clone Repository"
: "Create Blank Repository"}
</button>
</div>
</form>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useRef, useEffect } from "react";
import type { Session } from "../../../api/sessions";
import { Icon } from "../../icon";
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
@@ -12,6 +12,7 @@ export interface SessionCardProps {
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
onRename?: (session: Session, newName: string) => void;
isBusy?: boolean;
tunnelHealth?: {
healthy: boolean;
@@ -43,12 +44,15 @@ export function SessionCard({
onStop,
onDelete,
onRecreateTunnel,
onRename,
isBusy = false,
tunnelHealth = null,
}: SessionCardProps) {
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showActionSheet, setShowActionSheet] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(session.display_name);
const [optionsOpen, setOptionsOpen] = useState(false);
const optionsRef = useRef<HTMLDivElement>(null);
const isMobile = useMobileViewport();
const status = statusConfig[session.status] || {
@@ -58,37 +62,11 @@ export function SessionCard({
const isTerminalOnly =
session.tool_type_interfaces?.includes("terminal") &&
!session.tool_type_interfaces?.includes("web");
const openHref = session.url
? session.url
: isTerminalOnly
? `/instances/${session.id}/terminal`
: undefined;
const hasTunnelError =
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
const hasAppError =
!isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
const handleStop = () => {
if (showStopConfirm) {
setShowStopConfirm(false);
onStop?.(session);
} else {
setShowStopConfirm(true);
}
};
const handleDelete = () => {
if (showDeleteConfirm) {
setShowDeleteConfirm(false);
onDelete?.(session);
} else {
setShowDeleteConfirm(true);
}
};
const handleCancelStop = () => setShowStopConfirm(false);
const handleCancelDelete = () => setShowDeleteConfirm(false);
const isActive = [
"running",
"building",
@@ -98,17 +76,76 @@ export function SessionCard({
"unhealthy",
].includes(session.status);
useEffect(() => {
if (!optionsOpen) return;
const handleClick = (e: MouseEvent) => {
if (
optionsRef.current &&
!optionsRef.current.contains(e.target as Node)
) {
setOptionsOpen(false);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOptionsOpen(false);
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKey);
};
}, [optionsOpen]);
const handleDeleteClick = () => {
setOptionsOpen(false);
if (window.confirm("Are you sure you want to delete this session?")) {
onDelete?.(session);
}
};
return (
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
{isBusy && (
<div className="session-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<article className="card session-card">
<div className="session-card-content">
<div className="session-card-header">
<div className="session-card-title">
<h4>{session.display_name}</h4>
{isEditingName ? (
<div className="session-card-rename">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
onRename?.(session, editName);
setIsEditingName(false);
}
if (e.key === "Escape") {
setEditName(session.display_name);
setIsEditingName(false);
}
}}
onBlur={() => {
if (editName.trim() && editName !== session.display_name) {
onRename?.(session, editName);
}
setIsEditingName(false);
}}
autoFocus
/>
</div>
) : (
<h4
onClick={() => {
setEditName(session.display_name);
setIsEditingName(true);
}}
title="Click to rename"
style={{ cursor: "pointer" }}
>
{session.display_name}
</h4>
)}
<div className="session-card-status-badges">
<span className={`status-badge ${status.color}`}>
{status.label}
@@ -123,10 +160,21 @@ export function SessionCard({
)}
</div>
</div>
<p className="muted session-card-meta">
{session.tool_type_name}
{session.project_name && ` · ${session.project_name}`}
{session.repository_name && ` · ${session.repository_name}`}
<p className="muted session-card-meta session-card-context">
<strong>{session.project_name}</strong>
{session.workspace_name && (
<>
{" "}
/ <span>{session.workspace_name}</span>
</>
)}
{!session.workspace_name && session.repository_name && (
<>
{" "}
/ <span>{session.repository_name}</span>
</>
)}{" "}
· {session.tool_type_name}
</p>
{session.clone_mode && (
<p className="muted session-card-meta">
@@ -138,9 +186,13 @@ export function SessionCard({
)}
{session.url && (
<p className="session-card-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
<button
type="button"
className="link-button"
onClick={() => onOpen?.(session)}
>
{session.url}
</a>
</button>
</p>
)}
{session.created_at && (
@@ -155,27 +207,15 @@ export function SessionCard({
<div className="session-card-actions mobile">
{isActive && (
<>
{openHref ? (
<a
href={openHref}
target="_blank"
rel="noopener noreferrer"
className="secondary-button mobile-primary"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button
className="secondary-button mobile-primary"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
Open
</button>
)}
<button
className="secondary-button mobile-primary"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
Open
</button>
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
@@ -187,99 +227,40 @@ export function SessionCard({
</>
)}
{!isActive && onStart && (
<button
className="secondary-button mobile-primary"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
Start
</button>
)}
{!isActive && (
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
<>
<button
className="secondary-button mobile-primary"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
</>
)}
</div>
) : (
<div className="session-card-actions">
{isActive && (
<>
{openHref ? (
<a
href={openHref}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</a>
) : (
<button
className="secondary-button small"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</button>
)}
{!isTerminalOnly && onRecreateTunnel && (
<button
className="ghost-button small"
onClick={() => onRecreateTunnel(session)}
type="button"
disabled={isBusy}
title="Recreate Cloudflare tunnel"
>
<Icon name="refresh" size="sm" />
<span className="action-label">Tunnel</span>
</button>
)}
{showStopConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
Stop
</button>
<button
className="ghost-button small"
onClick={handleCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
<Icon name="stop" size="sm" />
<span className="action-label">Stop</span>
</button>
)}
</>
{isActive && onOpen && (
<button
className="secondary-button small"
onClick={() => onOpen(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</button>
)}
{!isActive && onStart && (
<button
className="secondary-button small"
@@ -292,35 +273,77 @@ export function SessionCard({
</button>
)}
{showDeleteConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Delete?</span>
<button
className="danger-button small"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
Delete
</button>
<button
className="ghost-button small"
onClick={handleCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<div className="session-options" ref={optionsRef}>
<button
className="ghost-button small danger-text"
onClick={handleDelete}
className="ghost-button small"
onClick={() => setOptionsOpen((prev) => !prev)}
type="button"
disabled={isBusy}
aria-haspopup="menu"
aria-expanded={optionsOpen}
>
<Icon name="delete" size="sm" />
<Icon name="more" size="sm" />
<span className="action-label">Options</span>
</button>
)}
{optionsOpen && (
<div className="session-options-dropdown" role="menu">
{isActive && onStop && (
<button
className="session-option-item"
onClick={() => {
setOptionsOpen(false);
onStop(session);
}}
type="button"
role="menuitem"
>
<Icon name="stop" size="sm" />
Stop
</button>
)}
{!isActive && onStart && (
<button
className="session-option-item"
onClick={() => {
setOptionsOpen(false);
onStart(session);
}}
type="button"
role="menuitem"
>
<Icon name="play" size="sm" />
Start
</button>
)}
{isActive && !isTerminalOnly && onRecreateTunnel && (
<button
className="session-option-item"
onClick={() => {
setOptionsOpen(false);
onRecreateTunnel(session);
}}
type="button"
role="menuitem"
>
<Icon name="refresh" size="sm" />
Recreate Tunnel
</button>
)}
{onDelete && (
<button
className="session-option-item danger-text"
onClick={handleDeleteClick}
type="button"
role="menuitem"
>
<Icon name="delete" size="sm" />
Delete
</button>
)}
</div>
)}
</div>
</div>
)}
@@ -350,6 +373,16 @@ export function SessionCard({
},
]
: []),
...(!isActive && onStart
? [
{
id: "start",
label: "Start",
icon: "play" as IconName,
onClick: () => onStart(session),
},
]
: []),
...(onDelete
? [
{
@@ -3,123 +3,137 @@ import { SessionCard } from "./session-card";
import type { InstanceHealth } from "../../../api/sessions";
export interface SessionListProps {
sessions: Session[];
onOpen?: (session: Session) => void;
onStart?: (session: Session) => void;
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
actionBusyId?: string | null;
tunnelHealth?: Record<string, InstanceHealth>;
showGrouping?: boolean;
activeTitle?: string;
recentTitle?: string;
maxRecent?: number;
emptyMessage?: string;
sessions: Session[];
onOpen?: (session: Session) => void;
onStart?: (session: Session) => void;
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
onRename?: (session: Session, newName: string) => void;
actionBusyId?: string | null;
tunnelHealth?: Record<string, InstanceHealth>;
showGrouping?: boolean;
activeTitle?: string;
recentTitle?: string;
maxRecent?: number;
emptyMessage?: string;
}
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
const activeStatuses = [
"running",
"building",
"starting",
"probing",
"pending",
"unhealthy",
];
const recentStatuses = ["stopped", "error"];
export function SessionList({
sessions,
onOpen,
onStart,
onStop,
onDelete,
onRecreateTunnel,
actionBusyId = null,
tunnelHealth = {},
showGrouping = true,
activeTitle = "Active Sessions",
recentTitle = "Recent Sessions",
maxRecent = 5,
emptyMessage = "No sessions",
sessions,
onOpen,
onStart,
onStop,
onDelete,
onRecreateTunnel,
onRename,
actionBusyId = null,
tunnelHealth = {},
showGrouping = true,
activeTitle = "Active Sessions",
recentTitle = "Recent Sessions",
maxRecent = 5,
emptyMessage = "No sessions",
}: SessionListProps) {
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
const recentSessions = sessions
.filter((s) => recentStatuses.includes(s.status))
.slice(0, maxRecent);
const activeSessions = sessions.filter((s) =>
activeStatuses.includes(s.status),
);
const recentSessions = sessions
.filter((s) => recentStatuses.includes(s.status))
.slice(0, maxRecent);
if (!showGrouping) {
return (
<div className="sessions-grid">
{sessions.length === 0 ? (
<p className="muted">{emptyMessage}</p>
) : (
sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))
)}
</div>
);
}
if (!showGrouping) {
return (
<div className="sessions-grid">
{sessions.length === 0 ? (
<p className="muted">{emptyMessage}</p>
) : (
sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
onRename={onRename}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))
)}
</div>
);
}
return (
<div className="session-list">
{/* Active Sessions */}
<div className="session-group">
<div className="session-group-header">
<h3>{activeTitle}</h3>
{activeSessions.length > 0 && (
<span className="badge">{activeSessions.length}</span>
)}
</div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions</p>
) : (
<div className="sessions-grid">
{activeSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
)}
</div>
return (
<div className="session-list">
{/* Active Sessions */}
<div className="session-group">
<div className="session-group-header">
<h3>{activeTitle}</h3>
{activeSessions.length > 0 && (
<span className="badge">{activeSessions.length}</span>
)}
</div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions</p>
) : (
<div className="sessions-grid">
{activeSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
onRename={onRename}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
)}
</div>
{/* Recent Sessions */}
{recentSessions.length > 0 && (
<div className="session-group">
<div className="session-group-header">
<h3>{recentTitle}</h3>
<span className="badge">{recentSessions.length}</span>
</div>
<div className="sessions-grid">
{recentSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
</div>
)}
</div>
);
{/* Recent Sessions */}
{recentSessions.length > 0 && (
<div className="session-group">
<div className="session-group-header">
<h3>{recentTitle}</h3>
<span className="badge">{recentSessions.length}</span>
</div>
<div className="sessions-grid">
{recentSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
onRename={onRename}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,115 @@
import { useEffect } from "react";
import {
useSessionOperations,
type Operation,
} from "../../../state/session-operations";
import { useEventContext } from "../../../state/events";
import { Icon } from "../../icon";
interface StepConfig {
label: string;
index: number;
}
const steps: StepConfig[] = [
{ label: "Created", index: 1 },
{ label: "Building", index: 2 },
{ label: "Starting", index: 3 },
{ label: "Ready", index: 4 },
];
function OperationItem({
operation,
onDismiss,
}: {
operation: Operation;
onDismiss: () => void;
}) {
const isDone = operation.status === "success" || operation.status === "error";
const isError = operation.status === "error";
return (
<div
className={`session-operation-item ${operation.status}`}
role="status"
aria-live="polite"
>
<div className="session-operation-header">
<div className="session-operation-title">
{isError ? (
<Icon name="error" size="sm" />
) : isDone ? (
<Icon name="success" size="sm" />
) : (
<Icon name="loading" size="sm" />
)}
<span className="session-operation-name">{operation.displayName}</span>
</div>
{isDone && (
<button
type="button"
className="session-operation-dismiss"
onClick={onDismiss}
aria-label="Dismiss"
>
<Icon name="close" size="sm" />
</button>
)}
</div>
<p className="session-operation-message">{operation.message}</p>
<div className="session-operation-steps">
{steps.map((step) => {
const active = operation.step >= step.index;
const current = operation.step === step.index && !isDone;
return (
<span
key={step.label}
className={`session-operation-step ${active ? "active" : ""} ${current ? "current" : ""}`}
>
{step.label}
</span>
);
})}
</div>
</div>
);
}
export function SessionProgressPanel() {
const { operations, updateOperationFromEvent, dismissOperation } =
useSessionOperations();
const { events } = useEventContext();
useEffect(() => {
if (events.length === 0) return;
const latestEvent = events[events.length - 1];
updateOperationFromEvent(latestEvent);
}, [events, updateOperationFromEvent]);
const visibleOperations = operations.filter(
(op) =>
op.status === "pending" ||
op.status === "active" ||
(op.status === "success" && Date.now() - op.createdAt < 5000) ||
op.status === "error",
);
if (visibleOperations.length === 0) return null;
return (
<div className="session-progress-panel" role="region" aria-label="Session operations">
<div className="session-progress-panel-header">
<span className="session-progress-panel-title">Operations</span>
</div>
<div className="session-progress-panel-list">
{visibleOperations.map((operation) => (
<OperationItem
key={operation.id}
operation={operation}
onDismiss={() => dismissOperation(operation.id)}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,149 @@
import { useOutletContext } from "react-router-dom";
import { Icon } from "../../icon";
import type { UserConfig, UserConfigUpdate } from "../../../api/settings";
type SettingsOutletContext = {
config: UserConfig;
handleChange: (
key: keyof UserConfigUpdate,
value: string | string[] | null,
) => void;
handleSave: () => Promise<void>;
saveStatus: "idle" | "saving" | "saved" | "error";
};
const THEME_OPTIONS = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
const TOAST_LEVEL_OPTIONS = [
{ value: "all", label: "All" },
{ value: "errors", label: "Errors only" },
{ value: "none", label: "None" },
];
const MUTE_CATEGORIES = ["instance", "system", "health", "security"];
export const GeneralSettingsTab = () => {
const { config, handleChange, handleSave, saveStatus } =
useOutletContext<SettingsOutletContext>();
return (
<div className="stack">
<h2>General</h2>
<label className="form-field">
Theme
<select
value={config.theme}
onChange={(e) => handleChange("theme", e.target.value)}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="form-field">
Git user name
<input
type="text"
value={config.git_user_name ?? ""}
onChange={(e) =>
handleChange("git_user_name", e.target.value || null)
}
placeholder="Your git commit name"
/>
</label>
<label className="form-field">
Git user email
<input
type="email"
value={config.git_user_email ?? ""}
onChange={(e) =>
handleChange("git_user_email", e.target.value || null)
}
placeholder="your.email@example.com"
/>
</label>
<label className="form-field">
Default editor
<input
type="text"
value={config.default_editor ?? ""}
onChange={(e) =>
handleChange("default_editor", e.target.value || null)
}
placeholder="e.g., vscode, vim, cursor"
/>
</label>
<h3>Notifications</h3>
<label className="form-field">
Toast level
<select
value={config.notification_toast_level ?? "all"}
onChange={(e) =>
handleChange("notification_toast_level", e.target.value)
}
>
{TOAST_LEVEL_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<fieldset className="form-field">
<legend>Mute categories</legend>
<div className="stack-sm">
{MUTE_CATEGORIES.map((cat) => (
<label
key={cat}
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<input
type="checkbox"
checked={(config.notification_mute_categories ?? []).includes(
cat,
)}
onChange={(e) => {
const current = config.notification_mute_categories ?? [];
const next = e.target.checked
? [...current, cat]
: current.filter((c) => c !== cat);
handleChange("notification_mute_categories", next);
}}
/>
{cat}
</label>
))}
</div>
</fieldset>
<div className="settings-actions">
<button
className="primary-button"
onClick={() => void handleSave()}
type="button"
>
{saveStatus === "saving" ? (
<>
<Icon name="loading" size="sm" /> Saving...
</>
) : (
<>
<Icon name="save" size="sm" /> Save Settings
</>
)}
</button>
{saveStatus === "saved" && (
<span className="success-text">Settings saved!</span>
)}
{saveStatus === "error" && (
<span className="error-text">Failed to save</span>
)}
</div>
</div>
);
};
@@ -0,0 +1,39 @@
import { Icon } from "../../icon";
interface Props {
newKeyName: string;
setNewKeyName: (name: string) => void;
generating: boolean;
onSubmit: (e: React.FormEvent) => void;
}
export const SSHKeyCreateForm = ({ newKeyName, setNewKeyName, generating, onSubmit }: Props) => {
return (
<form onSubmit={onSubmit} className="stack">
<div className="form-group">
<label htmlFor="key-name">Key Name</label>
<input
id="key-name"
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="e.g., GitHub Work"
required
/>
</div>
<button type="submit" className="primary-button" disabled={generating}>
{generating ? (
<>
<Icon name="loading" size="sm" />
Generating...
</>
) : (
<>
<Icon name="add" size="sm" />
Generate SSH Key
</>
)}
</button>
</form>
);
};
@@ -0,0 +1,180 @@
import { Icon } from "../../icon";
import { EmptyState, ErrorState } from "../../data-states";
import type { SSHKey } from "../../../api/ssh-keys";
interface Props {
keys: SSHKey[];
status: "idle" | "loading" | "ready" | "error";
signPayloads: Record<string, string>;
signatures: Record<string, string>;
signing: Record<string, boolean>;
verifyPayloads: Record<string, string>;
verifySignatures: Record<string, string>;
verifyResults: Record<string, boolean | null>;
verifying: Record<string, boolean>;
onLoadKeys: () => void;
onDelete: (id: string) => void;
onCopy: (text: string) => void;
onSign: (id: string) => void;
onVerify: (id: string) => void;
onSignPayloadChange: (id: string, value: string) => void;
onVerifyPayloadChange: (id: string, value: string) => void;
onVerifySignatureChange: (id: string, value: string) => void;
}
export const SSHKeyList = ({
keys,
status,
signPayloads,
signatures,
signing,
verifyPayloads,
verifySignatures,
verifyResults,
verifying,
onLoadKeys,
onDelete,
onCopy,
onSign,
onVerify,
onSignPayloadChange,
onVerifyPayloadChange,
onVerifySignatureChange,
}: Props) => {
if (status === "error") {
return <ErrorState message="Failed to load SSH keys" onRetry={onLoadKeys} />;
}
if (keys.length === 0) {
return <EmptyState message="No SSH keys yet. Generate one above." />;
}
return (
<div className="keys-list">
{keys.map((key) => (
<div key={key.id} className="key-card">
<div className="key-header">
<h3>{key.name}</h3>
<button onClick={() => onDelete(key.id)} className="danger-button">
<Icon name="delete" size="sm" />
Delete
</button>
</div>
<div className="key-meta">
<span className="muted">
Created: {new Date(key.created_at).toLocaleDateString()}
</span>
</div>
<div className="key-public">
<code>{key.public_key.substring(0, 50)}...</code>
<button
onClick={() => onCopy(key.public_key)}
className="secondary-button"
>
<Icon name="copy" size="sm" />
Copy Full Key
</button>
</div>
<div className="key-signing">
<h4>Sign Payload</h4>
<div className="form-group">
<textarea
value={signPayloads[key.id] || ""}
onChange={(e) => onSignPayloadChange(key.id, e.target.value)}
placeholder="Enter payload to sign..."
rows={3}
/>
</div>
<button
onClick={() => onSign(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={() => onCopy(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) => onVerifyPayloadChange(key.id, e.target.value)}
placeholder="Enter payload..."
rows={2}
/>
</div>
<div className="form-group">
<textarea
value={verifySignatures[key.id] || ""}
onChange={(e) => onVerifySignatureChange(key.id, e.target.value)}
placeholder="Enter base64 signature..."
rows={2}
/>
</div>
<button
onClick={() => onVerify(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>
);
};
@@ -0,0 +1,198 @@
import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal";
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
import type { TerminalSession } from "../../../api/terminal";
interface Props {
instanceId: string;
sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[];
activeSessionId: string;
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
isFullscreen: boolean;
status: string;
error: string | null;
loading: boolean;
showResetConfirm: boolean;
onFullscreenClick: (e: React.MouseEvent<HTMLElement>) => void;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onCreate: () => void;
onRename: (id: string, name: string) => void;
onNavigateBack: () => void;
onToggleFullscreen: () => void;
onFontSizeChange: (delta: number) => void;
onShowResetConfirm: () => void;
onHideResetConfirm: () => void;
onReset: () => void;
onTerminalReady: (
sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => void;
}
export const DesktopTerminalView: React.FC<Props> = ({
instanceId,
sessions,
sessionInfos,
activeSessionId,
terminalRefs,
isFullscreen,
status,
error,
loading,
showResetConfirm,
onFullscreenClick,
onSelect,
onClose,
onCreate,
onRename,
onNavigateBack,
onToggleFullscreen,
onFontSizeChange,
onShowResetConfirm,
onHideResetConfirm,
onReset,
onTerminalReady,
}) => {
return (
<section
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
onClick={onFullscreenClick}
>
{!isFullscreen && (
<div className="terminal-page-header">
<button className="secondary-button" onClick={onNavigateBack} type="button">
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={onToggleFullscreen}
type="button"
title="Toggle fullscreen (Alt+Shift+F)"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</div>
)}
{isFullscreen ? (
<div className="terminal-fullscreen-header">
<div className="terminal-fullscreen-header-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId}
onSelect={onSelect}
onClose={onClose}
onCreate={onCreate}
onRename={onRename}
isMobile={false}
/>
</div>
<div className="terminal-fullscreen-header-controls">
<span
className={`terminal-fullscreen-status status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<button
className="terminal-header-button"
onClick={() => onFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => onFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={onShowResetConfirm}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
<button
className="terminal-close"
onClick={onToggleFullscreen}
type="button"
title="Exit fullscreen (Esc)"
>
Exit
</button>
</div>
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>
Reset terminal? This will kill the current shell session and
start fresh.
</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={onHideResetConfirm}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
onHideResetConfirm();
onReset();
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
</div>
) : (
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId}
onSelect={onSelect}
onClose={onClose}
onCreate={onCreate}
onRename={onRename}
isMobile={false}
/>
)}
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={false}
showControls={!isFullscreen}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
};
@@ -0,0 +1,161 @@
import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal";
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
import { Icon } from "../../icon";
import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel";
import type { ModifierKey } from "../../../hooks/use-special-keys";
import type { TerminalSession } from "../../../api/terminal";
interface Props {
instanceId: string;
sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[];
activeSessionId: string;
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
status: string;
error: string | null;
loading: boolean;
isKeyboardOpen: boolean;
keyboardHeight: number;
isVisible: boolean;
activeModifier: ModifierKey | null;
showSpecialKeysPanel: boolean;
onToggleHeader: () => void;
onNavigateBack: () => void;
onFontSizeChange: (delta: number) => void;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onCreate: () => void;
onRename: (id: string, name: string) => void;
onTerminalReady: (
sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => void;
onSendKey: (data: string) => void;
onModifierChange: (mod: ModifierKey | null) => void;
onShowSpecialKeys: () => void;
onHideSpecialKeys: () => void;
onKeepFocus: () => void;
}
export const MobileTerminalView: React.FC<Props> = ({
instanceId,
sessions,
sessionInfos,
activeSessionId,
terminalRefs,
status,
error,
loading,
isKeyboardOpen,
keyboardHeight,
isVisible,
activeModifier,
showSpecialKeysPanel,
onToggleHeader,
onNavigateBack,
onFontSizeChange,
onSelect,
onClose,
onCreate,
onRename,
onTerminalReady,
onSendKey,
onModifierChange,
onShowSpecialKeys,
onHideSpecialKeys,
onKeepFocus,
}) => {
const activeSession = sessions.find((s) => s.id === activeSessionId);
return (
<section className="terminal-page mobile">
<div className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`} onClick={(e) => e.stopPropagation()}>
<div className="mobile-terminal-toolbar">
<div className="mobile-terminal-toolbar-left">
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Back">
<Icon name="arrow-left" size="sm" />
</button>
</div>
<div className="mobile-terminal-toolbar-center">
<span className="mobile-terminal-title">{activeSession?.name || "Terminal"}</span>
<span className={`mobile-terminal-status status-dot ${status}`} aria-label={`Connection status: ${status}`} />
</div>
<div className="mobile-terminal-toolbar-right">
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(-1)} type="button" aria-label="Decrease font size">
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(1)} type="button" aria-label="Increase font size">
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Exit terminal">
<Icon name="close" size="sm" />
</button>
</div>
</div>
<div className="mobile-terminal-overlay-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId}
onSelect={onSelect}
onClose={onClose}
onCreate={onCreate}
onRename={onRename}
isMobile={true}
/>
</div>
</div>
<div
className="terminal-page-content mobile-full"
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
onClick={onToggleHeader}
>
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={true}
showControls={false}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
<SpecialKeysStrip
onSend={onSendKey}
isVisible={!showSpecialKeysPanel}
onMoreClick={onShowSpecialKeys}
onKeepFocus={onKeepFocus}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
/>
<SpecialKeysPanel
onSend={onSendKey}
isOpen={showSpecialKeysPanel}
onClose={onHideSpecialKeys}
onKeepFocus={onKeepFocus}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
/>
</section>
);
};
@@ -8,7 +8,6 @@ import React, {
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
import { WebglAddon } from "xterm-addon-webgl";
import "xterm/css/xterm.css";
import {
@@ -310,25 +309,13 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
// Load WebGL renderer for GPU acceleration, fall back to DOM
let webglAddon: WebglAddon | null = null;
try {
webglAddon = new WebglAddon();
term.loadAddon(webglAddon);
webglAddon.onContextLoss(() => {
console.warn("WebGL context lost, falling back to DOM renderer");
try {
webglAddon?.dispose();
} catch {
// ignore
}
webglAddon = null;
// Trigger a refit since cell dimensions may differ
requestAnimationFrame(() => fitTerminal());
});
} catch (e) {
console.warn("WebGL renderer failed to load, using DOM renderer", e);
}
// NOTE: WebGL renderer disabled.
// The WebGL addon causes black-on-black rendering artifacts with
// tmux/vim reverse-video (inverse color) sequences on desktop.
// Mobile already uses the DOM renderer (WebGL fails there), which
// handles these color attributes correctly. The DOM renderer is
// fast enough for typical terminal workloads.
// See: xterm.js WebGL known issues with reverse video / minimumContrastRatio
const container = terminalRef.current;
@@ -585,16 +572,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
// Dispose WebGL addon BEFORE the terminal to avoid race with
// RenderService.setRenderer accessing a disposed renderer
if (webglAddon) {
try {
webglAddon.dispose();
} catch {
// Ignore disposal errors from partially torn-down terminal
}
webglAddon = null;
}
try {
term.dispose();
} catch {
@@ -0,0 +1,373 @@
import { Icon } from "../../icon";
import { ManifestEditor } from "../tool/manifest-editor";
import type { ToolType } from "../../../api/tool-types";
import type { ToolDefinitionManifest } from "../../../api/tool-definitions";
export interface ToolTypeFormState {
name: string;
display_name: string;
description: string;
category: string;
interface_type: "web" | "terminal";
requires_port: boolean;
default_port: string;
definition_type: "compose" | "dockerfile" | "manifest";
compose_template: string;
dockerfile_template: string;
readiness_command: string;
readiness_timeout: string;
readiness_interval: string;
required_variables: string;
startup_command: string;
}
interface ToolTypeEditorPanelProps {
isCreating: boolean;
selectedToolType: ToolType | null;
form: ToolTypeFormState;
manifestData: Record<string, unknown> | null;
manifestDefinitionId: string | null;
baseDefinitions: ToolDefinitionManifest[];
toolTypeError: string | null;
toolTypeDirty: boolean;
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
onManifestChange: (manifest: Record<string, unknown> | null) => void;
onSubmit: (e: React.FormEvent) => void;
onReset: () => void;
}
export const ToolTypeEditorPanel = ({
isCreating,
selectedToolType,
form,
manifestData,
manifestDefinitionId,
baseDefinitions,
toolTypeError,
toolTypeDirty,
onFormChange,
onManifestChange,
onSubmit,
onReset,
}: ToolTypeEditorPanelProps) => {
const hasSelection = isCreating || selectedToolType;
return (
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
{!hasSelection ? (
<div
style={{
textAlign: "center",
paddingTop: "4rem",
color: "var(--muted)",
}}
>
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
<Icon name="code" size="lg" />
</div>
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>
Select a tool type
</h3>
<p style={{ margin: 0 }}>
Choose a tool from the list to edit, or create a new one.
</p>
</div>
) : (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
{isCreating
? "Create Tool Type"
: selectedToolType?.display_name}
</h1>
{!isCreating && (
<p className="muted" style={{ margin: 0 }}>
{selectedToolType?.name} · {selectedToolType?.definition_type}{" "}
·{" "}
{selectedToolType?.interface_type === "web"
? `Port ${selectedToolType?.default_port}`
: "Terminal"}
</p>
)}
</div>
<form
onSubmit={onSubmit}
className="stack"
style={{ gap: "1rem", maxWidth: "800px" }}
>
<div className="form-group">
<label htmlFor="definition-type">Definition Type</label>
<select
id="definition-type"
value={form.definition_type}
onChange={(e) => {
onFormChange({
definition_type: e.target.value as
| "compose"
| "dockerfile"
| "manifest",
});
}}
className="form-input"
disabled={!isCreating}
>
<option value="compose">Docker Compose</option>
<option value="dockerfile">Dockerfile</option>
<option value="manifest">Manifest (Declarative)</option>
</select>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-name">Name *</label>
<input
id="tool-type-name"
type="text"
value={form.name}
onChange={(e) => {
onFormChange({ name: e.target.value });
}}
disabled={!isCreating}
placeholder="e.g., code-server"
className="form-input"
required
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-display-name">
Display Name *
</label>
<input
id="tool-type-display-name"
type="text"
value={form.display_name}
onChange={(e) => {
onFormChange({ display_name: e.target.value });
}}
placeholder="e.g., VS Code Server"
className="form-input"
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="tool-type-description">Description</label>
<input
id="tool-type-description"
type="text"
value={form.description}
onChange={(e) => {
onFormChange({ description: e.target.value });
}}
placeholder="Optional description"
className="form-input"
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-category">Category</label>
<input
id="tool-type-category"
type="text"
value={form.category}
onChange={(e) => {
onFormChange({ category: e.target.value });
}}
placeholder="e.g., editor, notebook, ai-assistant"
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-interface">Interface Type</label>
<select
id="tool-type-interface"
value={form.interface_type}
onChange={(e) => {
const value = e.target.value as "web" | "terminal";
onFormChange({
interface_type: value,
requires_port: value === "web",
default_port:
value === "web" ? form.default_port : "",
});
}}
className="form-input"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
</div>
{form.interface_type === "terminal" && (
<div className="form-group">
<label htmlFor="tool-type-startup-command">
Startup Command
</label>
<input
id="tool-type-startup-command"
type="text"
value={form.startup_command}
onChange={(e) => {
onFormChange({ startup_command: e.target.value });
}}
placeholder="e.g., cd /workspace && ls"
className="form-input"
/>
<small className="form-help">
Command to run before the interactive shell for each new
terminal session.
</small>
</div>
)}
{form.requires_port && (
<div className="form-group">
<label htmlFor="tool-type-default-port">
Default Port *
</label>
<input
id="tool-type-default-port"
type="number"
value={form.default_port}
onChange={(e) => {
onFormChange({ default_port: e.target.value });
}}
placeholder="e.g., 8443"
className="form-input"
required
/>
</div>
)}
{form.definition_type === "manifest" ? (
<ManifestEditor
manifest={manifestData}
baseDefinitions={baseDefinitions}
onChange={(m) => {
onManifestChange(m);
}}
definitionId={manifestDefinitionId}
/>
) : (
<div className="form-group">
<label htmlFor="tool-type-template">
{form.definition_type === "compose"
? "Compose Template"
: "Dockerfile"}{" "}
*
</label>
<textarea
id="tool-type-template"
value={
form.definition_type === "compose"
? form.compose_template
: form.dockerfile_template
}
onChange={(e) => {
if (form.definition_type === "compose") {
onFormChange({ compose_template: e.target.value });
} else {
onFormChange({ dockerfile_template: e.target.value });
}
}}
rows={12}
placeholder={
form.definition_type === "compose"
? "version: '3.8'\nservices:\n app:\n image: ..."
: "FROM node:18\nWORKDIR /app\n..."
}
className="form-input"
style={{
fontFamily: "monospace",
fontSize: "0.875rem",
}}
required
/>
</div>
)}
<div className="form-group">
<label htmlFor="readiness-command">
Readiness Probe Command
</label>
<input
id="readiness-command"
type="text"
value={form.readiness_command}
onChange={(e) => {
onFormChange({ readiness_command: e.target.value });
}}
placeholder="e.g., curl -f http://localhost:8080"
className="form-input"
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
<input
id="readiness-timeout"
type="number"
value={form.readiness_timeout}
onChange={(e) => {
onFormChange({ readiness_timeout: e.target.value });
}}
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="readiness-interval">
Interval (seconds)
</label>
<input
id="readiness-interval"
type="number"
value={form.readiness_interval}
onChange={(e) => {
onFormChange({ readiness_interval: e.target.value });
}}
className="form-input"
/>
</div>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={form.required_variables}
onChange={(e) => {
onFormChange({ required_variables: e.target.value });
}}
placeholder="REPO_PATH, TOOL_NAME"
className="form-input"
/>
</div>
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
<div className="dialog-actions" style={{ marginTop: "1rem" }}>
<button type="submit">
<Icon name={isCreating ? "add" : "save"} size="sm" />
{isCreating ? "Create Tool Type" : "Save Changes"}
</button>
{toolTypeDirty && (
<button
type="button"
onClick={onReset}
className="button-secondary"
>
<Icon name="cancel" size="sm" /> Discard
</button>
)}
</div>
</form>
</div>
)}
</div>
);
};
@@ -0,0 +1,161 @@
import { Icon } from "../../icon";
import type { ToolType } from "../../../api/tool-types";
interface ToolTypeListSidebarProps {
toolTypes: ToolType[];
selectedToolTypeId: string | null;
onSelect: (toolType: ToolType) => void;
onCreate: () => void;
onDelete: (id: string) => void;
}
export const ToolTypeListSidebar = ({
toolTypes,
selectedToolTypeId,
onSelect,
onCreate,
onDelete,
}: ToolTypeListSidebarProps) => {
return (
<div
style={{
width: "280px",
minWidth: "280px",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
background: "var(--panel)",
}}
>
<div
style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}
>
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Tool Workshop</h2>
<p
className="muted"
style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}
>
{toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""}
</p>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
{toolTypes.map((toolType) => (
<button
key={toolType.id}
onClick={() => onSelect(toolType)}
style={{
width: "100%",
textAlign: "left",
padding: "0.75rem 1rem",
marginBottom: "0.25rem",
borderRadius: "0.375rem",
border: "none",
background:
selectedToolTypeId === toolType.id
? "var(--brand)"
: "transparent",
color:
selectedToolTypeId === toolType.id ? "white" : "var(--ink)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.75rem",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (selectedToolTypeId !== toolType.id) {
e.currentTarget.style.background = "#ece7df";
}
}}
onMouseLeave={(e) => {
if (selectedToolTypeId !== toolType.id) {
e.currentTarget.style.background = "transparent";
}
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{toolType.display_name}
</div>
<div
style={{
fontSize: "0.8125rem",
opacity: 0.8,
marginTop: "0.125rem",
}}
>
{toolType.category || "Uncategorized"} ·{" "}
{toolType.interface_type === "web"
? `Port ${toolType.default_port}`
: "Terminal"}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(toolType.id);
}}
style={{
background: "none",
border: "none",
color:
selectedToolTypeId === toolType.id
? "rgba(255,255,255,0.8)"
: "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete tool type"
>
<Icon name="delete" size="sm" />
</button>
</button>
))}
</div>
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
<button
onClick={onCreate}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "2px dashed var(--border)",
background: "transparent",
color: "var(--muted)",
cursor: "pointer",
fontWeight: 600,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--brand)";
e.currentTarget.style.color = "var(--brand)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--muted)";
}}
>
<Icon name="add" size="sm" /> New Tool Type
</button>
</div>
</div>
);
};
@@ -0,0 +1,350 @@
import { MobileListView } from "../mobile/mobile-list-view";
import { MobileDetailView } from "../mobile/mobile-detail-view";
import { MobileEditView } from "../mobile/mobile-edit-view";
import { MobileFAB } from "../mobile/mobile-fab";
import type { ToolType } from "../../../api/tool-types";
import type { ToolTypeFormState } from "./ToolTypeEditorPanel";
export type MobileView = "list" | "detail" | "edit";
interface ToolWorkshopMobileViewProps {
toolTypes: ToolType[];
selectedToolType: ToolType | null;
mobileView: MobileView;
isCreating: boolean;
toolTypeForm: ToolTypeFormState;
toolTypeError: string | null;
onViewChange: (view: MobileView) => void;
onSelect: (toolType: ToolType) => void;
onCreate: () => void;
onDelete: (id: string) => void;
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
onSubmit: () => void;
onCancel: () => void;
}
export const ToolWorkshopMobileView = ({
toolTypes,
selectedToolType,
mobileView,
isCreating,
toolTypeForm,
toolTypeError,
onViewChange,
onSelect,
onCreate,
onDelete,
onFormChange,
onSubmit,
onCancel,
}: ToolWorkshopMobileViewProps) => {
if (mobileView === "list") {
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Tool Workshop</h1>
<span className="muted">{toolTypes.length} tool types</span>
</div>
<MobileListView
items={toolTypes.map((t) => ({
id: t.id,
title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))}
onItemClick={(id) => {
const toolType = toolTypes.find((t) => t.id === id);
if (toolType) {
onSelect(toolType);
onViewChange("detail");
}
}}
emptyMessage="No tool types yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
}
if (mobileView === "detail" && selectedToolType) {
return (
<MobileDetailView
title={selectedToolType.display_name}
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
fields={[
{ label: "Name", value: selectedToolType.name },
{ label: "Display Name", value: selectedToolType.display_name },
{ label: "Description", value: selectedToolType.description },
{ label: "Category", value: selectedToolType.category },
{ label: "Interface Type", value: selectedToolType.interface_type },
{
label: "Requires Port",
value: selectedToolType.requires_port,
type: "boolean",
},
{ label: "Default Port", value: selectedToolType.default_port },
{
label: "Definition Type",
value: selectedToolType.definition_type,
},
{
label: "Startup Command",
value: selectedToolType.startup_command,
},
{
label: "Readiness Command",
value: selectedToolType.readiness_probe?.command ?? null,
},
{
label: "Readiness Timeout",
value: selectedToolType.readiness_probe?.timeout ?? null,
},
{
label: "Readiness Interval",
value: selectedToolType.readiness_probe?.interval ?? null,
},
{
label: "Required Variables",
value: selectedToolType.required_variables?.join(", ") ?? null,
},
{
label: "Compose Template",
value: selectedToolType.compose_template,
type: "code",
},
{
label: "Dockerfile Template",
value: selectedToolType.dockerfile_template,
type: "code",
},
]}
onEdit={() => {
onViewChange("edit");
}}
onDelete={() => {
void onDelete(selectedToolType.id);
onViewChange("list");
}}
onBack={() => {
onViewChange("list");
}}
/>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
onCancel={onCancel}
onSave={() => {
onSubmit();
if (!toolTypeError) {
onViewChange("list");
}
}}
isSaving={false}
>
<div className="mobile-form-group">
<label className="mobile-form-label">Name *</label>
<input
type="text"
value={toolTypeForm.name}
onChange={(e) => onFormChange({ name: e.target.value })}
className="mobile-form-input"
placeholder="e.g., my-tool"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Display Name *</label>
<input
type="text"
value={toolTypeForm.display_name}
onChange={(e) => onFormChange({ display_name: e.target.value })}
className="mobile-form-input"
placeholder="e.g., My Tool"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Description</label>
<textarea
value={toolTypeForm.description}
onChange={(e) => onFormChange({ description: e.target.value })}
className="mobile-form-textarea"
placeholder="What does this tool do?"
rows={3}
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Category</label>
<input
type="text"
value={toolTypeForm.category}
onChange={(e) => onFormChange({ category: e.target.value })}
className="mobile-form-input"
placeholder="e.g., development"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Interface Type</label>
<select
value={toolTypeForm.interface_type}
onChange={(e) =>
onFormChange({
interface_type: e.target.value as "web" | "terminal",
})
}
className="mobile-form-select"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Requires Port</label>
<input
type="checkbox"
checked={toolTypeForm.requires_port}
onChange={(e) => onFormChange({ requires_port: e.target.checked })}
className="mobile-form-checkbox"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Default Port</label>
<input
type="text"
value={toolTypeForm.default_port}
onChange={(e) => onFormChange({ default_port: e.target.value })}
className="mobile-form-input"
placeholder="e.g., 8080"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Definition Type</label>
<select
value={toolTypeForm.definition_type}
onChange={(e) =>
onFormChange({
definition_type: e.target.value as "compose" | "dockerfile",
})
}
className="mobile-form-select"
>
<option value="compose">Compose</option>
<option value="dockerfile">Dockerfile</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Startup Command</label>
<input
type="text"
value={toolTypeForm.startup_command}
onChange={(e) => onFormChange({ startup_command: e.target.value })}
className="mobile-form-input"
placeholder="Command to run on startup"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Command</label>
<input
type="text"
value={toolTypeForm.readiness_command}
onChange={(e) => onFormChange({ readiness_command: e.target.value })}
className="mobile-form-input"
placeholder="e.g., curl -f http://localhost:8080/health"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Timeout</label>
<input
type="text"
value={toolTypeForm.readiness_timeout}
onChange={(e) => onFormChange({ readiness_timeout: e.target.value })}
className="mobile-form-input"
placeholder="30"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Interval</label>
<input
type="text"
value={toolTypeForm.readiness_interval}
onChange={(e) => onFormChange({ readiness_interval: e.target.value })}
className="mobile-form-input"
placeholder="2"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Required Variables</label>
<input
type="text"
value={toolTypeForm.required_variables}
onChange={(e) => onFormChange({ required_variables: e.target.value })}
className="mobile-form-input"
placeholder="VAR1, VAR2, VAR3"
/>
</div>
{toolTypeForm.definition_type === "compose" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Compose Template</label>
<textarea
value={toolTypeForm.compose_template}
onChange={(e) => onFormChange({ compose_template: e.target.value })}
className="mobile-form-textarea mobile-form-code"
placeholder="version: '3'"
rows={10}
/>
</div>
)}
{toolTypeForm.definition_type === "dockerfile" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Dockerfile Template</label>
<textarea
value={toolTypeForm.dockerfile_template}
onChange={(e) =>
onFormChange({ dockerfile_template: e.target.value })
}
className="mobile-form-textarea mobile-form-code"
placeholder="FROM ubuntu:22.04"
rows={10}
/>
</div>
)}
</MobileEditView>
);
}
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Tool Workshop</h1>
<span className="muted">{toolTypes.length} tool types</span>
</div>
<MobileListView
items={toolTypes.map((t) => ({
id: t.id,
title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))}
onItemClick={(id) => {
const toolType = toolTypes.find((t) => t.id === id);
if (toolType) {
onSelect(toolType);
onViewChange("detail");
}
}}
emptyMessage="No tool types yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
};
@@ -11,9 +11,13 @@ import {
} from "../../../api/sessions";
import type { ToolType } from "../../../api/tool-types";
import { CreateSessionForm } from "../session/create-session-form";
import { listConfigProfiles, type ConfigProfile } from "../../../api/config_profiles";
import {
listConfigProfiles,
type ConfigProfile,
} from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { useEventContext } from "../../../state/events";
import { useSessions } from "../../../state/sessions";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -34,6 +38,7 @@ export const InstanceList = ({
toolTypes,
}: InstanceListProps) => {
const navigate = useNavigate();
const { refreshSessions } = useSessions();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
@@ -105,6 +110,7 @@ export const InstanceList = ({
const handleCreateSuccess = async () => {
setShowCreate(false);
await loadInstances();
await refreshSessions();
};
const loadConfigProfiles = useCallback(
@@ -193,6 +199,7 @@ export const InstanceList = ({
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
await refreshSessions();
} catch {
setError("Failed to delete instance");
} finally {
@@ -242,15 +249,7 @@ export const InstanceList = ({
) : (
<div className="instance-grid">
{instances.map((instance) => (
<div
key={instance.id}
className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}
>
{busyInstanceId === instance.id && (
<div className="instance-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div key={instance.id} className="instance-card">
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
@@ -282,8 +281,8 @@ export const InstanceList = ({
? instance.url
: `${API_BASE_URL}${instance.url}`
}
target="_blank"
rel="noopener noreferrer"
target={`instance-${instance.id}`}
rel="noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
@@ -307,120 +306,117 @@ export const InstanceList = ({
)}
{instance.status !== "running" && (
<>
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIdsForAction.includes(
key.id,
)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIdsForAction(
(prev) => [...prev, key.id],
);
} else {
setSelectedSshKeyIdsForAction(
(prev) =>
prev.filter(
(id) =>
id !== key.id,
),
);
}
}}
/>
{key.name}
</label>
))}
</div>
<button
className="primary-button small"
onClick={() =>
void handleStart(
instance.id,
selectedProfileForAction || undefined,
selectedSshKeyIdsForAction.length > 0
? selectedSshKeyIdsForAction
: undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
setSelectedSshKeyIdsForAction(
instance.ssh_key_ids || [],
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
)}
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIdsForAction.includes(
key.id,
)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIdsForAction((prev) => [
...prev,
key.id,
]);
} else {
setSelectedSshKeyIdsForAction((prev) =>
prev.filter((id) => id !== key.id),
);
}
}}
/>
{key.name}
</label>
))}
</div>
<button
className="primary-button small"
onClick={() =>
void handleStart(
instance.id,
selectedProfileForAction || undefined,
selectedSshKeyIdsForAction.length > 0
? selectedSshKeyIdsForAction
: undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
setSelectedSshKeyIdsForAction(
instance.ssh_key_ids || [],
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
)}
</>
)}
{instance.status === "running" && (
@@ -455,119 +451,116 @@ export const InstanceList = ({
<Icon name="stop" size="sm" />
</button>
)}
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIdsForAction.includes(
key.id,
)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIdsForAction(
(prev) => [...prev, key.id],
);
} else {
setSelectedSshKeyIdsForAction(
(prev) =>
prev.filter(
(id) =>
id !== key.id,
),
);
}
}}
/>
{key.name}
</label>
))}
</div>
<button
className="primary-button small"
onClick={() =>
void handleRestart(
instance.id,
selectedProfileForAction || undefined,
selectedSshKeyIdsForAction.length > 0
? selectedSshKeyIdsForAction
: undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
setSelectedSshKeyIdsForAction(
instance.ssh_key_ids || [],
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
</button>
)}
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIdsForAction.includes(
key.id,
)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIdsForAction((prev) => [
...prev,
key.id,
]);
} else {
setSelectedSshKeyIdsForAction((prev) =>
prev.filter((id) => id !== key.id),
);
}
}}
/>
{key.name}
</label>
))}
</div>
<button
className="primary-button small"
onClick={() =>
void handleRestart(
instance.id,
selectedProfileForAction || undefined,
selectedSshKeyIdsForAction.length > 0
? selectedSshKeyIdsForAction
: undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
setSelectedSshKeyIdsForAction(
instance.ssh_key_ids || [],
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
</button>
)}
</>
)}
<button
@@ -4,7 +4,7 @@ import { extractErrorMessage } from "../../../utils/errors";
import {
compileToolDefinition,
type ToolDefinitionManifest,
} from "../../../api/tool_definitions";
} from "../../../api/tool-definitions";
interface PackageEntry {
name: string;
@@ -3,8 +3,13 @@
import { useState, useEffect, useCallback } from "react";
import { Icon } from "../../icon";
import { listToolTypes, type ToolType } from "../../../api/tool-types";
import { listConfigProfiles, type ConfigProfile } from "../../../api/config_profiles";
import {
listConfigProfiles,
type ConfigProfile,
} from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { useSessions } from "../../../state/sessions";
import { useSessionOperations } from "../../../state/session-operations";
import type { Workspace } from "../../../types/workspace";
import type { ToolInstance } from "../../../api/sessions";
@@ -19,6 +24,8 @@ export function ToolStarter({
onStarted,
onCancel,
}: ToolStarterProps) {
const { addOrUpdateSession } = useSessions();
const { startOperation } = useSessionOperations();
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
@@ -33,6 +40,8 @@ export function ToolStarter({
const [sshKeysLoading, setSshKeysLoading] = useState(true);
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
const [displayName, setDisplayName] = useState(workspace.name);
const [nameEdited, setNameEdited] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -114,12 +123,14 @@ export function ToolStarter({
setStarting(true);
setError(null);
try {
const { createInstance, startInstance } = await import("../../../api/sessions");
const { createInstance, startInstance } = await import(
"../../../api/sessions"
);
const instance = await createInstance(
workspace.project_id,
workspace.repo_id,
selectedToolTypeId,
workspace.name,
displayName.trim() || undefined,
undefined,
undefined,
undefined,
@@ -134,13 +145,37 @@ export function ToolStarter({
selectedProfileId || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
);
addOrUpdateSession({
id: instance.id,
display_name: instance.display_name,
tool_type_name: instance.tool_type_name,
tool_icon: "code",
tool_type_interfaces: instance.tool_type_interfaces || [],
repository_name: workspace.repo_name,
repository_id: workspace.repo_id,
project_name: workspace.project_name,
project_id: workspace.project_id,
workspace_name: workspace.name,
status: instance.status || "pending",
url: instance.url || null,
});
startOperation("create", instance.id, instance.display_name);
onStarted(instance);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool");
} finally {
setStarting(false);
}
}, [selectedToolTypeId, selectedProfileId, workspace, onStarted]);
}, [
selectedToolTypeId,
selectedProfileId,
displayName,
workspace,
onStarted,
toolTypes,
addOrUpdateSession,
startOperation,
]);
return (
<div className="tool-starter">
@@ -170,8 +205,13 @@ export function ToolStarter({
id="tool-type"
value={selectedToolTypeId}
onChange={(e) => {
setSelectedToolTypeId(e.target.value);
const toolId = e.target.value;
setSelectedToolTypeId(toolId);
setError(null);
const tt = toolTypes.find((t) => t.id === toolId);
if (tt && !nameEdited) {
setDisplayName(`${workspace.name} ${tt.display_name}`);
}
}}
disabled={toolTypesLoading || starting}
>
@@ -187,6 +227,22 @@ export function ToolStarter({
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
</div>
{/* Session Name */}
<div className="form-group">
<label htmlFor="session-name">Session Name</label>
<input
id="session-name"
type="text"
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
setNameEdited(true);
}}
placeholder="My dev environment"
disabled={starting}
/>
</div>
{/* Config Profile */}
{selectedToolTypeId && (
<div className="form-group">
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiClient } from "../../../api/client";
import { EmptyState } from "../../data-states";
import { Icon } from "../../icon";
import type { GitStatus } from "../../../api/git-repositories";
interface FileTreeEntry {
name: string;
type: "file" | "directory";
path: string;
size?: number;
mode?: string;
last_commit?: {
hash: string;
message: string;
author: string;
date: string;
} | null;
}
interface Props {
projectId: string;
repoId: string;
gitStatus: GitStatus | null;
}
export const FileBrowser = ({ projectId, repoId, gitStatus }: Props) => {
const [searchParams, setSearchParams] = useSearchParams();
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const branch = searchParams.get("branch") || "main";
const path = searchParams.get("path") || "";
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files`,
{ params: { branch, path } }
);
setEntries(response.data.entries || []);
} catch {
setError("Failed to load files");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, path]);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
useEffect(() => {
const handleRefresh = () => void loadFiles();
window.addEventListener("refresh-file-tree", handleRefresh);
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
}, [loadFiles]);
const handleEntryClick = (entry: FileTreeEntry) => {
if (entry.type === "directory") {
const newParams = new URLSearchParams(searchParams);
newParams.set("path", entry.path);
setSearchParams(newParams);
} else {
const newParams = new URLSearchParams(searchParams);
newParams.set("file", entry.path);
setSearchParams(newParams);
}
};
const navigateUp = () => {
if (!path) return;
const parentPath = path.split("/").slice(0, -1).join("/");
const newParams = new URLSearchParams(searchParams);
if (parentPath) {
newParams.set("path", parentPath);
} else {
newParams.delete("path");
}
setSearchParams(newParams);
};
const getFileStatus = (filePath: string): string | null => {
if (!gitStatus) return null;
if (gitStatus.modified.includes(filePath)) return "modified";
if (gitStatus.added.includes(filePath)) return "added";
if (gitStatus.deleted.includes(filePath)) return "deleted";
if (gitStatus.untracked.includes(filePath)) return "untracked";
return null;
};
if (loading) return <p className="muted">Loading files...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-tree">
{path && (
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
<Icon name="folder" size="sm" /> ..
</button>
)}
{entries.length === 0 && (
<EmptyState message="No files in this repository yet." />
)}
{entries.map((entry) => {
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
return (
<button
key={entry.path}
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
onClick={() => handleEntryClick(entry)}
type="button"
>
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
{fileStatus === "added" && "A"}
{fileStatus === "deleted" && "D"}
{fileStatus === "untracked" && "?"}
</span>
)}
</button>
);
})}
</div>
);
};
@@ -0,0 +1,214 @@
import { Icon } from "../../icon";
import { FileBrowser } from "./FileBrowser";
import { FileEditor } from "../git/file-editor";
import { CommitPanel } from "../git/commit-panel";
import { GitToolbar } from "../git/git-toolbar";
import { InstanceList } from "../tool/instance-list";
import type { GitRepository, GitStatus } from "../../../api/git-repositories";
import type { ToolType } from "../../../api/tool-types";
import type { Project } from "../../../hooks/use-repo-workspace";
type MobileTab = "files" | "editor" | "git" | "terminal";
interface Props {
projectId: string;
project: Project | null;
isMobile: boolean;
mobileTab: MobileTab;
selectedRepoId: string | null;
selectedRepo: GitRepository | undefined;
branches: string[];
currentBranch: string;
gitStatus: GitStatus | null;
toolTypes: ToolType[];
repositories: GitRepository[];
onMobileTabChange: (tab: MobileTab) => void;
onRepoChange: (repoId: string) => void;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
export const WorkspaceLayout = ({
projectId,
project,
isMobile,
mobileTab,
selectedRepoId,
selectedRepo,
branches,
currentBranch,
gitStatus,
toolTypes,
repositories,
onMobileTabChange,
onRepoChange,
onBranchChange,
onRefresh,
}: Props) => {
if (isMobile) {
return (
<div className="mobile-workspace">
<div className="mobile-workspace-header">
<select
value={selectedRepoId || ""}
onChange={(e) => onRepoChange(e.target.value)}
className="mobile-repo-selector"
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
{selectedRepoId && (
<select
value={currentBranch}
onChange={(e) => onBranchChange(e.target.value)}
className="mobile-branch-selector"
>
{branches.map((branch) => (
<option key={branch} value={branch}>
{branch}
</option>
))}
</select>
)}
</div>
<div className="mobile-workspace-content">
{mobileTab === "files" && selectedRepoId && (
<FileBrowser projectId={projectId} repoId={selectedRepoId} gitStatus={gitStatus} />
)}
{mobileTab === "editor" && selectedRepoId && (
<FileEditor projectId={projectId} repoId={selectedRepoId} />
)}
{mobileTab === "git" && selectedRepoId && gitStatus && (
<div className="mobile-git-view">
<CommitPanel
projectId={projectId}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
onRefresh();
}}
/>
</div>
)}
{mobileTab === "terminal" && selectedRepoId && (
<InstanceList
projectId={projectId}
repoId={selectedRepoId}
projectName={project?.name}
repoName={selectedRepo?.name}
toolTypes={toolTypes}
/>
)}
</div>
<div className="mobile-workspace-tabs">
<button
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
onClick={() => onMobileTabChange("files")}
type="button"
>
<Icon name="folder" size="sm" />
<span>Files</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
onClick={() => onMobileTabChange("editor")}
type="button"
>
<Icon name="edit" size="sm" />
<span>Editor</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
onClick={() => onMobileTabChange("git")}
type="button"
>
<Icon name="branch" size="sm" />
<span>Git</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
onClick={() => onMobileTabChange("terminal")}
type="button"
>
<Icon name="terminal" size="sm" />
<span>Terminal</span>
</button>
</div>
</div>
);
}
return (
<>
{selectedRepoId && (
<GitToolbar
projectId={projectId}
repoId={selectedRepoId}
currentBranch={currentBranch}
branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)}
isMirror={Boolean(selectedRepo?.is_mirror)}
onBranchChange={onBranchChange}
onRefresh={onRefresh}
/>
)}
<div className="workspace-layout">
<aside className="workspace-sidebar">
<div className="sidebar-section">
<label className="form-field">
Repository
<select
value={selectedRepoId || ""}
onChange={(e) => onRepoChange(e.target.value)}
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
</label>
</div>
{selectedRepoId && (
<>
<FileBrowser projectId={projectId} repoId={selectedRepoId} gitStatus={gitStatus} />
{gitStatus && (
<CommitPanel
projectId={projectId}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={onRefresh}
/>
)}
<InstanceList
projectId={projectId}
repoId={selectedRepoId}
projectName={project?.name}
repoName={selectedRepo?.name}
toolTypes={toolTypes}
/>
</>
)}
</aside>
<main className="workspace-main">
{selectedRepoId && (
<FileEditor projectId={projectId} repoId={selectedRepoId} />
)}
</main>
</div>
</>
);
};
@@ -43,8 +43,8 @@ export function WorkspaceInstanceChips({
{inst.status === "running" && inst.url && (
<a
href={inst.url}
target="_blank"
rel="noopener noreferrer"
target={`instance-${inst.id}`}
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
>
+3
View File
@@ -35,6 +35,7 @@ import {
Terminal,
ArrowLeft,
DotsSixVertical,
DotsThreeVertical,
Bell,
CaretDown,
CaretRight,
@@ -81,6 +82,7 @@ export type IconName =
| "terminal"
| "arrow-left"
| "drag"
| "more"
| "bell"
| "chevron-down"
| "chevron-right";
@@ -132,6 +134,7 @@ const iconMap: Record<
terminal: Terminal,
"arrow-left": ArrowLeft,
drag: DotsSixVertical,
more: DotsThreeVertical,
bell: Bell,
"chevron-down": CaretDown,
"chevron-right": CaretRight,
+431
View File
@@ -0,0 +1,431 @@
import { useCallback, useEffect, useState } from "react";
import { extractErrorMessage } from "../utils/errors";
import {
createConfigProfile,
deleteConfigProfile,
listConfigProfiles,
previewConfigProfile,
updateConfigProfile,
updateProfileIncludes,
type ConfigProfile,
type CreateConfigProfileRequest,
type ResolvedProfile,
} from "../api/config-profiles";
import { listProjects } from "../api/projects";
import { listToolTypes, type ToolType } from "../api/tool-types";
import type { ProjectWithRepos } from "../types";
type Status = "loading" | "ready" | "error";
type SaveStatus = "idle" | "saving" | "saved" | "error";
const defaultForm: CreateConfigProfileRequest = {
name: "",
description: "",
env_vars: {},
runtime_hints: {},
mounts: [],
git_mounts: [],
files: {},
is_default: false,
};
export const useConfigProfiles = () => {
const [status, setStatus] = useState<Status>("loading");
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
const loadData = useCallback(async () => {
setStatus("loading");
try {
const [profs, projs, types] = await Promise.all([
listConfigProfiles(),
listProjects(),
listToolTypes(),
]);
setProfiles(profs || []);
setProjects(projs || []);
setToolTypes(types || []);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const resetForm = () => {
setFormData(defaultForm);
setIncludedProfileIds([]);
setError(null);
setSaveStatus("idle");
setPreviewData(null);
};
const populateForm = (profile: ConfigProfile) => {
setFormData({
name: profile.name,
description: profile.description || undefined,
project_id: profile.project_id || undefined,
tool_type_id: profile.tool_type_id || undefined,
env_vars: profile.env_vars,
runtime_hints: profile.runtime_hints,
mounts: profile.mounts,
git_mounts: profile.git_mounts || [],
files: profile.files,
is_default: profile.is_default,
});
setIncludedProfileIds(
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
);
setError(null);
setSaveStatus("idle");
setPreviewData(null);
};
const handleSelectProfile = (profile: ConfigProfile | null) => {
if (profile) {
setSelectedProfileId(profile.id);
setIsCreating(false);
populateForm(profile);
} else {
setSelectedProfileId(null);
}
};
const handleCreateNew = () => {
setSelectedProfileId(null);
setIsCreating(true);
resetForm();
};
const getIncludedProfile = (id: string): ConfigProfile | undefined =>
profiles.find((p) => p.id === id);
const getScopeLabel = (profile: ConfigProfile): string => {
if (profile.project_id && profile.tool_type_id) return "Project + Tool";
if (profile.project_id) return "Project";
if (profile.tool_type_id) return "Tool";
return "Global";
};
const wouldCreateCycle = (
profileId: string,
targetId: string,
visited = new Set<string>(),
): boolean => {
if (visited.has(targetId)) return true;
const target = getIncludedProfile(targetId);
if (!target) return false;
const nextVisited = new Set(visited);
nextVisited.add(targetId);
for (const inc of target.includes) {
if (
inc.included_profile_id === profileId ||
wouldCreateCycle(profileId, inc.included_profile_id, nextVisited)
) {
return true;
}
}
return false;
};
const availableProfilesForInclude = (): ConfigProfile[] => {
const currentId = selectedProfile?.id;
if (!currentId) return [];
return profiles.filter((p) => {
if (p.id === currentId) return false;
if (includedProfileIds.includes(p.id)) return false;
if (wouldCreateCycle(currentId, p.id)) return false;
return true;
});
};
const addInclude = (profileId: string) => {
setIncludedProfileIds((prev) => [...prev, profileId]);
};
const removeInclude = (index: number) => {
setIncludedProfileIds((prev) => prev.filter((_, i) => i !== index));
};
const handleDragStart = (e: React.DragEvent, index: number) => {
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
const dragIndex = Number(e.dataTransfer.getData("text/plain"));
if (dragIndex === dropIndex) {
setDragOverIndex(null);
return;
}
setIncludedProfileIds((prev) => {
const newOrder = [...prev];
const [removed] = newOrder.splice(dragIndex, 1);
newOrder.splice(dropIndex, 0, removed);
return newOrder;
});
setDragOverIndex(null);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
setError(null);
setSaveStatus("saving");
if (!formData.name?.trim()) {
setError("Name is required");
setSaveStatus("error");
return;
}
try {
if (isCreating) {
const newProfile = await createConfigProfile(formData);
if (includedProfileIds.length > 0) {
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
}
setIsCreating(false);
setSelectedProfileId(newProfile.id);
setSaveStatus("saved");
await loadData();
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
if (refreshed) populateForm(refreshed);
} else if (selectedProfile) {
await updateConfigProfile(selectedProfile.id, formData);
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
setSaveStatus("saved");
await loadData();
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
if (refreshed) populateForm(refreshed);
}
} catch (err) {
setError(extractErrorMessage(err));
setSaveStatus("error");
}
};
const handleDelete = async (id: string) => {
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
try {
await deleteConfigProfile(id);
if (selectedProfileId === id) {
setSelectedProfileId(null);
setIsCreating(false);
resetForm();
}
await loadData();
} catch {
alert("Failed to delete config profile");
}
};
const handlePreview = async (id: string) => {
try {
setPreviewingId(id);
const data = await previewConfigProfile(id);
setPreviewData(data);
} catch {
setError("Failed to preview config profile");
} finally {
setPreviewingId(null);
}
};
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
key: K,
value: CreateConfigProfileRequest[K],
) => {
setFormData((prev) => ({ ...prev, [key]: value }));
setSaveStatus("idle");
};
const addEnvVar = () => {
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
setSaveStatus("idle");
};
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
setFormData((prev) => {
const envVars = { ...prev.env_vars };
if (oldKey !== newKey) delete envVars[oldKey];
envVars[newKey] = value;
return { ...prev, env_vars: envVars };
});
setSaveStatus("idle");
};
const removeEnvVar = (key: string) => {
setFormData((prev) => {
const envVars = { ...prev.env_vars };
delete envVars[key];
return { ...prev, env_vars: envVars };
});
setSaveStatus("idle");
};
const addFile = () => {
setFormData((prev) => ({ ...prev, files: { ...prev.files, "": "" } }));
setSaveStatus("idle");
};
const updateFile = (oldPath: string, newPath: string, content: string) => {
setFormData((prev) => {
const files = { ...prev.files };
if (oldPath !== newPath) delete files[oldPath];
files[newPath] = content;
return { ...prev, files };
});
setSaveStatus("idle");
};
const removeFile = (path: string) => {
setFormData((prev) => {
const files = { ...prev.files };
delete files[path];
return { ...prev, files };
});
setSaveStatus("idle");
};
const addMount = () => {
setFormData((prev) => ({
...prev,
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
}));
setSaveStatus("idle");
};
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[index] = { ...mounts[index], ...updates };
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const removeMount = (index: number) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts.splice(index, 1);
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const addMountFile = (mountIndex: number) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[mountIndex] = {
...mounts[mountIndex],
files: { ...mounts[mountIndex].files, "": "" },
};
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const updateMountFile = (
mountIndex: number,
oldPath: string,
newPath: string,
content: string,
) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
const files = { ...mounts[mountIndex].files };
if (oldPath !== newPath) delete files[oldPath];
files[newPath] = content;
mounts[mountIndex] = { ...mounts[mountIndex], files };
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const removeMountFile = (mountIndex: number, path: string) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
const files = { ...mounts[mountIndex].files };
delete files[path];
mounts[mountIndex] = { ...mounts[mountIndex], files };
return { ...prev, mounts };
});
setSaveStatus("idle");
};
return {
status,
profiles,
projects,
toolTypes,
selectedProfile,
selectedProfileId,
isCreating,
saveStatus,
error,
previewData,
previewingId,
formData,
includedProfileIds,
dragOverIndex,
loadData,
handleSelectProfile,
handleCreateNew,
handleSubmit,
handleDelete,
handlePreview,
updateFormField,
addEnvVar,
updateEnvVar,
removeEnvVar,
addFile,
updateFile,
removeFile,
addMount,
updateMount,
removeMount,
addMountFile,
updateMountFile,
removeMountFile,
getIncludedProfile,
getScopeLabel,
availableProfilesForInclude,
addInclude,
removeInclude,
handleDragStart,
handleDragOver,
handleDragLeave,
handleDrop,
setPreviewData,
setFormData,
setSaveStatus,
setIncludedProfileIds,
populateForm,
};
};
+76 -16
View File
@@ -1,11 +1,14 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useRef } from "react";
import {
stopInstance,
deleteInstance,
startInstance,
recreateInstanceTunnel,
renameInstance,
} from "../api/sessions";
import type { Session } from "../api/sessions";
import { useSessions } from "../state/sessions";
import { useSessionOperations } from "../state/session-operations";
interface UseInstanceActionsOptions {
onRefresh: () => Promise<void>;
@@ -21,6 +24,7 @@ interface UseInstanceActionsReturn {
handleDelete: (session: Session) => Promise<void>;
handleForceDelete: (session: Session) => Promise<void>;
handleRecreateTunnel: (session: Session) => Promise<void>;
handleRename: (session: Session, newName: string) => Promise<void>;
clearDirtyDelete: () => void;
}
@@ -28,28 +32,41 @@ export function useInstanceActions(
options: UseInstanceActionsOptions,
): UseInstanceActionsReturn {
const { onRefresh } = options;
const { removeSession } = useSessions();
const { startOperation, completeOperation } = useSessionOperations();
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
null,
);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const tabRefs = useRef<Map<string, Window | null>>(new Map());
const handleOpen = useCallback((session: Session) => {
const key = session.id;
const existing = tabRefs.current.get(key);
if (existing && !existing.closed) {
existing.focus();
return;
}
let url: string;
if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer");
return;
url = session.url;
} else if (session.tool_type_interfaces?.includes("terminal")) {
url = `/instances/${session.id}/terminal`;
} else {
url = `/projects/${session.project_id}`;
}
if (session.tool_type_interfaces?.includes("terminal")) {
window.open(`/instances/${session.id}/terminal`, "_blank", "noopener,noreferrer");
return;
}
window.open(`/projects/${session.project_id}`, "_blank", "noopener,noreferrer");
const w = window.open(url, `session-${session.id}`);
tabRefs.current.set(key, w);
}, []);
const handleStart = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("start", session.id, session.display_name);
try {
await startInstance(
session.project_id,
@@ -58,18 +75,19 @@ export function useInstanceActions(
);
await onRefresh();
} catch {
// ignore
completeOperation(session.id, "start", "error");
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[loadingSessionId, onRefresh, startOperation, completeOperation],
);
const handleStop = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("stop", session.id, session.display_name);
try {
await stopInstance(
session.project_id,
@@ -78,18 +96,19 @@ export function useInstanceActions(
);
await onRefresh();
} catch {
// ignore
completeOperation(session.id, "stop", "error");
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[loadingSessionId, onRefresh, startOperation, completeOperation],
);
const handleDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("delete", session.id, session.display_name);
try {
await deleteInstance(
session.project_id,
@@ -98,8 +117,10 @@ export function useInstanceActions(
);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
removeSession(session.id);
await onRefresh();
} catch (error) {
completeOperation(session.id, "delete", "error");
const axiosError = error as {
response?: {
status?: number;
@@ -118,13 +139,20 @@ export function useInstanceActions(
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[
loadingSessionId,
onRefresh,
removeSession,
startOperation,
completeOperation,
],
);
const handleForceDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("delete", session.id, session.display_name);
try {
await deleteInstance(
session.project_id,
@@ -134,20 +162,28 @@ export function useInstanceActions(
);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
removeSession(session.id);
await onRefresh();
} catch {
// ignore
completeOperation(session.id, "delete", "error");
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[
loadingSessionId,
onRefresh,
removeSession,
startOperation,
completeOperation,
],
);
const handleRecreateTunnel = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("recreate-tunnel", session.id, session.display_name);
try {
await recreateInstanceTunnel(
session.project_id,
@@ -155,16 +191,39 @@ export function useInstanceActions(
session.id,
);
await onRefresh();
completeOperation(session.id, "recreate-tunnel", "success");
} catch (err) {
const message =
(err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail || "Failed to recreate tunnel";
completeOperation(session.id, "recreate-tunnel", "error", message);
alert(message);
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[loadingSessionId, onRefresh, startOperation, completeOperation],
);
const handleRename = useCallback(
async (session: Session, newName: string) => {
if (!newName.trim()) return;
setLoadingSessionId(session.id);
try {
await renameInstance(
session.project_id,
session.repository_id,
session.id,
newName.trim(),
);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[onRefresh],
);
const clearDirtyDelete = useCallback(() => {
@@ -182,6 +241,7 @@ export function useInstanceActions(
handleDelete,
handleForceDelete,
handleRecreateTunnel,
handleRename,
clearDirtyDelete,
};
}
+155
View File
@@ -0,0 +1,155 @@
import { useState } from "react";
import {
createProject,
deleteProject,
listProjects,
updateProject,
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
import { useAsyncData } from "./use-async-data";
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
type DialogMode = "none" | "create" | "edit";
export const useProjects = () => {
const {
data: projects,
status,
reload,
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
null,
);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [expandedProject, setExpandedProject] = useState<string | null>(null);
const [creatingWorkspace, setCreatingWorkspace] = useState<{
projectId: string;
repoId: string;
} | null>(null);
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
const safeProjects = projects ?? [];
const openCreate = () => {
setFormName("");
setFormDescription("");
setFormError(null);
setEditingProject(null);
setDialogMode("create");
};
const openEdit = (project: ProjectWithRepos) => {
setFormName(project.name);
setFormDescription(project.description ?? "");
setFormError(null);
setEditingProject(project);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingProject(null);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Project name is required");
return;
}
try {
if (dialogMode === "create") {
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
} else if (dialogMode === "edit" && editingProject) {
const input: ProjectUpdateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await updateProject(editingProject.id, input);
}
closeDialog();
reload();
} catch {
setFormError("Failed to save project");
}
};
const handleDelete = async (projectId: string) => {
try {
await deleteProject(projectId);
setDeleteConfirmId(null);
reload();
} catch {
setDeleteConfirmId(null);
}
};
const handleSyncWorkspace = async (
projectId: string,
repoId: string,
workspace: WorkspaceSummary,
) => {
setWorkspaceLoading(workspace.id);
try {
await syncWorkspace(projectId, repoId, workspace.id);
reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to sync workspace");
} finally {
setWorkspaceLoading(null);
}
};
const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
setWorkspaceLoading(workspace.id);
try {
await deleteWorkspace(workspace.id);
reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete workspace");
} finally {
setWorkspaceLoading(null);
}
};
return {
projects: safeProjects,
status,
reload,
dialogMode,
formName,
setFormName,
formDescription,
setFormDescription,
formError,
deleteConfirmId,
setDeleteConfirmId,
expandedProject,
setExpandedProject,
creatingWorkspace,
setCreatingWorkspace,
workspaceLoading,
openCreate,
openEdit,
closeDialog,
handleSubmit,
handleDelete,
handleSyncWorkspace,
handleDeleteWorkspace,
};
};
+148
View File
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { apiClient } from "../api/client";
import {
getRepositoryStatus,
listRepositories,
type GitRepository,
type GitStatus,
} from "../api/git-repositories";
import { listToolTypes, type ToolType } from "../api/tool-types";
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
export interface Project {
id: string;
name: string;
description?: string | null;
}
export const useRepoWorkspace = () => {
const { projectId } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const [status, setStatus] = useState<WorkspaceStatus>("loading");
const [project, setProject] = useState<Project | null>(null);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
searchParams.get("repo")
);
const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState<string>("main");
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const loadProject = useCallback(async () => {
if (!projectId) return;
try {
const response = await apiClient.get(`/projects/${projectId}`);
setProject(response.data);
} catch {
setProject(null);
}
}, [projectId]);
const loadRepositories = useCallback(async () => {
if (!projectId) return;
setStatus("loading");
try {
const data = await listRepositories(projectId);
setRepositories(data);
if (data.length === 0) {
setStatus("empty");
} else {
setStatus("ready");
if (!selectedRepoId) {
setSelectedRepoId(data[0].id);
const newParams = new URLSearchParams(searchParams);
newParams.set("repo", data[0].id);
setSearchParams(newParams, { replace: true });
}
}
} catch {
setRepositories([]);
setStatus("error");
}
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
const loadBranches = useCallback(async () => {
if (!projectId || !selectedRepoId) return;
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
);
const branchList = response.data.branches.map((b: { name: string }) => b.name);
setBranches(branchList);
const defaultBranch = response.data.default_branch;
if (defaultBranch) setCurrentBranch(defaultBranch);
} catch {
setBranches([]);
}
}, [projectId, selectedRepoId]);
const loadGitStatus = useCallback(async () => {
if (!projectId || !selectedRepoId) return;
try {
const data = await getRepositoryStatus(projectId, selectedRepoId);
setGitStatus(data);
} catch {
setGitStatus(null);
}
}, [projectId, selectedRepoId]);
const loadToolTypes = useCallback(async () => {
try {
const data = await listToolTypes();
setToolTypes(data);
} catch {
setToolTypes([]);
}
}, []);
useEffect(() => {
void loadProject();
void loadRepositories();
void loadToolTypes();
}, [loadProject, loadRepositories, loadToolTypes]);
useEffect(() => {
void loadBranches();
void loadGitStatus();
}, [loadBranches, loadGitStatus]);
const handleRepoChange = (repoId: string) => {
setSelectedRepoId(repoId);
const newParams = new URLSearchParams(searchParams);
newParams.set("repo", repoId);
newParams.delete("branch");
newParams.delete("path");
setSearchParams(newParams);
};
const handleBranchChange = (branch: string) => {
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
newParams.set("branch", branch);
setSearchParams(newParams);
};
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
return {
projectId,
project,
status,
repositories,
selectedRepoId,
selectedRepo,
branches,
currentBranch,
gitStatus,
toolTypes,
handleRepoChange,
handleBranchChange,
loadGitStatus,
loadBranches,
loadRepositories,
};
};
+108
View File
@@ -0,0 +1,108 @@
import { useState } from "react";
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh-keys";
import { useAsyncData } from "./use-async-data";
export const useSSHKeys = () => {
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
const [newKeyName, setNewKeyName] = useState("");
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>>({});
const [mutationError, setMutationError] = useState<string | null>(null);
const safeKeys = keys ?? [];
async function handleGenerate(e: React.FormEvent) {
e.preventDefault();
if (!newKeyName.trim()) return;
try {
setGenerating(true);
await createSSHKey({ name: newKeyName.trim() });
setNewKeyName("");
await loadKeys();
} catch {
setMutationError("Failed to generate SSH key");
} finally {
setGenerating(false);
}
}
async function handleDelete(keyId: string) {
if (!confirm("Are you sure you want to delete this SSH key?")) return;
try {
await deleteSSHKey(keyId);
await loadKeys();
} catch {
setMutationError("Failed to delete SSH key");
}
}
function copyToClipboard(text: string) {
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 }));
setMutationError(null);
} catch {
setMutationError("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 }));
setMutationError(null);
} catch {
setMutationError("Failed to verify signature");
} finally {
setVerifying((prev) => ({ ...prev, [keyId]: false }));
}
}
return {
keys: safeKeys,
status,
loadKeys,
newKeyName,
setNewKeyName,
generating,
mutationError,
setMutationError,
signPayloads,
setSignPayloads,
signatures,
signing,
verifyPayloads,
setVerifyPayloads,
verifySignatures,
setVerifySignatures,
verifyResults,
verifying,
handleGenerate,
handleDelete,
copyToClipboard,
handleSign,
handleVerify,
};
};
+345
View File
@@ -0,0 +1,345 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import type { TerminalRef } from "../components/features/terminal/terminal";
import type { TerminalSessionInfo } from "../components/features/terminal/terminal-session-tabs";
import { useMobileViewport } from "./use-mobile-viewport";
import { useAutoHide } from "./use-auto-hide";
import { useVirtualKeyboard } from "./use-virtual-keyboard";
import { useTerminalSessions } from "./use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
import type { ModifierKey } from "./use-special-keys";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
type TerminalStatus =
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting";
export const useTerminalPage = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const [terminalStatuses, setTerminalStatuses] = useState<
Record<string, TerminalStatus>
>({});
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
const sendDataRef = useRef<((data: string) => void) | null>(null);
const focusInputRef = useRef<(() => void) | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
null,
);
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [instanceInfo, setInstanceInfo] = useState<{
display_name: string;
workspace_name?: string | null;
tool_type_name: string;
} | null>(null);
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(instanceId ?? "");
// Fetch instance details for tab title
useEffect(() => {
if (!instanceId) return;
const load = async () => {
try {
const { getUserSessions } = await import("../api/sessions");
const allSessions = await getUserSessions();
const match = allSessions.find((s) => s.id === instanceId);
if (match) {
setInstanceInfo({
display_name: match.display_name,
workspace_name: match.workspace_name,
tool_type_name: match.tool_type_name,
});
}
} catch {
// ignore
}
};
void load();
}, [instanceId]);
// Auto-create default session
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
// Update document title based on active terminal session
useEffect(() => {
if (!instanceId) {
document.title = "Terminal";
return;
}
const active = sessions.find((s) => s.id === activeSessionId);
const baseName = instanceInfo
? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}`
: `Instance ${instanceId.slice(0, 8)}`;
if (sessions.length <= 1) {
document.title = baseName;
} else {
const sessionName = active?.name ?? "Session";
document.title = `${baseName} ${sessionName}`;
}
return () => {
document.title = "Headquarter";
};
}, [instanceId, activeSessionId, sessions, instanceInfo]);
// Sync refs with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
}
}
}, [sessions]);
// Fit and focus active terminal
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
let raf1 = 0;
let raf2 = 0;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
ref.current?.fit();
ref.current?.focus();
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1)
setActiveSessionId(sessions[idx + 1].id);
}
break;
case "r":
e.preventDefault();
if (activeSessionId) void resetSession(activeSessionId);
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Keep screen awake
useEffect(() => {
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch {
// ignore
}
};
void requestWakeLock();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void requestWakeLock();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
};
}, []);
// Lock page scroll on mobile
useEffect(() => {
if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open");
return () => {
document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!isFullscreen) return;
const target = e.target as Node;
const current = e.currentTarget as HTMLElement;
const content = current.querySelector(".terminal-page-content");
const header = current.querySelector(".terminal-fullscreen-header");
if (content?.contains(target) || header?.contains(target)) return;
setIsFullscreen(false);
},
[isFullscreen],
);
const handleSelect = useCallback(
(sessionId: string) => setActiveSessionId(sessionId),
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => closeSession(sessionId),
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
const handleTerminalReady = useCallback(
(
sendData: (data: string) => void,
status: TerminalStatus,
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
setTerminalStatuses((prev) => ({
...prev,
[activeSessionId ?? "default"]: status,
}));
sendDataRef.current = sendData;
focusInputRef.current = focusInput;
changeFontSizeRef.current = changeFontSize;
},
[activeSessionId],
);
const handleFontSizeChange = useCallback((delta: number) => {
changeFontSizeRef.current?.(delta);
}, []);
const handleSendKey = useCallback((data: string) => {
sendDataRef.current?.(data);
}, []);
const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset();
}
}, [activeSessionId]);
return {
instanceId,
navigate,
isMobile,
isFullscreen,
setIsFullscreen,
terminalRefs,
headerAutoHide,
terminalStatuses,
sendDataRef,
focusInputRef,
changeFontSizeRef,
showResetConfirm,
setShowResetConfirm,
showSpecialKeysPanel,
setShowSpecialKeysPanel,
activeModifier,
setActiveModifier,
isKeyboardOpen,
keyboardHeight,
sessions,
activeSessionId,
setActiveSessionId,
loading,
error,
handleFullscreenClick,
handleSelect,
handleClose,
handleCreate,
handleRename,
handleTerminalReady,
handleFontSizeChange,
handleSendKey,
handleReset,
sessionInfos: SESSIONS_TO_INFO(sessions),
};
};
+370
View File
@@ -0,0 +1,370 @@
import { useCallback, useEffect, useState } from "react";
import { extractErrorMessage } from "../utils/errors";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
type CreateToolTypeRequest,
type ReadinessProbe,
type ToolType,
type UpdateToolTypeRequest,
} from "../api/tool-types";
import {
createToolDefinition,
getToolDefinition,
listToolDefinitions,
updateToolDefinition,
} from "../api/tool-definitions";
import type { ToolTypeFormState } from "../components/features/tool-workshop/ToolTypeEditorPanel";
type Status = "loading" | "ready" | "error";
const defaultForm: ToolTypeFormState = {
name: "",
display_name: "",
description: "",
category: "",
interface_type: "web",
requires_port: true,
default_port: "",
definition_type: "compose",
compose_template: "",
dockerfile_template: "",
readiness_command: "",
readiness_timeout: "30",
readiness_interval: "2",
required_variables: "",
startup_command: "",
};
export const useToolWorkshop = () => {
const [status, setStatus] = useState<Status>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [baseDefinitions, setBaseDefinitions] = useState<
Awaited<ReturnType<typeof listToolDefinitions>>
>([]);
const [manifestData, setManifestData] = useState<Record<
string,
unknown
> | null>(null);
const [manifestDefinitionId, setManifestDefinitionId] = useState<
string | null
>(null);
const [selectedToolTypeId, setSelectedToolTypeId] = useState<string | null>(
null,
);
const [isCreating, setIsCreating] = useState(false);
const [toolTypeForm, setToolTypeForm] = useState<ToolTypeFormState>(defaultForm);
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
const [toolTypeDirty, setToolTypeDirty] = useState(false);
const selectedToolType =
(toolTypes || []).find((t) => t.id === selectedToolTypeId) || null;
const loadData = useCallback(async () => {
setStatus("loading");
try {
const [types, defs] = await Promise.all([
listToolTypes(),
listToolDefinitions(),
]);
setToolTypes(types || []);
setBaseDefinitions((defs || []).filter((d) => d.is_base));
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const resetToolTypeForm = () => {
setToolTypeForm(defaultForm);
setToolTypeError(null);
setToolTypeDirty(false);
setManifestData(null);
setManifestDefinitionId(null);
};
const populateToolTypeForm = async (toolType: ToolType) => {
setToolTypeForm({
name: toolType.name,
display_name: toolType.display_name,
description: toolType.description || "",
category: toolType.category || "",
interface_type: (toolType.interface_type as "web" | "terminal") || "web",
requires_port: toolType.requires_port ?? true,
default_port: toolType.default_port?.toString() || "",
definition_type:
(toolType.definition_type as "compose" | "dockerfile" | "manifest") ||
"compose",
compose_template: toolType.compose_template || "",
dockerfile_template: toolType.dockerfile_template || "",
readiness_command: toolType.readiness_probe?.command || "",
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
required_variables: toolType.required_variables?.join(", ") || "",
startup_command: toolType.startup_command || "",
});
setToolTypeError(null);
setToolTypeDirty(false);
setManifestDefinitionId(toolType.manifest_id || null);
if (toolType.definition_type === "manifest" && toolType.manifest_id) {
try {
const defn = await getToolDefinition(toolType.manifest_id);
setManifestData(defn.manifest);
} catch {
setManifestData(null);
}
} else {
setManifestData(null);
}
};
const handleSelectToolType = (toolType: ToolType | null) => {
if (toolTypeDirty) {
if (!window.confirm("You have unsaved changes. Discard them?")) {
return;
}
}
if (toolType) {
setSelectedToolTypeId(toolType.id);
setIsCreating(false);
void populateToolTypeForm(toolType);
} else {
setSelectedToolTypeId(null);
}
};
const handleCreateNew = () => {
if (toolTypeDirty) {
if (!window.confirm("You have unsaved changes. Discard them?")) {
return;
}
}
setSelectedToolTypeId(null);
setIsCreating(true);
resetToolTypeForm();
};
const handleToolTypeSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
setToolTypeError(null);
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
setToolTypeError("Name and display name are required");
return;
}
if (
toolTypeForm.requires_port &&
(!toolTypeForm.default_port.trim() ||
isNaN(Number(toolTypeForm.default_port)))
) {
setToolTypeError("Default port is required and must be a number");
return;
}
if (toolTypeForm.definition_type !== "manifest") {
const template =
toolTypeForm.definition_type === "compose"
? toolTypeForm.compose_template
: toolTypeForm.dockerfile_template;
if (!template.trim()) {
setToolTypeError(
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
);
return;
}
} else if (!manifestData) {
setToolTypeError(
"Manifest data is required for manifest definition type",
);
return;
}
const variables = toolTypeForm.required_variables
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
const readinessProbe: ReadinessProbe | undefined =
toolTypeForm.readiness_command.trim()
? {
command: toolTypeForm.readiness_command.trim(),
timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
interval: parseInt(toolTypeForm.readiness_interval) || 2,
}
: undefined;
const template =
toolTypeForm.definition_type === "compose"
? toolTypeForm.compose_template
: toolTypeForm.dockerfile_template;
try {
if (isCreating) {
let manifestId: string | undefined;
if (toolTypeForm.definition_type === "manifest" && manifestData) {
const manifestPayload = {
name: toolTypeForm.name.trim(),
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interface_type: toolTypeForm.interface_type,
base_image: (manifestData.base_image as string) || undefined,
base_definition_id:
(manifestData.base_definition_id as string) || undefined,
manifest: manifestData,
};
const newManifest = await createToolDefinition(manifestPayload);
manifestId = newManifest.id;
}
const input: CreateToolTypeRequest = {
name: toolTypeForm.name.trim(),
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interface_type: toolTypeForm.interface_type,
requires_port: toolTypeForm.requires_port,
default_port: toolTypeForm.requires_port
? Number(toolTypeForm.default_port)
: 0,
definition_type: toolTypeForm.definition_type,
manifest_id: manifestId,
compose_template:
toolTypeForm.definition_type === "compose" ? template : undefined,
dockerfile_template:
toolTypeForm.definition_type === "dockerfile"
? template
: undefined,
readiness_probe: readinessProbe,
required_variables: variables,
startup_command: toolTypeForm.startup_command.trim() || undefined,
};
const newTool = await createToolType(input);
setIsCreating(false);
setSelectedToolTypeId(newTool.id);
setToolTypeDirty(false);
} else if (selectedToolType) {
let manifestId = selectedToolType.manifest_id || undefined;
if (toolTypeForm.definition_type === "manifest" && manifestData) {
if (manifestId) {
await updateToolDefinition(manifestId, {
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
manifest: manifestData,
});
} else {
const manifestPayload = {
name: toolTypeForm.name.trim(),
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interface_type: toolTypeForm.interface_type,
base_image: (manifestData.base_image as string) || undefined,
base_definition_id:
(manifestData.base_definition_id as string) || undefined,
manifest: manifestData,
};
const newManifest = await createToolDefinition(manifestPayload);
manifestId = newManifest.id;
}
}
const input: UpdateToolTypeRequest = {
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interface_type: toolTypeForm.interface_type,
requires_port: toolTypeForm.requires_port,
default_port: toolTypeForm.requires_port
? Number(toolTypeForm.default_port)
: 0,
definition_type: toolTypeForm.definition_type,
manifest_id:
toolTypeForm.definition_type === "manifest"
? manifestId
: undefined,
compose_template:
toolTypeForm.definition_type === "compose" ? template : undefined,
dockerfile_template:
toolTypeForm.definition_type === "dockerfile"
? template
: undefined,
readiness_probe: readinessProbe,
required_variables: variables,
startup_command: toolTypeForm.startup_command.trim() || undefined,
};
await updateToolType(selectedToolType.id, input);
setToolTypeDirty(false);
}
await loadData();
} catch (err) {
setToolTypeError(extractErrorMessage(err));
}
};
const handleDeleteToolType = async (id: string) => {
if (
!window.confirm(
"Delete this tool type? All associated configs will be removed.",
)
)
return;
try {
await deleteToolType(id);
if (selectedToolTypeId === id) {
setSelectedToolTypeId(null);
setIsCreating(false);
resetToolTypeForm();
}
await loadData();
} catch {
alert("Failed to delete tool type");
}
};
const handleFormChange = (changes: Partial<ToolTypeFormState>) => {
setToolTypeForm((prev) => ({ ...prev, ...changes }));
setToolTypeDirty(true);
};
const handleReset = () => {
if (isCreating) {
resetToolTypeForm();
} else if (selectedToolType) {
void populateToolTypeForm(selectedToolType);
}
};
return {
status,
toolTypes,
baseDefinitions,
selectedToolType,
selectedToolTypeId,
isCreating,
toolTypeForm,
manifestData,
manifestDefinitionId,
toolTypeError,
toolTypeDirty,
loadData,
handleSelectToolType,
handleCreateNew,
handleToolTypeSubmit,
handleDeleteToolType,
handleFormChange,
handleReset,
setManifestData,
setToolTypeDirty,
};
};
+21 -3
View File
@@ -11,11 +11,13 @@ import {
export interface UseWorkspaceFilesResult {
entries: FileEntry[];
content: string | null;
currentPath: string;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
loadFile: (path: string) => Promise<void>;
saveFile: (path: string, content: string, message?: string) => Promise<void>;
navigateTo: (path: string) => void;
}
export function useWorkspaceFiles(
@@ -23,6 +25,7 @@ export function useWorkspaceFiles(
): UseWorkspaceFilesResult {
const [entries, setEntries] = useState<FileEntry[]>([]);
const [content, setContent] = useState<string | null>(null);
const [currentPath, setCurrentPath] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -30,14 +33,19 @@ export function useWorkspaceFiles(
setLoading(true);
setError(null);
try {
const data = await listWorkspaceFiles(workspaceId);
const data = await listWorkspaceFiles(workspaceId, currentPath);
setEntries(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load files");
} finally {
setLoading(false);
}
}, [workspaceId]);
}, [workspaceId, currentPath]);
const navigateTo = useCallback((path: string) => {
setCurrentPath(path);
setContent(null);
}, []);
const loadFile = useCallback(
async (path: string) => {
@@ -64,5 +72,15 @@ export function useWorkspaceFiles(
refresh();
}, [refresh]);
return { entries, content, loading, error, refresh, loadFile, saveFile };
return {
entries,
content,
currentPath,
loading,
error,
refresh,
loadFile,
saveFile,
navigateTo,
};
}
+20 -10
View File
@@ -5,16 +5,26 @@ import { BrowserRouter } from "react-router-dom";
import { AppRouter } from "./router";
import { AuthProvider } from "./state/auth";
import { SessionsProvider } from "./state/sessions";
import "./styles.css";
import "./styles/tokens.css";
import "./styles/global.css";
import "./styles/utilities.css";
import "./styles/syntax-highlight.css";
import "./styles/pages/git-history.css";
import "./styles/pages/repo-workspace.css";
import "./styles/pages/projects.css";
import "./styles/pages/sessions.css";
import "./styles/pages/ssh-keys.css";
import "./styles/pages/workspace-detail.css";
import "./styles/pages/workspaces.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<SessionsProvider>
<AppRouter />
</SessionsProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<SessionsProvider>
<AppRouter />
</SessionsProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);
File diff suppressed because it is too large Load Diff
+65 -47
View File
@@ -1,8 +1,11 @@
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HomePage } from "./dashboard";
import { HomePage } from "./DashboardPage";
import { SessionsProvider } from "../state/sessions";
import { SessionOperationsProvider } from "../state/session-operations";
const mockDashboard = vi.fn();
const mockSessions = vi.fn();
@@ -10,72 +13,87 @@ const mockProjects = vi.fn();
const mockRepos = vi.fn();
vi.mock("../api/dashboard", () => ({
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args),
}));
vi.mock("../api/sessions", () => ({
getUserSessions: (...args: unknown[]) => mockSessions(...args),
createInstance: vi.fn(),
startInstance: vi.fn(),
stopInstance: vi.fn(),
deleteInstance: vi.fn(),
recreateInstanceTunnel: vi.fn()
getUserSessions: (...args: unknown[]) => mockSessions(...args),
createInstance: vi.fn(),
startInstance: vi.fn(),
stopInstance: vi.fn(),
deleteInstance: vi.fn(),
recreateInstanceTunnel: vi.fn(),
}));
vi.mock("../api/projects", () => ({
listProjects: (...args: unknown[]) => mockProjects(...args)
listProjects: (...args: unknown[]) => mockProjects(...args),
}));
vi.mock("../api/git-repositories", () => ({
listRepositories: (...args: unknown[]) => mockRepos(...args)
listRepositories: (...args: unknown[]) => mockRepos(...args),
}));
vi.mock("../api/tool-types", () => ({
listToolTypes: vi.fn().mockResolvedValue([])
listToolTypes: vi.fn().mockResolvedValue([]),
}));
describe("HomePage", () => {
beforeEach(() => {
mockDashboard.mockReset();
mockSessions.mockReset();
mockProjects.mockReset();
mockRepos.mockReset();
});
beforeEach(() => {
mockDashboard.mockReset();
mockSessions.mockReset();
mockProjects.mockReset();
mockRepos.mockReset();
});
it("shows overview sections", async () => {
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
mockSessions.mockResolvedValue([]);
mockProjects.mockResolvedValue([]);
mockRepos.mockResolvedValue([]);
it("shows overview sections", async () => {
mockDashboard.mockResolvedValue({
projects: 1,
repositories: 2,
sshKeys: 3,
recentActivity: [],
});
mockSessions.mockResolvedValue([]);
mockProjects.mockResolvedValue([]);
mockRepos.mockResolvedValue([]);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
render(
<MemoryRouter>
<SessionsProvider>
<SessionOperationsProvider>
<HomePage />
</SessionOperationsProvider>
</SessionsProvider>
</MemoryRouter>,
);
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
expect(screen.getByText("Available projects")).toBeInTheDocument();
});
});
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
expect(screen.getByText("Workspaces")).toBeInTheDocument();
});
});
it("shows retry action when home load fails", async () => {
mockDashboard.mockRejectedValueOnce(new Error("failed"));
mockSessions.mockRejectedValueOnce(new Error("failed"));
mockProjects.mockRejectedValueOnce(new Error("failed"));
it("shows retry action when home load fails", async () => {
mockDashboard.mockRejectedValueOnce(new Error("failed"));
mockSessions.mockRejectedValueOnce(new Error("failed"));
mockProjects.mockRejectedValueOnce(new Error("failed"));
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
render(
<MemoryRouter>
<SessionsProvider>
<SessionOperationsProvider>
<HomePage />
</SessionOperationsProvider>
</SessionsProvider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText("Unable to load your workspace overview."),
).toBeInTheDocument();
});
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
});
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
});
});
+7 -14
View File
@@ -2,15 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import {
getUserSessions,
checkInstanceHealth,
type Session as SessionApi,
type InstanceHealth,
} from "../api/sessions";
import { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
import { ErrorState, LoadingState } from "../components/data-states";
import { SessionList } from "../components/features/session/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
import { useSessions } from "../state/sessions";
type HomeStatus = "loading" | "ready" | "error";
@@ -20,13 +16,11 @@ const summaryCards = [
{ label: "Repositories", key: "repositories" },
] as const;
type SessionView = SessionApi;
export const HomePage = () => {
const navigate = useNavigate();
const { sessions, refreshSessions } = useSessions();
const [status, setStatus] = useState<HomeStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [sessions, setSessions] = useState<SessionView[]>([]);
const [tunnelHealth, setTunnelHealth] = useState<
Record<string, InstanceHealth>
>({});
@@ -35,17 +29,16 @@ export const HomePage = () => {
const loadHome = useCallback(async () => {
setStatus("loading");
try {
const [dashboard, sessionData] = await Promise.all([
const [dashboard] = await Promise.all([
getDashboardSummary(),
getUserSessions(),
refreshSessions(),
]);
setSummary(dashboard);
setSessions(sessionData as SessionView[]);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
}, [refreshSessions]);
useEffect(() => {
void loadHome();
@@ -58,7 +51,7 @@ export const HomePage = () => {
handleStop,
handleDelete,
handleRecreateTunnel,
} = useInstanceActions({ onRefresh: loadHome });
} = useInstanceActions({ onRefresh: refreshSessions });
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
+1 -1
View File
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./projects";
import { ProjectsPage } from "./ProjectsPage";
import * as projectsApi from "../api/projects";
const mockProjects = [
+211 -350
View File
@@ -1,144 +1,198 @@
/** Projects page with inline repositories and workspaces. */
import { useState } from "react";
import {
createProject,
deleteProject,
listProjects,
updateProject,
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
import {
EmptyState,
ErrorState,
LoadingState,
} from "../components/data-states";
import { Icon } from "../components/icon";
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
import { useAsyncData } from "../hooks/use-async-data";
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { ProjectCard } from "../components/features/project/ProjectCard";
import { ProjectDialog } from "../components/features/project/ProjectDialog";
import { RepositoryCreateDialog } from "../components/features/project/repository-create-dialog";
import { MobileListView } from "../components/features/mobile/mobile-list-view";
import { MobileFAB } from "../components/features/mobile/mobile-fab";
import { useProjects } from "../hooks/use-projects";
import type { ProjectWithRepos } from "../types";
type DialogMode = "none" | "create" | "edit";
type MobileView = "list" | "detail" | "create-project" | "create-repo";
export const ProjectsPage = () => {
const isMobile = useMobileViewport();
const [mobileView, setMobileView] = useState<MobileView>("list");
const [selectedProject, setSelectedProject] =
useState<ProjectWithRepos | null>(null);
const {
data: projects,
projects,
status,
reload,
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
null,
);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [expandedProject, setExpandedProject] = useState<string | null>(null);
const [creatingWorkspace, setCreatingWorkspace] = useState<{
projectId: string;
repoId: string;
} | null>(null);
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
dialogMode,
formName,
setFormName,
formDescription,
setFormDescription,
formError,
deleteConfirmId,
setDeleteConfirmId,
expandedProject,
setExpandedProject,
creatingWorkspace,
setCreatingWorkspace,
workspaceLoading,
openCreate,
openEdit,
closeDialog,
handleSubmit,
handleDelete,
handleSyncWorkspace,
handleDeleteWorkspace,
} = useProjects();
const safeProjects = projects ?? [];
const isEmpty = status === "ready" && projects.length === 0;
const openCreate = () => {
setFormName("");
setFormDescription("");
setFormError(null);
setEditingProject(null);
setDialogMode("create");
};
const openEdit = (project: ProjectWithRepos) => {
setFormName(project.name);
setFormDescription(project.description ?? "");
setFormError(null);
setEditingProject(project);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingProject(null);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Project name is required");
return;
/* ── Mobile views ── */
if (isMobile) {
if (mobileView === "create-project") {
return (
<div className="mobile-page">
<ProjectDialog
mode="create"
name={formName}
description={formDescription}
error={formError}
onNameChange={setFormName}
onDescriptionChange={setFormDescription}
onSubmit={handleSubmit}
onCancel={() => {
closeDialog();
setMobileView("list");
}}
/>
</div>
);
}
try {
if (dialogMode === "create") {
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
} else if (dialogMode === "edit" && editingProject) {
const input: ProjectUpdateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await updateProject(editingProject.id, input);
}
closeDialog();
reload();
} catch {
setFormError("Failed to save project");
if (mobileView === "create-repo" && selectedProject) {
return (
<div className="mobile-page">
<RepositoryCreateDialog
projectId={selectedProject.id}
open={true}
title="Add Repository"
onClose={() => setMobileView("detail")}
onCreated={async () => {
setMobileView("detail");
await reload();
}}
/>
</div>
);
}
};
const handleDelete = async (projectId: string) => {
try {
await deleteProject(projectId);
setDeleteConfirmId(null);
reload();
} catch {
setDeleteConfirmId(null);
if (mobileView === "detail" && selectedProject) {
return (
<div className="mobile-page">
<ProjectCard
project={selectedProject}
expanded={true}
showBackButton={true}
deleteConfirm={deleteConfirmId === selectedProject.id}
workspaceLoading={workspaceLoading}
showCreateForm={
creatingWorkspace?.projectId === selectedProject.id
? creatingWorkspace.repoId
: null
}
onBack={() => {
setMobileView("list");
setSelectedProject(null);
}}
onEdit={() => {
setFormName(selectedProject.name);
setFormDescription(selectedProject.description ?? "");
setMobileView("create-project");
}}
onDelete={() => setDeleteConfirmId(selectedProject.id)}
onConfirmDelete={() => {
void handleDelete(selectedProject.id);
setMobileView("list");
setSelectedProject(null);
}}
onCancelDelete={() => setDeleteConfirmId(null)}
onCreateWorkspace={(repoId) =>
setCreatingWorkspace({
projectId: selectedProject.id,
repoId,
})
}
onWorkspaceAction={(repoId, workspace, action) => {
if (action === "sync") {
void handleSyncWorkspace(selectedProject.id, repoId, workspace);
} else if (action === "delete") {
void handleDeleteWorkspace(workspace);
}
}}
onAddRepository={() => setMobileView("create-repo")}
onCancelCreate={() => setCreatingWorkspace(null)}
onCreated={() => {
setCreatingWorkspace(null);
reload();
}}
/>
</div>
);
}
};
const handleSyncWorkspace = async (
projectId: string,
repoId: string,
workspace: WorkspaceSummary,
) => {
setWorkspaceLoading(workspace.id);
try {
await syncWorkspace(projectId, repoId, workspace.id);
reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to sync workspace");
} finally {
setWorkspaceLoading(null);
}
};
/* Mobile list view */
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Projects</h1>
<span className="muted">
{projects.length} project{projects.length !== 1 ? "s" : ""}
</span>
</div>
const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
setWorkspaceLoading(workspace.id);
try {
await deleteWorkspace(workspace.id);
reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete workspace");
} finally {
setWorkspaceLoading(null);
}
};
{status === "loading" && <LoadingState message="Loading projects..." />}
const isEmpty = status === "ready" && safeProjects.length === 0;
{status === "error" && (
<ErrorState message="Failed to load projects" onRetry={reload} />
)}
{isEmpty && (
<EmptyState message="No projects yet. Create your first project above." />
)}
{status === "ready" && projects.length > 0 && (
<MobileListView
items={projects.map((p) => ({
id: p.id,
title: p.name,
subtitle: `${p.repositories?.length ?? 0} repo${(p.repositories?.length ?? 0) !== 1 ? "s" : ""}${p.description ? " · " + p.description : ""}`,
}))}
onItemClick={(id) => {
const project = projects.find((p) => p.id === id);
if (project) {
setSelectedProject(project);
setMobileView("detail");
}
}}
emptyMessage="No projects yet"
/>
)}
<MobileFAB
onClick={() => {
openCreate();
setMobileView("create-project");
}}
label="Create project"
/>
</div>
);
}
/* ── Desktop view ── */
return (
<section className="stack">
<div className="page-header">
@@ -159,13 +213,20 @@ export const ProjectsPage = () => {
<EmptyState message="No projects yet. Create your first project above." />
)}
{status === "ready" && safeProjects.length > 0 && (
{status === "ready" && projects.length > 0 && (
<div className="project-list">
{safeProjects.map((project) => (
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
expanded={expandedProject === project.id}
deleteConfirm={deleteConfirmId === project.id}
workspaceLoading={workspaceLoading}
showCreateForm={
creatingWorkspace?.projectId === project.id
? creatingWorkspace.repoId
: null
}
onToggle={() =>
setExpandedProject(
expandedProject === project.id ? null : project.id,
@@ -173,7 +234,6 @@ export const ProjectsPage = () => {
}
onEdit={() => openEdit(project)}
onDelete={() => setDeleteConfirmId(project.id)}
deleteConfirm={deleteConfirmId === project.id}
onConfirmDelete={() => void handleDelete(project.id)}
onCancelDelete={() => setDeleteConfirmId(null)}
onCreateWorkspace={(repoId) =>
@@ -186,13 +246,11 @@ export const ProjectsPage = () => {
void handleDeleteWorkspace(workspace);
}
}}
workspaceLoading={workspaceLoading}
showCreateForm={
creatingWorkspace?.projectId === project.id
? creatingWorkspace.repoId
: null
}
onCancelCreate={() => setCreatingWorkspace(null)}
onAddRepository={() => {
setSelectedProject(project);
setMobileView("create-repo");
}}
onCreated={() => {
setCreatingWorkspace(null);
reload();
@@ -203,231 +261,34 @@ export const ProjectsPage = () => {
)}
{dialogMode !== "none" && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>
{dialogMode === "create" ? "Create Project" : "Edit Project"}
</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Project name"
/>
</label>
<label className="form-field">
Description
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button
className="secondary-button"
onClick={closeDialog}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
</div>
</form>
</div>
</div>
<ProjectDialog
mode={dialogMode}
name={formName}
description={formDescription}
error={formError}
onNameChange={setFormName}
onDescriptionChange={setFormDescription}
onSubmit={handleSubmit}
onCancel={closeDialog}
/>
)}
{mobileView === "create-repo" && selectedProject && (
<RepositoryCreateDialog
projectId={selectedProject.id}
open={true}
title="Add Repository"
onClose={() => {
setMobileView("list");
setSelectedProject(null);
}}
onCreated={async () => {
setMobileView("list");
setSelectedProject(null);
await reload();
}}
/>
)}
</section>
);
};
/* ─── Project Card ─── */
function ProjectCard({
project,
expanded,
onToggle,
onEdit,
onDelete,
deleteConfirm,
onConfirmDelete,
onCancelDelete,
onCreateWorkspace,
onWorkspaceAction,
workspaceLoading,
showCreateForm,
onCancelCreate,
onCreated,
}: {
project: ProjectWithRepos;
expanded: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
deleteConfirm: boolean;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onCreateWorkspace: (repoId: string) => void;
onWorkspaceAction: (
repoId: string,
workspace: WorkspaceSummary,
action: "sync" | "delete",
) => void;
workspaceLoading: string | null;
onCancelCreate: () => void;
showCreateForm: string | null;
onCreated: () => void;
}) {
return (
<article className="card project-card">
<div className="project-info-row">
<button
className="project-toggle"
onClick={onToggle}
type="button"
aria-expanded={expanded}
>
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
<h3>{project.name}</h3>
{project.repositories.length > 0 && (
<span className="repo-count">
{project.repositories.length} repo
{project.repositories.length > 1 ? "s" : ""}
</span>
)}
</button>
<div className="project-actions">
<button className="ghost-button" onClick={onEdit} type="button">
<Icon name="edit" size="sm" />
Edit
</button>
{deleteConfirm ? (
<div className="delete-confirm">
<span>Are you sure?</span>
<button
className="danger-button"
onClick={onConfirmDelete}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
<button
className="ghost-button"
onClick={onCancelDelete}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
) : (
<button
className="ghost-button danger-text"
onClick={onDelete}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
)}
</div>
</div>
{expanded && (
<div className="project-detail">
{project.repositories.length === 0 ? (
<p className="muted">No repositories yet.</p>
) : (
<div className="repo-list">
{project.repositories.map((repo) => (
<div key={repo.id} className="repo-block">
<div className="repo-header">
<h4>{repo.name}</h4>
<button
className="btn btn-sm btn-primary"
onClick={() => onCreateWorkspace(repo.id)}
type="button"
>
<Icon name="add" size="sm" /> New Workspace
</button>
</div>
{showCreateForm === repo.id && (
<WorkspaceCreateForm
defaultProjectId={project.id}
defaultRepoId={repo.id}
onSubmit={onCreated}
onCancel={onCancelCreate}
/>
)}
{repo.workspaces.length === 0 ? (
<p className="muted">No workspaces.</p>
) : (
<div className="workspace-grid">
{repo.workspaces.map((ws) => (
<div
key={ws.id}
className={`workspace-chip ${ws.status}`}
>
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
<span className="ws-branch">
<Icon name="branch" size="sm" /> {ws.branch}
</span>
{ws.instance_count > 0 && (
<span className="ws-instances">
{ws.instance_count} tool
{ws.instance_count > 1 ? "s" : ""}
</span>
)}
<div className="ws-actions">
<button
type="button"
disabled={workspaceLoading === ws.id}
onClick={() =>
onWorkspaceAction(repo.id, ws, "sync")
}
>
<Icon name="refresh" size="sm" />
</button>
<button
type="button"
className="danger-text"
disabled={workspaceLoading === ws.id}
onClick={() =>
onWorkspaceAction(repo.id, ws, "delete")
}
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</article>
);
}
+76 -492
View File
@@ -1,505 +1,89 @@
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { apiClient } from "../api/client";
import {
getRepositoryStatus,
listRepositories,
type GitRepository,
type GitStatus,
} from "../api/git-repositories";
import { CommitPanel } from "../components/features/git/commit-panel";
import { FileEditor } from "../components/features/git/file-editor";
import { GitToolbar } from "../components/features/git/git-toolbar";
import { InstanceList } from "../components/features/tool/instance-list";
import { useRepoWorkspace } from "../hooks/use-repo-workspace";
import { WorkspaceHeader } from "../components/features/workspace/workspace-header";
import { listToolTypes } from "../api/tool-types";
import type { ToolType } from "../api/tool-types";
import { WorkspaceLayout } from "../components/features/workspace/WorkspaceLayout";
type MobileTab = "files" | "editor" | "git" | "terminal";
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
interface FileTreeEntry {
name: string;
type: "file" | "directory";
path: string;
size?: number;
mode?: string;
last_commit?: {
hash: string;
message: string;
author: string;
date: string;
} | null;
}
interface Project {
id: string;
name: string;
description?: string | null;
}
export const RepoWorkspace = () => {
const { projectId } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useMobileViewport();
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
const {
projectId,
project,
status,
repositories,
selectedRepoId,
selectedRepo,
branches,
currentBranch,
gitStatus,
toolTypes,
handleRepoChange,
handleBranchChange,
loadGitStatus,
loadBranches,
loadRepositories,
} = useRepoWorkspace();
const isMobile = useMobileViewport();
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
const [status, setStatus] = useState<WorkspaceStatus>("loading");
const [project, setProject] = useState<Project | null>(null);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
searchParams.get("repo")
);
const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState<string>("main");
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
return (
<section className="repo-workspace">
{project && (
<WorkspaceHeader
project={project}
currentRepo={selectedRepo || null}
/>
)}
const loadProject = useCallback(async () => {
if (!projectId) return;
try {
const response = await apiClient.get(`/projects/${projectId}`);
setProject(response.data);
} catch {
setProject(null);
}
}, [projectId]);
{status === "loading" && (
<LoadingState message="Loading repositories..." />
)}
const loadRepositories = useCallback(async () => {
if (!projectId) return;
{status === "error" && (
<ErrorState
message="Failed to load repositories"
onRetry={() => void loadRepositories()}
/>
)}
setStatus("loading");
try {
const data = await listRepositories(projectId);
setRepositories(data);
{status === "empty" && (
<div className="card stack">
<EmptyState message="No repositories in this project yet." />
<Link
className="primary-button"
to={`/projects/${projectId}/settings/repositories`}
>
Manage Repositories
</Link>
</div>
)}
if (data.length === 0) {
setStatus("empty");
} else {
setStatus("ready");
// If no repo selected, select the first one
if (!selectedRepoId) {
setSelectedRepoId(data[0].id);
const newParams = new URLSearchParams(searchParams);
newParams.set("repo", data[0].id);
setSearchParams(newParams, { replace: true });
}
}
} catch {
setRepositories([]);
setStatus("error");
}
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
const loadBranches = useCallback(async () => {
if (!projectId || !selectedRepoId) return;
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
);
const branchList = response.data.branches.map((b: { name: string }) => b.name);
setBranches(branchList);
const defaultBranch = response.data.default_branch;
if (defaultBranch) {
setCurrentBranch(defaultBranch);
}
} catch {
setBranches([]);
}
}, [projectId, selectedRepoId]);
const loadGitStatus = useCallback(async () => {
if (!projectId || !selectedRepoId) return;
try {
const data = await getRepositoryStatus(projectId, selectedRepoId);
setGitStatus(data);
} catch {
setGitStatus(null);
}
}, [projectId, selectedRepoId]);
const loadToolTypes = useCallback(async () => {
try {
const data = await listToolTypes();
setToolTypes(data);
} catch {
setToolTypes([]);
}
}, []);
useEffect(() => {
void loadProject();
void loadRepositories();
void loadToolTypes();
}, [loadProject, loadRepositories, loadToolTypes]);
useEffect(() => {
void loadBranches();
void loadGitStatus();
}, [loadBranches, loadGitStatus]);
const handleRepoChange = (repoId: string) => {
setSelectedRepoId(repoId);
const newParams = new URLSearchParams(searchParams);
newParams.set("repo", repoId);
newParams.delete("branch");
newParams.delete("path");
setSearchParams(newParams);
};
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
return (
<section className="repo-workspace">
{project && (
<WorkspaceHeader
project={project}
currentRepo={selectedRepo || null}
/>
)}
{status === "loading" && (
<LoadingState message="Loading repositories..." />
)}
{status === "error" && (
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
)}
{status === "empty" && (
<div className="card stack">
<EmptyState message="No repositories in this project yet." />
<Link
className="primary-button"
to={`/projects/${projectId}/settings/repositories`}
>
Manage Repositories
</Link>
</div>
)}
{status === "ready" && repositories.length > 0 && (
<>
{isMobile ? (
// Mobile Layout
<div className="mobile-workspace">
<div className="mobile-workspace-header">
<select
value={selectedRepoId || ""}
onChange={(e) => handleRepoChange(e.target.value)}
className="mobile-repo-selector"
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
{selectedRepoId && (
<select
value={currentBranch}
onChange={(e) => {
const branch = e.target.value;
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
newParams.set("branch", branch);
setSearchParams(newParams);
}}
className="mobile-branch-selector"
>
{branches.map((branch) => (
<option key={branch} value={branch}>
{branch}
</option>
))}
</select>
)}
</div>
<div className="mobile-workspace-content">
{mobileTab === "files" && selectedRepoId && (
<FileBrowser
projectId={projectId!}
repoId={selectedRepoId}
gitStatus={gitStatus}
/>
)}
{mobileTab === "editor" && selectedRepoId && (
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
)}
{mobileTab === "git" && selectedRepoId && gitStatus && (
<div className="mobile-git-view">
<CommitPanel
projectId={projectId!}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
</div>
)}
{mobileTab === "terminal" && selectedRepoId && (
<InstanceList
projectId={projectId!}
repoId={selectedRepoId}
projectName={project?.name}
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
toolTypes={toolTypes}
/>
)}
</div>
<div className="mobile-workspace-tabs">
<button
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
onClick={() => setMobileTab("files")}
type="button"
>
<Icon name="folder" size="sm" />
<span>Files</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
onClick={() => setMobileTab("editor")}
type="button"
>
<Icon name="edit" size="sm" />
<span>Editor</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
onClick={() => setMobileTab("git")}
type="button"
>
<Icon name="branch" size="sm" />
<span>Git</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
onClick={() => setMobileTab("terminal")}
type="button"
>
<Icon name="terminal" size="sm" />
<span>Terminal</span>
</button>
</div>
</div>
) : (
// Desktop Layout
<>
{selectedRepoId && (
<GitToolbar
projectId={projectId!}
repoId={selectedRepoId}
currentBranch={currentBranch}
branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)}
isMirror={Boolean(selectedRepo?.is_mirror)}
onBranchChange={(branch) => {
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
newParams.set("branch", branch);
setSearchParams(newParams);
}}
onRefresh={() => {
void loadBranches();
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
<div className="workspace-layout">
<aside className="workspace-sidebar">
<div className="sidebar-section">
<label className="form-field">
Repository
<select
value={selectedRepoId || ""}
onChange={(e) => handleRepoChange(e.target.value)}
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
</label>
</div>
{selectedRepoId && (
<>
<FileBrowser
projectId={projectId!}
repoId={selectedRepoId}
gitStatus={gitStatus}
/>
{gitStatus && (
<CommitPanel
projectId={projectId!}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
<InstanceList
projectId={projectId!}
repoId={selectedRepoId}
projectName={project?.name}
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
toolTypes={toolTypes}
/>
</>
)}
</aside>
<main className="workspace-main">
{selectedRepoId && (
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
)}
</main>
</div>
</>
)}
</>
)}
</section>
);
};
// File Browser Component
const FileBrowser = ({
projectId,
repoId,
gitStatus,
}: {
projectId: string;
repoId: string;
gitStatus: GitStatus | null;
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const branch = searchParams.get("branch") || "main";
const path = searchParams.get("path") || "";
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files`,
{
params: {
branch,
path,
},
}
);
setEntries(response.data.entries || []);
} catch {
setError("Failed to load files");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, path]);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
// Listen for refresh events
useEffect(() => {
const handleRefresh = () => void loadFiles();
window.addEventListener("refresh-file-tree", handleRefresh);
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
}, [loadFiles]);
const handleEntryClick = (entry: FileTreeEntry) => {
if (entry.type === "directory") {
const newParams = new URLSearchParams(searchParams);
newParams.set("path", entry.path);
setSearchParams(newParams);
} else {
const newParams = new URLSearchParams(searchParams);
newParams.set("file", entry.path);
setSearchParams(newParams);
}
};
const navigateUp = () => {
if (!path) return;
const parentPath = path.split("/").slice(0, -1).join("/");
const newParams = new URLSearchParams(searchParams);
if (parentPath) {
newParams.set("path", parentPath);
} else {
newParams.delete("path");
}
setSearchParams(newParams);
};
const getFileStatus = (filePath: string): string | null => {
if (!gitStatus) return null;
if (gitStatus.modified.includes(filePath)) return "modified";
if (gitStatus.added.includes(filePath)) return "added";
if (gitStatus.deleted.includes(filePath)) return "deleted";
if (gitStatus.untracked.includes(filePath)) return "untracked";
return null;
};
if (loading) return <p className="muted">Loading files...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-tree">
{path && (
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
<Icon name="folder" size="sm" /> ..
</button>
)}
{entries.length === 0 && (
<EmptyState message="No files in this repository yet." />
)}
{entries.map((entry) => {
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
return (
<button
key={entry.path}
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
onClick={() => handleEntryClick(entry)}
type="button"
>
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
{fileStatus === "added" && "A"}
{fileStatus === "deleted" && "D"}
{fileStatus === "untracked" && "?"}
</span>
)}
</button>
);
})}
</div>
);
{status === "ready" && repositories.length > 0 && (
<WorkspaceLayout
projectId={projectId!}
project={project}
isMobile={isMobile}
mobileTab={mobileTab}
selectedRepoId={selectedRepoId}
selectedRepo={selectedRepo}
branches={branches}
currentBranch={currentBranch}
gitStatus={gitStatus}
toolTypes={toolTypes}
repositories={repositories}
onMobileTabChange={setMobileTab}
onRepoChange={handleRepoChange}
onBranchChange={handleBranchChange}
onRefresh={() => {
void loadBranches();
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
</section>
);
};
+22 -22
View File
@@ -1,46 +1,39 @@
import { useCallback, useEffect, useState } from "react";
import {
getUserSessions,
type Session,
checkInstanceHealth,
} from "../api/sessions";
import { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
import { getUserConfig } from "../api/settings";
import { ErrorState, LoadingState } from "../components/data-states";
import { SessionList } from "../components/features/session/session-list";
import { SessionCard } from "../components/features/session/session-card";
import { useInstanceActions } from "../hooks/use-instance-actions";
import type { InstanceHealth } from "../api/sessions";
import { useSessions } from "../state/sessions";
type SessionsStatus = "loading" | "ready" | "error";
export const SessionsPage = () => {
const { sessions, isLoading, error, refreshSessions } = useSessions();
const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<
Record<string, InstanceHealth>
>({});
const loadSessions = useCallback(async () => {
const loadPageData = useCallback(async () => {
setStatus("loading");
try {
const [sessionsData, config] = await Promise.all([
getUserSessions(),
getUserConfig(),
]);
setSessions(sessionsData);
await refreshSessions();
const config = await getUserConfig();
setLastSessionId(config.last_session_id ?? null);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
}, [refreshSessions]);
useEffect(() => {
void loadSessions();
}, [loadSessions]);
void loadPageData();
}, [loadPageData]);
const {
loadingSessionId,
@@ -52,8 +45,9 @@ export const SessionsPage = () => {
handleDelete,
handleForceDelete,
handleRecreateTunnel,
handleRename,
clearDirtyDelete,
} = useInstanceActions({ onRefresh: loadSessions });
} = useInstanceActions({ onRefresh: refreshSessions });
// Poll health every 30 seconds for active web-enabled instances
useEffect(() => {
@@ -99,22 +93,26 @@ export const SessionsPage = () => {
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
const isPageLoading = status === "loading" || (status === "ready" && isLoading && sessions.length === 0);
return (
<section className="stack sessions-page">
<div className="page-header">
<h1>Sessions</h1>
</div>
{status === "loading" && <LoadingState message="Loading sessions..." />}
{status === "error" && (
<ErrorState
message="Failed to load sessions"
onRetry={() => void loadSessions()}
message={error?.message ?? "Failed to load sessions"}
onRetry={() => void loadPageData()}
/>
)}
{status === "ready" && (
{(status === "loading" || isPageLoading) && (
<LoadingState message="Loading sessions..." />
)}
{status === "ready" && !isPageLoading && (
<>
{/* Last Session */}
{lastSession && (
@@ -124,6 +122,7 @@ export const SessionsPage = () => {
session={lastSession}
onOpen={handleOpen}
onDelete={handleDelete}
onRename={handleRename}
isBusy={loadingSessionId === lastSession.id}
tunnelHealth={tunnelHealth[lastSession.id] || null}
/>
@@ -139,6 +138,7 @@ export const SessionsPage = () => {
onStop={handleStop}
onDelete={handleDelete}
onRecreateTunnel={handleRecreateTunnel}
onRename={handleRename}
actionBusyId={loadingSessionId}
tunnelHealth={tunnelHealth}
/>
+2 -149
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { Link, Outlet, useLocation } from "react-router-dom";
import {
getUserConfig,
updateUserConfig,
@@ -8,7 +7,6 @@ import {
type UserConfigUpdate,
} from "../api/settings";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
const TABS = [
@@ -16,29 +14,7 @@ const TABS = [
{ label: "SSH Keys", path: "ssh-keys" },
] as const;
const THEME_OPTIONS = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
const TOAST_LEVEL_OPTIONS = [
{ value: "all", label: "All" },
{ value: "errors", label: "Errors only" },
{ value: "none", label: "None" },
];
const MUTE_CATEGORIES = ["instance", "system", "health", "security"];
type SettingsOutletContext = {
config: UserConfig;
handleChange: (
key: keyof UserConfigUpdate,
value: string | string[] | null,
) => void;
handleSave: () => Promise<void>;
saveStatus: "idle" | "saving" | "saved" | "error";
};
export { GeneralSettingsTab } from "../components/features/settings/GeneralSettingsTab";
export const SettingsPage = () => {
const location = useLocation();
@@ -60,7 +36,6 @@ export const SettingsPage = () => {
"idle" | "saving" | "saved" | "error"
>("idle");
// Sync loaded config into local editable state
useEffect(() => {
if (loadedConfig) {
setConfig({
@@ -160,125 +135,3 @@ export const SettingsPage = () => {
</section>
);
};
export const GeneralSettingsTab = () => {
const { config, handleChange, handleSave, saveStatus } =
useOutletContext<SettingsOutletContext>();
return (
<div className="stack">
<h2>General</h2>
<label className="form-field">
Theme
<select
value={config.theme}
onChange={(e) => handleChange("theme", e.target.value)}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="form-field">
Git user name
<input
type="text"
value={config.git_user_name ?? ""}
onChange={(e) =>
handleChange("git_user_name", e.target.value || null)
}
placeholder="Your git commit name"
/>
</label>
<label className="form-field">
Git user email
<input
type="email"
value={config.git_user_email ?? ""}
onChange={(e) =>
handleChange("git_user_email", e.target.value || null)
}
placeholder="your.email@example.com"
/>
</label>
<label className="form-field">
Default editor
<input
type="text"
value={config.default_editor ?? ""}
onChange={(e) =>
handleChange("default_editor", e.target.value || null)
}
placeholder="e.g., vscode, vim, cursor"
/>
</label>
<h3>Notifications</h3>
<label className="form-field">
Toast level
<select
value={config.notification_toast_level ?? "all"}
onChange={(e) =>
handleChange("notification_toast_level", e.target.value)
}
>
{TOAST_LEVEL_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<fieldset className="form-field">
<legend>Mute categories</legend>
<div className="stack-sm">
{MUTE_CATEGORIES.map((cat) => (
<label
key={cat}
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<input
type="checkbox"
checked={(config.notification_mute_categories ?? []).includes(
cat,
)}
onChange={(e) => {
const current = config.notification_mute_categories ?? [];
const next = e.target.checked
? [...current, cat]
: current.filter((c) => c !== cat);
handleChange("notification_mute_categories", next);
}}
/>
{cat}
</label>
))}
</div>
</fieldset>
<div className="settings-actions">
<button
className="primary-button"
onClick={() => void handleSave()}
type="button"
>
{saveStatus === "saving" ? (
<>
<Icon name="loading" size="sm" /> Saving...
</>
) : (
<>
<Icon name="save" size="sm" /> Save Settings
</>
)}
</button>
{saveStatus === "saved" && (
<span className="success-text">Settings saved!</span>
)}
{saveStatus === "error" && (
<span className="error-text">Failed to save</span>
)}
</div>
</div>
);
};
+59 -252
View File
@@ -1,92 +1,35 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh-keys";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
import { LoadingState } from "../components/data-states";
import { useSSHKeys } from "../hooks/use-ssh-keys";
import { SSHKeyCreateForm } from "../components/features/ssh-keys/SSHKeyCreateForm";
import { SSHKeyList } from "../components/features/ssh-keys/SSHKeyList";
export const SSHKeysPage = () => {
const navigate = useNavigate();
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
const [newKeyName, setNewKeyName] = useState("");
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>>({});
const [mutationError, setMutationError] = useState<string | null>(null);
const safeKeys = keys ?? [];
async function handleGenerate(e: React.FormEvent) {
e.preventDefault();
if (!newKeyName.trim()) return;
try {
setGenerating(true);
await createSSHKey({ name: newKeyName.trim() });
setNewKeyName("");
await loadKeys();
} catch {
setMutationError("Failed to generate SSH key");
} finally {
setGenerating(false);
}
}
async function handleDelete(keyId: string) {
if (!confirm("Are you sure you want to delete this SSH key?")) return;
try {
await deleteSSHKey(keyId);
await loadKeys();
} catch {
setMutationError("Failed to delete SSH key");
}
}
function copyToClipboard(text: string) {
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 }));
setMutationError(null);
} catch {
setMutationError("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 }));
setMutationError(null);
} catch {
setMutationError("Failed to verify signature");
} finally {
setVerifying((prev) => ({ ...prev, [keyId]: false }));
}
}
const {
keys,
status,
loadKeys,
newKeyName,
setNewKeyName,
generating,
mutationError,
signPayloads,
signatures,
signing,
verifyPayloads,
verifySignatures,
verifyResults,
verifying,
handleGenerate,
handleDelete,
copyToClipboard,
handleSign,
handleVerify,
setSignPayloads,
setVerifyPayloads,
setVerifySignatures,
} = useSSHKeys();
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
@@ -104,174 +47,38 @@ export const SSHKeysPage = () => {
{mutationError && <div className="error">{mutationError}</div>}
<form onSubmit={handleGenerate} className="stack">
<div className="form-group">
<label htmlFor="key-name">Key Name</label>
<input
id="key-name"
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="e.g., GitHub Work"
required
/>
</div>
<button type="submit" className="primary-button" disabled={generating}>
{generating ? (
<>
<Icon name="loading" size="sm" />
Generating...
</>
) : (
<>
<Icon name="add" size="sm" />
Generate SSH Key
</>
)}
</button>
</form>
<SSHKeyCreateForm
newKeyName={newKeyName}
setNewKeyName={setNewKeyName}
generating={generating}
onSubmit={handleGenerate}
/>
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
<div className="keys-list">
{safeKeys.length === 0 ? (
<EmptyState message="No SSH keys yet. Generate one above." />
) : (
safeKeys.map((key) => (
<div key={key.id} className="key-card">
<div className="key-header">
<h3>{key.name}</h3>
<button
onClick={() => handleDelete(key.id)}
className="danger-button"
>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
<div className="key-meta">
<span className="muted">
Created: {new Date(key.created_at).toLocaleDateString()}
</span>
</div>
<div className="key-public">
<code>{key.public_key.substring(0, 50)}...</code>
<button
onClick={() => copyToClipboard(key.public_key)}
className="secondary-button"
>
<Icon name="copy" size="sm" />
Copy Full Key
</button>
</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>
<SSHKeyList
keys={keys}
status={status}
signPayloads={signPayloads}
signatures={signatures}
signing={signing}
verifyPayloads={verifyPayloads}
verifySignatures={verifySignatures}
verifyResults={verifyResults}
verifying={verifying}
onLoadKeys={loadKeys}
onDelete={handleDelete}
onCopy={copyToClipboard}
onSign={handleSign}
onVerify={handleVerify}
onSignPayloadChange={(id, value) =>
setSignPayloads((prev) => ({ ...prev, [id]: value }))
}
onVerifyPayloadChange={(id, value) =>
setVerifyPayloads((prev) => ({ ...prev, [id]: value }))
}
onVerifySignatureChange={(id, value) =>
setVerifySignatures((prev) => ({ ...prev, [id]: value }))
}
/>
</section>
);
};
+105 -564
View File
@@ -1,571 +1,112 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent, type TerminalRef } from "../components/features/terminal/terminal";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "../components/features/terminal/terminal-session-tabs";
import { Icon } from "../components/icon";
import { SpecialKeysStrip } from "../components/features/terminal/special-keys-strip";
import { SpecialKeysPanel } from "../components/features/terminal/special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useAutoHide } from "../hooks/use-auto-hide";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
import type { ModifierKey } from "../hooks/use-special-keys";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
type TerminalStatus =
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting";
import React from "react";
import { useTerminalPage } from "../hooks/use-terminal-page";
import { MobileTerminalView } from "../components/features/terminal/MobileTerminalView";
import { DesktopTerminalView } from "../components/features/terminal/DesktopTerminalView";
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{
instanceId: string;
}>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const {
instanceId,
navigate,
isMobile,
isFullscreen,
setIsFullscreen,
terminalRefs,
headerAutoHide,
terminalStatuses,
showResetConfirm,
setShowResetConfirm,
showSpecialKeysPanel,
setShowSpecialKeysPanel,
activeModifier,
setActiveModifier,
isKeyboardOpen,
keyboardHeight,
sessions,
activeSessionId,
loading,
error,
handleFullscreenClick,
handleSelect,
handleClose,
handleCreate,
handleRename,
handleTerminalReady,
handleFontSizeChange,
handleSendKey,
handleReset,
sessionInfos,
} = useTerminalPage();
// Track terminal status and callbacks for unified fullscreen header
const [terminalStatuses, setTerminalStatuses] = useState<
Record<string, TerminalStatus>
>({});
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
const sendDataRef = useRef<((data: string) => void) | null>(null);
const focusInputRef = useRef<(() => void) | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
null,
);
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(instanceId ?? "");
const status = terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
// Auto-create default session if none exist after loading completes
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
if (isMobile) {
return (
<MobileTerminalView
instanceId={instanceId}
sessions={sessions}
sessionInfos={sessionInfos}
activeSessionId={activeSessionId ?? ""}
terminalRefs={terminalRefs}
status={status}
error={error}
loading={loading}
isKeyboardOpen={isKeyboardOpen}
keyboardHeight={keyboardHeight}
isVisible={headerAutoHide.isVisible}
activeModifier={activeModifier}
showSpecialKeysPanel={showSpecialKeysPanel}
onToggleHeader={headerAutoHide.toggle}
onNavigateBack={() => navigate("/sessions")}
onFontSizeChange={handleFontSizeChange}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
onTerminalReady={handleTerminalReady}
onSendKey={handleSendKey}
onModifierChange={setActiveModifier}
onShowSpecialKeys={() => setShowSpecialKeysPanel(true)}
onHideSpecialKeys={() => setShowSpecialKeysPanel(false)}
onKeepFocus={() => {
/* focus handled by ref */
}}
/>
);
}
// Ensure refs map is kept in sync with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
// Clean up refs for closed sessions
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
}
}
}, [sessions]);
// Fit and focus active terminal when switching tabs
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
// Double rAF ensures layout has settled after the display:block switch
let raf1 = 0;
let raf2 = 0;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
ref.current?.fit();
ref.current?.focus();
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) {
setActiveSessionId(sessions[idx - 1].id);
}
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1) {
setActiveSessionId(sessions[idx + 1].id);
}
}
break;
case "r":
e.preventDefault();
if (activeSessionId) {
void resetSession(activeSessionId);
}
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
default:
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Keep screen awake while terminal is open
useEffect(() => {
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch {
// Wake lock may be denied; silently ignore
}
};
void requestWakeLock();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void requestWakeLock();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
};
}, []);
// Lock page scroll on mobile terminal so swipes scroll the terminal buffer,
// not the page.
useEffect(() => {
if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open");
return () => {
document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
// Click outside terminal content/header to exit fullscreen
const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!isFullscreen) return;
const target = e.target as Node;
const current = e.currentTarget as HTMLElement;
const content = current.querySelector(".terminal-page-content");
const header = current.querySelector(".terminal-fullscreen-header");
if (content?.contains(target) || header?.contains(target)) {
return;
}
setIsFullscreen(false);
},
[isFullscreen],
);
const handleSelect = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
},
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => {
await closeSession(sessionId);
},
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
const handleTerminalReady = useCallback(
(
sendData: (data: string) => void,
status: TerminalStatus,
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
setTerminalStatuses((prev) => ({
...prev,
[activeSessionId ?? "default"]: status,
}));
sendDataRef.current = sendData;
focusInputRef.current = focusInput;
changeFontSizeRef.current = changeFontSize;
},
[activeSessionId],
);
const handleFontSizeChange = useCallback((delta: number) => {
changeFontSizeRef.current?.(delta);
}, []);
const handleSendKey = useCallback((data: string) => {
sendDataRef.current?.(data);
}, []);
const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset();
}
}, [activeSessionId]);
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const sessionInfos = SESSIONS_TO_INFO(sessions);
if (isMobile) {
const activeSession = sessions.find((s) => s.id === activeSessionId);
const status =
terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
return (
<section
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
>
{/* Overlay status bar — floats over terminal, never resizes it */}
<div
className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={(e) => e.stopPropagation()}
>
<div className="mobile-terminal-toolbar">
<div className="mobile-terminal-toolbar-left">
<button
className="mobile-terminal-toolbtn"
onClick={() => navigate("/sessions")}
type="button"
aria-label="Back"
>
<Icon name="arrow-left" size="sm" />
</button>
</div>
<div className="mobile-terminal-toolbar-center">
<span className="mobile-terminal-title">
{activeSession?.name || "Terminal"}
</span>
<span
className={`mobile-terminal-status status-dot ${status}`}
aria-label={`Connection status: ${status}`}
/>
</div>
<div className="mobile-terminal-toolbar-right">
<button
className="mobile-terminal-toolbtn"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button
className="mobile-terminal-toolbtn"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
<button
className="mobile-terminal-toolbtn"
onClick={() => navigate("/sessions")}
type="button"
aria-label="Exit terminal"
>
<Icon name="close" size="sm" />
</button>
</div>
</div>
<div className="mobile-terminal-overlay-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
</div>
</div>
{/* Terminal content — always fills full viewport */}
<div
className="terminal-page-content mobile-full"
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
onClick={() => headerAutoHide.toggle()}
>
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
showControls={false}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
<SpecialKeysStrip
onSend={handleSendKey}
isVisible={!showSpecialKeysPanel}
onMoreClick={() => setShowSpecialKeysPanel(true)}
onKeepFocus={() => focusInputRef.current?.()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
<SpecialKeysPanel
onSend={handleSendKey}
isOpen={showSpecialKeysPanel}
onClose={() => setShowSpecialKeysPanel(false)}
onKeepFocus={() => focusInputRef.current?.()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
</section>
);
}
return (
<section
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
onClick={handleFullscreenClick}
>
{!isFullscreen && (
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
title="Toggle fullscreen (Alt+Shift+F)"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</div>
)}
{isFullscreen ? (
<div className="terminal-fullscreen-header">
<div className="terminal-fullscreen-header-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
</div>
<div className="terminal-fullscreen-header-controls">
<span
className={`terminal-fullscreen-status status-dot ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
aria-label={`Terminal status: ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
/>
<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>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
<button
className="terminal-close"
onClick={() => setIsFullscreen(false)}
type="button"
title="Exit fullscreen (Esc)"
>
Exit
</button>
</div>
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>
Reset terminal? This will kill the current shell session and
start fresh.
</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={() => setShowResetConfirm(false)}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
setShowResetConfirm(false);
handleReset();
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
</div>
) : (
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
)}
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={false}
showControls={!isFullscreen}
onTerminalReady={handleTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
return (
<DesktopTerminalView
instanceId={instanceId}
sessions={sessions}
sessionInfos={sessionInfos}
activeSessionId={activeSessionId ?? ""}
terminalRefs={terminalRefs}
isFullscreen={isFullscreen}
status={status}
error={error}
loading={loading}
showResetConfirm={showResetConfirm}
onFullscreenClick={handleFullscreenClick}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
onNavigateBack={() => navigate("/sessions")}
onToggleFullscreen={() => setIsFullscreen((p) => !p)}
onFontSizeChange={handleFontSizeChange}
onShowResetConfirm={() => setShowResetConfirm(true)}
onHideResetConfirm={() => setShowResetConfirm(false)}
onReset={handleReset}
onTerminalReady={handleTerminalReady}
/>
);
};
File diff suppressed because it is too large Load Diff
+37 -5
View File
@@ -150,8 +150,16 @@ function MobileTabBar({
/* ─── Files Tab ─── */
function FilesTab({ workspaceId }: { workspaceId: string }) {
const { entries, content, loadFile, saveFile, loading, error } =
useWorkspaceFiles(workspaceId);
const {
entries,
content,
currentPath,
loadFile,
saveFile,
loading,
error,
navigateTo,
} = useWorkspaceFiles(workspaceId);
const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [editContent, setEditContent] = useState<string | null>(null);
@@ -159,13 +167,28 @@ function FilesTab({ workspaceId }: { workspaceId: string }) {
const [commitMessage, setCommitMessage] = useState("");
const handleSelect = (entry: FileEntry) => {
if (entry.type === "directory") return;
if (entry.type === "directory") {
setSelectedPath(null);
setIsEditing(false);
setEditContent(null);
navigateTo(entry.path);
return;
}
setSelectedPath(entry.path);
setIsEditing(false);
setEditContent(null);
loadFile(entry.path);
};
const navigateUp = () => {
if (!currentPath) return;
const parentPath = currentPath.split("/").slice(0, -1).join("/");
navigateTo(parentPath);
setSelectedPath(null);
setIsEditing(false);
setEditContent(null);
};
const handleEdit = () => {
if (content !== null) {
setEditContent(content);
@@ -224,6 +247,15 @@ function FilesTab({ workspaceId }: { workspaceId: string }) {
)}
<div className="files-split">
<div className="file-tree">
{currentPath && (
<button
className="tree-entry tree-up"
onClick={navigateUp}
type="button"
>
<Icon name="folder" size="sm" /> ..
</button>
)}
{loading && <p className="muted">Loading...</p>}
{error && <p className="error-text">{error}</p>}
{entries.map((entry) => (
@@ -343,8 +375,8 @@ function ToolsTab({ workspace }: { workspace: Workspace }) {
{instance.url && (
<a
href={instance.url}
target="_blank"
rel="noopener noreferrer"
target={`instance-${instance.id}`}
rel="noreferrer"
>
Open
</a>
+118
View File
@@ -2,14 +2,25 @@
import { useState } from "react";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useWorkspaces } from "../hooks/use-workspaces";
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
import { WorkspaceCard } from "../components/features/workspace/workspace-card";
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
import { MobileListView } from "../components/features/mobile/mobile-list-view";
import { MobileDetailView } from "../components/features/mobile/mobile-detail-view";
import { MobileFAB } from "../components/features/mobile/mobile-fab";
import { ToolStarter } from "../components/features/tool/tool-starter";
import type { Workspace } from "../types/workspace";
type MobileView = "list" | "detail" | "create";
export function WorkspacesPage() {
const isMobile = useMobileViewport();
const [mobileView, setMobileView] = useState<MobileView>("list");
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
null,
);
const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
@@ -28,6 +39,113 @@ export function WorkspacesPage() {
);
};
/* ── Mobile views ── */
if (isMobile) {
if (mobileView === "create") {
return (
<div className="mobile-page">
<WorkspaceCreateForm
onSubmit={async () => {
setMobileView("list");
await refresh();
}}
onCancel={() => setMobileView("list")}
/>
</div>
);
}
if (mobileView === "detail" && selectedWorkspace) {
const ws = selectedWorkspace;
return (
<MobileDetailView
title={ws.name}
subtitle={`${ws.project_name} / ${ws.repo_name}`}
fields={[
{ label: "Branch", value: ws.branch },
{ label: "Status", value: ws.status },
{ label: "Path", value: ws.path },
{ label: "Instances", value: ws.instance_count },
{ label: "Last Sync", value: ws.last_sync_at ?? "Never" },
{ label: "Created", value: ws.created_at },
]}
onEdit={() => {
setStartWorkspace(ws);
}}
onDelete={() => {
void handleDelete(ws);
setMobileView("list");
setSelectedWorkspace(null);
}}
onBack={() => {
setMobileView("list");
setSelectedWorkspace(null);
}}
/>
);
}
/* Mobile list view */
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Workspaces</h1>
<span className="muted">
{workspaces.length} workspace{workspaces.length !== 1 ? "s" : ""}
</span>
</div>
{error && <div className="alert alert-error">{error}</div>}
{loading && workspaces.length === 0 ? (
<div className="loading-state">Loading workspaces...</div>
) : (
<MobileListView
items={workspaces.map((ws) => ({
id: ws.id,
title: ws.name,
subtitle: `${ws.project_name} · ${ws.repo_name} · ${ws.branch}`,
status: ws.status,
}))}
onItemClick={(id) => {
const ws = workspaces.find((w) => w.id === id);
if (ws) {
setSelectedWorkspace(ws);
setMobileView("detail");
}
}}
emptyMessage="No workspaces yet"
/>
)}
<MobileFAB
onClick={() => setMobileView("create")}
label="Create workspace"
/>
{startWorkspace && (
<div
className="modal-overlay"
onClick={() => setStartWorkspace(null)}
>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Start Tool</h3>
<ToolStarter
workspace={startWorkspace}
onStarted={() => {
setStartWorkspace(null);
void refresh();
}}
onCancel={() => setStartWorkspace(null)}
/>
</div>
</div>
)}
</div>
);
}
/* ── Desktop view ── */
return (
<div className="page workspaces-page">
<header className="page-header">
-2
View File
@@ -84,7 +84,6 @@ export function NotificationProvider({
}
}
// Silently log other errors; next cycle proceeds
// eslint-disable-next-line no-console
console.error("Notification unread count poll failed", err);
}
}, []);
@@ -111,7 +110,6 @@ export function NotificationProvider({
listIntervalRef.current = null;
}
}
// eslint-disable-next-line no-console
console.error("Notification list poll failed", err);
} finally {
setIsLoading(false);
+259
View File
@@ -0,0 +1,259 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import type { InstanceEventPayload } from "../types/events";
export type OperationType =
| "create"
| "start"
| "stop"
| "restart"
| "delete"
| "recreate-tunnel";
export type OperationStatus = "pending" | "active" | "success" | "error";
export interface Operation {
id: string;
type: OperationType;
instanceId: string;
displayName: string;
status: OperationStatus;
message: string;
step: number;
createdAt: number;
}
export interface SessionOperationsContextType {
operations: Operation[];
startOperation: (
type: OperationType,
instanceId: string,
displayName: string,
) => string;
updateOperationFromEvent: (event: InstanceEventPayload) => void;
completeOperation: (
instanceId: string,
type: OperationType,
outcome: "success" | "error",
message?: string,
) => void;
dismissOperation: (id: string) => void;
}
const SessionOperationsContext =
createContext<SessionOperationsContextType | undefined>(undefined);
let operationIdCounter = 0;
function actionLabel(type: OperationType): string {
switch (type) {
case "create":
return "Creating";
case "start":
return "Starting";
case "stop":
return "Stopping";
case "restart":
return "Restarting";
case "delete":
return "Deleting";
case "recreate-tunnel":
return "Recreating tunnel";
default:
return "Working";
}
}
function messageForEvent(
type: OperationType,
event: InstanceEventPayload,
): string {
if (event.message) return event.message;
switch (event.event) {
case "instance.created":
return "Created";
case "instance.started":
return "Starting container";
case "instance.restarted":
return "Restarting container";
case "instance.stopped":
return "Stopped";
case "instance.deleted":
return "Deleted";
case "instance.health_changed":
if (event.status === "running") return "Running";
if (event.status === "unhealthy") return "Unhealthy";
return `Status: ${event.status ?? event.event}`;
case "instance.error":
return event.message ?? "Error";
default:
return event.message ?? actionLabel(type);
}
}
function stepForEvent(event: InstanceEventPayload): number {
switch (event.event) {
case "instance.created":
return 1;
case "instance.started":
case "instance.restarted":
return 2;
case "instance.health_changed":
if (event.status === "running") return 4;
if (event.status === "unhealthy") return 4;
return 3;
case "instance.error":
return 4;
case "instance.stopped":
return 4;
case "instance.deleted":
return 4;
default:
return 0;
}
}
export const SessionOperationsProvider = ({
children,
}: {
children: ReactNode;
}) => {
const [operations, setOperations] = useState<Operation[]>([]);
const startOperation = useCallback(
(type: OperationType, instanceId: string, displayName: string): string => {
const id = `op-${++operationIdCounter}`;
const operation: Operation = {
id,
type,
instanceId,
displayName,
status: "pending",
message: actionLabel(type),
step: 0,
createdAt: Date.now(),
};
setOperations((prev) => [operation, ...prev].slice(0, 20));
return id;
},
[],
);
const updateOperationFromEvent = useCallback(
(event: InstanceEventPayload) => {
setOperations((prev) => {
const matches = prev.filter(
(op) => op.instanceId === event.instance_id && op.status !== "success",
);
if (matches.length === 0) return prev;
const updated = new Map<string, Operation>();
for (const op of prev) updated.set(op.id, op);
for (const op of matches) {
const nextStep = stepForEvent(event);
const message = messageForEvent(op.type, event);
let nextStatus: OperationStatus = op.status;
if (event.event === "instance.error") {
nextStatus = "error";
} else if (
event.event === "instance.health_changed" &&
event.status === "running"
) {
nextStatus = "success";
} else if (event.event === "instance.deleted") {
nextStatus = "success";
} else if (event.event === "instance.stopped") {
nextStatus = "success";
} else if (nextStatus === "pending") {
nextStatus = "active";
}
updated.set(op.id, {
...op,
status: nextStatus,
message,
step: Math.max(op.step, nextStep),
});
}
return Array.from(updated.values());
});
},
[],
);
const completeOperation = useCallback(
(
instanceId: string,
type: OperationType,
outcome: "success" | "error",
message?: string,
) => {
setOperations((prev) => {
const match = prev.find(
(op) => op.instanceId === instanceId && op.type === type,
);
if (!match) return prev;
return prev.map((op) =>
op.id === match.id
? {
...op,
status: outcome,
message:
message ?? (outcome === "success" ? "Done" : "Failed"),
step: 4,
}
: op,
);
});
},
[],
);
const dismissOperation = useCallback((id: string) => {
setOperations((prev) => prev.filter((op) => op.id !== id));
}, []);
const value = useMemo(
() => ({
operations,
startOperation,
updateOperationFromEvent,
completeOperation,
dismissOperation,
}),
[
operations,
startOperation,
updateOperationFromEvent,
completeOperation,
dismissOperation,
],
);
return (
<SessionOperationsContext.Provider value={value}>
{children}
</SessionOperationsContext.Provider>
);
};
export const useSessionOperations = (): SessionOperationsContextType => {
const context = useContext(SessionOperationsContext);
if (context === undefined) {
throw new Error(
"useSessionOperations must be used within a SessionOperationsProvider",
);
}
return context;
};
+66 -19
View File
@@ -1,35 +1,82 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
export interface Session {
id: string;
display_name: string;
tool_type_name: string;
tool_icon: string;
tool_type_interfaces: string[];
repository_name: string;
repository_id: string;
project_name: string;
project_id: string;
status: string;
url: string | null;
}
import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions";
interface SessionsContextType {
export interface SessionsContextType {
sessions: Session[];
setAllSessions: (sessions: Session[]) => void;
isLoading: boolean;
error: Error | null;
refreshSessions: () => Promise<void>;
addOrUpdateSession: (session: Session) => void;
removeSession: (id: string) => void;
}
const SessionsContext = createContext<SessionsContextType | undefined>(undefined);
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const setAllSessions = useCallback((newSessions: Session[]) => {
setSessions(newSessions);
const refreshSessions = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await getUserSessions();
setSessions(data);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load sessions"));
} finally {
setIsLoading(false);
}
}, []);
const addOrUpdateSession = useCallback((session: Session) => {
setSessions((prev) => {
const existing = prev.find((s) => s.id === session.id);
if (existing) {
return prev.map((s) => (s.id === session.id ? { ...s, ...session } : s));
}
return [session, ...prev];
});
}, []);
const removeSession = useCallback((id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
}, []);
useEffect(() => {
void refreshSessions();
// Poll every 30 seconds to reconcile shared state with the server.
const interval = setInterval(() => {
void refreshSessions();
}, 30000);
return () => clearInterval(interval);
}, [refreshSessions]);
const value = useMemo(
() => ({
sessions,
isLoading,
error,
refreshSessions,
addOrUpdateSession,
removeSession,
}),
[sessions, isLoading, error, refreshSessions, addOrUpdateSession, removeSession],
);
return (
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
<SessionsContext.Provider value={value}>
{children}
</SessionsContext.Provider>
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+257
View File
@@ -0,0 +1,257 @@
/* Git History Page Styles */
.history-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.branch-selector {
padding: 0.45rem 0.7rem;
border: 1px solid var(--border);
border-radius: 10px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.history-container {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
min-height: 60vh;
}
.commit-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
max-height: 70vh;
}
.commit-list.with-detail {
grid-column: 1;
}
.commit-item {
display: flex;
gap: 0.75rem;
padding: 0.75rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: background-color 0.15s ease;
}
.commit-item:hover {
background: #ece7df;
}
.commit-item.selected {
border-color: var(--brand);
background: #f0f7f4;
}
.commit-graph {
font-family: monospace;
font-size: 0.9rem;
color: var(--brand);
white-space: pre;
flex-shrink: 0;
min-width: 60px;
}
.graph-line {
display: inline-block;
}
.commit-content {
flex: 1;
min-width: 0;
}
.commit-header {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.35rem;
}
.commit-hash {
font-family: monospace;
font-size: 0.85rem;
color: var(--brand);
background: #f0f7f4;
padding: 0.15rem 0.4rem;
border-radius: 6px;
}
.commit-refs {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.ref-tag {
font-size: 0.75rem;
padding: 0.15rem 0.4rem;
background: var(--brand);
color: white;
border-radius: 999px;
}
.commit-message {
margin: 0 0 0.35rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.commit-meta {
display: flex;
gap: 0.75rem;
font-size: 0.85rem;
color: var(--muted);
}
.commit-detail-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.25rem;
overflow-y: auto;
max-height: 70vh;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.detail-header h3 {
margin: 0;
}
.detail-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.detail-section {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.detail-section h4 {
margin: 0 0 0.5rem;
color: var(--muted);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.commit-hash-full {
font-family: monospace;
font-size: 0.85rem;
color: var(--brand);
margin: 0;
}
.commit-message-full {
margin: 0.5rem 0 0;
line-height: 1.5;
white-space: pre-wrap;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
background: #f5f3ee;
border-radius: 10px;
}
.stat.additions {
background: #f0fdf4;
}
.stat.deletions {
background: #fef2f2;
}
.stat-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--ink);
}
.stat.additions .stat-value {
color: #16a34a;
}
.stat.deletions .stat-value {
color: #dc2626;
}
.stat-label {
font-size: 0.8rem;
color: var(--muted);
}
.parent-list {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.parent-hash {
font-family: monospace;
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
background: #f5f3ee;
border-radius: 6px;
}
.diff-content {
font-family: monospace;
font-size: 0.8rem;
line-height: 1.5;
background: #f5f3ee;
padding: 0.75rem;
border-radius: 10px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
@media (min-width: 1024px) {
.history-container {
grid-template-columns: 1fr 400px;
}
.commit-list.with-detail {
grid-column: 1;
}
.commit-detail-panel {
grid-column: 2;
position: sticky;
top: 1rem;
}
}
+164
View File
@@ -0,0 +1,164 @@
/* ─── Projects Page Refresh ─── */
.project-info-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.project-toggle {
display: flex;
align-items: center;
gap: var(--space-3);
background: none;
border: none;
font: inherit;
color: inherit;
cursor: pointer;
padding: var(--space-2);
border-radius: 10px;
flex: 1;
}
.project-toggle:hover {
background: var(--bg);
}
.project-toggle h3 {
margin: 0;
font-size: var(--font-size-lg);
}
.repo-count {
font-size: var(--font-size-xs);
padding: var(--space-1) var(--space-2);
background: var(--bg);
border-radius: 999px;
color: var(--muted);
}
.project-detail {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--border);
}
.repo-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.repo-block {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
}
.repo-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.repo-header h4 {
margin: 0;
font-size: var(--font-size-base);
}
.workspace-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
}
.workspace-chip {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
font-size: var(--font-size-sm);
}
.workspace-chip a {
font-weight: 600;
color: var(--brand);
}
.workspace-chip .ws-branch {
color: var(--muted);
font-size: var(--font-size-xs);
}
.workspace-chip .ws-instances {
font-size: var(--font-size-xs);
color: var(--success);
}
.workspace-chip .ws-actions {
display: flex;
gap: var(--space-1);
margin-top: var(--space-1);
}
.workspace-chip .ws-actions button {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: var(--space-1);
border-radius: 4px;
}
.workspace-chip .ws-actions button:hover {
background: var(--bg);
color: var(--ink);
}
.workspace-chip .ws-actions button.danger-text:hover {
color: var(--danger);
}
.project-add-repo {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-start;
}
/* Inline radio buttons for repository creation mode */
.repo-mode-radios {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
padding: var(--space-2) 0;
}
.repo-mode-label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
color: var(--ink);
}
.repo-mode-label input[type="radio"] {
width: 18px;
height: 18px;
accent-color: var(--brand);
margin: 0;
}
@@ -0,0 +1,709 @@
/* Repository Workspace */
.repo-workspace {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
overflow: hidden;
}
.workspace-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.workspace-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.workspace-header-icon {
font-size: 1.5rem;
}
.workspace-header-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.workspace-header-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.workspace-header-subtitle {
color: var(--muted);
font-size: 0.875rem;
}
.workspace-header-actions {
display: flex;
gap: 0.5rem;
}
.workspace-header-action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--ink);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.workspace-header-action-btn:hover {
background: var(--bg);
border-color: var(--brand);
}
.workspace-title {
display: flex;
align-items: center;
gap: 1rem;
}
.workspace-title h1 {
margin: 0;
font-size: 1.25rem;
}
.repo-name {
color: var(--muted);
font-size: 0.875rem;
}
.workspace-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.workspace-sidebar {
width: 280px;
min-width: 280px;
border-right: 1px solid var(--border);
background: var(--panel);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (max-width: 767px) {
.workspace-layout {
flex-direction: column;
}
.workspace-sidebar {
width: 100%;
min-width: auto;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid var(--border);
}
.workspace-header {
flex-direction: column;
gap: var(--space-3);
align-items: flex-start;
padding: var(--space-4);
}
.workspace-header-actions {
width: 100%;
flex-wrap: wrap;
}
}
.sidebar-section {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar-section label {
margin: 0;
}
.workspace-main {
flex: 1;
overflow: hidden;
padding: 1rem;
background: var(--bg);
display: flex;
flex-direction: column;
}
/* File Tree */
.file-tree {
flex: 1;
overflow: auto;
padding: 0.5rem;
}
.tree-entry {
display: block;
width: 100%;
padding: 0.375rem 0.5rem;
border: none;
background: none;
color: var(--ink);
text-align: left;
cursor: pointer;
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-entry:hover {
background: var(--bg);
}
.tree-directory {
font-weight: 500;
}
.tree-up {
color: var(--muted);
font-style: italic;
}
/* File Viewer */
.file-viewer {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.file-viewer-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.file-breadcrumbs {
font-size: 0.875rem;
font-family: monospace;
}
.breadcrumb-sep {
color: var(--muted);
margin: 0 0.25rem;
}
.file-content {
padding: 1rem;
overflow: auto;
max-height: calc(100vh - 200px);
}
.file-content pre {
margin: 0;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
.file-viewer-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
}
/* File Status Indicators */
.file-status-indicator {
float: right;
font-size: 0.75rem;
font-weight: bold;
padding: 0 0.375rem;
border-radius: 3px;
margin-left: 0.5rem;
}
.file-status-indicator.modified {
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.file-status-indicator.added {
color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
.file-status-indicator.deleted {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.file-status-indicator.untracked {
color: #6b7280;
background: rgba(107, 114, 128, 0.1);
}
/* Commit Panel */
.commit-panel {
padding: 1rem;
border-top: 1px solid var(--border);
background: var(--panel);
}
.commit-panel h4 {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
}
.file-list {
max-height: 150px;
overflow: auto;
margin-bottom: 0.75rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.file-status {
font-weight: bold;
font-size: 0.75rem;
width: 1rem;
text-align: center;
}
.file-item.modified .file-status {
color: #f59e0b;
}
.file-item.added .file-status {
color: #10b981;
}
.file-item.deleted .file-status {
color: #ef4444;
}
.file-item.untracked .file-status {
color: #6b7280;
}
.commit-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.commit-message-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
}
.commit-button {
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.commit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.commit-error {
color: #ef4444;
font-size: 0.8125rem;
}
/* Merge Dialog */
.merge-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.merge-form .form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.merge-form label {
font-size: 0.875rem;
font-weight: 500;
}
.merge-form select,
.merge-form input,
.merge-form textarea {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.875rem;
}
.merge-form textarea {
resize: vertical;
}
.input-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.success-text {
color: #10b981;
font-size: 0.875rem;
padding: 0.5rem;
background: rgba(16, 185, 129, 0.1);
border-radius: 4px;
}
/* Git Toolbar - Top Bar Styles */
.git-toolbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1.5rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
min-height: 48px;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toolbar-button {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--ink);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.toolbar-button:hover:not(:disabled) {
background: var(--brand);
color: white;
border-color: var(--brand);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar-button.primary {
background: var(--brand);
color: white;
border-color: var(--brand);
}
.branch-select {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--ink);
font-size: 0.85rem;
cursor: pointer;
min-width: 140px;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--brand);
color: white;
font-size: 0.7rem;
font-weight: 600;
border-radius: 999px;
}
.toolbar-error {
color: #ef4444;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 4px;
}
.toolbar-input {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--ink);
font-size: 0.85rem;
}
.new-branch-form {
padding: 0.75rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
margin-top: 0.5rem;
}
.status-summary {
gap: 0.75rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.status-badge.modified {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
}
.status-badge.added {
background: rgba(16, 185, 129, 0.1);
color: #059669;
}
.status-badge.deleted {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.status-badge.untracked {
background: rgba(107, 114, 128, 0.1);
color: #4b5563;
}
/* File Editor */
.file-editor {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.file-editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.file-editor-content {
flex: 1;
overflow: hidden;
background: var(--bg);
display: flex;
flex-direction: column;
}
/* Git Mount Editor Styles */
.git-mount-editor {
margin-top: 1rem;
}
.git-mount-editor .section-subtitle {
margin: 0 0 0.75rem 0;
font-size: 1rem;
font-weight: 600;
}
.git-mount-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.git-mount-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.75rem;
}
.git-mount-display {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.git-mount-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.git-mount-repo {
font-weight: 600;
color: var(--text);
}
.git-mount-paths {
font-size: 0.875rem;
color: var(--text-muted);
font-family: monospace;
}
.git-mount-branch {
font-size: 0.75rem;
color: var(--accent);
background: var(--accent-bg);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
width: fit-content;
}
.git-mount-actions {
display: flex;
gap: 0.25rem;
}
.git-mount-add {
border-top: 1px solid var(--border);
padding-top: 1rem;
margin-top: 1rem;
}
.git-mount-add h5 {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.git-mount-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.git-mount-form .form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.git-mount-form .form-row label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.git-mount-form .form-row input,
.git-mount-form .form-row select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
}
.git-mount-form .form-row input.error,
.git-mount-form .form-row select.error {
border-color: #cd3131;
}
.git-mount-form .form-row .hint {
font-size: 0.75rem;
color: var(--text-muted);
}
.git-mount-form .form-row .error-text {
font-size: 0.75rem;
color: #cd3131;
}
.git-mount-form .form-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.new-repo-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.375rem;
}
.new-repo-form input {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface);
color: var(--text);
font-size: 0.875rem;
}
.new-repo-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
+150
View File
@@ -0,0 +1,150 @@
/* Session card rename */
.session-card-rename input {
font: inherit;
font-weight: 600;
font-size: 1rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--panel);
color: var(--text);
width: 100%;
min-width: 0;
}
.session-card-context {
font-size: 0.8125rem;
margin-top: 0.25rem;
}
.session-card-context strong {
color: var(--text);
}
/* Mobile Sessions Page */
@media (max-width: 767px) {
.sessions-page {
padding: var(--space-2);
}
.sessions-page .page-header {
margin-bottom: var(--space-3);
}
.sessions-page .page-header h1 {
font-size: 1.25rem;
}
.last-session-section {
margin-bottom: var(--space-4);
}
.last-session-section h2 {
font-size: 1rem;
margin-bottom: var(--space-2);
}
.create-session-section {
padding: var(--space-3);
}
.create-session-section h2 {
font-size: 1rem;
margin-bottom: var(--space-2);
}
}
/* Session options dropdown */
.session-options {
position: relative;
}
.session-options-dropdown {
position: absolute;
bottom: calc(100% + 0.25rem);
right: 0;
z-index: 100;
min-width: 12rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
animation: dropdown-in 0.12s ease-out;
}
@keyframes dropdown-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.session-option-item {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: 0.875rem;
color: var(--text);
background: transparent;
border: none;
cursor: pointer;
white-space: nowrap;
}
.session-option-item:hover {
background: var(--bg);
}
.session-option-item.danger-text {
color: var(--danger);
}
.session-option-item.danger-text:hover {
background: var(--danger-subtle, rgba(239, 68, 68, 0.08));
}
.session-card-actions {
display: flex;
align-items: center;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.session-card-actions.mobile {
display: flex;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.session-card-actions.mobile .mobile-primary {
flex: 1;
justify-content: center;
min-height: 44px;
padding: var(--space-2) var(--space-3);
}
.session-card-actions.mobile .mobile-more {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
}
@media (max-width: 767px) {
.create-session-form input,
.create-session-form select,
.create-session-form textarea,
.create-session-form button {
min-height: 44px;
}
}
+25
View File
@@ -0,0 +1,25 @@
/* SSH keys table responsive */
.ssh-key-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.ssh-key-item {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
}
@media (min-width: 768px) {
.ssh-key-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}
@@ -0,0 +1,504 @@
/* ─── Workspace Detail Page ─── */
.workspace-detail {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.workspace-header-link {
display: block;
text-decoration: none;
color: inherit;
}
.workspace-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-shrink: 0;
}
.workspace-breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--muted);
font-size: var(--font-size-sm);
}
.workspace-breadcrumb .sep {
color: var(--border);
}
.workspace-breadcrumb strong {
color: var(--ink);
font-size: var(--font-size-lg);
}
.branch-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
font-size: var(--font-size-sm);
color: var(--muted);
}
/* Tab Bar */
.tab-bar {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-5);
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-shrink: 0;
overflow-x: auto;
}
.tab {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
border: 1px solid transparent;
border-radius: 10px;
background: none;
color: var(--muted);
font: inherit;
font-size: var(--font-size-sm);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.tab:hover {
background: var(--bg);
color: var(--ink);
}
.tab.active {
background: var(--brand);
color: var(--primary-fg);
}
/* Mobile Tab Bar */
.mobile-tab-bar {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
justify-content: space-around;
padding: var(--space-2) 0;
background: var(--panel);
border-top: 1px solid var(--border);
z-index: 50;
}
.mobile-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: var(--space-1) var(--space-2);
border: none;
background: none;
color: var(--muted);
font: inherit;
font-size: var(--font-size-xs);
cursor: pointer;
}
.mobile-tab.active {
color: var(--brand);
}
/* Workspace Content */
.workspace-content {
flex: 1;
overflow: hidden;
padding: var(--space-4) var(--space-5);
overflow-y: auto;
}
/* Files Tab */
.files-tab {
display: flex;
flex-direction: column;
gap: var(--space-3);
height: 100%;
}
.git-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
flex-wrap: wrap;
}
.git-toolbar-status {
display: flex;
gap: var(--space-2);
}
.git-toolbar-status span {
padding: var(--space-1) var(--space-2);
border-radius: 6px;
font-size: var(--font-size-xs);
font-weight: 600;
}
.status-modified {
background: var(--warning-light);
color: var(--warning);
}
.status-added {
background: var(--success-light);
color: var(--success);
}
.status-deleted {
background: var(--danger-light);
color: var(--danger);
}
.status-untracked {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
.git-toolbar-actions {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
.git-toolbar-actions input {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
background: var(--panel);
color: var(--ink);
min-width: 180px;
}
.files-split {
display: grid;
grid-template-columns: 260px 1fr;
gap: var(--space-4);
flex: 1;
min-height: 0;
overflow: hidden;
}
.file-tree {
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: var(--space-3);
background: var(--panel);
}
.tree-entry {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-1) var(--space-2);
border: none;
border-radius: 6px;
background: none;
color: var(--ink);
font: inherit;
font-size: var(--font-size-sm);
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.tree-entry:hover {
background: var(--bg);
}
.tree-entry.selected {
background: var(--brand);
color: var(--primary-fg);
}
.file-viewer {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
overflow: hidden;
}
.file-viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3);
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.file-content {
flex: 1;
padding: var(--space-4);
overflow: auto;
margin: 0;
font-family: "IBM Plex Mono", monospace;
font-size: var(--font-size-sm);
line-height: 1.6;
white-space: pre-wrap;
}
.file-editor {
flex: 1;
padding: var(--space-3);
border: none;
font-family: "IBM Plex Mono", monospace;
font-size: var(--font-size-sm);
line-height: 1.6;
resize: none;
background: var(--panel);
color: var(--ink);
}
.file-editor-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3);
border-top: 1px solid var(--border);
}
/* Git Tab */
.git-tab {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.git-tab-header {
display: flex;
gap: var(--space-3);
align-items: center;
}
.git-tab-header select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.commit-history {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.commit-row {
display: grid;
grid-template-columns: 60px 1fr 120px 120px;
gap: var(--space-3);
padding: var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
align-items: center;
font-size: var(--font-size-sm);
}
.commit-hash {
font-family: monospace;
color: var(--brand);
}
.commit-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.commit-author,
.commit-date {
color: var(--muted);
font-size: var(--font-size-xs);
}
/* Tools Tab */
.tools-tab {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.empty-state-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-10);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
text-align: center;
}
.empty-state-card h3 {
margin: 0;
}
.empty-state-card p {
margin: 0;
color: var(--muted);
}
.instances-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
}
.instance-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
}
.instance-card.running {
border-color: var(--success);
}
/* Settings Tab */
.settings-tab {
max-width: 640px;
}
.settings-section {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
}
.settings-section h3 {
margin: 0;
}
/* Mobile Workspace Detail */
@media (max-width: 767px) {
.workspace-detail.mobile .workspace-content {
padding-bottom: 72px;
}
.mobile-tab-bar {
display: flex;
}
.files-split {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.commit-row {
grid-template-columns: 1fr;
gap: var(--space-1);
}
.git-toolbar {
flex-direction: column;
align-items: flex-start;
}
}
/* Workspace Card Link */
.workspace-header-link {
display: block;
text-decoration: none;
color: inherit;
margin: -1rem -1rem 0;
padding: 1rem;
}
.workspace-header-link:hover .workspace-header h4 {
color: var(--brand);
}
/* ─── Workspace Create Inline ─── */
.workspace-create-inline {
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.workspace-create-inline h3 {
margin: 0 0 var(--space-4) 0;
display: flex;
align-items: center;
gap: var(--space-2);
}
.workspace-create-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4);
align-items: end;
}
.workspace-create-form-grid .form-group {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.workspace-create-form-grid label {
font-size: var(--font-size-sm);
color: var(--muted);
}
.workspace-create-form-grid input,
.workspace-create-form-grid select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: 8px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.workspace-create-form-grid .form-actions {
display: flex;
gap: var(--space-3);
justify-content: flex-end;
margin-top: var(--space-2);
}
+126
View File
@@ -0,0 +1,126 @@
/** Workspaces page styles */
/* Desktop */
.workspaces-page {
padding: var(--space-4);
}
.workspaces-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-4);
margin-top: var(--space-4);
}
/* Workspace card */
.workspace-card {
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
transition: box-shadow 0.15s ease;
}
.workspace-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.workspace-card.loading {
opacity: 0.6;
pointer-events: none;
}
.workspace-header-link {
display: block;
text-decoration: none;
color: inherit;
}
.workspace-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.workspace-header h4 {
margin: 0;
font-size: 1.1rem;
flex: 1;
}
.workspace-meta {
margin-bottom: var(--space-3);
}
.workspace-meta p {
margin: 0.15rem 0;
font-size: 0.875rem;
color: var(--muted);
}
.workspace-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
/* Status badges */
.status-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 999px;
text-transform: capitalize;
}
.status-ready {
background: var(--success-light);
color: var(--success);
}
.status-syncing {
background: var(--warning-light);
color: var(--warning);
}
.status-error {
background: var(--danger-light);
color: var(--danger);
}
/* Empty state */
.empty-state {
text-align: center;
padding: var(--space-10) var(--space-4);
color: var(--muted);
}
.empty-state p {
margin-bottom: var(--space-4);
}
/* Mobile */
@media (max-width: 767px) {
.workspaces-page {
padding: var(--space-2);
}
.workspaces-grid {
grid-template-columns: 1fr;
gap: var(--space-3);
}
.workspace-card {
padding: var(--space-3);
}
.workspace-actions {
flex-direction: column;
}
.workspace-actions .btn {
width: 100%;
justify-content: center;
}
}
+195
View File
@@ -0,0 +1,195 @@
/* Syntax Highlighter */
.syntax-highlighter {
display: flex;
flex-direction: column;
height: 100%;
}
.highlighter-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.language-badge {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
background: var(--bg);
border-radius: 4px;
color: var(--muted);
}
.copy-button {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--ink);
}
.copy-button:hover {
background: var(--brand);
color: white;
border-color: var(--brand);
}
.code-container {
display: flex;
flex: 1;
overflow: auto;
font-family: "Fira Code", "Monaco", "Courier New", monospace;
font-size: 14px;
line-height: 1.5;
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 1rem 0.5rem;
background: var(--panel);
border-right: 1px solid var(--border);
color: var(--muted);
text-align: right;
user-select: none;
min-width: 3rem;
}
.line-number {
padding: 0 0.5rem;
}
.code-block {
flex: 1;
margin: 0;
padding: 1rem;
overflow: visible;
background: transparent;
}
.code-block code {
display: block;
background: transparent;
}
/* Code Editor */
.code-editor {
height: 100%;
overflow: auto;
}
.editor-textarea {
font-family: "Fira Code", "Monaco", "Courier New", monospace;
font-size: 14px;
line-height: 1.5;
min-height: 100%;
}
.editor-textarea-input {
background: transparent;
color: var(--ink);
caret-color: var(--ink);
}
.editor-line {
display: flex;
}
.editor-line-number {
display: inline-block;
width: 3rem;
padding: 0 0.5rem;
text-align: right;
color: var(--muted);
user-select: none;
background: var(--panel);
border-right: 1px solid var(--border);
}
.editor-line-content {
flex: 1;
padding: 0 0.5rem;
white-space: pre;
}
/* Prism.js Theme Integration */
code[class*="language-"],
pre[class*="language-"] {
color: var(--ink);
text-shadow: none;
font-family: "Fira Code", "Monaco", "Courier New", monospace;
font-size: 14px;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
tab-size: 2;
hyphens: none;
}
/* Syntax Highlighting Colors */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: var(--muted);
}
.token.punctuation {
color: var(--ink);
}
.token.namespace {
opacity: 0.7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #f59e0b;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #10b981;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #f43f5e;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #3b82f6;
}
.token.function,
.token.class-name {
color: #8b5cf6;
}
.token.regex,
.token.important,
.token.variable {
color: #ec4899;
}
+92
View File
@@ -0,0 +1,92 @@
:root {
color-scheme: light;
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
--bg: #f4f1ea;
--panel: #fffef9;
--ink: #1d1d1b;
--muted: #5f5b55;
--brand: #275d4b;
--brand-strong: #154236;
--border: #d8d0c5;
--primary: #275d4b;
--primary-fg: #fffef9;
--color-primary: #275d4b;
--success: #2f8f62;
--success-light: rgba(47, 143, 98, 0.14);
--warning: #c08a1e;
--warning-light: rgba(192, 138, 30, 0.14);
--danger: #b94a3c;
--danger-light: rgba(185, 74, 60, 0.14);
--info: #4f7fb8;
--info-light: rgba(79, 127, 184, 0.14);
/* Spacing Scale (4px base) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--space-8: 3rem;
--space-10: 4rem;
/* Breakpoints */
--bp-sm: 480px;
--bp-md: 768px;
--bp-lg: 1024px;
--bp-xl: 1280px;
/* Fluid Typography */
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
--font-size-2xl: clamp(1.5rem, 1.3rem + 1vw, 2rem);
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #171613;
--panel: #22201d;
--ink: #ece7df;
--muted: #a59d92;
--brand: #5fa889;
--brand-strong: #4d9175;
--border: #39342d;
--primary: #5fa889;
--primary-fg: #171613;
--color-primary: #5fa889;
--success: #22c55e;
--success-light: rgba(34, 197, 94, 0.15);
--warning: #f59e0b;
--warning-light: rgba(245, 158, 11, 0.15);
--danger: #ef4444;
--danger-light: rgba(239, 68, 68, 0.15);
--info: #3b82f6;
--info-light: rgba(59, 130, 246, 0.15);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
}
html.terminal-page-open,
body.terminal-page-open {
overflow: hidden;
}
[data-theme="dark"] body {
background: radial-gradient(circle at top right, #2a2520, var(--bg));
}
a {
color: inherit;
text-decoration: none;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-04
@@ -0,0 +1,73 @@
## Why
After the backend-frontend refactoring (commits `0591b00` through `8c7affc`), the codebase gained proper directory structure but several files grew into monoliths. The `main` branch (pre-refactor baseline at `5ed5e1c`) kept pages thin by delegating to extracted components. On `dev`, new features were added inline, causing pages and routers to absorb responsibilities that belong in components or services.
### Problem Files (Frontend)
| File | Lines | Problem |
|------|-------|---------|
| `pages/ToolWorkshopPage.tsx` | **1,269** | Merged 3 tab components inline (ToolTypes, ToolConfigs, ConfigFolders) |
| `pages/ConfigProfilesPage.tsx` | **1,611** | List, detail, edit, create, and mobile views all in one file |
| `pages/TerminalPage.tsx` | **571** | Session tabs, keyboard shortcuts, fullscreen, mobile overlay, special keys all inline |
| `pages/RepoWorkspacePage.tsx` | **505** | File editor, git toolbar, workspace header, sidebar logic inline |
| `pages/SettingsPage.tsx` | **284** | Settings nav + multiple setting sections inline |
| `pages/SshKeysPage.tsx` | **277** | List and create inline |
### Problem Files (Backend)
| File | Lines | Problem |
|------|-------|---------|
| `api/tool/tool_instances.py` | **2,900** | CRUD, Docker lifecycle, WebSocket proxy, terminal sessions, instance proxy all in one router |
| `api/project/git_repositories.py` | **1,588** | HTTP endpoints mixed with git command orchestration |
| `api/config/config_profiles.py` | **842** | CRUD + validation + resolver + mount/include management |
### What `main` Did Differently
`main` at `5ed5e1c`:
- `ToolWorkshopPage.tsx` = **77 lines** (just a tab switcher, tabs imported from `features/tool-workshop/`)
- `TerminalPage.tsx` = **38 lines** (just a wrapper around `TerminalComponent`)
- `api/tool_instances.py` = **284 lines** (HTTP endpoints only)
- `api/terminal.py` = **158 lines** (separate WebSocket router)
## What Changes
Restore the **thin-page / thin-router / fat-component** pattern from `main`, adapted to current `dev` features:
1. **Frontend page extraction** — Split monolithic pages into:
- Page shell (orchestrator, 50-150 lines)
- Tab components (for tabbed pages)
- List / Detail / Edit / Create components (for CRUD pages)
- Mobile-specific views (extracted, not inline)
2. **Backend router slimming** — Split `tool_instances.py` into:
- `tool_instances.py` — CRUD endpoints only
- `tool_lifecycle.py` — Start/stop/restart/delete logic
- Move terminal WebSocket back to dedicated `terminal.py`
3. **Git repository router** — Extract git command orchestration into `services/git/`
## Capabilities
### New Capabilities
- None (pure structural refactor)
### Modified Capabilities
- `frontend-structure`: Pages become orchestrators; components carry the UI logic
- `backend-structure`: Routers become HTTP-only; services carry business logic
## Impact
- **Frontend**: New `features/tool-workshop/` tab components, new `features/config-profiles/` components, `features/terminal/` session manager, etc.
- **Backend**: New `api/tool/tool_lifecycle.py`, `api/tool/terminal.py`, slimmer `api/tool/tool_instances.py`
- **Tests**: Test files may need import path updates (component moved → test follows)
## Exclusions (Already Done / Out of Scope)
- Directory structure already exists (`features/`, `services/`, etc.)
- Schema extraction already done (`schemas/` subpackages)
- Model subpackages already done (`models/` subpackages)
- API router subpackages already done (`api/tool/`, `api/project/`, etc.)
- File naming already done (kebab-case APIs, PascalCase pages)
- No behavioral changes to any endpoint or UI flow
- No database schema changes
- No new features
@@ -0,0 +1,128 @@
## Scope
This change is a **pure structural refactoring** to split monolithic pages and routers into focused components and services. No API contracts, database schemas, or user-facing behaviors change.
### In Scope
#### 1. Frontend Page Extraction
Split the following pages into a thin page shell + extracted components:
**`pages/ToolWorkshopPage.tsx` (1,269 → ~80 lines)**
- Extract `ToolTypesTab``components/features/tool-workshop/ToolTypesTab.tsx`
- Extract `ToolConfigsTab``components/features/tool-workshop/ToolConfigsTab.tsx`
- Extract `ConfigFoldersTab``components/features/tool-workshop/ConfigFoldersTab.tsx`
- Page becomes: tab switcher only, imports the 3 tabs
**`pages/ConfigProfilesPage.tsx` (1,611 → ~80 lines)**
- Extract `ConfigProfileListView` → list view + mobile list view
- Extract `ConfigProfileDetailView` → detail view with edit toggle
- Extract `ConfigProfileEditForm` → edit/create form
- Extract `ConfigProfileMobileView` → mobile view state machine wrapper
- Page becomes: router between list/detail/edit views
**`pages/TerminalPage.tsx` (571 → ~80 lines)**
- Extract `TerminalSessionManager` → session tabs + auto-create logic
- Extract `TerminalKeyboardShortcuts` → shortcut handler hook (already exists, just use it)
- Extract `MobileTerminalOverlay` → mobile overlay toolbar + tabs
- Page becomes: choose between desktop (`TerminalComponent` + `TerminalSessionTabs`) and mobile (`MobileTerminalOverlay` + `TerminalComponent`) wrappers
**`pages/SettingsPage.tsx` (284 → ~80 lines)**
- Extract `SettingsNavigation` → settings nav sidebar
- Extract `GeneralSettingsTab`, `SSHKeysTab` (already separate pages, but move sections into components if inline)
- Page becomes: nav + `<Outlet>` for nested routes
**`pages/SshKeysPage.tsx` (277 → ~80 lines)**
- Extract `SSHKeyList` → list with actions
- Extract `SSHKeyCreateForm` → create form
- Page becomes: layout wrapper + conditionally render list or form
**`pages/RepoWorkspacePage.tsx` (505 → ~150 lines)**
- Extract `WorkspaceLayout` → sidebar + main content layout
- Page becomes: data loader + layout wrapper
**`pages/ProjectsPage.tsx` (433 → ~100 lines)**
- Extract `ProjectList` → list with cards
- Extract `ProjectCreateDialog` → create form in dialog
- Extract `ProjectEditDialog` → edit form in dialog
- Page becomes: data loader + layout + dialog state manager
#### 2. CSS Reorganization
**`styles.css` (5,683 lines → deleted)**
- Restore `styles/` directory with extracted files:
- `styles/tokens.css` — CSS custom properties (colors, spacing, typography)
- `styles/global.css` — global reset, body, shell layout
- `styles/utilities.css` — utility classes (.stack, .card, .muted, etc.)
- `styles/syntax-highlight.css` — code highlighting
- Restore `styles/pages/*.css` — page-specific styles:
- `styles/pages/dashboard.css`
- `styles/pages/projects.css`
- `styles/pages/sessions.css`
- `styles/pages/settings.css`
- `styles/pages/ssh-keys.css`
- `styles/pages/git-history.css`
- `styles/pages/repo-workspace.css`
- Restore component CSS modules:
- `components/features/terminal/TerminalComponent.module.css`
- `components/features/git/GitToolbar.module.css`
- `components/features/git/CommitDialog.module.css`
- `components/features/git/MergeDialog.module.css`
- `components/features/git/FileEditor.module.css`
- `components/features/git/FileBrowser.module.css`
- `components/features/git/FileViewer.module.css`
- `components/features/git/CommitPanel.module.css`
- `components/features/session/InstanceList.module.css`
- `components/features/settings/SettingsTabLayout.module.css`
- `components/layout/AppShell.module.css`
- Update all component imports to use `import styles from './ComponentName.module.css'`
- Update `main.tsx` to import `styles/tokens.css`, `styles/global.css`, `styles/utilities.css`, `styles/syntax-highlight.css`
- Update each page to import its `styles/pages/*.css`
- Delete monolithic `styles.css`
#### 3. Backend Router Slimming
**`api/tool/tool_instances.py` (2,900 → ~300 lines)**
- Extract terminal WebSocket handlers → `api/tool/terminal.py` (~400 lines)
- Extract instance lifecycle (create/start/stop/delete/restart) → `api/tool/tool_lifecycle.py` (~600 lines)
- Keep in `tool_instances.py`: CRUD endpoints (GET list, GET detail, POST, PATCH, DELETE) + instance proxy endpoint
**`api/project/git_repositories.py` (1,588 → ~300 lines)**
- Extract git command orchestration into `services/git/operations.py`
- Router keeps: auth, parameter validation, response building, error handling
- Service functions: `clone_repo`, `fetch_repo`, `pull_repo`, `push_repo`, `merge_repo`, etc.
**`api/config/config_profiles.py` (842 → ~200 lines)**
- Extract resolver orchestration into `services/config/resolver_service.py`
- Extract CRUD helpers into `services/config/crud_service.py`
- Router keeps: endpoint definitions, auth, input validation
### Out of Scope
- Any new features or behavioral changes
- Database schema changes (no migrations)
- API contract changes (same endpoints, same request/response shapes)
- Frontend UI behavior changes (same components, same interactions)
- Moving existing `features/` components (already organized)
- Renaming files (naming already done)
- Changing any CSS rules (only moving them)
## Acceptance Criteria
1. All pages ≤ 150 lines (except `RepoWorkspacePage` which may stay at ~150)
2. All API routers ≤ 400 lines
3. No monolithic `styles.css` — all CSS in `styles/` directory or `.module.css` files
4. All existing tests pass without modification (behavior unchanged)
5. All existing API endpoints return identical responses
6. Frontend `npm run typecheck` passes
7. Frontend `npm run build` passes
8. Backend `py_compile` passes on all files
9. No import errors in browser console
10. File count increases (more files, smaller files)
## Preconditions
- `dev` branch is stable (all fixes from this session are committed)
- Backend compiles (`py_compile` pass)
- Frontend typechecks and builds (`tsc`, `vite build` pass)
- Current tests pass (or known failures are documented)
@@ -0,0 +1,144 @@
## Phase 0: Preparation
- [ ] 0.1 Verify `dev` builds cleanly (backend `py_compile`, frontend `tsc` + `vite build`)
- [ ] 0.2 Document current file sizes for before/after comparison
- [ ] 0.3 Create component directory stubs if missing:
- `apps/web/src/components/features/tool-workshop/`
- `apps/web/src/components/features/config-profiles/`
- `apps/web/src/components/features/terminal/`
- `apps/web/src/components/features/settings/`
- `apps/web/src/components/features/ssh-keys/`
- `apps/api/src/services/git/operations.py` (extract from router)
- `apps/api/src/services/config/crud_service.py`
- `apps/api/src/services/config/resolver_service.py`
## Phase 1: Backend — Router Slimming
### 1.1 Terminal WebSocket Extraction
- [ ] 1.1.1 Create `api/tool/terminal.py` from terminal WebSocket handlers in `api/tool/tool_instances.py`
- [ ] 1.1.2 Move `_handle_terminal_websocket`, `_get_user_from_websocket`, `SessionRef` class
- [ ] 1.1.3 Update `main.py` to include `terminal_router` from `api.tool.terminal`
- [ ] 1.1.4 Remove terminal routes from `api/tool/tool_instances.py`
- [ ] 1.1.5 Verify `py_compile` passes
### 1.2 Instance Lifecycle Extraction
- [ ] 1.2.1 Create `api/tool/tool_lifecycle.py` for start/stop/restart/delete endpoints
- [ ] 1.2.2 Extract lifecycle endpoints from `api/tool/tool_instances.py`
- [ ] 1.2.3 Update `main.py` to include lifecycle router
- [ ] 1.2.4 Verify `py_compile` passes
### 1.3 Git Repository Router
- [ ] 1.3.1 Create `services/git/operations.py` for git command orchestration
- [ ] 1.3.2 Extract `clone_repo`, `fetch_repo`, `pull_repo`, `push_repo`, `merge_repo`, `commit_repo` helpers
- [ ] 1.3.3 Update `api/project/git_repositories.py` to call service functions
- [ ] 1.3.4 Verify `py_compile` passes
### 1.4 Config Profile Router
- [ ] 1.4.1 Extract CRUD helpers into `services/config/crud_service.py`
- [ ] 1.4.2 Extract resolver helpers into `services/config/resolver_service.py`
- [ ] 1.4.3 Update `api/config/config_profiles.py` to call services
- [ ] 1.4.4 Verify `py_compile` passes
## Phase 2: CSS Reorganization
### 2.1 Restore `styles/` Directory Structure
- [ ] 2.1.1 Create `styles/` directory
- [ ] 2.1.2 Extract `styles/tokens.css` from `styles.css` — CSS custom properties
- [ ] 2.1.3 Extract `styles/global.css` from `styles.css` — global reset, body, shell layout
- [ ] 2.1.4 Extract `styles/utilities.css` from `styles.css` — utility classes (.stack, .card, .muted, .dialog, etc.)
- [ ] 2.1.5 Extract `styles/syntax-highlight.css` from `styles.css` — code highlighting
- [ ] 2.1.6 Update `main.tsx` to import: `styles/tokens.css`, `styles/global.css`, `styles/utilities.css`, `styles/syntax-highlight.css`
- [ ] 2.1.7 Verify build passes
### 2.2 Restore Page-Specific CSS
- [ ] 2.2.1 Extract `styles/pages/dashboard.css` from `styles.css`
- [ ] 2.2.2 Extract `styles/pages/projects.css` from `styles.css`
- [ ] 2.2.3 Extract `styles/pages/sessions.css` from `styles.css`
- [ ] 2.2.4 Extract `styles/pages/settings.css` from `styles.css`
- [ ] 2.2.5 Extract `styles/pages/ssh-keys.css` from `styles.css`
- [ ] 2.2.6 Extract `styles/pages/git-history.css` from `styles.css`
- [ ] 2.2.7 Extract `styles/pages/repo-workspace.css` from `styles.css`
- [ ] 2.2.8 Update each page to import its page CSS
- [ ] 2.2.9 Verify build passes
### 2.3 Restore Component CSS Modules
- [ ] 2.3.1 Create `components/features/terminal/TerminalComponent.module.css` from terminal styles in `styles.css`
- [ ] 2.3.2 Create `components/features/git/GitToolbar.module.css` from git toolbar styles in `styles.css`
- [ ] 2.3.3 Create `components/features/git/CommitDialog.module.css` from commit dialog styles in `styles.css`
- [ ] 2.3.4 Create `components/features/git/MergeDialog.module.css` from merge dialog styles in `styles.css`
- [ ] 2.3.5 Create `components/features/git/FileEditor.module.css` from file editor styles in `styles.css`
- [ ] 2.3.6 Create `components/features/git/FileBrowser.module.css` from file browser styles in `styles.css`
- [ ] 2.3.7 Create `components/features/git/FileViewer.module.css` from file viewer styles in `styles.css`
- [ ] 2.3.8 Create `components/features/git/CommitPanel.module.css` from commit panel styles in `styles.css`
- [ ] 2.3.9 Create `components/features/session/InstanceList.module.css` from instance list styles in `styles.css`
- [ ] 2.3.10 Create `components/features/settings/SettingsTabLayout.module.css` from settings tab layout styles in `styles.css`
- [ ] 2.3.11 Create `components/layout/AppShell.module.css` from shell styles in `styles.css`
- [ ] 2.3.12 Update each component to use `import styles from './ComponentName.module.css'`
- [ ] 2.3.13 Remove extracted styles from `styles.css`
- [ ] 2.3.14 Verify build passes
### 2.4 Verify and Delete Monolith
- [ ] 2.4.1 Confirm `styles.css` is empty (or only has truly unclassifiable styles)
- [ ] 2.4.2 Delete `styles.css`
- [ ] 2.4.3 Verify build passes
- [ ] 2.4.4 Verify no visual regressions
## Phase 3: Frontend — Tool Workshop Page
- [ ] 3.1 Extract `ToolTypesTab` from `pages/ToolWorkshopPage.tsx` into `components/features/tool-workshop/ToolTypesTab.tsx`
- [ ] 3.2 Extract `ToolConfigsTab` into `components/features/tool-workshop/ToolConfigsTab.tsx`
- [ ] 3.3 Extract `ConfigFoldersTab` into `components/features/tool-workshop/ConfigFoldersTab.tsx`
- [ ] 3.4 Slim `pages/ToolWorkshopPage.tsx` to ~80 lines (tab switcher only)
- [ ] 3.5 Update imports in all consumers
- [ ] 3.6 Verify `tsc --noEmit` and `npm run build` pass
## Phase 4: Frontend — Config Profiles Page
- [ ] 4.1 Extract `ConfigProfileListView` into `components/features/config-profiles/ConfigProfileListView.tsx`
- [ ] 4.2 Extract `ConfigProfileDetailView` into `components/features/config-profiles/ConfigProfileDetailView.tsx`
- [ ] 4.3 Extract `ConfigProfileEditForm` into `components/features/config-profiles/ConfigProfileEditForm.tsx`
- [ ] 4.4 Extract `ConfigProfileMobileView` into `components/features/config-profiles/ConfigProfileMobileView.tsx`
- [ ] 4.5 Slim `pages/ConfigProfilesPage.tsx` to ~80 lines
- [ ] 4.6 Update imports
- [ ] 4.7 Verify `tsc --noEmit` and `npm run build` pass
## Phase 5: Frontend — Terminal Page
- [ ] 5.1 Extract `TerminalSessionManager` (tabs + auto-create) into `components/features/terminal/TerminalSessionManager.tsx`
- [ ] 5.2 Extract `MobileTerminalOverlay` into `components/features/terminal/MobileTerminalOverlay.tsx`
- [ ] 5.3 Extract fullscreen keyboard shortcut handler into `hooks/use-terminal-shortcuts.ts`
- [ ] 5.4 Slim `pages/TerminalPage.tsx` to ~80 lines
- [ ] 5.5 Update imports
- [ ] 5.6 Verify `tsc --noEmit` and `npm run build` pass
## Phase 6: Frontend — Settings & SSH Keys Pages
- [ ] 6.1 Extract `SettingsNavigation` into `components/features/settings/SettingsNavigation.tsx`
- [ ] 6.2 Slim `pages/SettingsPage.tsx` to ~80 lines
- [ ] 6.3 Extract `SSHKeyList` into `components/features/ssh-keys/SSHKeyList.tsx`
- [ ] 6.4 Extract `SSHKeyCreateForm` into `components/features/ssh-keys/SSHKeyCreateForm.tsx`
- [ ] 6.5 Slim `pages/SshKeysPage.tsx` to ~80 lines
- [ ] 6.6 Verify `tsc --noEmit` and `npm run build` pass
## Phase 7: Frontend — Projects & Repo Workspace Pages
- [ ] 7.1 Extract `ProjectList` into `components/features/project/ProjectList.tsx`
- [ ] 7.2 Extract `ProjectCreateDialog` into `components/features/project/ProjectCreateDialog.tsx`
- [ ] 7.3 Extract `ProjectEditDialog` into `components/features/project/ProjectEditDialog.tsx`
- [ ] 7.4 Slim `pages/ProjectsPage.tsx` to ~100 lines
- [ ] 7.5 Extract `WorkspaceLayout` into `components/features/workspace/WorkspaceLayout.tsx`
- [ ] 7.6 Slim `pages/RepoWorkspacePage.tsx` to ~150 lines
- [ ] 7.7 Verify `tsc --noEmit` and `npm run build` pass
## Phase 8: Integration and Verification
- [ ] 8.1 Run backend `py_compile` on all files
- [ ] 8.2 Run frontend `npm run typecheck`
- [ ] 8.3 Run frontend `npm run build`
- [ ] 8.4 Run frontend tests: `npm test`
- [ ] 8.5 Verify file size targets met (pages ≤ 150, routers ≤ 400, no `styles.css` monolith)
- [ ] 8.6 Verify no 404s or import errors in browser console
- [ ] 8.7 Manual smoke test: create project, start terminal, open config profiles
- [ ] 8.8 Verify visual regression: colors, spacing, typography unchanged
- [ ] 8.9 Verify mobile terminal styles intact
- [ ] 8.10 Verify notification dropdown styles intact
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-12
@@ -0,0 +1,37 @@
# . (index)
dir: .
## Project Map Protocol
1. Read this protocol and the root `.pi-map.index.md` first.
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
3. Load indexes before rich maps during task-start navigation.
4. Read the local rich map and actual source before editing.
5. Treat non-empty `## dirty` sections in either artifact as stale.
6. If source and generated artifacts disagree, trust source.
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
8. After editing source, run `project_map_patch` for each changed file.
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
Trust boundary: index routes, map orients, source decides.
## role
Frontend architecture specification for real-time session progress tracking using Server-Sent Events and shared React state management.
## parent
-
## children
- specs
index: specs/.pi-map.index.md
map: specs/.pi-map.md
## files
- .openspec.yaml
- design.md
- proposal.md
- tasks.md
## links
index: ./.pi-map.index.md
map: ./.pi-map.md
## workflows
-
## dirty
-
@@ -0,0 +1,36 @@
# .
dir: .
index: ./.pi-map.index.md
## Project Map Protocol
1. Read this protocol and the root `.pi-map.index.md` first.
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
3. Load indexes before rich maps during task-start navigation.
4. Read the local rich map and actual source before editing.
5. Treat non-empty `## dirty` sections in either artifact as stale.
6. If source and generated artifacts disagree, trust source.
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
8. After editing source, run `project_map_patch` for each changed file.
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
Trust boundary: index routes, map orients, source decides.
## role
Frontend architecture specification for real-time session progress tracking using Server-Sent Events and shared React state management.
## files
- .openspec.yaml | Defines an OpenAPI specification metadata file with schema type and creation date
- design.md | Design document for frontend-only refactoring of session lifecycle progress tracking and live UI updates using SSE events and shared React context. | dep: React, SSE (useEvents), SessionsContext, SessionOperationsContext, AppShell, DashboardPage, SessionsPage, CreateSessionForm, InstanceList, useInstanceActions, ToolStarter, StartToolFAB, EventToastBridge
- proposal.md | Proposes a frontend architecture change to add real-time session progress tracking via SSE and shared state across the application. | dep: React, SSE events, state management, AppShell, DashboardPage, SessionsPage, use-instance-actions, CreateSessionForm, SessionCard, InstanceCard
- tasks.md | A task checklist for refactoring a web application's session management to use shared state, a global progress panel, and removing legacy progress overlays. | dep: React, state/sessions.tsx, state/session-operations.tsx, useEvents, use-instance-actions.ts, AppShell, SessionsPage, DashboardPage, tool-starter.tsx, start-tool-fab.tsx, instance-list.tsx, create-session-form.tsx, session-card.tsx
## arch
Documentation-driven design package using OpenAPI metadata, markdown specifications (design/proposal/tasks), and event-driven UI patterns with SSE, shared React context, and global progress panel replacing legacy overlay components.
## tags
tsx, session, progress, react, state, design, shared, appshell
## symbols
-
## workflows
-
## dirty
-
@@ -0,0 +1,71 @@
# Design: Tool Session Progress and Live Updates
## Goals / Non-Goals
**Goals:**
- Provide structured, real-time progress feedback for every tool lifecycle action.
- Ensure all session lists (nav, dashboard, sessions page) update immediately after create/delete.
- Remove blocking and card-dimming overlays that hide context and provide no step detail.
- Keep the change frontend-only, reusing existing SSE and session APIs.
**Non-Goals:**
- No new backend endpoints or event types.
- No changes to the actual Docker/container orchestration logic.
- No redesign of the session card layout beyond action/progress affordances.
## Decisions
### Decision: Global progress panel in the corner
A fixed panel (bottom-right desktop, bottom sheet style on mobile) lists in-flight operations. Each operation shows:
- Action icon + session display name
- Current step label derived from the latest SSE event
- A compact stepper: Created → Building → Starting → Probing → Ready/Error
- Dismiss button once settled
This is non-blocking, works across pages, and does not interfere with the modal create flow.
### Decision: SSE event-driven updates
The panel subscribes to `useEvents`. When an operation is started we record `instanceId` + `action`. Incoming events that match a tracked instance update the operation's message, status, and step. Events handled:
- `instance.created`, `instance.started`, `instance.restarted` → advance
- `instance.health_changed` with `status=running` → complete success
- `instance.error` → complete error
- `instance.stopped` → complete for stop action
- `instance.deleted` → complete for delete action
### Decision: Shared session state
`SessionsContext` is promoted from a nav-only data holder to the authoritative session list:
- Holds `sessions`, `isLoading`, `error`, `refreshSessions()`.
- Provides `addOrUpdateSession`, `removeSession` for optimistic updates.
- `AppShell`, `DashboardPage`, and `SessionsPage` read from this context instead of fetching independently.
### Decision: Optimistic create/delete updates
- **Create**: after the API returns a pending instance, add it to shared state and start tracking. Subsequent SSE events update its status.
- **Delete**: remove from shared state as soon as the API succeeds; the progress panel tracks the action until the `instance.deleted` event confirms it.
- **Other actions**: keep the existing per-action busy flag on the card for button disabled states, but the panel provides the detailed progress.
### Decision: Remove legacy overlays
- Delete `loading-overlay` and workflow step markup from `CreateSessionForm`.
- Remove `session-busy-overlay` and `instance-busy-overlay` (the dimming overlays), but keep button disabled states and small inline spinners.
## Risks / Trade-offs
**Risk: Shared context causes extra re-renders**
→ Mitigation: context value is memoized; lists use the same data they already fetched.
**Risk: SSE events arriving before operation is tracked**
→ Mitigation: start tracking before calling the create/start API; for deletes the removal is optimistic and the panel reconciles on the event.
**Risk: Duplicate feedback between panel and toasts**
→ Mitigation: panel shows in-flight steps; toasts remain for terminal success/error only. Existing `EventToastBridge` logic is left largely unchanged.
## Migration Plan
1. Extend `SessionsContext` with loading/error/refresh/update helpers.
2. Create `SessionOperationsContext` + `SessionProgressPanel` and render it in `AppShell`.
3. Update `useInstanceActions` to use shared state and start/stop tracking operations.
4. Update `ToolStarter`/`StartToolFAB` to add pending sessions and start tracking.
5. Update `CreateSessionForm` to remove overlay and report status to parent.
6. Update `InstanceList` to refresh shared state after create/delete.
7. Update `SessionsPage` and `DashboardPage` to consume shared context.
8. Remove legacy overlay styles.
9. Run typecheck, lint, and tests.
@@ -0,0 +1,34 @@
# Tool Session Progress Indication and Live Updates
## Why
Creating or deleting a tool session currently leaves the UI out of sync. After starting a tool from the floating action button, the new session does not appear on `/sessions` or the dashboard until the user manually refreshes or the 30-second poll fires. The existing progress feedback is also poor: `CreateSessionForm` shows a blocking overlay with only two static messages ("Creating instance…", "Starting container…"), and `SessionCard`/`InstanceCard` dim the whole card with a generic spinner. Users cannot see real backend progress (building, starting, probing, running, error) and receive no confirmation when an action finishes.
## What Changes
- Introduce a **global session progress panel** that tracks all lifecycle actions (create, start, stop, restart, delete, recreate tunnel) using the existing SSE event stream (`instance.created`, `instance.started`, `instance.health_changed`, `instance.error`, `instance.stopped`, `instance.deleted`, `instance.restarted`).
- Make lists update **immediately** after create/delete by sharing session state across `AppShell`, `DashboardPage`, and `SessionsPage`.
- Remove the blocking full-screen overlay in `CreateSessionForm` and the card-level busy overlays; replace them with minimal disabled/spinner states and the global panel.
- Keep existing toast notifications for terminal states (success/error) while the panel handles in-flight progress.
## Capabilities
### New Capabilities
- `session-progress-panel`: Global, non-blocking progress UI for tool lifecycle actions driven by SSE events.
- `shared-session-state`: Centralized session list used by navigation, dashboard, and sessions page.
### Modified Capabilities
- `session-lifecycle-ux`: Delete and stop actions now update shared state immediately; create/start actions add a pending session and show progress.
- `sessions-hub`: Dashboard and sessions lists reflect new/deleted sessions without manual refresh.
## Impact
- Frontend: new `state/session-operations.tsx`, new `components/features/session/session-progress-panel.tsx`, updates to `state/sessions.tsx`, `AppShell`, `SessionsPage`, `DashboardPage`, `hooks/use-instance-actions.ts`, `components/features/tool/instance-list.tsx`, `components/features/tool/tool-starter.tsx`, `components/features/session/create-session-form.tsx`, `components/features/session/session-card.tsx`, and styles.
- No API changes: relies on existing lifecycle events and `/users/me/sessions`.
## Quality Gates
- `npm run typecheck`
- `npm run lint`
- Existing frontend tests still pass
- Manual verification: create and delete sessions from the FAB and repository detail page; verify panel updates and lists refresh without manual reload.

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