- 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
- 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
- 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
- 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
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
- 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)
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)
The notification dropdown was at z-index: 100, well below fullscreen
terminals (1000), modal overlays (1000), and dialog overlays (1000).
Because .notification-center creates a stacking context with no explicit
z-index, the dropdown was trapped behind any of those overlays and
became unclickable.
- Set .notification-center z-index to 9999 so it competes above all
other overlays in the root stacking context.
- Set .notification-dropdown z-index to 9999 for consistency.
Quality gates: tsc --noEmit (pass), build (pass)
Terminal sessions are opened in a new tab via window.open with
noopener,noreferrer. In a new tab, window.history has no previous
entry, so navigate(-1) silently does nothing. Both the back arrow
and the X button in the mobile terminal overlay called navigate(-1),
which made them appear broken.
Change both buttons to navigate('/sessions') so they always exit
to a sensible page regardless of how the terminal was opened.
Quality gates: tsc --noEmit (pass), build (pass)
Fixed import paths for 43 components moved into features/ directories.
Key fixes:
- api/, types/, hooks/, state/, utils/ imports need ../../../ from features/*/
- components/ imports need ../../ from features/*/
- Cross-feature imports use relative paths (e.g., ../tool/tools-bottom-sheet)
- app-shell.tsx updated to import from features/ subdirectories
Frontend typecheck now passes except for one pre-existing error:
xterm-addon-webgl missing type declarations.
Quality gates: ruff passed on backend, py_compile passed on all backend files.
Sidebar SessionItem was only opening web tool URLs in new tabs.
Terminal sessions linked to the project page in the same tab.
SessionCard 'Open' buttons for terminal sessions navigated in-place.
Changes:
- app-shell.tsx: SessionItem now builds terminal URLs
(/instances/:id/terminal) and always uses target=_blank
- session-card.tsx: compute openHref for both web and terminal sessions,
render <a> links with target=_blank instead of callback buttons
- use-instance-actions.ts: handleOpen now uses window.open(..., '_blank')
for terminal sessions and project fallback
All session opening (sidebar, cards, callbacks) now consistently opens
in a new tab.
Quality gates: tsc clean
tool-starter.tsx was hardcoding ssh_key_ids=[] and only showing a read-only
SSH key status. Users couldn't select keys when starting tools from workspaces.
Changes:
- tool-starter.tsx: add checkboxes for SSH key selection with repo key
pre-selected, pass selected keys to createInstance/startInstance
- AGENTS.md: add explicit rule forbidding docker compose commands without
user approval and proper isolation
The web container must be rebuilt to pick up the frontend changes:
docker compose up -d --build web
Quality gates: tsc clean, pytest (19 passed, 1 skipped)
Bug 1 — in-container repo mounting:
- docker-compose.yml: added /data/working-copies:/data/working-copies mount
to API container so workspace dirs are visible on host filesystem
- Dockerfile: create /data/working-copies dir in image
Bug 2 — /home/user not writable:
- workspace_manager.py: chmod 777 workspace dirs + 666 files after clone
and after sync, so any container user can write
- manifest_compiler.py: explicit mkdir + chown + chmod 755 for home dir
in generated Dockerfile
Bug 3 — terminal text shifts left on typing:
- terminal.tsx: removed manual term.refresh() after fit (caused reflow)
- Track lastSentCols/lastSentRows and only send resize when dimensions
actually changed, preventing resize feedback loops
Bug 4 — ESC key captured by terminal:
- terminal.tsx: attachCustomKeyEventHandler allows ESC to propagate to
browser when not in alternate buffer (vim/tmux), so modals/navigation
work; ESC still sent to PTY when in vim/tmux alternate screen
Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces (9 passed)
- New StartToolFAB component: fixed floating button (bottom-right) opens
a modal with workspace selector + ToolStarter
- Added to AppShell: available on every page except mobile terminal
- Dashboard (home): removed old CreateSessionForm and 'Quick create' section,
replaced with FAB + 'Workspaces quick access' prompt
- Sessions page: removed inline workspace selector + ToolStarter, now
shows prompt to use the FAB
- Styles: .start-tool-fab with hover scale, shadow, mobile offset above tab bar
Quality gates: tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
- Delete inline hardcoded StartToolModal from workspace-detail.tsx
- Import shared StartToolModal that fetches real tool types from API
- Pass workspace object to ToolsTab so shared modal gets proper context
- onStart handler passes optional configProfileId through to useWorkspaceInstances.create()
Quality gates: tsc --noEmit clean
- Fetch real tool types from API instead of hardcoded string names
- Use actual tool type UUID (id) as select value
- Remove fake 'terminal' option — terminal is a feature, not a tool type
- Show display_name in dropdown, handle loading/error states
Quality gates: tsc --noEmit clean
Backend (git_repositories.py):
- get_repository_branches: check for .git dir OR HEAD file (handles bare repos)
- When local repo is missing, git ls-remote fallback now uses SSH key auth
via _prepare_ssh_env() for repos with ssh_key_id
- Cleans up temp SSH key file after ls-remote
- Logs ls-remote stderr/exit code for debugging
- Returns server's detail message instead of raw axios 404 text
Frontend (use-git-repo.ts):
- extractError() helper pulls server detail/message from axios responses
- User sees 'repository not found on disk — re-clone or re-create'
instead of generic 'Request failed with status code 404'
Quality gates: ruff clean, tsc --noEmit clean, 11 passed + 1 pre-existing failure
Backend (git_repositories.py):
- get_repository_branches now checks for .git subdirectory (not just dir existence)
- If local repo is corrupt/missing but has remote_url, falls back to git ls-remote
to list branches from the remote
- Returns 404 with actionable message instead of 400 with raw git stderr
- Pre-existing test failure in test_git_repository_clone_preflight.py unchanged
Frontend (workspace-create-form.tsx):
- When branch API fails, auto-switches to manual text input (no dropdown selection needed)
- Shows hint text: 'Couldn't load branches — type one manually'
- useGitRepo hook auto-fetches branches when projectId/repoId change
Quality gates: ruff clean, tsc --noEmit clean, 93 passed + 1 pre-existing failure
- Rewrite WorkspaceCreateForm as unified component used in both pages
- Standalone mode (WorkspacesPage): shows project/repo/branch selectors
- Contextual mode (ProjectsPage): accepts defaultProjectId/defaultRepoId,
skips project/repo selectors, shows only name + branch dropdown
- Branch dropdown fetched from repo via listRepositoryBranches API
- Auto-selects first/only option for project, repo, and branch
- '+ Create new branch...' option reveals text input for custom branch
- Falls back to free-text branch input if branch API fails
- Removes duplicated inline creation logic from WorkspacesPage
- TypeScript + eslint clean
- Fetch branches from selected repo via listRepositoryBranches API
- Branch dropdown with default branch pre-selected
- '+ Create new branch...' option reveals text input for custom branch
- Auto-select first option when only one available:
- Project: auto-selects when only 1 project
- Repo: auto-selects when only 1 repo
- Branch: auto-selects when only 1 branch, otherwise defaults to remote default
- Falls back to free-text branch input if branch API fails
- TypeScript + eslint clean
- Replace awkward first-workspace-guessing logic with inline project/repo selector
- New WorkspaceCreateInline component with cascading dropdowns:
- Select project → loads repositories for that project
- Select repository → enter workspace name + branch
- Submit creates workspace via top-level POST /workspaces/
- Add createWorkspaceTopLevel() API client for flat endpoint
- Works even with zero existing workspaces (shows create button in empty state)
- Add CSS grid layout for inline create form
- TypeScript + eslint clean
- Add all_workspaces_router with GET /workspaces/ (no project/repo required)
- Include project_id in workspace responses
- Frontend: useWorkspaces() calls listAllWorkspaces when no args
- Frontend: WorkspacesPage uses top-level list, derives project/repo from workspace for mutations
- Fixes 422 from invalid UUID path params
FastAPI auto-redirects /workspaces to /workspaces/ with 307.
Behind Traefik (HTTP internal), the 307 becomes http://,
triggering Mixed Content in the browser. Adding trailing
slashes avoids the redirect entirely.
Fixes NameError: ToolInstance not defined at runtime because
type annotations are evaluated at class definition time.
Deferring annotation evaluation with __future__ annotations
keeps TYPE_CHECKING imports from causing runtime crashes.
Also includes ruff formatting cleanup on workspace-related files.
The API container used a named Docker volume (repo_data:/data/repos) for
storing repositories. When creating tool instances with direct mount mode,
the API told Docker to bind-mount /data/repos/<repo>:/workspace into the
tool container. But the Docker daemon resolves bind-mount paths on the HOST
filesystem, not inside the API container. Since the host had no /data/repos
(the repos only existed inside the named volume), tool containers mounted
empty directories.
Changed both compose files to use a host bind mount (/data/repos:/data/repos)
instead of a named volume. This ensures:
- The API container and tool containers both see the same /data/repos path
- Bind mounts from /data/repos into tool containers work correctly
For existing installations: repos previously stored in the repo_data named
volume should be copied to /data/repos on the host before restarting the
stack.
Quality gates: compose file syntax valid
- apps/api/src/services/tunnel.py: add 2-second sleep after discovering the
tunnel URL to allow Cloudflare DNS edge propagation before returning
- apps/web/src/hooks/use-instance-actions.ts: show alert() with the backend
error message when recreate tunnel fails, instead of silently swallowing
errors
Quality gates: ruff clean, tsc clean
Replace the subprocess-based tunnel implementation with Docker containers
running on the host network. This eliminates all container name resolution
bugs that caused tunnel 502 errors.
New design:
- Each tunnel is a docker run --network host cloudflare/cloudflared container
- cloudflared connects to localhost:{published_port} (Docker port forwarding)
- No dependency on container names, backend network DNS, or binding diagnostics
- Tunnels named predictably: tunnel-{instance_name}
- Start/stop/recreate use container names instead of PIDs
Files changed:
- NEW: apps/api/src/services/tunnel.py — clean tunnel module (start/stop/recreate/health)
- apps/api/src/services/docker.py — removed 250 lines of old tunnel code
- apps/api/src/api/tool_instances.py — use new tunnel module, store container_name
- apps/api/src/services/health_monitor.py — updated import
- apps/web/src/components/session-card.tsx — Recreate Tunnel button always visible
Quality gates: ruff clean, 13 tests passed (health_monitor + notifications)
Show the Recreate Tunnel button on all active web-enabled session cards
(instead of only when tunnel_status is unreachable) so users can manually
trigger tunnel recreation at any time. Also adds it to the mobile action sheet.
Quality gates: eslint clean, tsc clean
- Dual-mode touch scroll:
- Normal mode: scroll .xterm-viewport directly when scrollHeight > clientHeight
- Alternate screen (tmux/vim): send SGR 1006 mouse-wheel protocol data
using cursor position so tmux knows which pane to scroll
- Add touch-action: none to .terminal-container to prevent browser gestures
- Lock both html and body overflow when terminal page is open on mobile
- Remove synthetic WheelEvent approach (xterm.js SmoothScrollableElement
doesn't reliably handle synthetic events)
- Attach capture-phase touch listeners to .terminal-container (parent of xterm)
- On vertical swipe: e.preventDefault() blocks page scroll, then directly
adjust .xterm-viewport.scrollTop by the swipe delta
- This bypasses term.scrollLines() API and directly manipulates the DOM
element that xterm.js watches via its internal scroll handler
- Remove all CSS touch-action overrides — container handles it in JS
- Add full mobile viewport CSS: overflow-y scroll, -webkit-overflow-scrolling
touch, overscroll-behavior-y contain, translate3d hardware accel,
scroll-behavior smooth, touch-action pan-y
- After term.open(), find .xterm-viewport and add passive touch listeners
that call stopPropagation() (not preventDefault) — this lets the browser
handle native touch scrolling while preventing xterm.js internal handlers
from interfering
- Based on xterm.js known issue #5489 and SCROLLING_FIX.md approach