chore: archive final 4 completed OpenSpec changes
Move the following completed changes from openspec/changes/ to openspec/changes/archive/2026-06-12-completed-changes-archive/: - multi-session-terminal-ux - reorganize-long-files - working-copies - workspace-first-ui Update parent and archive .pi-map*.md indexes to reflect the move and remove the transient active-changes-archive grouping. openspec/changes/ now contains only the archive/ directory.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
dir: archive
|
||||
|
||||
## role
|
||||
This package provides functionality for creating, reading, and extracting compressed archive files in various formats.
|
||||
Contains historical or deprecated code and resources that are no longer active in the project but retained for reference or backup purposes.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: archive
|
||||
index: archive/.pi-map.index.md
|
||||
|
||||
## role
|
||||
This package provides functionality for creating, reading, and extracting compressed archive files in various formats.
|
||||
Contains historical or deprecated code and resources that are no longer active in the project but retained for reference or backup purposes.
|
||||
## files
|
||||
## arch
|
||||
The package uses a modular, format-agnostic interface pattern where a generic Archive type wraps format-specific implementations (tar, zip, etc.) with unified read/write/extract operations, supported by internal helper packages for each archive format.
|
||||
No active architecture; typically flat storage of obsolete files with no dependencies or patterns, isolated from the main codebase.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -52,6 +52,18 @@ map: archive/.pi-map.md
|
||||
- archive/2026-06-12-completed-changes-archive/tool-session-progress-and-updates
|
||||
index: archive/2026-06-12-completed-changes-archive/tool-session-progress-and-updates/.pi-map.index.md
|
||||
map: archive/2026-06-12-completed-changes-archive/tool-session-progress-and-updates/.pi-map.md
|
||||
- archive/2026-06-12-completed-changes-archive/multi-session-terminal-ux
|
||||
index: archive/2026-06-12-completed-changes-archive/multi-session-terminal-ux/.pi-map.index.md
|
||||
map: archive/2026-06-12-completed-changes-archive/multi-session-terminal-ux/.pi-map.md
|
||||
- archive/2026-06-12-completed-changes-archive/reorganize-long-files
|
||||
index: archive/2026-06-12-completed-changes-archive/reorganize-long-files/.pi-map.index.md
|
||||
map: archive/2026-06-12-completed-changes-archive/reorganize-long-files/.pi-map.md
|
||||
- archive/2026-06-12-completed-changes-archive/working-copies
|
||||
index: archive/2026-06-12-completed-changes-archive/working-copies/.pi-map.index.md
|
||||
map: archive/2026-06-12-completed-changes-archive/working-copies/.pi-map.md
|
||||
- archive/2026-06-12-completed-changes-archive/workspace-first-ui
|
||||
index: archive/2026-06-12-completed-changes-archive/workspace-first-ui/.pi-map.index.md
|
||||
map: archive/2026-06-12-completed-changes-archive/workspace-first-ui/.pi-map.md
|
||||
## files
|
||||
- README.md
|
||||
## links
|
||||
|
||||
@@ -7,6 +7,10 @@ index: archive/2026-06-12-completed-changes-archive/.pi-map.index.md
|
||||
Stores historical records of fully implemented and audited OpenSpec changes for reference and audit trail purposes.
|
||||
## files
|
||||
- README.md | Documents archived completed OpenSpec changes that have been audited and confirmed as fully implemented
|
||||
- multi-session-terminal-ux | Completed OpenSpec change for multi-session terminal UX
|
||||
- reorganize-long-files | Completed OpenSpec change for reorganizing long files
|
||||
- working-copies | Completed OpenSpec change for workspace-first working copies
|
||||
- workspace-first-ui | Completed OpenSpec change for workspace-first UI
|
||||
## arch
|
||||
Simple flat-file archive using date-based directory naming and markdown documentation for immutable change tracking.
|
||||
## tags
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
name: multi-session-terminal-ux
|
||||
status: completed
|
||||
started_at: 2026-05-28
|
||||
completed_at: 2026-06-12
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# archive/2026-06-12-active-changes-archive/multi-session-terminal-ux (index)
|
||||
dir: archive/2026-06-12-active-changes-archive/multi-session-terminal-ux
|
||||
|
||||
## role
|
||||
Archive of design documents and implementation plans for adding multi-session terminal UX support to a tool instance platform, preserving historical decision-making and phased development records.
|
||||
## parent
|
||||
index: archive/2026-06-12-active-changes-archive/.pi-map.index.md
|
||||
map: archive/2026-06-12-active-changes-archive/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- .openspec.yaml
|
||||
- apply-pr1.md
|
||||
- apply-pr2.md
|
||||
- apply-pr3.md
|
||||
- design.md
|
||||
- explore.md
|
||||
- proposal.md
|
||||
- tasks.md
|
||||
## links
|
||||
index: archive/2026-06-12-active-changes-archive/multi-session-terminal-ux/.pi-map.index.md
|
||||
map: archive/2026-06-12-active-changes-archive/multi-session-terminal-ux/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# archive/2026-06-12-active-changes-archive/multi-session-terminal-ux
|
||||
dir: archive/2026-06-12-active-changes-archive/multi-session-terminal-ux
|
||||
|
||||
index: archive/2026-06-12-active-changes-archive/multi-session-terminal-ux/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Archive of design documents and implementation plans for adding multi-session terminal UX support to a tool instance platform, preserving historical decision-making and phased development records.
|
||||
## files
|
||||
- .openspec.yaml | Defines metadata for a completed project specification named "multi-session-terminal-ux"
|
||||
- apply-pr1.md | Documents the implementation of database schema, Alembic migration, and TerminalManager multi-session core for a multi-session terminal UX feature. | dep: Alembic, SQLAlchemy, aiosqlite, asyncio, pytest, TerminalSession, TerminalManager, UUIDPrimaryKeyMixin, TimestampMixin, SessionLocal
|
||||
- apply-pr2.md | Documents the implementation of a multi-session terminal UX with WebSocket routing, REST CRUD endpoints, frontend API client/hook, and auth-focused tests, while maintaining backward compatibility with legacy single-session endpoints. | dep: WebSocket, REST API, React hooks, Python pytest, Docker/PTY, terminal session manager
|
||||
- apply-pr3.md | Documents a pull request implementing a frontend multi-session terminal UI with tabbed management, fullscreen mode, keyboard shortcuts, and mobile support. | dep: React, TypeScript, WebSocket, CSS, Python (backend terminal manager), testing framework
|
||||
- design.md | Software design document specifying architecture and implementation for adding multi-session terminal support (up to 5 concurrent sessions) to an existing tool instance platform | dep: FastAPI, SQLAlchemy, Alembic, React, xterm.js, WebSocket, PTY, docker exec, Python, TypeScript
|
||||
- explore.md | Explores architectural options for adding multi-session terminal support to an existing persistent terminal system, evaluating backend, frontend, protocol, and database design choices with recommendations for MVP implementation. | dep: TerminalManager, TerminalSession, TerminalComponent, TerminalPage, MobileTerminalWrapper, xterm.js, FastAPI, SQLAlchemy, alembic
|
||||
- proposal.md | Proposes architectural changes to support multiple concurrent terminal sessions per tool instance with improved UI/UX features | dep: TerminalManager, TerminalSession, api/terminal.py, TerminalComponent, terminal.tsx, WebSocket protocol, database schema
|
||||
- tasks.md | A software design document specifying 12 implementation tasks for adding multi-session terminal UX support across backend (Python/FastAPI) and frontend (React/TypeScript) with detailed acceptance criteria, file changes, and TDD testing steps. | dep: Alembic, FastAPI, WebSocket, React, TypeScript, xterm.js (implied), pytest, Jest/npm test
|
||||
## arch
|
||||
Specification-driven development with phased PR documentation (backend schema/migrations → API/WebSocket/frontend hooks → UI components), using FastAPI/Python backend with Alembic migrations, React/TypeScript frontend, WebSocket protocol, and TDD acceptance criteria across 12 structured implementation tasks.
|
||||
## tags
|
||||
terminal, session, multi, apply, implementation, alembic, websocket, support
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
# Apply Report: PR 1 – Database + Backend Core for Multi-Session Terminal UX
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the database schema, Alembic migration, TerminalManager multi-session core, and TerminalSession name/status tracking for the multi-session terminal UX feature. All changes are backward-compatible with the existing single-session `/terminal` WebSocket endpoint.
|
||||
|
||||
### Key Changes
|
||||
|
||||
1. **Database Schema** – Added `terminal_sessions` table with `UUIDPrimaryKeyMixin` + `TimestampMixin`, storing `instance_id`, `name`, `status`, `last_activity_at`, and `closed_at`.
|
||||
2. **Alembic Migration** – Created migration `2026_05_28_add_terminal_sessions` (down-revision from `20260527_160017_add_pi_agent`).
|
||||
3. **TerminalSession** – Added `name` (auto-generated as "Session N"), `status` field (`active`/`resetting`/`closed`), and updated `reset()`/`close()` to set status appropriately.
|
||||
4. **TerminalManager** – Migrated `_sessions` dict from `dict[str, TerminalSession]` to `dict[tuple[str, str], TerminalSession]`. Added `create_session()`, `get_session()`, `get_sessions_for_instance()`, `close_session()`, and updated `reset_session()` to accept an optional `session_id`. Preserved `get_or_create_session()` for backward compatibility (uses `"default"` session_id). Idle cleanup now operates on composite keys and fires DB status updates asynchronously.
|
||||
5. **Tests** – Created 7 unit tests covering session creation, max-5 enforcement, filtering, close/removal, WebSocket isolation, default session keying, and idle cleanup DB updates.
|
||||
|
||||
## Files Created
|
||||
|
||||
- `apps/api/src/models/terminal_session.py`
|
||||
- `apps/api/alembic/versions/2026_05_28_add_terminal_sessions_table.py`
|
||||
- `apps/api/tests/services/test_terminal_manager_multi.py`
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `apps/api/src/models/__init__.py` – Imported `TerminalSessionModel`
|
||||
- `apps/api/src/main.py` – Imported `TerminalSessionModel` for Alembic model discovery
|
||||
- `apps/api/src/services/terminal_manager.py` – Full refactor to composite-key session management with DB fire-and-forget helpers
|
||||
- `apps/api/src/services/terminal_session.py` – Added `name`, `status`, `_instance_counters`, and status transitions
|
||||
|
||||
## Test Results
|
||||
|
||||
### New Tests (7/7 passed)
|
||||
|
||||
```
|
||||
$ cd apps/api && python -m pytest tests/services/test_terminal_manager_multi.py -v
|
||||
|
||||
tests/services/test_terminal_manager_multi.py::test_create_session_increases_count PASSED
|
||||
tests/services/test_terminal_manager_multi.py::test_create_session_enforces_max_5 PASSED
|
||||
tests/services/test_terminal_manager_multi.py::test_get_sessions_for_instance_filters_by_instance PASSED
|
||||
tests/services/test_terminal_manager_multi.py::test_close_session_removes_from_dict PASSED
|
||||
tests/services/test_terminal_manager_multi.py::test_attach_websocket_only_closes_same_session PASSED
|
||||
tests/services/test_terminal_manager_multi.py::test_default_session_keyed_separately PASSED
|
||||
tests/services/test_terminal_manager_multi.py::test_idle_cleanup_updates_db_status PASSED
|
||||
|
||||
======================== 7 passed, 4 warnings in 0.11s =========================
|
||||
```
|
||||
|
||||
### Full Suite (no regressions)
|
||||
|
||||
```
|
||||
$ cd apps/api && python -m pytest tests/ -q
|
||||
|
||||
51 failed, 174 passed, 6 warnings in 15.96s
|
||||
```
|
||||
|
||||
- **Baseline failures**: 51 (pre-existing, unchanged by this PR)
|
||||
- **New passes**: +7 (from `test_terminal_manager_multi.py`)
|
||||
- **No new failures introduced**
|
||||
|
||||
## Deviations from Design
|
||||
|
||||
1. **Duplicate `created_at` column** – The design spec and its Alembic snippet listed `created_at` twice (once explicitly, once from `TimestampMixin`). I removed the explicit `created_at` from the model and migration, relying on `TimestampMixin` which provides `server_default=func.now()`.
|
||||
2. **DB write implementation** – The design showed DB writes inside `TerminalManager` but didn't specify the exact async pattern. I implemented them as `asyncio.create_task`-wrapped coroutines using `SessionLocal()` so they are non-blocking. Unit tests mock `_mark_closed_in_db` and `_insert_db_session_row` to verify calls without needing a live DB.
|
||||
3. **`get_or_create_session` auto-name** – The design said default session should count toward the 5-session limit. The current implementation does count it, but `get_or_create_session` creates the default session outside the `create_session` path (to preserve backward compat). Future REST endpoints can enforce the limit at the API layer before calling either path.
|
||||
|
||||
## Blockers / Risks
|
||||
|
||||
- **Global singleton test isolation** – `TerminalManager` is still a global singleton (`terminal_manager = TerminalManager()`). The unit tests create fresh instances via the `manager` fixture, but integration tests that import the global may need care to reset state between tests.
|
||||
- **DB fire-and-forget in tests** – The aiosqlite background thread emits `RuntimeError: Event loop is closed` warnings when the test event loop tears down before the fire-and-forget DB task completes. This is harmless in tests but worth monitoring.
|
||||
- **Migration head** – The migration chains from `20260527_160017_add_pi_agent`. If a new migration lands on `dev` before this PR merges, the `down_revision` must be updated.
|
||||
|
||||
## Next Recommended Action
|
||||
|
||||
1. **Task 5 (WebSocket endpoint + REST API)** – Implement the new `/ws/tool-instances/{instance_id}/terminal/{session_id}` WebSocket route and the REST endpoints (`GET/POST/DELETE .../terminal/sessions`) in `apps/api/src/api/terminal.py`. Extract the shared auth/validation/I/O loop into `_handle_terminal_websocket()` as specified in the design.
|
||||
2. **Run migration in a staging environment** – Verify `alembic upgrade head` applies cleanly and `downgrade` reverses without data loss.
|
||||
3. **Integration tests for WebSocket multi-session** – Create `apps/api/tests/api/test_terminal_ws_multi.py` to validate concurrent session isolation and the default-session alias.
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# Apply Report: PR 2 – Backend API + Frontend Client for Multi-Session Terminal UX
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the session-scoped WebSocket route, REST endpoints for terminal session CRUD, and the frontend API client/hook. All changes are backward-compatible with the legacy single-session `/terminal` WebSocket endpoint and `POST .../terminal/reset` alias.
|
||||
|
||||
### Key Changes
|
||||
|
||||
1. **WebSocket Routing** – Added `/ws/tool-instances/{instance_id}/terminal/{session_id}` route and preserved `/ws/tool-instances/{instance_id}/terminal` as the default-session alias. Extracted a shared `_handle_terminal_websocket()` handler containing auth, validation, and the I/O loop.
|
||||
2. **REST Endpoints** – Added the following endpoints under `/instances/{instance_id}/terminal`:
|
||||
- `GET /sessions` – list sessions with live `has_websockets` flag
|
||||
- `POST /sessions` – create a new session (409 when max 5 reached)
|
||||
- `DELETE /sessions/{session_id}` – close a session
|
||||
- `POST /sessions/{session_id}/reset` – reset a specific session
|
||||
- `POST /sessions/{session_id}/rename` – rename a session
|
||||
- `POST /reset` – legacy alias for resetting the default session
|
||||
3. **Frontend API Client** – Created `apps/web/src/api/terminal.ts` with typed REST wrappers.
|
||||
4. **Frontend Hook** – Created `apps/web/src/hooks/use-terminal-sessions.ts` for loading, creating, closing, resetting, and renaming sessions with optimistic UI updates.
|
||||
5. **Tests** – Added `apps/api/tests/api/test_terminal_ws_multi.py` with auth-requirement coverage for every new endpoint and WebSocket route.
|
||||
|
||||
## Files Created
|
||||
|
||||
- `apps/api/tests/api/test_terminal_ws_multi.py`
|
||||
- `apps/web/src/api/terminal.ts`
|
||||
- `apps/web/src/hooks/use-terminal-sessions.ts`
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `apps/api/src/api/system/terminal.py` – added WebSocket session route, shared handler, REST endpoints, legacy aliases
|
||||
- `apps/web/src/api/terminal.ts` (created)
|
||||
- `apps/web/src/hooks/use-terminal-sessions.ts` (created)
|
||||
|
||||
## Test Results
|
||||
|
||||
### New Tests (8/8 passed)
|
||||
|
||||
```text
|
||||
$ cd apps/api && python -m pytest tests/api/test_terminal_ws_multi.py -v
|
||||
|
||||
tests/api/test_terminal_ws_multi.py::TestTerminalWebSocketMultiSession::test_specific_session_websocket_route_exists PASSED
|
||||
tests/api/test_terminal_ws_multi.py::TestTerminalWebSocketMultiSession::test_default_session_alias_route_exists PASSED
|
||||
tests/api/test_terminal_ws_multi.py::TestTerminalRestApi::test_list_sessions_requires_auth PASSED
|
||||
tests/api/test_terminal_ws_multi.py::TestTerminalRestApi::test_create_session_requires_auth PASSED
|
||||
tests/api/test_terminal_ws_multi.py::TestTerminalRestApi::test_close_session_requires_auth PASSED
|
||||
tests/api/test_terminal_ws_multi.py::TestTerminalRestApi::test_reset_session_requires_auth PASSED
|
||||
tests/api/test_terminal_ws_multi.py::TestTerminalRestApi::test_rename_session_requires_auth PASSED
|
||||
tests/api/test_terminal_ws_multi.py::TestTerminalRestApi::test_legacy_reset_alias_requires_auth PASSED
|
||||
|
||||
======================== 8 passed in 0.42s ========================
|
||||
```
|
||||
|
||||
### Full Suite (no regressions)
|
||||
|
||||
```text
|
||||
$ cd apps/api && python -m pytest tests/ -q
|
||||
|
||||
182 passed, 51 pre-existing failures, 6 warnings
|
||||
```
|
||||
|
||||
- **Baseline failures**: 51 (pre-existing, unchanged by this PR)
|
||||
- **New passes**: +8 (from `test_terminal_ws_multi.py`)
|
||||
- **No new failures introduced**
|
||||
|
||||
## Deviations from Design
|
||||
|
||||
1. **Path simplification** – REST endpoints were mounted under `/instances/{instance_id}/terminal/...` rather than the originally proposed `/projects/{pid}/repositories/{rid}/instances/{iid}/terminal/...`. This keeps the API consistent with the existing terminal WebSocket path which only requires `instance_id`, and matches the frontend's routing model where the terminal page is reached directly by instance ID.
|
||||
2. **Endpoint path prefix** – The terminal router is included at the application root; endpoints live at `/instances/{instance_id}/terminal/sessions` (no `/projects/...` nesting).
|
||||
3. **Test scope** – The integration tests for PR 2 focus on auth/404 routing because fully exercising WebSocket multi-session I/O requires a running Docker container and authenticated cookie flow. Those deeper integration scenarios are covered in PR 1 unit tests (manager behavior) and can be added to PR 2 later if an async test client with cookie auth becomes practical.
|
||||
|
||||
## Blockers / Risks
|
||||
|
||||
- **Auth-only REST tests** – The new REST endpoints are only tested for auth gating in this PR. End-to-end CRUD behavior with real instances is exercised indirectly through the frontend and the manager unit tests from PR 1.
|
||||
- **WebSocket restore path** – When a client connects to `/ws/.../terminal/{session_id}` for a session that is in the DB but not in memory (e.g. after API restart), the handler recreates the PTY from the DB row. This path is not yet covered by automated tests.
|
||||
|
||||
## Next Recommended Action
|
||||
|
||||
1. **Task 9–12 (Frontend UI + tests)** – Implement `TerminalSessionTabs`, update `TerminalPage` for multi-session orchestration, fullscreen, and keyboard shortcuts, and add frontend tests.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# PR 3: Frontend Multi-Session Terminal UI
|
||||
|
||||
## Summary
|
||||
Implemented the frontend UI for multi-session terminal support: tabbed session management, fullscreen mode, keyboard shortcuts, and mobile integration.
|
||||
|
||||
## Files Created
|
||||
- `apps/web/src/components/terminal-session-tabs.tsx` — Tab bar component with rename, close, status dots, overflow scroll
|
||||
- `apps/web/src/components/terminal-session-tabs.test.tsx` — 7 passing component tests
|
||||
|
||||
## Files Modified
|
||||
- `apps/web/src/components/terminal.tsx` — Added `sessionId` prop, `TerminalRef` with `fit()`, `forwardRef` wrapper
|
||||
- `apps/web/src/pages/terminal.tsx` — Multi-session orchestration with tabs, fullscreen, keyboard shortcuts
|
||||
- `apps/web/src/hooks/use-terminal-sessions.ts` — Hook for session CRUD + state management
|
||||
- `apps/web/src/api/terminal.ts` — API client for terminal session endpoints
|
||||
- `apps/web/src/styles.css` — Terminal tab styles, fullscreen mode, mobile responsive
|
||||
- `apps/api/src/services/terminal_manager.py` — Added lookup by internal session_id fallback
|
||||
|
||||
## Acceptance Criteria
|
||||
- [x] TerminalComponent accepts optional sessionId prop
|
||||
- [x] WS URL includes sessionId when provided
|
||||
- [x] TerminalSessionTabs renders sessions with status dots
|
||||
- [x] Double-click to rename, click × to close (with confirm)
|
||||
- [x] New session (+) button, disabled at 5 sessions
|
||||
- [x] Tab switching updates active terminal, calls fit()
|
||||
- [x] Fullscreen toggle (Alt+Shift+F), exit via Esc
|
||||
- [x] Keyboard shortcuts: Alt+Shift+N (new), W (close), ←/→ (navigate), R (reset)
|
||||
- [x] Auto-creates default session if none exist
|
||||
- [x] Closing last session auto-creates new default
|
||||
- [x] Mobile: tabs in compact strip, same keyboard shortcuts
|
||||
- [x] No browser shortcuts overridden (uses Alt+Shift, not Ctrl+Shift)
|
||||
|
||||
## Quality Gates
|
||||
- TypeScript typecheck: ✅ clean
|
||||
- Frontend tests: ✅ 7/7 terminal-session-tabs tests passing
|
||||
- Backend tests: ✅ 182 passed, 51 pre-existing failures (no regressions)
|
||||
- Lint: ✅ 0 errors
|
||||
|
||||
## Blockers / Deviations
|
||||
- MobileTerminalWrapper was not fully integrated with session tabs due to complexity. Mobile path uses inline tab rendering instead.
|
||||
- This is acceptable for MVP; full mobile integration can be refined in follow-up.
|
||||
+867
@@ -0,0 +1,867 @@
|
||||
# SDD Design: Multi-Session Terminal UX
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The multi-session terminal extends the existing persistent-session foundation to support up to 5 concurrent terminal sessions per tool instance. The architecture uses a **hybrid storage model**: active PTY processes and WebSocket routing live in-memory (performance-critical path), while session metadata (name, status, timestamps) persists in a new `terminal_sessions` database table.
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ TerminalSession │ │ TerminalSession │ │ TerminalSession │ ... │
|
||||
│ │ Tabs (Desktop) │ │ Tabs (Mobile) │ │ FullscreenMgr │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────────▼──────────────────────▼──────────────────────▼─────────┐ │
|
||||
│ │ TerminalSessionManager │ │
|
||||
│ │ (React state: sessions[], activeSessionId) │ │
|
||||
│ └────────┬──────────────────────┬──────────────────────┬─────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────────▼─────────┐ ┌────────▼─────────┐ ┌────────▼─────────┐ │
|
||||
│ │ TerminalComponent│ │ TerminalComponent│ │ TerminalComponent│ ... │
|
||||
│ │ (xterm.js + WS) │ │ (xterm.js + WS) │ │ (xterm.js + WS) │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
└───────────┼─────────────────────┼─────────────────────┼────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FastAPI Backend │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌────────────┐ │
|
||||
│ │ /terminal │ │ /terminal/{sid} │ │ REST /ses- │ │
|
||||
│ │ (default alias) │ │ (specific sess) │ │ sions │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────────▼──────────────────────▼────────────────────▼─────┐ │
|
||||
│ │ TerminalManager │ │
|
||||
│ │ dict[(instance_id, session_id)] → TerminalSession │ │
|
||||
│ └────────┬──────────────────────┬──────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌────────▼─────────┐ ┌────────▼─────────┐ │
|
||||
│ │ TerminalSession │ │ TerminalSession │ ... │
|
||||
│ │ (PTY + docker │ │ (PTY + docker │ │
|
||||
│ │ exec process) │ │ exec process) │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌────────▼──────────────────────▼───────────────────────────┐│
|
||||
│ │ TerminalSessionModel (DB) ││
|
||||
│ │ instance_id | name | status | created_at | closed_at ││
|
||||
│ └───────────────────────────────────────────────────────────┘│
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
- **One WebSocket per session**: Each `TerminalComponent` opens its own WebSocket to its specific `session_id`. Inactive sessions keep their WebSocket open to preserve scrollback and real-time output.
|
||||
- **Max 5 sessions per instance**: Enforced in `TerminalManager.create_session()` and validated in the REST endpoint.
|
||||
- **Default session alias**: `/ws/tool-instances/{instance_id}/terminal` maps to the single legacy session (or the first/only active session) for backward compatibility.
|
||||
- **Tab-only UI**: No split panes for MVP. Sessions are presented as tabs on desktop and as a scrollable tab strip integrated into the mobile header area.
|
||||
|
||||
---
|
||||
|
||||
## Backend Design
|
||||
|
||||
### 1. TerminalManager Changes
|
||||
|
||||
**File**: `apps/api/src/services/terminal_manager.py`
|
||||
|
||||
#### Session Key Change
|
||||
|
||||
```python
|
||||
# BEFORE
|
||||
self._sessions: dict[str, TerminalSession] = {} # keyed by instance_id
|
||||
|
||||
# AFTER
|
||||
self._sessions: dict[tuple[str, str], TerminalSession] = {} # keyed by (instance_id, session_id)
|
||||
```
|
||||
|
||||
#### New / Modified Methods
|
||||
|
||||
| Method | Signature | Behavior |
|
||||
|--------|-----------|----------|
|
||||
| `create_session` | `(instance_id, container_id, startup_command=None, name=None) → TerminalSession` | Creates a new `TerminalSession`, starts it, stores under `(instance_id, session_id)`, and inserts a `TerminalSessionModel` DB row. Enforces max 5 sessions. |
|
||||
| `get_or_create_session` | *(preserved)* | **Backward-compat only.** Returns existing default session or creates one with `session_id="default"`. Called by the legacy `/terminal` WebSocket endpoint. |
|
||||
| `get_session` | `(instance_id, session_id) → TerminalSession \| None` | Lookup by composite key. |
|
||||
| `get_sessions_for_instance` | `(instance_id) → list[TerminalSession]` | Returns all in-memory sessions for an instance. |
|
||||
| `close_session` | `(instance_id, session_id) → None` | Kills the PTY process, removes from `_sessions`, updates DB row `status=closed`, `closed_at=now()`. |
|
||||
| `reset_session` | *(modified)* | Now accepts an optional `session_id`. If omitted, resets the default session. |
|
||||
| `attach_websocket` | *(preserved)* | **Critical fix**: The "close existing WebSockets" logic must only close sockets **within the same `(instance_id, session_id)`**. Previously it closed all sockets for the instance. |
|
||||
|
||||
#### Default Session Behavior
|
||||
|
||||
- The first time a client hits `/ws/.../terminal` (no `session_id`), `TerminalManager` checks if a "default" session exists under key `(instance_id, "default")`.
|
||||
- If none exists, it creates one (same as `get_or_create_session`).
|
||||
- The default session counts toward the 5-session limit.
|
||||
|
||||
#### Idle Cleanup
|
||||
|
||||
```python
|
||||
async def _cleanup_idle_sessions(self) -> None:
|
||||
idle_keys = []
|
||||
for (instance_id, session_id), session in list(self._sessions.items()):
|
||||
if session.is_idle():
|
||||
idle_keys.append((instance_id, session_id))
|
||||
for key in idle_keys:
|
||||
session = self._sessions.pop(key, None)
|
||||
if session:
|
||||
await session.close()
|
||||
# Update DB status
|
||||
await self._mark_closed_in_db(key[1])
|
||||
```
|
||||
|
||||
### 2. TerminalSession Changes
|
||||
|
||||
**File**: `apps/api/src/services/terminal_session.py`
|
||||
|
||||
#### New Fields
|
||||
|
||||
```python
|
||||
class TerminalSession:
|
||||
# ... existing fields ...
|
||||
|
||||
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str,
|
||||
startup_command: str | None = None, name: str | None = None) -> None:
|
||||
# ... existing init ...
|
||||
self.name = name or f"Session {self._next_session_number(instance_id)}"
|
||||
self.status: str = "active" # active, resetting, closed
|
||||
```
|
||||
|
||||
The `name` field is runtime-only in `TerminalSession`. Renames update the DB via REST, then the frontend uses the new name on next mount or via a lightweight WS status broadcast (optional optimization).
|
||||
|
||||
#### Status Tracking
|
||||
|
||||
- `active`: Normal operation.
|
||||
- `resetting`: Transient during `reset()` — cleared after new process starts.
|
||||
- `closed`: Set after `close()` is called.
|
||||
|
||||
### 3. WebSocket Endpoint Changes
|
||||
|
||||
**File**: `apps/api/src/api/terminal.py`
|
||||
|
||||
#### New Route (Specific Session)
|
||||
|
||||
```python
|
||||
@router.websocket("/ws/tool-instances/{instance_id}/terminal/{session_id}")
|
||||
async def terminal_websocket_specific(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
#### Backward-Compatible Route (Default Session)
|
||||
|
||||
```python
|
||||
@router.websocket("/ws/tool-instances/{instance_id}/terminal")
|
||||
async def terminal_websocket_default(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
# Identical auth/validation logic
|
||||
# Calls terminal_manager.get_or_create_session(...) # uses "default" session_id
|
||||
# Rest of the loop is identical to specific-session endpoint
|
||||
...
|
||||
```
|
||||
|
||||
#### Refactoring
|
||||
|
||||
Both endpoints share the same auth/validation and I/O loop logic. Extract a common coroutine:
|
||||
|
||||
```python
|
||||
async def _handle_terminal_websocket(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
session_id: str | None, # None means default
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
# Shared: auth, instance lookup, tool_type fetch, session fetch/create,
|
||||
# attach_websocket, read/write/heartbeat loops, detach_websocket
|
||||
```
|
||||
|
||||
#### Control Messages (Unchanged)
|
||||
|
||||
The WebSocket control message protocol is unchanged:
|
||||
|
||||
- `{"type": "resize", "cols": 80, "rows": 24}`
|
||||
- `{"type": "reset"}` — resets the **current** session only
|
||||
|
||||
### 4. Database Schema
|
||||
|
||||
**File**: `apps/api/src/models/terminal_session.py` (new)
|
||||
|
||||
```python
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "terminal_sessions"
|
||||
|
||||
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(),
|
||||
ForeignKey("tool_instances.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="active",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
last_activity_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
```
|
||||
|
||||
#### Rationale
|
||||
|
||||
- `instance_id` is indexed because lookups by instance are frequent (listing sessions, cleanup).
|
||||
- `name` is nullable; auto-generated names are stored here so they survive page reloads.
|
||||
- `status` tracks `active` vs `closed`. The `TerminalManager` updates `last_activity_at` whenever a WebSocket attaches/detaches or I/O occurs.
|
||||
- On API restart, in-memory sessions are lost, but `terminal_sessions` rows remain as metadata history. A future enhancement could resurrect sessions, but that is out of scope.
|
||||
|
||||
### 5. Alembic Migration
|
||||
|
||||
**File**: `apps/api/src/alembic/versions/XXXX_add_terminal_sessions_table.py`
|
||||
|
||||
```python
|
||||
"""Add terminal_sessions table."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "<generated>"
|
||||
down_revision = "<previous>"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"terminal_sessions",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("instance_id", sa.UUID(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), # TimestampMixin
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), # TimestampMixin
|
||||
sa.ForeignKeyConstraint(["instance_id"], ["tool_instances.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_terminal_sessions_instance_id"), "terminal_sessions", ["instance_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_terminal_sessions_instance_id"), table_name="terminal_sessions")
|
||||
op.drop_table("terminal_sessions")
|
||||
```
|
||||
|
||||
### 6. REST API Additions
|
||||
|
||||
**File**: `apps/api/src/api/terminal.py` (same file as WebSocket endpoint)
|
||||
|
||||
All new endpoints follow the existing URL pattern: `/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/sessions`.
|
||||
|
||||
#### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `.../instances/{instance_id}/terminal/sessions` | List sessions for an instance. Returns metadata from DB + live `has_websockets` flag by querying `TerminalManager`. |
|
||||
| `POST` | `.../instances/{instance_id}/terminal/sessions` | Create a new session. Optional body: `{ "name": "Custom Name" }`. Returns `{ session_id, name, status, created_at }`. Enforces max 5. |
|
||||
| `DELETE` | `.../instances/{instance_id}/terminal/sessions/{session_id}` | Close a specific session. Kills PTY, updates DB. Returns `{ status: "closed" }`. |
|
||||
| `POST` | `.../instances/{instance_id}/terminal/sessions/{session_id}/reset` | Reset a specific session (kill + recreate). Returns `{ session_id, name, status }`. |
|
||||
| `POST` | `.../instances/{instance_id}/terminal/sessions/{session_id}/rename` | Rename a session. Body: `{ "name": "New Name" }`. Updates DB; name reflected on next session list fetch. |
|
||||
|
||||
#### Existing Endpoint Preservation
|
||||
|
||||
| Method | Path | Behavior |
|
||||
|--------|------|----------|
|
||||
| `POST` | `.../instances/{instance_id}/terminal/reset` | **Preserved as alias.** Resets the default session (same as `POST .../sessions/default/reset`). |
|
||||
|
||||
#### Response Schema (List Sessions)
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Session 1",
|
||||
"status": "active",
|
||||
"has_websockets": true,
|
||||
"created_at": "2026-05-28T10:00:00Z",
|
||||
"last_activity_at": "2026-05-28T10:05:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Design
|
||||
|
||||
### 1. Session Tabs Component (`TerminalSessionTabs`)
|
||||
|
||||
**File**: `apps/web/src/components/terminal-session-tabs.tsx`
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
interface TerminalSessionTabsProps {
|
||||
sessions: TerminalSessionInfo[];
|
||||
activeSessionId: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onClose: (sessionId: string) => void;
|
||||
onCreate: () => void;
|
||||
onRename: (sessionId: string, newName: string) => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
interface TerminalSessionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||
}
|
||||
```
|
||||
|
||||
#### Desktop Behavior
|
||||
|
||||
- Horizontal tab strip positioned **above** the terminal container.
|
||||
- Each tab shows: session name, status dot (colored), close button (×) visible on hover/active.
|
||||
- Overflow: horizontal scroll with subtle fade indicator.
|
||||
- **New session button (+)**: Fixed at the right end of the tab strip. Disabled when 5 sessions exist.
|
||||
- **Double-click to rename**: Inline `<input>` replaces tab text. `Enter` to confirm, `Escape` to cancel. Blur confirms.
|
||||
- **Close confirmation**: For sessions with an active process and WebSocket, show a lightweight inline confirm tooltip (not a full modal) to avoid friction.
|
||||
|
||||
#### Mobile Behavior
|
||||
|
||||
- Tab strip is integrated into the existing auto-hide chrome.
|
||||
- `MobileTerminalHeader` gains a `sessionTabs` render prop or child area below the title row.
|
||||
- Tabs are compact (icon + truncated name + ×). Horizontal swipe scrolls.
|
||||
- New session (+) is the rightmost item.
|
||||
- The tab strip shares the auto-hide behavior with the header (tapping the terminal toggles visibility).
|
||||
|
||||
### 2. Modified `TerminalPage`
|
||||
|
||||
**File**: `apps/web/src/pages/terminal.tsx`
|
||||
|
||||
#### State Management
|
||||
|
||||
```typescript
|
||||
interface TerminalPageState {
|
||||
sessions: TerminalSessionInfo[];
|
||||
activeSessionId: string | null;
|
||||
isFullscreen: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
#### Session Lifecycle
|
||||
|
||||
1. **Mount**: `useEffect` calls `GET .../terminal/sessions`. If no sessions exist, auto-creates one via `POST`.
|
||||
2. **Active session**: Only one tab is visually active. **All `TerminalComponent` instances remain mounted** but inactive ones use CSS `display: none` to preserve xterm.js scrollback and WebSocket connections.
|
||||
3. **Switch tabs**: Updates `activeSessionId`. The newly active tab's `TerminalComponent` triggers `fitAddon.fit()` via a ref callback after becoming visible (using a `useEffect` on visibility).
|
||||
|
||||
#### Render Structure
|
||||
|
||||
```tsx
|
||||
<section className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}>
|
||||
{!isFullscreen && (
|
||||
<div className="terminal-page-header">...</div>
|
||||
)}
|
||||
|
||||
<TerminalSessionTabs
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={setActiveSessionId}
|
||||
onClose={handleCloseSession}
|
||||
onCreate={handleCreateSession}
|
||||
onRename={handleRenameSession}
|
||||
/>
|
||||
|
||||
<div className="terminal-sessions-container">
|
||||
{sessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={s.id === activeSessionId ? "active" : "hidden"}
|
||||
>
|
||||
<TerminalComponent
|
||||
instanceId={instanceId}
|
||||
sessionId={s.id} // NEW PROP
|
||||
onClose={() => handleCloseSession(s.id)}
|
||||
isMobile={isMobile}
|
||||
// ... other props
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### 3. Modified `TerminalComponent`
|
||||
|
||||
**File**: `apps/web/src/components/terminal.tsx`
|
||||
|
||||
#### New Props
|
||||
|
||||
```typescript
|
||||
interface TerminalProps {
|
||||
instanceId: string;
|
||||
sessionId?: string; // NEW: omitted → uses default session (backward compat)
|
||||
// ... existing props
|
||||
}
|
||||
```
|
||||
|
||||
#### WebSocket URL
|
||||
|
||||
```typescript
|
||||
const wsPath = sessionId
|
||||
? `/ws/tool-instances/${instanceId}/terminal/${sessionId}`
|
||||
: `/ws/tool-instances/${instanceId}/terminal`;
|
||||
```
|
||||
|
||||
#### Reset Semantics Update
|
||||
|
||||
The component's reset button now sends `{"type": "reset"}` to its own session. The `SessionRef` loop in the backend handles resetting that specific session. After reset, the backend sends `{"type": "status", "status": "connected"}` with the new session object, and the frontend clears the terminal.
|
||||
|
||||
#### Fullscreen Awareness
|
||||
|
||||
When `TerminalPage` enters fullscreen, it passes `isFullscreen` down (via context or prop drilling). `TerminalComponent` adjusts its container height to `100vh` (minus tab strip if visible in fullscreen).
|
||||
|
||||
### 4. Mobile Integration
|
||||
|
||||
**File**: `apps/web/src/components/mobile-terminal-wrapper.tsx`
|
||||
|
||||
#### Changes
|
||||
|
||||
- Accepts `sessions`, `activeSessionId`, and tab callbacks as props from `TerminalPage`.
|
||||
- Renders `TerminalSessionTabs` between `MobileTerminalHeader` and the terminal content area.
|
||||
- The tab strip auto-hides along with the header (`useAutoHide`).
|
||||
- `MobileTerminalHeader` title is updated to show `activeSession.name` instead of generic "Terminal".
|
||||
- Fullscreen on mobile: hides the header, tab strip, and special-keys strip. A tap in the bottom-right corner (or swipe from edge) reveals the tab strip temporarily.
|
||||
|
||||
### 5. Fullscreen Mode
|
||||
|
||||
**Trigger**: UI button (maximize icon in header) or `Ctrl+Shift+F`.
|
||||
|
||||
#### Desktop Fullscreen
|
||||
|
||||
- `TerminalPage` adds `.fullscreen` class.
|
||||
- Header and page chrome are hidden (`display: none`).
|
||||
- Tab strip remains visible as a minimal overlay (semi-transparent, auto-hides after 3s of inactivity, reappears on mouse move).
|
||||
- Terminal container fills viewport.
|
||||
- Exit: `Esc` key or click exit-fullscreen button.
|
||||
|
||||
#### Mobile Fullscreen
|
||||
|
||||
- Same as desktop but also hides `SpecialKeysStrip` and `SpecialKeysPanel`.
|
||||
- A small floating handle at the bottom center reveals the tab strip and special keys on tap.
|
||||
|
||||
### 6. Keyboard Shortcuts
|
||||
|
||||
**Constraint**: Do not override browser defaults. All shortcuts use combinations that are either unassigned or safe in major browsers.
|
||||
|
||||
| Shortcut | Action | Browser Conflict? |
|
||||
|----------|--------|-------------------|
|
||||
| `Ctrl+Shift+F` | Toggle fullscreen | None major |
|
||||
| `Alt+Shift+N` | New session | None major |
|
||||
| `Alt+Shift+W` | Close current session | None major |
|
||||
| `Alt+Shift+←` / `Alt+Shift+→` | Previous / next session | None major |
|
||||
| `Alt+Shift+R` | Reset current session | None major |
|
||||
|
||||
All actions are also accessible via UI buttons. Shortcuts are registered in `TerminalPage` via a `useEffect` on `keydown` with `event.preventDefault()` only for the specific combos above.
|
||||
|
||||
### 7. Session State Management
|
||||
|
||||
**File**: `apps/web/src/hooks/use-terminal-sessions.ts` (new hook)
|
||||
|
||||
```typescript
|
||||
export function useTerminalSessions(instanceId: string) {
|
||||
const [sessions, setSessions] = useState<TerminalSessionInfo[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
|
||||
const createSession = useCallback(async (name?: string) => { ... }, [instanceId]);
|
||||
const closeSession = useCallback(async (sessionId: string) => { ... }, [instanceId]);
|
||||
const renameSession = useCallback(async (sessionId: string, name: string) => { ... }, [instanceId]);
|
||||
const resetSession = useCallback(async (sessionId: string) => { ... }, [instanceId]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadSessions().then((sess) => {
|
||||
if (sess.length === 0) {
|
||||
createSession().then((s) => setActiveSessionId(s.id));
|
||||
} else {
|
||||
setSessions(sess);
|
||||
setActiveSessionId(sess[0].id);
|
||||
}
|
||||
});
|
||||
}, [instanceId]);
|
||||
|
||||
return { sessions, activeSessionId, setActiveSessionId, createSession, closeSession, renameSession, resetSession };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
### 1. Create New Session
|
||||
|
||||
```
|
||||
User clicks [+] tab
|
||||
│
|
||||
▼
|
||||
Frontend: POST /instances/{id}/terminal/sessions { name?: "Session 3" }
|
||||
│
|
||||
▼
|
||||
Backend:
|
||||
1. Auth + validate instance running
|
||||
2. Check session count < 5
|
||||
3. TerminalManager.create_session()
|
||||
- Generates UUID session_id
|
||||
- Starts docker exec PTY
|
||||
- Inserts TerminalSessionModel row
|
||||
4. Returns { session_id, name, status, created_at }
|
||||
│
|
||||
▼
|
||||
Frontend:
|
||||
1. Append session to sessions[]
|
||||
2. setActiveSessionId(newId)
|
||||
3. React renders new <TerminalComponent> with sessionId prop
|
||||
4. Component opens WS to /terminal/{session_id}
|
||||
5. Backend attaches WS, replays buffer
|
||||
```
|
||||
|
||||
### 2. Switch Between Sessions
|
||||
|
||||
```
|
||||
User clicks tab "Session 2"
|
||||
│
|
||||
▼
|
||||
Frontend: setActiveSessionId("session-2-uuid")
|
||||
│
|
||||
▼
|
||||
React re-renders:
|
||||
- Session 1 container → className="hidden" (display: none)
|
||||
- Session 2 container → className="active" (display: block)
|
||||
│
|
||||
▼
|
||||
Session 2 useEffect (on visibility change):
|
||||
- Calls fitAddon.fit()
|
||||
- Sends resize message over its existing WS
|
||||
│
|
||||
▼
|
||||
(Backend: no operation needed. Both WS connections remain open.)
|
||||
```
|
||||
|
||||
### 3. Close Session
|
||||
|
||||
```
|
||||
User clicks [×] on "Session 2"
|
||||
│
|
||||
▼
|
||||
Frontend: confirm() or inline tooltip
|
||||
│
|
||||
▼
|
||||
Frontend: DELETE /instances/{id}/terminal/sessions/{session_id}
|
||||
│
|
||||
▼
|
||||
Backend:
|
||||
1. Auth
|
||||
2. TerminalManager.close_session(instance_id, session_id)
|
||||
- Kills docker exec process
|
||||
- Removes from _sessions dict
|
||||
- Updates DB: status=closed, closed_at=now()
|
||||
3. Returns { status: "closed" }
|
||||
│
|
||||
▼
|
||||
Frontend:
|
||||
1. Remove session from sessions[]
|
||||
2. Unmount <TerminalComponent> (WS closes with code 1000)
|
||||
3. If closed session was active, setActiveSessionId to another session (or create one if none left)
|
||||
```
|
||||
|
||||
### 4. Reconnect to Existing Session
|
||||
|
||||
```
|
||||
User reloads page
|
||||
│
|
||||
▼
|
||||
Frontend: GET /instances/{id}/terminal/sessions
|
||||
│
|
||||
▼
|
||||
Backend: Returns all DB rows with status != "closed"
|
||||
│
|
||||
▼
|
||||
Frontend: Populate sessions[]. For each session, render <TerminalComponent>.
|
||||
│
|
||||
▼
|
||||
Each TerminalComponent opens its WS:
|
||||
WS URL: /ws/tool-instances/{id}/terminal/{session_id}
|
||||
│
|
||||
▼
|
||||
Backend:
|
||||
1. Auth
|
||||
2. TerminalManager.get_session(instance_id, session_id)
|
||||
- If found in-memory: attach_websocket, replay buffer
|
||||
- If not found in-memory (API restarted): WS closes with code 4004 "Session not found"
|
||||
(Frontend handles by showing "Session expired" with option to reset/recreate.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contracts
|
||||
|
||||
### WebSocket Protocol
|
||||
|
||||
#### Connection URLs
|
||||
|
||||
| URL | Purpose |
|
||||
|-----|---------|
|
||||
| `/ws/tool-instances/{instance_id}/terminal` | Default session (backward compatible). Creates/attaches to the single legacy session. |
|
||||
| `/ws/tool-instances/{instance_id}/terminal/{session_id}` | Specific session. Attaches to an existing session or fails if not found. |
|
||||
|
||||
#### Client → Server Messages
|
||||
|
||||
| Type | Payload | Purpose |
|
||||
|------|---------|---------|
|
||||
| `resize` | `{ cols: number, rows: number }` | Resize PTY |
|
||||
| `reset` | `{}` | Kill and restart the **current** session's shell |
|
||||
| `pong` | `{}` | Heartbeat response |
|
||||
|
||||
#### Server → Client Messages
|
||||
|
||||
| Type | Payload | Purpose |
|
||||
|------|---------|---------|
|
||||
| (binary) | `bytes` | PTY output |
|
||||
| `status` | `{ status: "connected" \| "resetting" }` | Lifecycle status |
|
||||
| `ping` | `{}` | Heartbeat |
|
||||
|
||||
### REST API Contract
|
||||
|
||||
#### `GET /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions`
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Session 1",
|
||||
"status": "active",
|
||||
"has_websockets": true,
|
||||
"created_at": "2026-05-28T10:00:00Z",
|
||||
"last_activity_at": "2026-05-28T10:05:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions`
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "name": "Optional Custom Name" }
|
||||
```
|
||||
|
||||
**Response 201:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Session 2",
|
||||
"status": "active",
|
||||
"created_at": "2026-05-28T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 409:** (max sessions reached)
|
||||
```json
|
||||
{ "detail": "Maximum of 5 terminal sessions reached for this instance" }
|
||||
```
|
||||
|
||||
#### `DELETE /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}`
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{ "status": "closed", "session_id": "uuid" }
|
||||
```
|
||||
|
||||
#### `POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}/reset`
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Session 1",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}/rename`
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "name": "New Name" }
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{ "id": "uuid", "name": "New Name" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Backend**: `apps/api/tests/services/test_terminal_manager.py`
|
||||
|
||||
| Test | Scenario |
|
||||
|------|----------|
|
||||
| `test_create_session_increases_count` | Creating sessions increments the per-instance count |
|
||||
| `test_create_session_enforces_max_5` | 6th creation raises `MaxSessionsExceededError` |
|
||||
| `test_get_sessions_for_instance` | Returns only sessions for the requested instance |
|
||||
| `test_close_session_removes_from_dict` | `close_session` removes key from `_sessions` |
|
||||
| `test_attach_websocket_only_closes_same_session` | Attaching to session A does not close websockets on session B |
|
||||
| `test_default_session_keyed_separately` | Default session uses `"default"` session_id and does not collide with named sessions |
|
||||
| `test_idle_cleanup_updates_db` | Idle cleanup calls DB update with `status=closed` |
|
||||
|
||||
**Frontend**: `apps/web/src/components/terminal-session-tabs.test.tsx`
|
||||
|
||||
| Test | Scenario |
|
||||
|------|----------|
|
||||
| `test_renders_all_tabs` | Renders one tab per session |
|
||||
| `test_click_tab_selects_session` | Clicking a tab calls `onSelect` with correct ID |
|
||||
| `test_close_button_calls_onClose` | Clicking × calls `onClose` |
|
||||
| `test_double_click_enables_rename` | Double-click shows input; Enter commits |
|
||||
| `test_plus_disabled_at_max_sessions` | `+` button is disabled when 5 sessions exist |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Backend**: `apps/api/tests/api/test_terminal_ws.py`
|
||||
|
||||
| Test | Scenario |
|
||||
|------|----------|
|
||||
| `test_specific_session_websocket` | Connect to `/terminal/{session_id}`, verify output |
|
||||
| `test_default_session_alias` | Connect to `/terminal`, verify it creates/uses default session |
|
||||
| `test_concurrent_sessions_isolated` | Two WS connections to different session_ids receive independent output |
|
||||
| `test_reset_control_message_scoped` | `{"type":"reset"}` only resets the current session |
|
||||
| `test_list_sessions_returns_live_and_db` | `GET /sessions` reflects both in-memory state and DB rows |
|
||||
|
||||
**Frontend**: `apps/web/src/pages/terminal.test.tsx` (or E2E)
|
||||
|
||||
| Test | Scenario |
|
||||
|------|----------|
|
||||
| `test_create_session_adds_tab` | Clicking + creates a new tab and switches to it |
|
||||
| `test_switch_tab_preserves_scrollback` | Switching back to a previous tab shows prior output |
|
||||
| `test_close_last_session_creates_default` | Closing the final session auto-creates a new default session |
|
||||
| `test_fullscreen_toggle` | `Ctrl+Shift+F` toggles fullscreen class |
|
||||
|
||||
---
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
### Phase 1: Database (Zero-Downtime)
|
||||
|
||||
1. Run Alembic migration to create `terminal_sessions` table.
|
||||
2. No code reads from or writes to this table yet. Existing sessions remain purely in-memory.
|
||||
3. **Rollback**: Alembic downgrade removes table (no data loss risk since table is empty).
|
||||
|
||||
### Phase 2: Backend API (Backward Compatible)
|
||||
|
||||
1. Deploy updated `TerminalManager` with composite key `_sessions`.
|
||||
2. Deploy updated `TerminalSession` with `name` support.
|
||||
3. Deploy new WebSocket route `/terminal/{session_id}` and preserve `/terminal` alias.
|
||||
4. Deploy new REST endpoints (`GET/POST/DELETE .../sessions`).
|
||||
5. Update DB writes on session lifecycle (create, close, activity update).
|
||||
6. **Rollback**: Revert code. Old `/terminal` endpoint continues to work. New `/terminal/{session_id}` returns 404, but no clients call it yet.
|
||||
|
||||
### Phase 3: Frontend (Feature Flag Optional)
|
||||
|
||||
1. Deploy new components (`TerminalSessionTabs`, `useTerminalSessions`).
|
||||
2. Update `TerminalPage` and `MobileTerminalWrapper`.
|
||||
3. Update `TerminalComponent` to accept optional `sessionId` prop.
|
||||
4. If a feature flag is used, enable multi-session UI for beta users first.
|
||||
5. **Rollback**: Revert frontend. Users see the old single-session UI. Backend `/terminal` alias continues to serve them.
|
||||
|
||||
### Phase 4: Deprecation & Cleanup (Follow-Up Task)
|
||||
|
||||
1. Monitor usage of the legacy `/terminal` WebSocket endpoint and `POST .../terminal/reset` REST endpoint.
|
||||
2. After 2-4 weeks of stable multi-session usage:
|
||||
- Mark legacy endpoints as deprecated in OpenAPI docs.
|
||||
- Update frontend to always use `/terminal/{session_id}` (never rely on default alias).
|
||||
3. In a future release, remove the default alias if desired (not required for correctness).
|
||||
|
||||
### Backward Compatibility Strategy
|
||||
|
||||
| Layer | Compat Mechanism |
|
||||
|-------|-----------------|
|
||||
| WebSocket | `/terminal` remains default-session alias forever (or until explicit deprecation). Old clients continue to work. |
|
||||
| REST API | Existing `POST .../terminal/reset` preserved as alias. No breaking changes to response shape. |
|
||||
| Frontend | `sessionId` prop on `TerminalComponent` is optional. Omitting it uses the default session path. |
|
||||
| DB | New table is additive only. No changes to `tool_instances` schema. |
|
||||
|
||||
---
|
||||
|
||||
## Files to Create / Modify
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `apps/api/src/models/terminal_session.py` | SQLAlchemy `TerminalSessionModel` |
|
||||
| `apps/api/src/alembic/versions/XXXX_add_terminal_sessions_table.py` | Alembic migration |
|
||||
| `apps/web/src/components/terminal-session-tabs.tsx` | Tab bar UI (desktop + mobile) |
|
||||
| `apps/web/src/hooks/use-terminal-sessions.ts` | Session CRUD + state hook |
|
||||
| `apps/web/src/components/terminal-session-tabs.test.tsx` | Unit tests |
|
||||
| `apps/api/tests/services/test_terminal_manager_multi.py` | TerminalManager multi-session tests |
|
||||
| `apps/api/tests/api/test_terminal_ws_multi.py` | WS integration tests |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `apps/api/src/services/terminal_manager.py` | Composite key dict, new CRUD methods, max session limit, DB integration |
|
||||
| `apps/api/src/services/terminal_session.py` | Add `name` field, status tracking |
|
||||
| `apps/api/src/api/terminal.py` | New WS route, REST endpoints, shared handler coroutine |
|
||||
| `apps/api/src/main.py` | Import new model (if needed for Alembic autogenerate) |
|
||||
| `apps/web/src/components/terminal.tsx` | Accept `sessionId` prop, use it in WS URL |
|
||||
| `apps/web/src/pages/terminal.tsx` | Multi-session orchestration, tabs, fullscreen |
|
||||
| `apps/web/src/components/mobile-terminal-wrapper.tsx` | Integrate tabs, pass session state |
|
||||
| `apps/web/src/components/mobile-terminal-header.tsx` | Show active session name |
|
||||
| `apps/web/src/api/sessions.ts` (or new `terminal.ts`) | REST client functions for session CRUD |
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Resource exhaustion from 5× docker exec per instance | Medium | High | Max 5 enforced. Idle timeout (30 min) still applies per session. |
|
||||
| Mobile UX degraded by tab bar + special keys strip | Medium | Medium | Auto-hide shared between tabs and header. Minimal tab design. |
|
||||
| Concurrent WS policy closes wrong session's sockets | Medium | High | Unit test explicitly: attach to session A must not affect session B's websockets. |
|
||||
| DB writes on hot path (activity tracking) | Low | Medium | `last_activity_at` updates are non-blocking fire-and-forget asyncio tasks. No await on commit. |
|
||||
| Frontend performance with 5 mounted xterm.js instances | Low | Medium | Max 5 sessions. Inactive terminals are `display: none` (not unmounted). GPU acceleration in xterm.js handles this well. |
|
||||
| Default session alias ambiguity | Low | Low | Document that `/terminal` maps to `"default"` session. Future deprecation can migrate default to explicit ID. |
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
# SDD Explore: Multi-Session Terminal UX
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The codebase has a well-built persistent terminal foundation from the `persistent-terminal-sessions` change. `TerminalManager` currently tracks exactly one `TerminalSession` per `instance_id` in an in-memory dict. `TerminalSession` already supports WebSocket attach/detach, circular output buffer replay, idle timeout, and process lifecycle management.
|
||||
|
||||
Implementing multi-session terminal support is a **moderate-complexity, medium-risk** change. The core backend refactor is straightforward: change the session tracking key from `instance_id` to `(instance_id, session_id)` and update the WebSocket endpoint to accept a `session_id`. The frontend work is more involved: designing a tabbed session UI that works on both desktop and mobile, handling session creation/switching/closing, and integrating with the existing `MobileTerminalWrapper`.
|
||||
|
||||
No database schema change is **strictly required** for an MVP—sessions can remain purely in-memory with the same idle-timeout cleanup. However, adding a `terminal_sessions` table would provide cross-API-restart persistence, session auditability, and a foundation for future features like session history or named sessions.
|
||||
|
||||
## Current Architecture (as explored)
|
||||
|
||||
### Backend
|
||||
- **`TerminalManager`** (`apps/api/src/services/terminal_manager.py`):
|
||||
- `self._sessions: dict[str, TerminalSession]` keyed by `instance_id` string.
|
||||
- `get_or_create_session(instance_id, container_id, startup_command)` — returns the single existing session or creates a new one.
|
||||
- `attach_websocket(session, websocket)` — detaches any *existing* WebSocket connections on that session (closes them with code 4000) before attaching the new one. This enforces single-active-client per session.
|
||||
- `reset_session(instance_id, container_id, ...)` — kills the existing session and creates a new one.
|
||||
- Idle check loop every 60s; sessions with no WebSockets attached for 30 minutes are cleaned up.
|
||||
- **`TerminalSession`** (`apps/api/src/services/terminal_session.py`):
|
||||
- Already has a `session_id: str` field (UUID) but it is not used as a lookup key.
|
||||
- Manages one `docker exec` PTY process per session.
|
||||
- Circular buffer (10KB) for output replay.
|
||||
- Tracks `self._websockets: set[Any]` for attached connections.
|
||||
- **`api/terminal.py`** (`apps/api/src/api/terminal.py`):
|
||||
- WebSocket endpoint: `/ws/tool-instances/{instance_id}/terminal`
|
||||
- Authenticates user, verifies instance ownership/running state, then calls `terminal_manager.get_or_create_session()`.
|
||||
- Supports JSON control messages: `resize`, `reset`.
|
||||
- POST endpoint: `/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset` — resets the single session.
|
||||
- **Database**:
|
||||
- No `terminal_sessions` table exists. Terminal sessions are purely in-memory.
|
||||
- `ToolInstance` model (`apps/api/src/models/tool_instance.py`) has no terminal-related fields.
|
||||
|
||||
### Frontend
|
||||
- **`TerminalComponent`** (`apps/web/src/components/terminal.tsx`):
|
||||
- Single xterm.js terminal per component.
|
||||
- One WebSocket connection to `/ws/tool-instances/{instance_id}/terminal`.
|
||||
- Handles reconnect with exponential backoff (max 3 attempts).
|
||||
- Font size persisted globally in `localStorage` under key `terminal-font-size`.
|
||||
- Copy/paste buttons on mobile only.
|
||||
- Status indicator: connecting, connected, disconnected, error, resetting.
|
||||
- **`TerminalPage`** (`apps/web/src/pages/terminal.tsx`):
|
||||
- Desktop: renders one `TerminalComponent` inside a page shell.
|
||||
- Mobile: renders `MobileTerminalWrapper` which composes `MobileTerminalHeader`, `TerminalComponent`, `SpecialKeysStrip`, and `SpecialKeysPanel`.
|
||||
- **`MobileTerminalWrapper`** (`apps/web/src/components/mobile-terminal-wrapper.tsx`):
|
||||
- Already handles auto-hide header, virtual keyboard height, special keys, and mobile viewport detection.
|
||||
- Manages terminal ref callbacks (`sendData`, `connectionStatus`, `focusInput`, `changeFontSize`).
|
||||
|
||||
### Prior Art
|
||||
- **`persistent-terminal-sessions`** (fully implemented):
|
||||
- Sessions survive WebSocket disconnections.
|
||||
- Buffer replay on reconnect.
|
||||
- Idle timeout cleanup.
|
||||
- Reset functionality.
|
||||
- **`mobile-terminal-ux`** (mostly implemented):
|
||||
- Mobile fullscreen terminal with collapsible chrome.
|
||||
- Special keys toolbar.
|
||||
- Dynamic viewport handling for virtual keyboard.
|
||||
|
||||
## Architecture Options for Multi-Session
|
||||
|
||||
### Option A: In-Memory Multi-Session (MVP)
|
||||
- Change `TerminalManager._sessions` to `dict[tuple[str, str], TerminalSession]` keyed by `(instance_id, session_id)`.
|
||||
- Add `create_session(instance_id, container_id, ...)` that always creates a new session.
|
||||
- Keep `get_or_create_session()` for backward compatibility (returns the "default" or only session).
|
||||
- Add `get_sessions_for_instance(instance_id) -> list[TerminalSession]`.
|
||||
- Add `close_session(instance_id, session_id)` to kill a specific session.
|
||||
- **Tradeoffs**: Simplest, no DB migration, survives existing patterns. Loses sessions on API restart.
|
||||
|
||||
### Option B: Database-Backed Session Metadata
|
||||
- Create `terminal_sessions` table:
|
||||
```sql
|
||||
id UUID PRIMARY KEY,
|
||||
instance_id UUID FK(tool_instances.id, ondelete=CASCADE),
|
||||
session_name VARCHAR(255),
|
||||
status VARCHAR(50), -- active, idle, closed
|
||||
created_at TIMESTAMPTZ,
|
||||
last_activity_at TIMESTAMPTZ,
|
||||
closed_at TIMESTAMPTZ
|
||||
```
|
||||
- `TerminalManager` still keeps `TerminalSession` objects in memory, but creates/updates DB rows on lifecycle events.
|
||||
- **Tradeoffs**: Enables cross-restart persistence, session history, named sessions, and auditability. Adds migration and async DB overhead to hot paths.
|
||||
|
||||
### Option C: Hybrid (Recommended)
|
||||
- In-memory active sessions for performance.
|
||||
- DB table for metadata, created on session start, updated on activity/close.
|
||||
- On API restart, sessions are gone (no process resurrection), but metadata remains for history.
|
||||
- **Tradeoffs**: Best of both worlds. Slightly more complex than Option A but much simpler than full persistence.
|
||||
|
||||
### Decision Matrix
|
||||
|
||||
| Criterion | Option A | Option B | Option C |
|
||||
|-----------|----------|----------|----------|
|
||||
| Implementation complexity | Low | Medium | Medium |
|
||||
| DB migration required | No | Yes | Yes |
|
||||
| Cross-restart persistence | No | Yes (full) | Metadata only |
|
||||
| Resource auditability | No | Yes | Yes |
|
||||
| Performance | Best | Good (cacheable) | Best |
|
||||
| Recommended for MVP | **Yes** | No | **Preferred** |
|
||||
|
||||
## WebSocket Protocol Options
|
||||
|
||||
### Option 1: URL Path Segment (Recommended)
|
||||
```
|
||||
/ws/tool-instances/{instance_id}/terminal/{session_id}
|
||||
```
|
||||
- Clean, RESTful, easy to route in FastAPI.
|
||||
- Default session can use a reserved ID like `default` or keep `/terminal` as an alias.
|
||||
- **Tradeoff**: Breaks existing hardcoded URLs; needs backward-compatibility route.
|
||||
|
||||
### Option 2: Query Parameter
|
||||
```
|
||||
/ws/tool-instances/{instance_id}/terminal?session_id=...
|
||||
```
|
||||
- Easier to add without changing route structure.
|
||||
- Less idiomatic for WebSocket APIs.
|
||||
- **Tradeoff**: Query params in WebSocket URLs can be inconsistently supported by proxies.
|
||||
|
||||
### Option 3: First-Message JSON Payload
|
||||
- Client connects to `/terminal`, then sends `{"type": "attach", "session_id": "..."}`.
|
||||
- Server must hold the connection in limbo until the attach message arrives.
|
||||
- **Tradeoff**: More complex state machine; harder to reject invalid sessions early.
|
||||
|
||||
**Recommendation**: Option 1 with a backward-compatible fallback:
|
||||
- `/ws/tool-instances/{instance_id}/terminal` → attaches to the "default" session (existing behavior).
|
||||
- `/ws/tool-instances/{instance_id}/terminal/{session_id}` → attaches to the specified session.
|
||||
|
||||
## Frontend UX Design Options
|
||||
|
||||
### Session Presentation: Tabs vs Panes
|
||||
|
||||
| Feature | Tabs | Panes (Split) |
|
||||
|---------|------|---------------|
|
||||
| Desktop UX | Good | Excellent (tmux-like) |
|
||||
| Mobile UX | Good | Poor (too cramped) |
|
||||
| Implementation | Medium | High |
|
||||
| Accessibility | Good | Complex |
|
||||
| Recommendation | **Preferred** | Future enhancement |
|
||||
|
||||
**Decision**: Start with tabs. A split-pane layout can be added later as an advanced feature without breaking the tab model.
|
||||
|
||||
### Tab Bar Design
|
||||
- Position: Above the terminal container on desktop; integrated into `MobileTerminalHeader` on mobile.
|
||||
- Contents:
|
||||
- Session name (auto-named "Session 1", "Session 2", or custom).
|
||||
- Status dot (connecting, connected, error).
|
||||
- Close button (×) on hover/active.
|
||||
- New tab button (+).
|
||||
- Overflow: Horizontal scroll on mobile; wrap or scroll on desktop.
|
||||
|
||||
### Fullscreen Mode
|
||||
- **Behavior**: Toggle hides all page chrome (header, sidebar, tab bar can optionally be shown as a minimal overlay).
|
||||
- **Trigger**: `Ctrl+Shift+F` or UI button.
|
||||
- **Mobile**: Should integrate with existing mobile fullscreen behavior (already hides AppShell). Fullscreen on mobile could mean hiding the special-keys strip too, with a gesture to reveal.
|
||||
- **Exit**: `Esc` or UI button.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action | Notes |
|
||||
|----------|--------|-------|
|
||||
| `Ctrl+Shift+N` | New session | May conflict with browser "New window" on some platforms. Consider `Ctrl+Shift+T` if not used for "Reopen tab". |
|
||||
| `Ctrl+Shift+W` | Close current session | Conflicts with browser "Close window". May need `Ctrl+Shift+D` or accept override with `preventDefault()`. |
|
||||
| `Ctrl+Shift+F` | Toggle fullscreen | Safe, no major browser conflict. |
|
||||
| `Ctrl+Shift+T` | Toggle tab bar visibility | Conflicts with "Reopen closed tab" in browsers. Consider `Ctrl+Shift+B` or `Ctrl+Shift+~`. |
|
||||
|
||||
**Recommendation**: Use `preventDefault()` aggressively and show a shortcuts help modal (e.g., `Ctrl+Shift+/` or `?`).
|
||||
|
||||
### Session Naming
|
||||
- **Auto-name**: "Session 1", "Session 2", etc. based on creation order.
|
||||
- **Custom name**: Editable by double-clicking the tab. Persisted in DB if Option B/C, or in-memory only for Option A.
|
||||
- **Default session**: The first session created for an instance can be unnamed or named "Default".
|
||||
|
||||
### Reset/Kill Semantics
|
||||
Current behavior: "Reset Terminal" kills the single session and starts fresh.
|
||||
|
||||
With multi-session:
|
||||
- **Close Session** (× on tab): Kills the `docker exec` process and removes the session.
|
||||
- **New Session** (+ on tab bar): Creates a new session and switches to it.
|
||||
- **Reset Session** (in menu): Same as current reset but scoped to the active session.
|
||||
- **Reset All** (optional, in menu): Kill all sessions for the instance and recreate a default one.
|
||||
|
||||
### Font Size Persistence
|
||||
- Currently global (`localStorage` key `terminal-font-size`).
|
||||
- With multi-session, users may want different font sizes per session (e.g., larger for presentations, smaller for logs).
|
||||
- **Options**:
|
||||
1. Keep global (simplest, no change).
|
||||
2. Per-session font size (stored in session state or DB).
|
||||
3. Per-instance font size.
|
||||
- **Recommendation**: Keep global for MVP. Per-session font size is a nice-to-have that adds complexity.
|
||||
|
||||
### Status Per Session
|
||||
- Each tab shows a status dot.
|
||||
- Possible statuses: `connecting` (pulsing), `connected` (green), `disconnected` (yellow), `error` (red), `closed` (gray).
|
||||
- The terminal component already tracks these statuses; they just need to be surfaced at the tab level.
|
||||
|
||||
## Database Schema Recommendation (Option C)
|
||||
|
||||
```python
|
||||
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "terminal_sessions"
|
||||
|
||||
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("tool_instances.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="active"
|
||||
)
|
||||
# Not storing process PID here — that's runtime-only in TerminalManager
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=datetime.utcnow
|
||||
)
|
||||
last_activity_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
```
|
||||
|
||||
**Migration**: New alembic revision adding `terminal_sessions` table.
|
||||
|
||||
## Open Questions Needing User/Product Decisions
|
||||
|
||||
1. **Max sessions per instance?** Suggest 5 for MVP to prevent resource exhaustion.
|
||||
2. **Should we persist sessions across API restarts?** Option A = no; Option C = metadata only. Product call.
|
||||
3. **Tab vs Pane UI?** Strongly recommend tabs for MVP. Panes as future work.
|
||||
4. **Keyboard shortcuts — override browser defaults?** `Ctrl+Shift+W` closes browser window. We can `preventDefault()` but should warn users.
|
||||
5. **Should the existing `/terminal` endpoint remain as a default-session alias?** Yes for backward compatibility, but confirm.
|
||||
6. **Session idle timeout per session or global per instance?** Currently per session. Keep per session.
|
||||
7. **Should font size be global, per-instance, or per-session?** Recommend global for MVP.
|
||||
8. **Copy/paste on desktop — any gaps?** Current desktop relies on native xterm.js copy/paste (`Ctrl+C`/`Ctrl+V` with selection). This is standard and sufficient. Mobile already has buttons.
|
||||
|
||||
## Risks and Feasibility Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Resource exhaustion from too many docker exec processes | Medium | High | Enforce max sessions per instance (5). Idle timeout already exists. |
|
||||
| Mobile UX degradation from tab bar clutter | Medium | Medium | Integrate tabs into existing `MobileTerminalHeader` auto-hide. Limit visible tabs, overflow scroll. |
|
||||
| Backward compat breakage from URL change | Low | Medium | Keep `/terminal` as default-session alias. |
|
||||
| Concurrent WebSocket policy bugs | Medium | High | Ensure "close existing" only applies within same `(instance_id, session_id)`, not across sessions. |
|
||||
| Scope creep (panes, detachable windows) | High | Medium | Explicitly exclude split panes and detachable windows from MVP. |
|
||||
|
||||
## Feasibility: Green/Yellow/Red
|
||||
|
||||
**Yellow-Green**. The backend changes are well-scoped and build on solid existing infrastructure. The frontend tab UI is the largest unknown, especially mobile integration, but the existing `MobileTerminalWrapper` provides a good foundation. No external dependencies needed.
|
||||
|
||||
## Recommended Next Step
|
||||
|
||||
**Proceed to `design` phase** after resolving these scoping decisions:
|
||||
1. Choose Option A or C for session storage (recommend Option C).
|
||||
2. Confirm max sessions limit (recommend 5).
|
||||
3. Confirm tab-only UI for MVP (no panes).
|
||||
4. Confirm backward-compatible WebSocket URL strategy.
|
||||
|
||||
Then write `design.md` with concrete decisions and `tasks.md` with implementation steps.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
## Why
|
||||
|
||||
Currently, each tool instance (e.g., pi-agent, code-server) supports exactly one terminal session. Users who want to run multiple concurrent tasks (e.g., a long-running build in one pane, an editor in another, and a shell for quick commands) must open multiple tool instances or use tmux/screen inside a single session. This is inefficient and confusing.
|
||||
|
||||
Additionally, the web terminal lacks basic usability features found in modern terminal emulators: fullscreen mode, detachable panes, session tabs, and keyboard shortcuts for common actions.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Backend**: Allow multiple `TerminalSession` objects per `ToolInstance`, each with a unique `session_id`
|
||||
- **Backend**: Update `TerminalManager` to track and route multiple sessions per instance
|
||||
- **Backend**: Update terminal WebSocket protocol to include `session_id` in connection URL or message
|
||||
- **Frontend**: Add session tabs/management UI (create new session, switch between sessions, close sessions)
|
||||
- **Frontend**: Add fullscreen mode for the terminal
|
||||
- **Frontend**: Add keyboard shortcuts for session management (Ctrl+Shift+N new session, etc.)
|
||||
- **Frontend**: Session list panel showing active sessions per instance
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `multi-session-terminal`: Multiple independent terminal sessions per tool instance
|
||||
- `terminal-fullscreen`: Fullscreen terminal mode
|
||||
- `terminal-session-management`: Create, switch, rename, and close terminal sessions
|
||||
|
||||
### Modified Capabilities
|
||||
- `tool-terminal`: Extend WebSocket protocol and UI to support multiple sessions per instance
|
||||
- `terminal-session-lifecycle`: Session creation, naming, and cleanup for multi-session model
|
||||
|
||||
## Impact
|
||||
|
||||
- Backend: `TerminalManager`, `TerminalSession`, `api/terminal.py`, database schema (session tracking)
|
||||
- Frontend: `TerminalComponent`, `terminal.tsx`, new `TerminalSessionTabs`, `TerminalSessionManager`
|
||||
- Protocol: WebSocket message format changes (add session_id field)
|
||||
- Database: New or extended table to track terminal sessions per instance
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
# SDD Tasks: Multi-Session Terminal UX
|
||||
|
||||
## Review Workload Forecast
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Estimated changed lines | ~1,400–1,600 (new ~900, modified ~600–700) |
|
||||
| 400-line budget risk | High |
|
||||
| Chained PRs recommended | Yes |
|
||||
| Suggested split | PR 1: DB + Backend Core → PR 2: Backend API + Tests → PR 3: Frontend + Tests |
|
||||
| Delivery strategy | auto-chain |
|
||||
| Chain strategy | stacked-to-main |
|
||||
|
||||
```text
|
||||
Decision needed before apply: Yes
|
||||
Chained PRs recommended: Yes
|
||||
Chain strategy: stacked-to-main
|
||||
400-line budget risk: High
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Overview
|
||||
|
||||
| # | Task | PR | Est. Lines | Dependencies |
|
||||
|---|------|-----|------------|--------------|
|
||||
| 1 | Database schema and Alembic migration | 1 | ~80 | None | ✅ |
|
||||
| 2 | TerminalManager multi-session core | 1 | ~250 | Task 1 | ✅ |
|
||||
| 3 | TerminalSession name and status fields | 1 | ~40 | Task 2 | ✅ |
|
||||
| 4 | WebSocket routing and backward-compat alias | 2 | ~200 | Task 2 | ✅ |
|
||||
| 5 | REST endpoints for session CRUD | 2 | ~180 | Task 2, 4 | ✅ |
|
||||
| 6 | Frontend API client and `useTerminalSessions` hook | 3 | ~180 | Task 5 | ✅ |
|
||||
| 7 | `TerminalComponent` `sessionId` support | 3 | ~100 | Task 4, 6 | ✅ |
|
||||
| 8 | `TerminalSessionTabs` UI component | 3 | ~220 | Task 6 | ✅ |
|
||||
| 9 | `TerminalPage` multi-session orchestration and fullscreen | 3 | ~200 | Task 7, 8 | ✅ |
|
||||
| 10 | Mobile terminal integration | 3 | ~100 | Task 8, 9 | ✅ |
|
||||
| 11 | Backend integration tests | 2 | ~250 | Task 4, 5 | ✅ |
|
||||
| 12 | Frontend component tests | 3 | ~150 | Task 8, 9, 10 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## PR 1: Database + Backend Core
|
||||
|
||||
### Task 1: Database Schema and Alembic Migration
|
||||
|
||||
**Scope**: Create the `terminal_sessions` metadata table and corresponding Alembic migration.
|
||||
|
||||
**Files to create**:
|
||||
- `apps/api/src/models/terminal_session.py`
|
||||
- `apps/api/alembic/versions/XXXX_add_terminal_sessions_table.py`
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/api/src/main.py` — import new model so Alembic autogenerate discovers it
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- `TerminalSessionModel` extends `Base`, `UUIDPrimaryKeyMixin`, `TimestampMixin`
|
||||
- Columns: `instance_id` (UUID, FK `tool_instances.id` ON DELETE CASCADE, indexed), `name` (String 255, nullable), `status` (String 50, default `"active"`), `created_at` (DateTime TZ, non-nullable), `last_activity_at` (DateTime TZ, nullable), `closed_at` (DateTime TZ, nullable)
|
||||
- Migration is reversible (`downgrade` drops table + index)
|
||||
- `make migrate` applies successfully in local dev
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Write a migration metadata test asserting the new table exists in `Base.metadata` and has expected columns
|
||||
- GREEN: Create model and migration
|
||||
- Run `pytest tests/integration/test_models.py` or equivalent to verify table registration
|
||||
|
||||
---
|
||||
|
||||
### Task 2: TerminalManager Multi-Session Core
|
||||
|
||||
**Scope**: Refactor `TerminalManager` to support up to 5 concurrent sessions per instance using composite keys.
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/api/src/services/terminal_manager.py`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- `self._sessions` keyed by `(instance_id: str, session_id: str)`
|
||||
- `create_session(instance_id, container_id, startup_command=None, name=None)`:
|
||||
- Generates UUID `session_id`
|
||||
- Enforces max 5 active sessions per instance (raise `MaxSessionsExceededError` / HTTP 409)
|
||||
- Inserts `TerminalSessionModel` DB row (fire-and-forget async task acceptable)
|
||||
- Returns `TerminalSession`
|
||||
- `get_or_create_session(instance_id, container_id, ...)` preserved for backward compatibility; uses `"default"` session_id
|
||||
- `get_session(instance_id, session_id)` returns session or `None`
|
||||
- `get_sessions_for_instance(instance_id)` returns list of in-memory sessions
|
||||
- `close_session(instance_id, session_id)`: kills PTY, removes from `_sessions`, updates DB `status=closed`, `closed_at=now()`
|
||||
- `reset_session(instance_id, container_id, session_id=None)`: if `session_id` omitted, resets `"default"` session
|
||||
- `attach_websocket` only closes existing WebSockets **within the same `(instance_id, session_id)`**
|
||||
- `_cleanup_idle_sessions` uses composite keys and updates DB status on cleanup
|
||||
- Idle timeout (30 min) and buffer replay behavior preserved
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Create `apps/api/tests/services/test_terminal_manager_multi.py` with tests:
|
||||
- `test_create_session_increases_count`
|
||||
- `test_create_session_enforces_max_5`
|
||||
- `test_get_sessions_for_instance_filters_by_instance`
|
||||
- `test_close_session_removes_from_dict_and_updates_db`
|
||||
- `test_attach_websocket_only_closes_same_session`
|
||||
- `test_default_session_keyed_separately`
|
||||
- `test_idle_cleanup_updates_db_status`
|
||||
- GREEN: Implement `TerminalManager` changes
|
||||
- Run `make test-unit`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: TerminalSession Name and Status Fields
|
||||
|
||||
**Scope**: Add runtime `name` and `status` tracking to `TerminalSession`.
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/api/src/services/terminal_session.py`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- `__init__` accepts optional `name`; auto-generates `"Session N"` if omitted (N = per-instance counter)
|
||||
- `self.name` stored as runtime attribute
|
||||
- `self.status` enum-like string: `"active"`, `"resetting"`, `"closed"`
|
||||
- `reset()` sets `status="resetting"` during transition, `"active"` after restart
|
||||
- `close()` sets `status="closed"`
|
||||
- No breaking changes to existing `TerminalSession` behavior
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Extend `test_terminal_manager_multi.py` or add `test_terminal_session_name_and_status.py` covering auto-naming, status transitions, and reset/close side effects
|
||||
- GREEN: Implement fields and transitions
|
||||
- Run `make test-unit`
|
||||
|
||||
---
|
||||
|
||||
## PR 2: Backend API + Tests
|
||||
|
||||
### Task 4: WebSocket Routing and Backward-Compat Alias
|
||||
|
||||
**Scope**: Add session-scoped WebSocket route, extract shared handler, preserve legacy alias.
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/api/src/api/terminal.py`
|
||||
|
||||
**Files to create**:
|
||||
- `apps/api/tests/api/test_terminal_ws_multi.py`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- New route: `@router.websocket("/ws/tool-instances/{instance_id}/terminal/{session_id}")`
|
||||
- Existing route `@router.websocket("/ws/tool-instances/{instance_id}/terminal")` preserved; calls `get_or_create_session(...)` for `"default"` session
|
||||
- Extract `async def _handle_terminal_websocket(websocket, instance_id, session_id, db_session)` containing shared auth/validation/I/O loop logic
|
||||
- Both routes call `_handle_terminal_websocket`
|
||||
- Auth/validation logic unchanged (cookie-based, ownership check, running status)
|
||||
- `reset` control message scoped to the current session only (via `SessionRef` update)
|
||||
- On unknown `session_id`, close WS with code `4004` "Session not found"
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Write `test_terminal_ws_multi.py`:
|
||||
- `test_specific_session_websocket_connects`
|
||||
- `test_default_session_alias_creates_default`
|
||||
- `test_concurrent_sessions_isolated_output`
|
||||
- `test_reset_control_message_scoped_to_session`
|
||||
- `test_unknown_session_id_returns_4004`
|
||||
- GREEN: Implement routes and shared handler
|
||||
- Run `pytest tests/api/test_terminal_ws_multi.py`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: REST Endpoints for Session CRUD
|
||||
|
||||
**Scope**: Add REST endpoints for listing, creating, closing, resetting, and renaming sessions.
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/api/src/api/terminal.py`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- `GET /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions`
|
||||
- Returns `{ sessions: [...] }` with `id`, `name`, `status`, `has_websockets`, `created_at`, `last_activity_at`
|
||||
- `has_websockets` queried live from `TerminalManager`
|
||||
- `POST .../terminal/sessions` — body `{ name?: string }`
|
||||
- Returns `201` with `{ id, name, status, created_at }`
|
||||
- Returns `409` if max 5 reached
|
||||
- `DELETE .../terminal/sessions/{sid}` — returns `{ status: "closed", session_id }`
|
||||
- `POST .../terminal/sessions/{sid}/reset` — returns `{ id, name, status }`
|
||||
- `POST .../terminal/sessions/{sid}/rename` — body `{ name: string }`, returns `{ id, name }`
|
||||
- Existing `POST .../terminal/reset` preserved as alias for default session reset
|
||||
- All endpoints validate auth, ownership, and running instance status
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Add integration tests in `test_terminal_ws_multi.py` or new `test_terminal_rest.py`:
|
||||
- `test_list_sessions_returns_db_and_live_state`
|
||||
- `test_create_session_201`
|
||||
- `test_create_session_409_at_max`
|
||||
- `test_close_session_200`
|
||||
- `test_reset_session_200`
|
||||
- `test_rename_session_200`
|
||||
- `test_legacy_reset_alias_still_works`
|
||||
- GREEN: Implement endpoints
|
||||
- Run `make test-integration`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend API Client and `useTerminalSessions` Hook
|
||||
|
||||
**Scope**: Add frontend REST client functions and the central session state hook.
|
||||
|
||||
**Files to create**:
|
||||
- `apps/web/src/api/terminal.ts` (new file for terminal-specific API calls)
|
||||
- `apps/web/src/hooks/use-terminal-sessions.ts`
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/web/src/api/sessions.ts` — optional, or keep terminal API separate
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- API functions: `listTerminalSessions`, `createTerminalSession`, `closeTerminalSession`, `resetTerminalSession`, `renameTerminalSession`
|
||||
- `useTerminalSessions(instanceId: string)` hook:
|
||||
- Loads sessions on mount; auto-creates one if list is empty
|
||||
- Exposes `sessions`, `activeSessionId`, `setActiveSessionId`
|
||||
- Exposes `createSession`, `closeSession`, `renameSession`, `resetSession` with optimistic UI updates
|
||||
- Handles 409 errors (max sessions) gracefully
|
||||
- Refetches after reset/rename to stay in sync
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Write hook unit tests mocking API client:
|
||||
- `test_loads_sessions_on_mount`
|
||||
- `test_auto_creates_session_if_empty`
|
||||
- `test_close_session_removes_from_state`
|
||||
- `test_create_session_enforces_max_5_error`
|
||||
- GREEN: Implement hook and API client
|
||||
- Run `cd apps/web && npm test`
|
||||
|
||||
---
|
||||
|
||||
## PR 3: Frontend + Tests
|
||||
|
||||
### Task 7: `TerminalComponent` `sessionId` Support
|
||||
|
||||
**Scope**: Update `TerminalComponent` to accept an optional `sessionId` and route WS accordingly.
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/web/src/components/terminal.tsx`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- New optional prop `sessionId?: string`
|
||||
- WS URL constructed as:
|
||||
- `/ws/tool-instances/{instanceId}/terminal/{sessionId}` if `sessionId` provided
|
||||
- `/ws/tool-instances/{instanceId}/terminal` if omitted (backward compat)
|
||||
- Reset button sends `{"type": "reset"}` to the correct session's WS
|
||||
- Component still supports all existing props and mobile behavior
|
||||
- `onTerminalReady` callback still works; parent can differentiate sessions by key
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Add/update `terminal.test.tsx` (or similar) to assert WS URL includes `sessionId` when provided
|
||||
- GREEN: Implement prop and URL logic
|
||||
- Run `cd apps/web && npm test`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: `TerminalSessionTabs` UI Component
|
||||
|
||||
**Scope**: Build the tab bar for desktop and mobile.
|
||||
|
||||
**Files to create**:
|
||||
- `apps/web/src/components/terminal-session-tabs.tsx`
|
||||
- `apps/web/src/components/terminal-session-tabs.test.tsx`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Props interface: `sessions`, `activeSessionId`, `onSelect`, `onClose`, `onCreate`, `onRename`, `isMobile?`
|
||||
- Desktop: horizontal tab strip above terminal, overflow scroll with fade indicator
|
||||
- Mobile: compact tabs integrated into auto-hide chrome, horizontal swipe scroll
|
||||
- Each tab shows: name, status dot (connecting/connected/disconnected/error), close button (×) on hover/active
|
||||
- Double-click to rename: inline `<input>`, `Enter` to confirm, `Escape` to cancel, blur confirms
|
||||
- New session button (+) at right end; disabled when 5 sessions exist
|
||||
- Close confirmation: lightweight inline confirm tooltip (not modal)
|
||||
- Accessible: `role="tablist"`, `role="tab"`, keyboard navigation
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Write `terminal-session-tabs.test.tsx`:
|
||||
- `test_renders_all_tabs`
|
||||
- `test_click_tab_calls_onSelect`
|
||||
- `test_close_button_calls_onClose`
|
||||
- `test_double_click_enables_rename`
|
||||
- `test_plus_disabled_at_max_sessions`
|
||||
- `test_status_dot_reflects_connection_state`
|
||||
- GREEN: Implement component
|
||||
- Run `cd apps/web && npm test`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: `TerminalPage` Multi-Session Orchestration, Fullscreen, and Shortcuts
|
||||
|
||||
**Scope**: Rewrite `TerminalPage` to manage multiple mounted terminals, fullscreen mode, and keyboard shortcuts.
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/web/src/pages/terminal.tsx`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Uses `useTerminalSessions` hook
|
||||
- Renders `<TerminalSessionTabs />` above terminal area
|
||||
- Renders one `<TerminalComponent />` per session; inactive sessions hidden via `display: none` (preserves scrollback and WS)
|
||||
- On tab switch, active terminal calls `fitAddon.fit()` via ref + `useEffect` on visibility
|
||||
- Fullscreen toggle:
|
||||
- `Ctrl+Shift+F` toggles `.fullscreen` class
|
||||
- Desktop: hides page header; tab strip becomes minimal overlay (auto-hides after 3s, reappears on mouse move)
|
||||
- Mobile: hides header, tab strip, special keys; floating handle reveals chrome
|
||||
- Exit via `Esc` or UI button
|
||||
- Keyboard shortcuts (registered in `useEffect` on `keydown`):
|
||||
- `Alt+Shift+N` — new session
|
||||
- `Alt+Shift+W` — close current session
|
||||
- `Alt+Shift+←` / `Alt+Shift+→` — prev/next session
|
||||
- `Alt+Shift+R` — reset current session
|
||||
- All use `preventDefault()` only for the exact combo; no browser overrides
|
||||
- Closing last session auto-creates a new default session
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Add `terminal-page.test.tsx`:
|
||||
- `test_creates_default_session_on_empty_load`
|
||||
- `test_switching_tabs_hides_inactive_terminals`
|
||||
- `test_fullscreen_toggle_adds_class`
|
||||
- `test_keyboard_shortcut_creates_session`
|
||||
- `test_close_last_session_auto_creates_default`
|
||||
- GREEN: Implement page orchestration
|
||||
- Run `cd apps/web && npm test`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Mobile Terminal Integration
|
||||
|
||||
**Scope**: Integrate session tabs into mobile terminal wrapper and update header.
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/web/src/components/mobile-terminal-wrapper.tsx`
|
||||
- `apps/web/src/components/mobile-terminal-header.tsx`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- `MobileTerminalWrapper` accepts session-related props from `TerminalPage` and passes them to `TerminalSessionTabs`
|
||||
- `MobileTerminalHeader` displays `activeSession.name` instead of generic `"Terminal"`
|
||||
- Tab strip shares `useAutoHide` behavior with header (tapping terminal toggles visibility)
|
||||
- Special keys strip remains functional; no z-index conflicts with tabs
|
||||
- Fullscreen on mobile correctly hides/shows all chrome layers
|
||||
|
||||
**Testing (TDD)**:
|
||||
- RED: Add/update mobile wrapper tests:
|
||||
- `test_renders_session_tabs`
|
||||
- `test_header_shows_session_name`
|
||||
- `test_auto_hide_applies_to_tabs`
|
||||
- GREEN: Implement mobile integration
|
||||
- Run `cd apps/web && npm test`
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Backend Integration Tests
|
||||
|
||||
**Scope**: Complete backend test coverage for multi-session WebSocket and REST behavior.
|
||||
|
||||
**Files to create / modify**:
|
||||
- `apps/api/tests/services/test_terminal_manager_multi.py` (finalize)
|
||||
- `apps/api/tests/api/test_terminal_ws_multi.py` (finalize)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- All tests from Tasks 2, 4, 5 pass
|
||||
- Additional integration tests:
|
||||
- `test_list_sessions_after_api_restart_shows_db_metadata` (simulates restart by clearing in-memory dict)
|
||||
- `test_two_websockets_on_same_session_receive_same_output`
|
||||
- `test_idle_cleanup_per_session_not_global`
|
||||
- `make test` passes (unit + integration)
|
||||
|
||||
**Testing (TDD)**:
|
||||
- These are the GREEN/TRIANGULATE phases for earlier backend tasks; ensure coverage is comprehensive
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Frontend Component Tests
|
||||
|
||||
**Scope**: Finalize frontend test coverage for tabs, page, and hook.
|
||||
|
||||
**Files to create / modify**:
|
||||
- `apps/web/src/components/terminal-session-tabs.test.tsx` (finalize)
|
||||
- `apps/web/src/hooks/use-terminal-sessions.test.ts` (new, if not created earlier)
|
||||
- `apps/web/src/pages/terminal.test.tsx` (new)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Tab component tests cover rendering, selection, close, rename, and max-session disable
|
||||
- Hook tests cover load, create, close, error handling
|
||||
- Page tests cover session lifecycle, fullscreen, and keyboard shortcuts
|
||||
- `cd apps/web && npm test` passes
|
||||
|
||||
**Testing (TDD)**:
|
||||
- Finalize RED→GREEN→TRIANGULATE for all frontend tasks
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Resource exhaustion (5× docker exec per instance) | Medium | High | Max 5 enforced in `create_session`. Idle timeout (30 min) applies per session. |
|
||||
| Mobile UX degraded by tab bar + special keys strip | Medium | Medium | Auto-hide shared between tabs and header. Compact tab design. Overflow scroll. |
|
||||
| Concurrent WS policy closes wrong session's sockets | Medium | High | Explicit unit test: `attach_websocket` must only affect same `(instance_id, session_id)`. |
|
||||
| DB writes on hot path (activity tracking) | Low | Medium | `last_activity_at` updates are fire-and-forget async tasks; do not block I/O loop. |
|
||||
| Frontend performance with 5 mounted xterm.js instances | Low | Medium | Max 5 sessions. Inactive terminals use `display: none` (not unmounted). xterm.js GPU acceleration handles this. |
|
||||
| Default session alias ambiguity | Low | Low | Document that `/terminal` maps to `"default"`. Future deprecation can migrate to explicit IDs. |
|
||||
| Browser shortcut conflicts | Low | Medium | Use `Alt+Shift+*` instead of `Ctrl+Shift+W/N`. Only `preventDefault()` on exact matching combos. |
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
- **PR 1 rollback**: Alembic downgrade removes `terminal_sessions` table. Old `TerminalManager` code is fully replaced, so reverting PR 1 requires reverting all subsequent PRs.
|
||||
- **PR 2 rollback**: Revert API changes. Legacy `/terminal` WS route and `POST .../terminal/reset` continue to work; new `/terminal/{session_id}` returns 404 but no clients call it until PR 3 is deployed.
|
||||
- **PR 3 rollback**: Revert frontend. Users see old single-session UI. Backend `/terminal` alias continues to serve them.
|
||||
|
||||
Because PRs are stacked, rolling back PR 2 or PR 1 requires rolling back all dependent PRs above it.
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
name: reorganize-long-files
|
||||
status: completed
|
||||
started_at: 2026-05-28
|
||||
completed_at: 2026-06-12
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# archive/2026-06-12-active-changes-archive/reorganize-long-files (index)
|
||||
dir: archive/2026-06-12-active-changes-archive/reorganize-long-files
|
||||
|
||||
## role
|
||||
Contains archived planning documents for a completed structural refactoring initiative that split monolithic frontend pages and backend routers into thinner, more modular components and services.
|
||||
## parent
|
||||
index: archive/2026-06-12-active-changes-archive/.pi-map.index.md
|
||||
map: archive/2026-06-12-active-changes-archive/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- .openspec.yaml
|
||||
- proposal.md
|
||||
- spec.md
|
||||
- tasks.md
|
||||
## links
|
||||
index: archive/2026-06-12-active-changes-archive/reorganize-long-files/.pi-map.index.md
|
||||
map: archive/2026-06-12-active-changes-archive/reorganize-long-files/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# archive/2026-06-12-active-changes-archive/reorganize-long-files
|
||||
dir: archive/2026-06-12-active-changes-archive/reorganize-long-files
|
||||
|
||||
index: archive/2026-06-12-active-changes-archive/reorganize-long-files/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains archived planning documents for a completed structural refactoring initiative that split monolithic frontend pages and backend routers into thinner, more modular components and services.
|
||||
## files
|
||||
- .openspec.yaml | Configuration file tracking a completed project task for reorganizing long files
|
||||
- proposal.md | Proposes a structural refactoring to split monolithic frontend pages and backend routers into thinner orchestrators with extracted components/services, restoring a thin-page/thin-router/fat-component pattern. | dep: React components, FastAPI routers, WebSocket, Docker, git services
|
||||
- spec.md | A specification document defining a pure structural refactoring to split monolithic frontend pages/backend routers and CSS into smaller, focused components and services without changing any behavior or API contracts. | dep: React, CSS Modules, WebSocket, FastAPI/Flask routers, TypeScript, Vite
|
||||
- tasks.md | Tracks the completion status of a codebase reorganization initiative to slim long files by extracting components and services into dedicated modules.
|
||||
## arch
|
||||
Documentation-driven refactoring using specification-proposal-task workflow with a thin-page/thin-router/fat-component pattern, emphasizing pure structural decomposition without behavioral changes.
|
||||
## tags
|
||||
components, services, long, .openspec, structural, refactoring, split, monolithic
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+73
@@ -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
|
||||
+128
@@ -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)
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# Tasks: Reorganize Long Files
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Tasks** |
|
||||
| Based on | [Spec](spec.md) |
|
||||
| Next | Apply → Verify → Sync |
|
||||
|
||||
## Summary of Work
|
||||
|
||||
This change was partially implemented incrementally across other features (`workspace-first-ui`, `tool-session-progress-and-updates`, etc.). This final pass completes the remaining high-value extractions and updates the spec to reflect the current codebase.
|
||||
|
||||
### Backend — Router Slimming
|
||||
|
||||
| # | Task | Status |
|
||||
|---|---|---|
|
||||
| 1.1 | Terminal WebSocket extraction from `api/tool/tool_instances.py` | Already extracted to `api/system/terminal.py` in prior work |
|
||||
| 1.2 | Instance lifecycle extraction into `api/tool/tool_lifecycle.py` | **Completed** |
|
||||
| 1.3 | Git operations service extraction | Already exists as `services/git/operations.py` |
|
||||
| 1.4 | Config profile service extraction | Already exists as `services/config/crud_service.py` and `services/config/resolver_service.py` |
|
||||
|
||||
### Frontend — Page/Component Slimming
|
||||
|
||||
| # | Task | Status |
|
||||
|---|---|---|
|
||||
| 3.1–3.6 | Tool Workshop page tabs | `pages/ToolWorkshopPage.tsx` is already ~60 lines; tabs extracted in prior work |
|
||||
| 4.1–4.7 | Config Profiles views | `pages/ConfigProfilesPage.tsx` is ~177 lines; list/detail/edit views already extracted as `ConfigProfileListSidebar`, `ConfigProfileEditorPanel`, `ConfigProfilesMobileView` |
|
||||
| 5.1–5.6 | Terminal page manager | `pages/TerminalPage.tsx` is ~100 lines; terminal UI already in `components/features/terminal/` |
|
||||
| 6.1–6.6 | Settings / SSH Keys navigation | `SSHKeyList` already extracted; settings nav simple enough |
|
||||
| 7.1–7.4 | Projects page list | `ProjectCard` already extracted; page is ~294 lines |
|
||||
| 7.5–7.6 | Repo Workspace page | Page and layout were removed in `workspace-first-ui`; N/A |
|
||||
| — | Workspace detail page extraction | **Completed**: extracted `workspace-detail-header`, `workspace-tab-bar`, `workspace-file-panel`, `workspace-git-panel`, `workspace-tools-panel`, `workspace-settings-panel`; `pages/WorkspaceDetailPage.tsx` slimmed from ~446 to ~62 lines |
|
||||
|
||||
### CSS Reorganization
|
||||
|
||||
| # | Task | Status |
|
||||
|---|---|---|
|
||||
| 2.1 | `styles/` directory with tokens/global/utilities/syntax-highlight | Already done |
|
||||
| 2.2 | Page-specific CSS files | Already done; `styles.css` monolith does not exist |
|
||||
| 2.3 | CSS Modules for every component | Deferred — global CSS files are already split by page/feature; converting every component to CSS Modules is a large, risky visual refactor beyond the current scope |
|
||||
| 2.4 | Delete `styles.css` | Already done |
|
||||
|
||||
## Completed This Pass
|
||||
|
||||
- [x] Extract start/stop/restart/delete lifecycle endpoints from `api/tool/tool_instances.py` into `api/tool/tool_lifecycle.py`
|
||||
- [x] Register `tool_lifecycle_router` in `main.py`
|
||||
- [x] Extract `WorkspaceDetailPage` inline components into `components/features/workspace/`
|
||||
- `workspace-detail-header.tsx`
|
||||
- `workspace-tab-bar.tsx` (shared `WorkspaceTab` type)
|
||||
- `workspace-file-panel.tsx`
|
||||
- `workspace-git-panel.tsx`
|
||||
- `workspace-tools-panel.tsx`
|
||||
- `workspace-settings-panel.tsx`
|
||||
- [x] Slim `pages/WorkspaceDetailPage.tsx` to ~62 lines
|
||||
- [x] Verify backend `py_compile` passes
|
||||
- [x] Verify frontend `npm run typecheck` passes
|
||||
- [x] Verify frontend `npm run lint` passes
|
||||
- [x] Verify frontend tests pass
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] No `styles.css` monolith exists
|
||||
- [x] Backend routers are ≤ 400 lines (`tool_instances.py` reduced to ~419 lines)
|
||||
- [x] `WorkspaceDetailPage` is ≤ 150 lines (~62 lines)
|
||||
- [x] Lifecycle endpoints moved to dedicated router
|
||||
- [x] All typecheck, lint, and test gates pass
|
||||
- [ ] Full CSS Modules conversion — deferred
|
||||
- [ ] Every frontend page ≤ 150 lines — partially met; remaining pages are within reasonable bounds and use extracted components
|
||||
|
||||
## Verification Steps
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
python3 -m py_compile src/api/tool/tool_instances.py src/api/tool/tool_lifecycle.py src/main.py src/api/tool/__init__.py
|
||||
|
||||
cd apps/web
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test -- --run
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Several originally planned extractions were already completed in earlier feature branches or became obsolete when `RepoWorkspacePage` and its related components were removed during `workspace-first-ui`.
|
||||
- The remaining meaningful structural win was extracting the monolithic `WorkspaceDetailPage` and the backend lifecycle endpoints.
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
name: working-copies
|
||||
status: completed
|
||||
started_at: 2026-05-28
|
||||
completed_at: 2026-06-12
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# archive/2026-06-12-active-changes-archive/working-copies (index)
|
||||
dir: archive/2026-06-12-active-changes-archive/working-copies
|
||||
|
||||
## role
|
||||
Archive of design documents and specifications for implementing persistent workspace-based tool instances that replace direct repository mounting with isolated git working copies.
|
||||
## parent
|
||||
index: archive/2026-06-12-active-changes-archive/.pi-map.index.md
|
||||
map: archive/2026-06-12-active-changes-archive/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- .openspec.yaml
|
||||
- design.md
|
||||
- explore.md
|
||||
- proposal.md
|
||||
- spec.md
|
||||
- tasks.md
|
||||
## links
|
||||
index: archive/2026-06-12-active-changes-archive/working-copies/.pi-map.index.md
|
||||
map: archive/2026-06-12-active-changes-archive/working-copies/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# archive/2026-06-12-active-changes-archive/working-copies
|
||||
dir: archive/2026-06-12-active-changes-archive/working-copies
|
||||
|
||||
index: archive/2026-06-12-active-changes-archive/working-copies/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Archive of design documents and specifications for implementing persistent workspace-based tool instances that replace direct repository mounting with isolated git working copies.
|
||||
## files
|
||||
- .openspec.yaml | Defines metadata for a completed project named "working-copies" with timeline tracking
|
||||
- design.md | Design document for implementing workspace-based tool instances that replace direct repository mounting with isolated git working copies | dep: FastAPI, SQLAlchemy, Alembic, Docker Compose, Git, React/TypeScript, asyncio subprocess
|
||||
- explore.md | Design document proposing "Working Copies" (named "Workspace") as persistent writable clones of repositories to replace direct repo mounting/cloning in tool instances | dep: GitRepository, ToolInstance, Project, User, database, compose generation, filesystem mount system
|
||||
- proposal.md | Proposes a new "Workspace" entity to replace the confusing mount/clone mode dichotomy for tool instances, enabling persistent writable repository clones that multiple tools can share.
|
||||
- spec.md | Technical specification for implementing persistent workspace-based tool instances that replace mount/clone modes with explicit Git repository workspaces | dep: Git, PostgreSQL, REST API, React/TypeScript frontend, Docker containers, Python backend
|
||||
- tasks.md | Project task tracking document for implementing workspace-based tool instances across backend and frontend in a multi-PR phased approach | dep: Alembic, FastAPI, SQLAlchemy, React, TypeScript, pytest, ruff, ESLint, Git, Docker Compose
|
||||
## arch
|
||||
Design-driven documentation package containing phased technical specifications (proposal → design → exploration → spec → tasks) for a Workspace entity abstraction, with YAML metadata tracking and multi-PR implementation planning across backend/frontend systems.
|
||||
## tags
|
||||
workspace, tool, instances, git, design, replace, project, working
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+504
@@ -0,0 +1,504 @@
|
||||
# Design: Workspace-Based Tool Instances
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Design** |
|
||||
| Based on | [Spec](spec.md) |
|
||||
| Next | Tasks |
|
||||
|
||||
## Decision: No Migration
|
||||
|
||||
Existing tool instances will be left as-is. Users will create new workspaces and new tool instances. Old instances remain functional but read-only (no migration path). This simplifies the implementation significantly.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||
│ │ Sidebar │ │Workspaces│ │Create WS │ │Start Tool │ │
|
||||
│ │ (new) │ │ List │ │ Flow │ │Modal │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend API │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │Workspace CRUD│ │Workspace Sync│ │Instance Start (refact)│ │
|
||||
│ │ /workspaces │ │ /sync │ │ /start │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┼──────────────────────────────┐ │
|
||||
│ │ GitService │ WorkspaceService │ │
|
||||
│ │ (clone, fetch, pull) │ (create, delete, sync) │ │
|
||||
│ └───────────────────────────┼──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┼──────────────────────────────┐ │
|
||||
│ │ Docker Compose │ File System │ │
|
||||
│ │ (mount workspace path) │ /data/working-copies/... │ │
|
||||
│ └───────────────────────────┴──────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
apps/api/src/
|
||||
├── api/
|
||||
│ ├── workspaces.py # NEW: Workspace CRUD endpoints
|
||||
│ └── tool_instances.py # MODIFIED: use workspace_id
|
||||
├── models/
|
||||
│ ├── workspace.py # NEW: Workspace model
|
||||
│ └── tool_instance.py # MODIFIED: add workspace_id
|
||||
├── services/
|
||||
│ ├── workspace_manager.py # NEW: Workspace lifecycle
|
||||
│ ├── git_service.py # NEW: Git operations (clone, fetch, pull)
|
||||
│ └── docker.py # EXISTING: mount workspace path
|
||||
└── alembic/versions/
|
||||
└── 2026_06_01_add_workspaces.py # NEW migration
|
||||
```
|
||||
|
||||
### Model: Workspace
|
||||
|
||||
```python
|
||||
class Workspace(Base):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("git_repositories.id"), nullable=False)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main")
|
||||
path: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready")
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||
)
|
||||
```
|
||||
|
||||
### Service: WorkspaceManager
|
||||
|
||||
```python
|
||||
class WorkspaceManager:
|
||||
"""Manages workspace lifecycle: create, delete, sync, validate."""
|
||||
|
||||
BASE_PATH = "/data/working-copies"
|
||||
|
||||
async def create(
|
||||
self,
|
||||
repo: GitRepository,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
branch: str = "main",
|
||||
) -> Workspace:
|
||||
"""Clone repo to workspace path and create DB record."""
|
||||
path = f"{self.BASE_PATH}/{repo.id}/{name}"
|
||||
# Clone repo
|
||||
await GitService.clone(repo.remote_url, branch, path)
|
||||
# Create record
|
||||
workspace = Workspace(...)
|
||||
return workspace
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Delete workspace and all associated tool instances."""
|
||||
instances = await self._get_running_instances(workspace)
|
||||
if instances and not force:
|
||||
raise WorkspaceHasInstancesError(instances)
|
||||
|
||||
# Stop and delete all instances
|
||||
for instance in instances:
|
||||
await self._stop_and_delete_instance(instance)
|
||||
|
||||
# Delete directory
|
||||
shutil.rmtree(workspace.path, ignore_errors=True)
|
||||
|
||||
# Delete record
|
||||
await session.delete(workspace)
|
||||
|
||||
async def sync(self, workspace: Workspace) -> SyncResult:
|
||||
"""Fetch remote and detect deleted branches."""
|
||||
result = await GitService.fetch(workspace.path)
|
||||
if not GitService.branch_exists_remotely(workspace.path, workspace.branch):
|
||||
return SyncResult(branch_deleted=True)
|
||||
|
||||
await GitService.pull(workspace.path, workspace.branch)
|
||||
workspace.last_sync_at = datetime.now()
|
||||
return SyncResult(branch_deleted=False)
|
||||
```
|
||||
|
||||
### Service: GitService
|
||||
|
||||
```python
|
||||
class GitService:
|
||||
"""Git operations for workspace management."""
|
||||
|
||||
@staticmethod
|
||||
async def clone(remote_url: str, branch: str, path: str) -> None:
|
||||
"""Clone a repo to the given path."""
|
||||
cmd = ["git", "clone", "--branch", branch, "--single-branch", remote_url, path]
|
||||
# Run via asyncio subprocess
|
||||
|
||||
@staticmethod
|
||||
async def fetch(path: str) -> None:
|
||||
"""Fetch from origin."""
|
||||
cmd = ["git", "-C", path, "fetch", "origin"]
|
||||
|
||||
@staticmethod
|
||||
async def pull(path: str, branch: str) -> None:
|
||||
"""Pull latest changes."""
|
||||
cmd = ["git", "-C", path, "pull", "origin", branch]
|
||||
|
||||
@staticmethod
|
||||
def branch_exists_remotely(path: str, branch: str) -> bool:
|
||||
"""Check if a branch exists on the remote."""
|
||||
cmd = ["git", "-C", path, "ls-remote", "--heads", "origin", branch]
|
||||
# Return True if output is not empty
|
||||
```
|
||||
|
||||
### API: Workspaces
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
||||
|
||||
@router.post("/")
|
||||
async def create_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: CreateWorkspaceRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> WorkspaceResponse:
|
||||
repo = await get_repo(repo_id, user_id, session)
|
||||
workspace = await WorkspaceManager().create(repo, user_id, data.name, data.branch)
|
||||
session.add(workspace)
|
||||
await session.commit()
|
||||
return workspace
|
||||
|
||||
@router.delete("/{workspace_id}")
|
||||
async def delete_workspace(
|
||||
workspace_id: uuid.UUID,
|
||||
force: bool = Query(False),
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
workspace = await get_workspace(workspace_id, user_id, session)
|
||||
try:
|
||||
await WorkspaceManager().delete(workspace, force=force)
|
||||
except WorkspaceHasInstancesError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Workspace has running tool instances",
|
||||
"instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
|
||||
},
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
|
||||
@router.post("/{workspace_id}/sync")
|
||||
async def sync_workspace(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> SyncResult:
|
||||
workspace = await get_workspace(workspace_id, user_id, session)
|
||||
result = await WorkspaceManager().sync(workspace)
|
||||
if result.branch_deleted:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Branch '{workspace.branch}' was deleted from remote",
|
||||
"branch_deleted": True,
|
||||
},
|
||||
)
|
||||
return result
|
||||
```
|
||||
|
||||
### Updated: Instance Start
|
||||
|
||||
```python
|
||||
@router.post("/{instance_id}/start")
|
||||
async def start_instance(
|
||||
instance_id: uuid.UUID,
|
||||
data: StartInstanceRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
instance = await get_instance(instance_id, user_id, session)
|
||||
|
||||
# Get workspace
|
||||
workspace = await session.get(Workspace, instance.workspace_id)
|
||||
if not workspace:
|
||||
raise HTTPException(400, "Workspace not found")
|
||||
|
||||
# Mount workspace path instead of repo path
|
||||
repo_path = workspace.path
|
||||
|
||||
# Generate compose with workspace mount
|
||||
compose_content = generate_compose(workspace, instance, tool_type)
|
||||
|
||||
# ... rest of start logic
|
||||
```
|
||||
|
||||
## Frontend Design
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── pages/
|
||||
│ ├── workspaces.tsx # NEW: Workspaces list page
|
||||
│ └── workspace-detail.tsx # NEW: Workspace detail page
|
||||
├── components/
|
||||
│ ├── workspace-card.tsx # NEW: Workspace card component
|
||||
│ ├── workspace-create-form.tsx # NEW: Create workspace form
|
||||
│ ├── start-tool-modal.tsx # NEW: Start tool on workspace modal
|
||||
│ └── sidebar.tsx # MODIFIED: add Workspaces nav
|
||||
├── hooks/
|
||||
│ ├── use-workspaces.ts # NEW: Workspace data hook
|
||||
│ └── use-workspace-actions.ts # NEW: Workspace CRUD actions
|
||||
├── api/
|
||||
│ └── workspaces.ts # NEW: Workspace API client
|
||||
└── types/
|
||||
└── workspace.ts # NEW: Workspace types
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
repo_id: string;
|
||||
repo_name: string;
|
||||
project_name: string;
|
||||
user_id: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
status: "ready" | "syncing" | "error";
|
||||
last_sync_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
instance_count: number;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequest {
|
||||
name: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
branch_deleted: boolean;
|
||||
pulled: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceCard
|
||||
|
||||
```tsx
|
||||
export function WorkspaceCard({
|
||||
workspace,
|
||||
onStartTool,
|
||||
onSync,
|
||||
onDelete,
|
||||
}: WorkspaceCardProps) {
|
||||
return (
|
||||
<article className="card workspace-card">
|
||||
<div className="workspace-header">
|
||||
<h4>{workspace.name}</h4>
|
||||
<span className={`status-badge ${workspace.status}`}>
|
||||
{workspace.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="workspace-meta">
|
||||
<p>{workspace.project_name} / {workspace.repo_name}</p>
|
||||
<p><Icon name="branch" /> {workspace.branch}</p>
|
||||
{workspace.instance_count > 0 && (
|
||||
<p>{workspace.instance_count} active tool{workspace.instance_count > 1 ? "s" : ""}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<button onClick={() => onStartTool(workspace)}>
|
||||
<Icon name="play" /> Start Tool
|
||||
</button>
|
||||
<button onClick={() => onSync(workspace)}>
|
||||
<Icon name="refresh" /> Sync
|
||||
</button>
|
||||
<button onClick={() => onDelete(workspace)} className="danger">
|
||||
<Icon name="delete" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: Sidebar (updated)
|
||||
|
||||
```tsx
|
||||
const navItems = [
|
||||
{ path: "/dashboard", label: "Dashboard", icon: "home" },
|
||||
{ path: "/projects", label: "Projects", icon: "folder" },
|
||||
{ path: "/workspaces", label: "Workspaces", icon: "workspace" },
|
||||
{ path: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
```
|
||||
|
||||
### Hook: useWorkspaceActions
|
||||
|
||||
```typescript
|
||||
export function useWorkspaceActions(options: { onRefresh: () => Promise<void> }) {
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = useCallback(async (workspace: Workspace, force = false) => {
|
||||
setLoadingId(workspace.id);
|
||||
try {
|
||||
await deleteWorkspace(workspace.repo_id, workspace.id, force);
|
||||
await options.onRefresh();
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ detail?: { instances?: Array<{id: string, name: string}> } }>;
|
||||
if (error.response?.status === 409 && !force) {
|
||||
const instances = error.response.data?.detail?.instances || [];
|
||||
const confirmed = confirm(
|
||||
`This workspace has ${instances.length} running tool instance(s):\n` +
|
||||
instances.map(i => `- ${i.name}`).join("\n") +
|
||||
`\n\nDelete workspace and all instances?`
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleDelete(workspace, true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}, [options.onRefresh]);
|
||||
|
||||
const handleSync = useCallback(async (workspace: Workspace) => {
|
||||
setLoadingId(workspace.id);
|
||||
try {
|
||||
const result = await syncWorkspace(workspace.repo_id, workspace.id);
|
||||
await options.onRefresh();
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ detail?: { branch_deleted?: boolean; message?: string } }>;
|
||||
if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
|
||||
const confirmed = confirm(
|
||||
`${error.response.data.detail.message}\n\nDelete this workspace?`
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleDelete(workspace, true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}, [options.onRefresh, handleDelete]);
|
||||
|
||||
return { loadingId, handleDelete, handleSync };
|
||||
}
|
||||
```
|
||||
|
||||
## Compose Template Updates
|
||||
|
||||
### Workspace Mount
|
||||
|
||||
All compose templates will mount the workspace path instead of the repo path:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
container_name: ${INSTANCE_NAME}
|
||||
volumes:
|
||||
- ${WORKSPACE_PATH}:/workspace
|
||||
working_dir: /workspace
|
||||
# ... rest of config
|
||||
```
|
||||
|
||||
The `${WORKSPACE_PATH}` variable replaces `${REPO_PATH}` in all templates.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | HTTP Status | Frontend Behavior |
|
||||
|---|---|---|
|
||||
| Workspace name not unique per repo | 409 | Show inline validation error |
|
||||
| Workspace has running instances | 409 | Show confirmation dialog |
|
||||
| Branch deleted from remote | 409 | Show confirmation dialog to delete workspace |
|
||||
| Repo not found | 404 | Show error toast |
|
||||
| Git clone failed | 500 | Show error toast with git stderr |
|
||||
| Workspace path missing | 500 | Show error toast |
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Git clone** is synchronous and slow; run in background with status polling
|
||||
- **Workspace list** should include `instance_count` via subquery (not N+1)
|
||||
- **Sync** is fast (fetch only), but pull may be slow; run async
|
||||
- **Delete with instances** stops instances sequentially; consider parallel
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Workspace paths must be validated to prevent path traversal
|
||||
- Users can only access their own workspaces
|
||||
- Git credentials (SSH keys) must be available during clone
|
||||
- Workspace directories must have correct ownership for container users
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Backend
|
||||
- Unit: WorkspaceManager.create, delete, sync
|
||||
- Unit: GitService.clone, fetch, pull, branch_exists_remotely
|
||||
- Integration: Create workspace → start tool → verify mount
|
||||
- Integration: Delete workspace with running instances
|
||||
- Integration: Sync with deleted branch
|
||||
|
||||
### Frontend
|
||||
- Component: WorkspaceCard renders correctly
|
||||
- Component: Create form validates name uniqueness
|
||||
- Hook: useWorkspaceActions handles 409 confirmation
|
||||
- E2E: Create workspace → start tool → delete workspace
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Auto-sync on schedule
|
||||
- Workspace sharing between users
|
||||
- Git push/pull/branch UI
|
||||
- Pre-created default workspaces
|
||||
- Read-only workspace mode
|
||||
- Workspace backup/restore
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New Files
|
||||
- `apps/api/src/models/workspace.py`
|
||||
- `apps/api/src/api/workspaces.py`
|
||||
- `apps/api/src/services/workspace_manager.py`
|
||||
- `apps/api/src/services/git_service.py`
|
||||
- `apps/api/alembic/versions/2026_06_01_add_workspaces.py`
|
||||
- `apps/web/src/pages/workspaces.tsx`
|
||||
- `apps/web/src/pages/workspace-detail.tsx`
|
||||
- `apps/web/src/components/workspace-card.tsx`
|
||||
- `apps/web/src/components/workspace-create-form.tsx`
|
||||
- `apps/web/src/components/start-tool-modal.tsx`
|
||||
- `apps/web/src/hooks/use-workspaces.ts`
|
||||
- `apps/web/src/hooks/use-workspace-actions.ts`
|
||||
- `apps/web/src/api/workspaces.ts`
|
||||
- `apps/web/src/types/workspace.ts`
|
||||
|
||||
### Modified Files
|
||||
- `apps/api/src/models/tool_instance.py` (add workspace_id)
|
||||
- `apps/api/src/api/tool_instances.py` (use workspace path)
|
||||
- `apps/web/src/components/sidebar.tsx` (add nav item)
|
||||
- `apps/web/src/pages/dashboard.tsx` (add workspaces section)
|
||||
- `apps/web/src/api/sessions.ts` (add workspace endpoints)
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Explore: Working Copies
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Currently, tool instances mount repositories directly. Each tool instance either:
|
||||
- **Mount mode**: Bind-mounts the shared repo path (`/data/repos/<repo>`) read-only
|
||||
- **Clone mode**: Clones the repo into the instance directory
|
||||
|
||||
This has several problems:
|
||||
1. **Mount mode**: Read-only, so users can't edit files in the tool
|
||||
2. **Clone mode**: Creates a full copy per instance, wasting disk space
|
||||
3. **UI complexity**: The create-instance form must ask "mount or clone?" and handle branch selection
|
||||
4. **No persistence**: Clone-mode repos live inside the instance directory and are lost on delete
|
||||
5. **Race conditions**: Multiple instances mounting the same repo can conflict
|
||||
|
||||
## Proposed Solution: Working Copies
|
||||
|
||||
Introduce a **Workspace** as a first-class entity: a persistent, writable local clone of a repository that lives independently of any tool instance. Tool instances are then *started on* a working copy, which is mounted into the container.
|
||||
|
||||
### Naming Candidates
|
||||
|
||||
| Name | Pros | Cons |
|
||||
|---|---|---|
|
||||
| Workspace | Common in IDEs; implies a working area | Conflicts with existing docs/features/workspace.md |
|
||||
| **Workspace** | Common in IDEs (VS Code, JetBrains); implies a working area | May conflict with existing "workspace" terminology in docs |
|
||||
| **Checkout** | Git-native term; implies a working tree | Too specific to git; implies a single commit/branch |
|
||||
| **Sandbox** | Implies isolation and experimentation | Suggests throwaway/ephemeral, not persistent |
|
||||
| **Dev Copy** | Simple and descriptive | Informal; "copy" still implies duplication |
|
||||
| **Project Clone** | Clear relationship to project+repo | Clunky; two words |
|
||||
| **Branch** | Git-native; each working copy is effectively a branch workspace | Too git-specific; may confuse with git branches |
|
||||
|
||||
**Decision: "Workspace"** — chosen by user despite existing docs/features/workspace.md. The existing workspace.md will be superseded/renamed to avoid confusion. — it's the most precise term. In SVN/Git parlance, a "working copy" is exactly what we want: a local, writable copy of a repository that you work on. The term is established enough that developers understand it, but not so overloaded in our domain that it conflicts.
|
||||
|
||||
### Entity Model
|
||||
|
||||
```
|
||||
Project
|
||||
└── GitRepository (the canonical repo, read-only source)
|
||||
└── WorkingCopy (writable local clone, 1+ per repo)
|
||||
└── ToolInstance (mounts the working copy)
|
||||
```
|
||||
|
||||
A Workspace:
|
||||
- Has a `name` (auto-generated or user-defined)
|
||||
- Has a `path` on disk (under `/data/working-copies/<repo-id>/<copy-name>`)
|
||||
- Has a `branch` (the branch it's currently on)
|
||||
- Has a `status` (ready, syncing, error)
|
||||
- Belongs to a `GitRepository`
|
||||
- Belongs to a `User`
|
||||
- Has many `ToolInstance`s
|
||||
|
||||
### User Flow
|
||||
|
||||
1. User navigates to **Working Copies** in the sidebar
|
||||
2. Sees list of working copies (or creates one from a repo)
|
||||
3. Clicks "New Workspace" → selects repo + branch → named copy created
|
||||
4. From a working copy, clicks "Start Tool" → selects tool type → instance starts with working copy mounted
|
||||
5. Multiple tool instances can share the same working copy (e.g., terminal + code-server side by side)
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Writable by default**: Working copies are clones, so tools can edit files
|
||||
2. **Shared across instances**: Multiple tools can mount the same working copy
|
||||
3. **Persistent**: Survives instance deletion
|
||||
4. **Simplified UI**: No more "mount vs clone" decision; tool creation is just "pick a working copy"
|
||||
5. **Git operations**: Working copies can support git push/pull/branch from the UI
|
||||
6. **Disk efficient**: One clone per working copy, not one per instance
|
||||
|
||||
### Open Questions
|
||||
|
||||
1. Should working copies auto-sync with the canonical repo?
|
||||
2. Should we support multiple working copies per repo (e.g., one per branch)?
|
||||
3. How do we handle merge conflicts if the canonical repo changes?
|
||||
4. Should working copies be scoped to a user or to a project?
|
||||
5. What happens to tool instances when a working copy is deleted?
|
||||
6. Should we pre-create a default working copy when a repo is added?
|
||||
|
||||
### Migration Path
|
||||
|
||||
Existing tool instances that use clone_mode can be migrated:
|
||||
- On first access, extract the cloned repo from the instance directory
|
||||
- Move it to `/data/working-copies/...`
|
||||
- Create a WorkingCopy record pointing to it
|
||||
- Update the instance to mount the working copy path
|
||||
|
||||
Mount-mode instances can be converted on restart:
|
||||
- Create a working copy from the canonical repo
|
||||
- Switch the instance to mount the working copy instead
|
||||
|
||||
### Scope for This Change
|
||||
|
||||
This change focuses on:
|
||||
- [ ] Creating the WorkingCopy entity and database table
|
||||
- [ ] Adding a Working Copies section to the UI (sidebar nav + list view)
|
||||
- [ ] Updating tool instance creation to select a working copy instead of repo+clone_mode
|
||||
- [ ] Updating compose generation to mount the working copy path
|
||||
- [ ] Migrating existing clone_mode instances to use working copies
|
||||
|
||||
Out of scope (future changes):
|
||||
- [ ] Auto-sync with canonical repo
|
||||
- [ ] Git operations UI (push/pull/branch)
|
||||
- [ ] Working copy sharing between users
|
||||
- [ ] Pre-create default working copies
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# Proposal: Workspace-Based Tool Instances
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Proposal** |
|
||||
| Based on | [Explore](explore.md) |
|
||||
| Next | Spec |
|
||||
|
||||
## Decisions from Explore
|
||||
|
||||
| Decision | Value |
|
||||
|---|---|
|
||||
| **Name** | "Workspace" (supersedes existing workspace.md) |
|
||||
| **Scope** | Unlimited workspaces per repository |
|
||||
| **Auto-create** | No — explicit creation only |
|
||||
| **Default branch** | Main/master or user-selected at creation time |
|
||||
| **Delete with running instances** | Allowed with confirmation; stops and deletes all associated tool instances |
|
||||
| **Name uniqueness** | Unique per project+repo (derived from project and repo names) |
|
||||
| **Deleted remote branch** | On sync/update, detect and ask for confirmation to delete local workspace/branch |
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current tool instance creation requires users to choose between "mount" (read-only) and "clone" (writable but ephemeral) modes. This is confusing and leads to either:
|
||||
- **Mount mode**: Tools open files read-only, frustrating editing
|
||||
- **Clone mode**: Each instance clones the repo, wasting disk space and losing work on deletion
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Introduce **Workspaces** as first-class entities: persistent, writable local clones of a repository that exist independently of tool instances. Users create workspaces explicitly, then start tool instances *on* a workspace.
|
||||
|
||||
### Entity Relationship
|
||||
|
||||
```
|
||||
Project
|
||||
└── GitRepository (canonical source)
|
||||
└── Workspace (writable clone, unlimited per repo)
|
||||
└── ToolInstance (mounts workspace path)
|
||||
```
|
||||
|
||||
### Key Behaviors
|
||||
|
||||
1. **Workspace Creation**: User selects a repository → picks a branch → names the workspace → clone is created on disk
|
||||
2. **Tool Instance Creation**: User selects a workspace → picks a tool type → instance starts with workspace mounted
|
||||
3. **Multiple Tools per Workspace**: Several tool instances can share the same workspace (e.g., terminal + code-server)
|
||||
4. **Persistence**: Workspaces survive tool instance deletion
|
||||
5. **No Auto-Create**: Users must explicitly create workspaces; no magic default workspace
|
||||
|
||||
### UI Changes
|
||||
|
||||
- **New sidebar entry**: "Workspaces" (between "Projects" and "Settings")
|
||||
- **Workspaces page**: List of all workspaces with repo/branch/status info
|
||||
- **Create workspace flow**: Repo picker → branch picker → name input
|
||||
- **Start tool from workspace**: Tool picker modal from workspace card
|
||||
- **Simplified tool creation**: Remove "clone mode" / "mount mode" toggle; always use workspace
|
||||
|
||||
### Database Changes
|
||||
|
||||
New table: `workspaces`
|
||||
- `id` (UUID, PK)
|
||||
- `name` (string, user-defined)
|
||||
- `repo_id` (UUID, FK → git_repositories)
|
||||
- `user_id` (UUID, FK → users)
|
||||
- `branch` (string)
|
||||
- `path` (string, absolute disk path)
|
||||
- `status` (enum: ready, syncing, error)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
Updated: `tool_instances`
|
||||
- Add `workspace_id` (UUID, FK → workspaces, nullable for migration)
|
||||
- Remove `clone_mode` (deprecated)
|
||||
- Remove `branch` (moved to workspace)
|
||||
|
||||
### File System Layout
|
||||
|
||||
```
|
||||
/data/working-copies/
|
||||
└── {repo-id}/
|
||||
└── {workspace-name}/
|
||||
└── .git/
|
||||
└── [repo files]
|
||||
```
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
Existing `clone_mode` instances:
|
||||
- Extract cloned repo from instance directory
|
||||
- Move to `/data/working-copies/{repo-id}/{instance-name}/`
|
||||
- Create Workspace record
|
||||
- Update instance to reference workspace
|
||||
- Remove `clone_mode` flag
|
||||
|
||||
Existing `mount_mode` instances:
|
||||
- On next start, create a workspace from the canonical repo
|
||||
- Switch instance to use workspace
|
||||
- Remove `clone_mode` flag
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Auto-sync with canonical repo
|
||||
- Git push/pull/branch UI
|
||||
- Workspace sharing between users
|
||||
- Pre-created default workspaces
|
||||
- Read-only workspace mode
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Existing users with many clone_mode instances | One-time migration on instance restart |
|
||||
| Disk space from many workspaces | User-managed; can delete workspaces |
|
||||
| Workspace deleted while instances are running | Allowed with confirmation; cascade-delete tool instances |
|
||||
| Name collisions for workspace names | Unique per project+repo; derived from project and repo names |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can create a workspace from any repository
|
||||
- [ ] User can create unlimited workspaces per repository
|
||||
- [ ] Tool instances mount the workspace path, not the canonical repo path
|
||||
- [ ] Multiple tool instances can share one workspace
|
||||
- [ ] Workspaces persist after tool instance deletion
|
||||
- [ ] Existing clone_mode instances migrate to workspace on restart
|
||||
- [ ] UI no longer shows "mount vs clone" toggle
|
||||
- [ ] New sidebar navigation "Workspaces" exists
|
||||
|
||||
## Open Questions for Spec
|
||||
|
||||
1. ~~Should workspace deletion cascade-delete associated tool instances, or block?~~ **Answered**: Allowed with confirmation; cascade-delete tool instances
|
||||
2. ~~Should workspace names be unique per-repo or globally unique?~~ **Answered**: Unique per project+repo; derived from project and repo names
|
||||
3. ~~How do we handle the case where a workspace's branch is deleted from the remote?~~ **Answered**: On sync/update, detect and ask for confirmation to delete local workspace/branch
|
||||
4. Should we validate the repo path exists before creating a workspace?
|
||||
@@ -0,0 +1,261 @@
|
||||
# Spec: Workspace-Based Tool Instances
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Spec** |
|
||||
| Based on | [Proposal](proposal.md) |
|
||||
| Next | Design |
|
||||
|
||||
## Overview
|
||||
|
||||
Workspaces are persistent, writable local clones of Git repositories. Users create workspaces explicitly, then start tool instances on them. This replaces the current "mount vs clone" decision with a simple "pick a workspace" flow.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Value |
|
||||
|---|---|
|
||||
| **Name** | "Workspace" |
|
||||
| **Scope** | Unlimited per repository |
|
||||
| **Auto-create** | No — explicit creation only |
|
||||
| **Delete with instances** | Allowed with confirmation; stops and deletes all associated tool instances |
|
||||
| **Name uniqueness** | Unique per project+repo; derived from project and repo names |
|
||||
| **Deleted remote branch** | On sync/update, detect and ask for confirmation to delete local workspace/branch |
|
||||
|
||||
## Database Schema
|
||||
|
||||
### New Table: `workspaces`
|
||||
|
||||
```sql
|
||||
CREATE TABLE workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
repo_id UUID NOT NULL REFERENCES git_repositories(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
branch VARCHAR(255) NOT NULL DEFAULT 'main',
|
||||
path VARCHAR(2048) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ready',
|
||||
last_sync_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
|
||||
UNIQUE (repo_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workspaces_repo_id ON workspaces(repo_id);
|
||||
CREATE INDEX idx_workspaces_user_id ON workspaces(user_id);
|
||||
CREATE INDEX idx_workspaces_status ON workspaces(status);
|
||||
```
|
||||
|
||||
### Updated Table: `tool_instances`
|
||||
|
||||
```sql
|
||||
ALTER TABLE tool_instances
|
||||
ADD COLUMN workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
ADD COLUMN clone_mode VARCHAR(16); -- deprecated, nullable for migration
|
||||
|
||||
-- Drop existing clone_mode column after all instances are migrated
|
||||
-- ALTER TABLE tool_instances DROP COLUMN clone_mode;
|
||||
```
|
||||
|
||||
Note: `tool_instances.branch` remains for now but is deprecated; the canonical branch lives on the workspace.
|
||||
|
||||
## Backend API
|
||||
|
||||
### Workspaces API
|
||||
|
||||
```
|
||||
GET /projects/{project_id}/repositories/{repo_id}/workspaces
|
||||
→ List workspaces for a repository
|
||||
|
||||
POST /projects/{project_id}/repositories/{repo_id}/workspaces
|
||||
→ Create a new workspace
|
||||
Body: { name: string, branch: string }
|
||||
|
||||
GET /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}
|
||||
→ Get workspace details
|
||||
|
||||
PATCH /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}
|
||||
→ Update workspace (rename, change branch)
|
||||
Body: { name?: string, branch?: string }
|
||||
|
||||
DELETE /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}
|
||||
→ Delete workspace (with ?force=true to skip confirmation)
|
||||
→ Stops and deletes all associated tool instances
|
||||
|
||||
POST /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}/sync
|
||||
→ Sync workspace with remote (detect deleted branches)
|
||||
```
|
||||
|
||||
### Tool Instances API (Updated)
|
||||
|
||||
```
|
||||
POST /projects/{project_id}/repositories/{repo_id}/instances
|
||||
Body: { tool_type_id, workspace_id, display_name?, config_profile_id? }
|
||||
→ Create instance on workspace
|
||||
|
||||
POST /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/start
|
||||
→ Start instance (creates workspace if mount_mode, migrates if clone_mode)
|
||||
```
|
||||
|
||||
### Instance Start Logic
|
||||
|
||||
```python
|
||||
def start_instance(instance, workspace_id=None):
|
||||
if instance.clone_mode == "clone":
|
||||
# Migrate: extract clone to workspace
|
||||
workspace = migrate_clone_to_workspace(instance)
|
||||
instance.workspace_id = workspace.id
|
||||
instance.clone_mode = None
|
||||
elif instance.workspace_id:
|
||||
# Already using a workspace
|
||||
workspace = get_workspace(instance.workspace_id)
|
||||
else:
|
||||
# Legacy mount_mode: create workspace on first start
|
||||
workspace = create_workspace_from_repo(instance.repo)
|
||||
instance.workspace_id = workspace.id
|
||||
|
||||
# Mount workspace path into container
|
||||
mount_path = workspace.path
|
||||
# ... rest of start logic
|
||||
```
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
```
|
||||
/workspaces → Workspaces list page
|
||||
/workspaces/new → Create workspace flow
|
||||
/workspaces/{id} → Workspace detail page
|
||||
/workspaces/{id}/tools → Start tool on workspace
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
### Sidebar Navigation
|
||||
|
||||
```
|
||||
Projects
|
||||
└── [project list]
|
||||
Workspaces (NEW)
|
||||
└── All Workspaces
|
||||
└── [recent workspaces]
|
||||
Settings
|
||||
```
|
||||
|
||||
### Workspaces Page
|
||||
|
||||
- Grid/list of workspace cards
|
||||
- Each card shows: name, repo, branch, status, active instances count
|
||||
- Actions: Start Tool, Sync, Settings, Delete
|
||||
|
||||
### Create Workspace Flow
|
||||
|
||||
1. **Repo picker**: Select from existing repositories
|
||||
2. **Branch picker**: Select branch (default: repo's default branch)
|
||||
3. **Name input**: Auto-suggested as `{project-name}-{repo-name}-{branch}` but editable
|
||||
4. **Create**: Clone repo to `/data/working-copies/{repo-id}/{name}/`
|
||||
|
||||
### Start Tool from Workspace
|
||||
|
||||
1. **Tool picker**: Select tool type
|
||||
2. **Config**: Optional config profile
|
||||
3. **Create**: Instance created with workspace mounted
|
||||
|
||||
## File System Layout
|
||||
|
||||
```
|
||||
/data/working-copies/
|
||||
└── {repo-id}/
|
||||
└── {workspace-name}/
|
||||
└── .git/
|
||||
└── [repo files]
|
||||
```
|
||||
|
||||
## Workspace Lifecycle
|
||||
|
||||
### Creation
|
||||
|
||||
1. Validate name uniqueness per repo
|
||||
2. Clone repo: `git clone --branch {branch} {remote_url} {path}`
|
||||
3. Set status to `ready`
|
||||
4. Return workspace record
|
||||
|
||||
### Deletion
|
||||
|
||||
1. Check for running tool instances
|
||||
2. If instances exist and no `?force=true`:
|
||||
- Return 409 Conflict with `{ instances: [...] }`
|
||||
- Frontend shows confirmation dialog
|
||||
3. If confirmed:
|
||||
- Stop all associated instances
|
||||
- Delete all associated instances
|
||||
- Delete workspace directory
|
||||
- Delete workspace record
|
||||
|
||||
### Sync
|
||||
|
||||
1. Fetch from remote: `git fetch origin`
|
||||
2. Check if workspace branch still exists on remote
|
||||
3. If branch deleted:
|
||||
- Return 409 with `{ branch_deleted: true }`
|
||||
- Frontend asks: "Branch '{branch}' was deleted. Delete this workspace?"
|
||||
4. If branch exists:
|
||||
- Pull changes: `git pull origin {branch}`
|
||||
- Update `last_sync_at`
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Existing clone_mode Instances
|
||||
|
||||
```python
|
||||
def migrate_clone_to_workspace(instance):
|
||||
# Find the cloned repo inside the instance directory
|
||||
clone_path = find_clone_in_instance_dir(instance)
|
||||
|
||||
# Create workspace
|
||||
workspace = Workspace(
|
||||
name=f"{instance.name}-migrated",
|
||||
repo_id=instance.repository_id,
|
||||
user_id=instance.owner_id,
|
||||
branch=instance.branch or "main",
|
||||
path=f"/data/working-copies/{instance.repository_id}/{instance.name}-migrated",
|
||||
)
|
||||
|
||||
# Move clone to workspace path
|
||||
move(clone_path, workspace.path)
|
||||
|
||||
return workspace
|
||||
```
|
||||
|
||||
### Existing mount_mode Instances
|
||||
|
||||
On first start after deployment:
|
||||
1. Create workspace from canonical repo
|
||||
2. Update instance to use workspace
|
||||
3. Remove clone_mode flag
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Database migration creates `workspaces` table
|
||||
- [ ] Database migration adds `workspace_id` to `tool_instances`
|
||||
- [ ] API endpoints for CRUD operations on workspaces
|
||||
- [ ] Workspace creation clones repo to `/data/working-copies/...`
|
||||
- [ ] Workspace deletion stops and deletes associated tool instances
|
||||
- [ ] Workspace sync detects deleted branches and asks for confirmation
|
||||
- [ ] Tool instance creation accepts `workspace_id` instead of `clone_mode`
|
||||
- [ ] Tool instance start mounts workspace path into container
|
||||
- [ ] Frontend has "Workspaces" sidebar entry
|
||||
- [ ] Frontend workspaces list page
|
||||
- [ ] Frontend create workspace flow
|
||||
- [ ] Frontend start tool from workspace
|
||||
- [ ] Existing clone_mode instances migrate on restart
|
||||
- [ ] Existing mount_mode instances create workspace on restart
|
||||
|
||||
## Quality Gates
|
||||
|
||||
- [ ] Backend tests: workspace CRUD, sync, deletion with instances
|
||||
- [ ] Frontend tests: workspace list, create, start tool
|
||||
- [ ] Integration tests: instance creation with workspace
|
||||
- [ ] ruff clean
|
||||
- [ ] TypeScript compilation clean
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Tasks: Workspace-Based Tool Instances
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Tasks** |
|
||||
| Based on | [Design](design.md) |
|
||||
| Next | Apply |
|
||||
|
||||
## PR Breakdown
|
||||
|
||||
### PR-1: Backend Foundation
|
||||
**Scope**: Database migration, models, services, API endpoints for workspaces
|
||||
**Est. lines**: ~800 backend, ~300 tests
|
||||
**Files touched**: 8 new, 2 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [x] Create Alembic migration for `workspaces` table + `workspace_id` on `tool_instances`
|
||||
2. [x] Create `Workspace` model (`apps/api/src/models/workspace.py`)
|
||||
3. [x] Add `workspace_id` to `ToolInstance` model (nullable FK)
|
||||
4. [x] Create `GitService` (`apps/api/src/services/git_service.py`) — clone, fetch, pull, branch_exists_remotely
|
||||
5. [x] Create `WorkspaceManager` (`apps/api/src/services/workspace_manager.py`) — create, delete, sync
|
||||
6. [x] Create workspace API router (`apps/api/src/api/workspaces.py`) — CRUD + sync endpoints
|
||||
7. [x] Add workspace routes to FastAPI app (`apps/api/src/main.py`)
|
||||
8. [ ] Write unit tests for GitService
|
||||
9. [ ] Write integration tests for workspace CRUD
|
||||
10. [ ] Write integration tests for delete-with-instances (409 behavior)
|
||||
11. [ ] Write integration tests for sync-with-deleted-branch (409 behavior)
|
||||
|
||||
### PR-2: Backend Integration
|
||||
**Scope**: Tool instance creation/start uses workspace instead of repo path
|
||||
**Est. lines**: ~400 backend, ~200 tests
|
||||
**Files touched**: 3 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [x] Update `create_instance` endpoint to accept `workspace_id` instead of `clone_mode`
|
||||
2. [x] Update `start_instance` to mount workspace path (`workspace.path`) instead of repo path
|
||||
3. [x] Update compose generation to use `WORKSPACE_PATH` variable
|
||||
4. [x] Update `tool_instances.py` compose template rendering
|
||||
5. [ ] Write integration tests for instance creation with workspace
|
||||
6. [ ] Write integration tests for instance start with workspace mount
|
||||
7. [x] Verify old mount_mode instances still work (backward compat)
|
||||
|
||||
### PR-3: Frontend Core
|
||||
**Scope**: Workspaces UI — list, create, card, actions
|
||||
**Est. lines**: ~1,200 frontend, ~400 tests
|
||||
**Files touched**: 10 new, 2 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [x] Create workspace types (`apps/web/src/types/workspace.ts`)
|
||||
2. [x] Create workspace API client (`apps/web/src/api/workspaces.ts`)
|
||||
3. [x] Create `useWorkspaces` hook (`apps/web/src/hooks/use-workspaces.ts`)
|
||||
4. [x] Create `useWorkspaceActions` hook (`apps/web/src/hooks/use-workspace-actions.ts`)
|
||||
5. [x] Create `WorkspaceCard` component (`apps/web/src/components/workspace-card.tsx`)
|
||||
6. [x] Create `WorkspaceCreateForm` component (`apps/web/src/components/workspace-create-form.tsx`)
|
||||
7. [x] Create `StartToolModal` component (`apps/web/src/components/start-tool-modal.tsx`)
|
||||
8. [ ] Create `WorkspacesPage` (`apps/web/src/pages/workspaces.tsx`)
|
||||
9. [ ] Update `Sidebar` to add Workspaces nav item
|
||||
10. [ ] Update router/routes to include `/workspaces`
|
||||
11. [ ] Write component tests for WorkspaceCard
|
||||
12. [ ] Write hook tests for useWorkspaceActions
|
||||
13. [ ] Write tests for create form validation
|
||||
|
||||
### PR-4: Frontend Integration
|
||||
**Scope**: Update existing flows to use workspaces, dashboard integration
|
||||
**Est. lines**: ~600 frontend, ~200 tests
|
||||
**Files touched**: 5 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [x] Update `CreateSessionForm` to use workspace picker instead of repo+clone_mode
|
||||
2. [ ] Update `SessionsPage` dashboard to show workspaces section
|
||||
3. [x] Update `SessionCard` to show workspace name instead of clone mode
|
||||
4. [x] Update `useInstanceActions` to pass `workspace_id` on create
|
||||
5. [x] Remove clone_mode/mount_mode UI toggles
|
||||
6. [x] Update types to remove deprecated `clone_mode` field
|
||||
7. [ ] Write integration tests for full create-workspace → start-tool flow
|
||||
8. [ ] Write tests for dashboard workspaces section
|
||||
|
||||
## Acceptance Criteria (All PRs)
|
||||
|
||||
- [x] User can create a workspace from any repository
|
||||
- [x] User can create unlimited workspaces per repository
|
||||
- [x] Workspace names are unique per repo
|
||||
- [x] Tool instances mount the workspace path
|
||||
- [x] Multiple tool instances can share one workspace
|
||||
- [x] Workspaces persist after tool instance deletion
|
||||
- [x] Deleting a workspace with running instances shows confirmation, stops and deletes instances
|
||||
- [x] Syncing a workspace with a deleted remote branch shows confirmation
|
||||
- [x] UI no longer shows "mount vs clone" toggle
|
||||
- [ ] New sidebar navigation "Workspaces" exists
|
||||
- [x] All existing tests still pass
|
||||
- [x] ruff clean
|
||||
- [x] TypeScript compilation clean
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
PR-1 (Backend Foundation)
|
||||
→ PR-2 (Backend Integration)
|
||||
→ PR-3 (Frontend Core)
|
||||
→ PR-4 (Frontend Integration)
|
||||
```
|
||||
|
||||
Each PR depends on the previous. No parallel work.
|
||||
|
||||
## Verification Steps per PR
|
||||
|
||||
### PR-1
|
||||
```bash
|
||||
cd apps/api
|
||||
alembic upgrade head
|
||||
pytest tests/unit/test_git_service.py tests/integration/test_workspaces.py -v
|
||||
python -m ruff check src/services/git_service.py src/services/workspace_manager.py src/api/workspaces.py
|
||||
```
|
||||
|
||||
### PR-2
|
||||
```bash
|
||||
cd apps/api
|
||||
pytest tests/integration/test_tool_instances_with_workspace.py -v
|
||||
python -m ruff check src/api/tool_instances.py
|
||||
```
|
||||
|
||||
### PR-3
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run test -- --run workspaces
|
||||
npx tsc --noEmit
|
||||
npx eslint src/pages/workspaces.tsx src/components/workspace-*.tsx
|
||||
```
|
||||
|
||||
### PR-4
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run test -- --run sessions create-session
|
||||
npx tsc --noEmit
|
||||
npx eslint src/pages/sessions.tsx src/components/create-session-form.tsx
|
||||
```
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
name: workspace-first-ui
|
||||
status: completed
|
||||
started_at: 2026-05-28
|
||||
completed_at: 2026-06-12
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# archive/2026-06-12-active-changes-archive/workspace-first-ui (index)
|
||||
dir: archive/2026-06-12-active-changes-archive/workspace-first-ui
|
||||
|
||||
## role
|
||||
Archive of planning documents for a workspace-first UI redesign that reorients the product around persistent writable workspaces rather than direct repository browsing.
|
||||
## parent
|
||||
index: archive/2026-06-12-active-changes-archive/.pi-map.index.md
|
||||
map: archive/2026-06-12-active-changes-archive/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- .openspec.yaml
|
||||
- design.md
|
||||
- proposal.md
|
||||
- spec.md
|
||||
- tasks.md
|
||||
## links
|
||||
index: archive/2026-06-12-active-changes-archive/workspace-first-ui/.pi-map.index.md
|
||||
map: archive/2026-06-12-active-changes-archive/workspace-first-ui/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# archive/2026-06-12-active-changes-archive/workspace-first-ui
|
||||
dir: archive/2026-06-12-active-changes-archive/workspace-first-ui
|
||||
|
||||
index: archive/2026-06-12-active-changes-archive/workspace-first-ui/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Archive of planning documents for a workspace-first UI redesign that reorients the product around persistent writable workspaces rather than direct repository browsing.
|
||||
## files
|
||||
- .openspec.yaml | Defines metadata for a completed project specification named "workspace-first-ui"
|
||||
- design.md | Design document for a workspace-first UI refresh, detailing backend API/services and frontend components for file management, git operations, and tool instances scoped to workspaces. | dep: FastAPI, SQLAlchemy/AsyncSession, React, React Router, asyncio, subprocess, os
|
||||
- proposal.md | Proposes a UI refresh to replace the old repository workspace with a workspace-first navigation model centered around persistent writable clones.
|
||||
- spec.md | Product specification document defining a workspace-first UI refresh that replaces direct repository file browsing with workspace-centric navigation, new routes, and redesigned pages for projects and workspace details. | dep: React/Next.js (implied by .tsx, routing), REST API backend, database (existing workspaces table), TypeScript, ruff, eslint
|
||||
- tasks.md | Project task tracking document for a workspace-first UI refresh, breaking work into 3 sequential PRs with backend endpoints, frontend workspace detail page, and projects page refresh with routing changes. | dep: markdown, pytest, ruff, TypeScript compiler, eslint, npm, git, python
|
||||
## arch
|
||||
Specification-driven development with phased PR-based delivery (backend → frontend → routing), using structured product docs (proposal → design → spec → tasks) and OpenAPI-style metadata for project tracking.
|
||||
## tags
|
||||
workspace, first, refresh, design, document, project, specification, .openspec
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+694
@@ -0,0 +1,694 @@
|
||||
# Design: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Design** |
|
||||
| Based on | [Spec](spec.md) |
|
||||
| Next | Tasks |
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
apps/api/src/
|
||||
├── api/
|
||||
│ ├── workspace_files.py # NEW: GET/POST /workspaces/{id}/files
|
||||
│ ├── workspace_git.py # NEW: /workspaces/{id}/git/*
|
||||
│ ├── workspace_instances.py # NEW: /workspaces/{id}/instances
|
||||
│ └── workspaces.py # MODIFIED: add repo_id to POST, enrich responses
|
||||
├── services/
|
||||
│ ├── git_operations.py # NEW: workspace-scoped git commands
|
||||
│ └── file_service.py # NEW: workspace file operations
|
||||
└── models/
|
||||
└── workspace.py # UNCHANGED
|
||||
```
|
||||
|
||||
### Service: FileService
|
||||
|
||||
```python
|
||||
class FileService:
|
||||
"""Read/write files within a workspace directory."""
|
||||
|
||||
def list_directory(self, workspace: Workspace, path: str = "") -> list[FileEntry]:
|
||||
abs_path = os.path.join(workspace.path, path)
|
||||
entries = []
|
||||
for item in os.listdir(abs_path):
|
||||
full = os.path.join(abs_path, item)
|
||||
stat = os.lstat(full)
|
||||
entries.append(FileEntry(
|
||||
name=item,
|
||||
path=os.path.join(path, item),
|
||||
type="directory" if os.path.isdir(full) else "file",
|
||||
size=stat.st_size if os.path.isfile(full) else None,
|
||||
))
|
||||
return entries
|
||||
|
||||
def read_file(self, workspace: Workspace, path: str) -> str:
|
||||
abs_path = os.path.join(workspace.path, path)
|
||||
with open(abs_path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
def write_file(self, workspace: Workspace, path: str, content: str) -> None:
|
||||
abs_path = os.path.join(workspace.path, path)
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
with open(abs_path, "w") as f:
|
||||
f.write(content)
|
||||
```
|
||||
|
||||
### Service: GitOperations
|
||||
|
||||
```python
|
||||
class GitOperations:
|
||||
"""Git commands scoped to a workspace directory."""
|
||||
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self.cwd = workspace.path
|
||||
self.branch = workspace.branch
|
||||
|
||||
async def status(self) -> GitStatus:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "-C", self.cwd, "status", "--porcelain",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
return self._parse_status(stdout.decode())
|
||||
|
||||
async def commit(self, message: str) -> None:
|
||||
await self._run("git", "-C", self.cwd, "add", "-A")
|
||||
await self._run("git", "-C", self.cwd, "commit", "-m", message)
|
||||
|
||||
async def push(self) -> None:
|
||||
await self._run("git", "-C", self.cwd, "push", "origin", self.branch)
|
||||
|
||||
async def pull(self) -> None:
|
||||
await self._run("git", "-C", self.cwd, "pull", "origin", self.branch)
|
||||
|
||||
async def fetch(self) -> None:
|
||||
await self._run("git", "-C", self.cwd, "fetch", "origin")
|
||||
|
||||
async def checkout(self, branch: str) -> None:
|
||||
await self._run("git", "-C", self.cwd, "checkout", branch)
|
||||
|
||||
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
|
||||
cmd = ["git", "-C", self.cwd, "log", f"--max-count={limit}", "--pretty=format:%H|%s|%an|%ad"]
|
||||
if path:
|
||||
cmd.extend(["--", path])
|
||||
stdout = await self._run_stdout(*cmd)
|
||||
return self._parse_log(stdout)
|
||||
```
|
||||
|
||||
### API: Workspace Files
|
||||
|
||||
```python
|
||||
@router.get("/{workspace_id}/files")
|
||||
async def list_files(workspace_id: uuid.UUID, path: str = ""):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
entries = FileService().list_directory(workspace, path)
|
||||
return {"entries": [e.dict() for e in entries]}
|
||||
|
||||
@router.get("/{workspace_id}/files/content")
|
||||
async def get_file_content(workspace_id: uuid.UUID, path: str):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
content = FileService().read_file(workspace, path)
|
||||
return {"content": content, "path": path}
|
||||
|
||||
@router.post("/{workspace_id}/files/content")
|
||||
async def write_file(workspace_id: uuid.UUID, data: dict):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
FileService().write_file(workspace, data["path"], data["content"])
|
||||
if data.get("message"):
|
||||
await GitOperations(workspace).commit(data["message"])
|
||||
return {"status": "saved"}
|
||||
```
|
||||
|
||||
### API: Workspace Git
|
||||
|
||||
```python
|
||||
@router.get("/{workspace_id}/git/status")
|
||||
async def git_status(workspace_id: uuid.UUID):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
return await GitOperations(workspace).status()
|
||||
|
||||
@router.post("/{workspace_id}/git/commit")
|
||||
async def git_commit(workspace_id: uuid.UUID, data: dict):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).commit(data["message"])
|
||||
return {"status": "committed"}
|
||||
|
||||
@router.post("/{workspace_id}/git/push")
|
||||
async def git_push(workspace_id: uuid.UUID):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).push()
|
||||
return {"status": "pushed"}
|
||||
|
||||
@router.post("/{workspace_id}/git/pull")
|
||||
async def git_pull(workspace_id: uuid.UUID):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).pull()
|
||||
return {"status": "pulled"}
|
||||
|
||||
@router.post("/{workspace_id}/git/fetch")
|
||||
async def git_fetch(workspace_id: uuid.UUID):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).fetch()
|
||||
return {"status": "fetched"}
|
||||
|
||||
@router.post("/{workspace_id}/git/checkout")
|
||||
async def git_checkout(workspace_id: uuid.UUID, data: dict):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).checkout(data["branch"])
|
||||
workspace.branch = data["branch"]
|
||||
await session.commit()
|
||||
return {"status": "checked_out", "branch": data["branch"]}
|
||||
|
||||
@router.get("/{workspace_id}/git/history")
|
||||
async def git_history(workspace_id: uuid.UUID, path: str | None = None, limit: int = 50):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
return await GitOperations(workspace).history(path, limit)
|
||||
```
|
||||
|
||||
### API: Workspace Instances
|
||||
|
||||
```python
|
||||
@router.get("/{workspace_id}/instances")
|
||||
async def list_workspace_instances(workspace_id: uuid.UUID, session: AsyncSession):
|
||||
result = await session.execute(
|
||||
select(ToolInstance).where(ToolInstance.workspace_id == workspace_id)
|
||||
)
|
||||
return [instance_to_dict(i) for i in result.scalars().all()]
|
||||
|
||||
@router.post("/{workspace_id}/instances")
|
||||
async def create_workspace_instance(
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
# Reuse existing create_instance logic but with workspace_id pre-set
|
||||
return await create_instance_internal(
|
||||
project_id=workspace.repo.project_id,
|
||||
repo_id=workspace.repo_id,
|
||||
tool_type_id=data["tool_type_id"],
|
||||
workspace_id=workspace_id,
|
||||
display_name=data.get("display_name"),
|
||||
config_profile_id=data.get("config_profile_id"),
|
||||
)
|
||||
```
|
||||
|
||||
### Modified: Projects API
|
||||
|
||||
```python
|
||||
@router.get("/")
|
||||
async def list_projects(user_id: uuid.UUID, session: AsyncSession):
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.owner_id == user_id).order_by(Project.created_at.desc())
|
||||
)
|
||||
projects = []
|
||||
for project in result.scalars().all():
|
||||
repos = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project.id)
|
||||
)
|
||||
repo_list = []
|
||||
for repo in repos.scalars().all():
|
||||
workspaces = await session.execute(
|
||||
select(Workspace).where(Workspace.repo_id == repo.id)
|
||||
)
|
||||
repo_list.append({
|
||||
"id": str(repo.id),
|
||||
"name": repo.name,
|
||||
"remote_url": repo.remote_url,
|
||||
"workspaces": [
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"branch": ws.branch,
|
||||
"status": ws.status,
|
||||
"instance_count": ...,
|
||||
}
|
||||
for ws in workspaces.scalars().all()
|
||||
],
|
||||
})
|
||||
projects.append({
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"repositories": repo_list,
|
||||
})
|
||||
return {"projects": projects}
|
||||
```
|
||||
|
||||
## Frontend Design
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── pages/
|
||||
│ ├── workspace-detail.tsx # NEW: /workspaces/:id
|
||||
│ ├── projects.tsx # MODIFIED: inline repos + workspaces
|
||||
│ └── workspaces.tsx # MODIFIED: link to detail
|
||||
├── components/
|
||||
│ ├── workspace/
|
||||
│ │ ├── workspace-header.tsx # NEW: breadcrumb + actions
|
||||
│ │ ├── workspace-tabs.tsx # NEW: tab bar component
|
||||
│ │ ├── workspace-file-panel.tsx # NEW: Files tab (tree + viewer + git toolbar)
|
||||
│ │ ├── workspace-git-panel.tsx # NEW: Git tab (history + diff)
|
||||
│ │ ├── workspace-tools-panel.tsx # NEW: Tools tab (instances + spawn)
|
||||
│ │ ├── workspace-settings-panel.tsx # NEW: Settings tab
|
||||
│ │ ├── git-toolbar.tsx # NEW: collapsible git toolbar
|
||||
│ │ ├── file-tree.tsx # NEW: extracted from repo-workspace
|
||||
│ │ ├── file-viewer.tsx # NEW: extracted from repo-workspace
|
||||
│ │ └── start-tool-modal.tsx # EXISTING: move to workspace/
|
||||
│ ├── project/
|
||||
│ │ ├── project-card.tsx # NEW: card with inline repos
|
||||
│ │ ├── repo-section.tsx # NEW: expandable repo + workspaces
|
||||
│ │ ├── workspace-chip.tsx # NEW: small workspace card
|
||||
│ │ └── new-workspace-inline.tsx # NEW: inline form
|
||||
│ └── app-shell.tsx # MODIFIED: nav order
|
||||
├── hooks/
|
||||
│ ├── use-workspace-files.ts # NEW
|
||||
│ ├── use-workspace-git.ts # NEW
|
||||
│ ├── use-workspace-instances.ts # NEW
|
||||
│ └── use-projects-enriched.ts # NEW: projects with repos + workspaces
|
||||
├── api/
|
||||
│ ├── workspace-files.ts # NEW
|
||||
│ ├── workspace-git.ts # NEW
|
||||
│ ├── workspace-instances.ts # NEW
|
||||
│ └── projects.ts # MODIFIED: enriched response
|
||||
└── router.tsx # MODIFIED: routes
|
||||
```
|
||||
|
||||
### Component: WorkspaceDetailPage
|
||||
|
||||
```tsx
|
||||
export function WorkspaceDetailPage() {
|
||||
const { workspaceId } = useParams();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("files");
|
||||
const { workspace, loading } = useWorkspace(workspaceId);
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
if (!workspace) return <NotFoundPage />;
|
||||
|
||||
return (
|
||||
<div className="workspace-detail">
|
||||
<WorkspaceHeader workspace={workspace} />
|
||||
<WorkspaceTabs active={activeTab} onChange={setActiveTab} />
|
||||
<div className="workspace-content">
|
||||
{activeTab === "files" && <WorkspaceFilePanel workspace={workspace} />}
|
||||
{activeTab === "git" && <WorkspaceGitPanel workspace={workspace} />}
|
||||
{activeTab === "tools" && <WorkspaceToolsPanel workspace={workspace} />}
|
||||
{activeTab === "settings" && <WorkspaceSettingsPanel workspace={workspace} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceFilePanel
|
||||
|
||||
```tsx
|
||||
export function WorkspaceFilePanel({ workspace }: { workspace: Workspace }) {
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { entries, loading } = useWorkspaceFiles(workspace.id);
|
||||
const { content } = useWorkspaceFileContent(workspace.id, selectedPath);
|
||||
const { status } = useWorkspaceGitStatus(workspace.id);
|
||||
|
||||
return (
|
||||
<div className="file-panel">
|
||||
<GitToolbar workspace={workspace} status={status} />
|
||||
<div className="file-panel-body">
|
||||
<FileTree
|
||||
entries={entries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
gitStatus={status}
|
||||
/>
|
||||
<FileViewer
|
||||
path={selectedPath}
|
||||
content={content}
|
||||
isEditing={isEditing}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
onSave={async (newContent, message) => {
|
||||
await saveWorkspaceFile(workspace.id, selectedPath, newContent, message);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: GitToolbar
|
||||
|
||||
```tsx
|
||||
export function GitToolbar({ workspace, status }: GitToolbarProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
|
||||
return (
|
||||
<div className={`git-toolbar ${expanded ? "expanded" : ""}`}>
|
||||
<div className="git-toolbar-summary">
|
||||
<span className="git-status modified">M {status.modified.length}</span>
|
||||
<span className="git-status added">A {status.added.length}</span>
|
||||
<span className="git-status deleted">D {status.deleted.length}</span>
|
||||
<button onClick={() => setExpanded(!expanded)}>Commit ▼</button>
|
||||
<button onClick={() => pushWorkspace(workspace.id)}>Push</button>
|
||||
<button onClick={() => pullWorkspace(workspace.id)}>Pull</button>
|
||||
<button onClick={() => fetchWorkspace(workspace.id)}>Fetch</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="git-toolbar-commit">
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Commit message"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
commitWorkspace(workspace.id, commitMessage);
|
||||
setCommitMessage("");
|
||||
setExpanded(false);
|
||||
}}
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceGitPanel
|
||||
|
||||
```tsx
|
||||
export function WorkspaceGitPanel({ workspace }: { workspace: Workspace }) {
|
||||
const { history, loading } = useWorkspaceGitHistory(workspace.id);
|
||||
const [selectedCommit, setSelectedCommit] = useState<Commit | null>(null);
|
||||
|
||||
return (
|
||||
<div className="git-panel">
|
||||
<div className="git-panel-header">
|
||||
<BranchSelector workspace={workspace} />
|
||||
<button>New Branch</button>
|
||||
</div>
|
||||
<div className="git-panel-body">
|
||||
<CommitHistory
|
||||
commits={history}
|
||||
selected={selectedCommit}
|
||||
onSelect={setSelectedCommit}
|
||||
/>
|
||||
{selectedCommit && (
|
||||
<CommitDetail commit={selectedCommit} workspace={workspace} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceToolsPanel
|
||||
|
||||
```tsx
|
||||
export function WorkspaceToolsPanel({ workspace }: { workspace: Workspace }) {
|
||||
const { instances, loading, refresh } = useWorkspaceInstances(workspace.id);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="tools-panel">
|
||||
{instances.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="terminal"
|
||||
title="No tools running"
|
||||
description="Start a tool to begin coding in this workspace"
|
||||
action={{ label: "Start Tool", onClick: () => setShowModal(true) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="tools-grid">
|
||||
{instances.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
onStop={refresh}
|
||||
onStart={refresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setShowModal(true)}>Start Another Tool</button>
|
||||
</>
|
||||
)}
|
||||
{showModal && (
|
||||
<StartToolModal
|
||||
workspace={workspace}
|
||||
onClose={() => setShowModal(false)}
|
||||
onStart={async (toolTypeId, configProfileId) => {
|
||||
await createWorkspaceInstance(workspace.id, toolTypeId, configProfileId);
|
||||
setShowModal(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: ProjectCard (refreshed)
|
||||
|
||||
```tsx
|
||||
export function ProjectCard({ project }: { project: EnrichedProject }) {
|
||||
return (
|
||||
<article className="card project-card">
|
||||
<div className="project-header">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-repos">
|
||||
{project.repositories.map((repo) => (
|
||||
<RepoSection key={repo.id} repo={repo} projectId={project.id} />
|
||||
))}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link to={`/projects/${project.id}`}>Open</Link>
|
||||
<button>Edit</button>
|
||||
<button>Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: RepoSection
|
||||
|
||||
```tsx
|
||||
export function RepoSection({ repo, projectId }: RepoSectionProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="repo-section">
|
||||
<button className="repo-header" onClick={() => setExpanded(!expanded)}>
|
||||
<Icon name={expanded ? "arrow-down" : "arrow-right"} />
|
||||
<span>{repo.name}</span>
|
||||
<span className="muted">{repo.remote_url}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="repo-workspaces">
|
||||
{repo.workspaces.map((ws) => (
|
||||
<Link key={ws.id} to={`/workspaces/${ws.id}`} className="workspace-chip">
|
||||
<span className="workspace-name">{ws.name}</span>
|
||||
<span className={`status-badge ${ws.status}`}>{ws.status}</span>
|
||||
{ws.instance_count > 0 && (
|
||||
<span className="instance-count">● {ws.instance_count}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
{showForm ? (
|
||||
<NewWorkspaceInline
|
||||
projectId={projectId}
|
||||
repoId={repo.id}
|
||||
onCreated={() => setShowForm(false)}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
) : (
|
||||
<button className="new-workspace-btn" onClick={() => setShowForm(true)}>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Mobile Layout
|
||||
|
||||
### Bottom Tab Bar
|
||||
|
||||
```tsx
|
||||
export function MobileTabBar({ active, onChange }: MobileTabBarProps) {
|
||||
const tabs: { id: Tab; icon: IconName; label: string }[] = [
|
||||
{ id: "files", icon: "folder", label: "Files" },
|
||||
{ id: "git", icon: "branch", label: "Git" },
|
||||
{ id: "tools", icon: "terminal", label: "Tools" },
|
||||
{ id: "settings", icon: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="mobile-tab-bar" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`mobile-tab ${active === tab.id ? "active" : ""}`}
|
||||
onClick={() => onChange(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={active === tab.id}
|
||||
>
|
||||
<Icon name={tab.icon} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Mobile Workspace Detail
|
||||
|
||||
```tsx
|
||||
export function MobileWorkspaceDetail({ workspace }: { workspace: Workspace }) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("files");
|
||||
|
||||
return (
|
||||
<div className="workspace-detail mobile">
|
||||
<WorkspaceHeader workspace={workspace} compact />
|
||||
<div className="workspace-content">
|
||||
{activeTab === "files" && <MobileFilePanel workspace={workspace} />}
|
||||
{activeTab === "git" && <MobileGitPanel workspace={workspace} />}
|
||||
{activeTab === "tools" && <MobileToolsPanel workspace={workspace} />}
|
||||
{activeTab === "settings" && <WorkspaceSettingsPanel workspace={workspace} />}
|
||||
</div>
|
||||
<MobileTabBar active={activeTab} onChange={setActiveTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Mobile Files tab**: Full-screen file tree. Tap file → opens viewer in slide-up panel.
|
||||
**Mobile Git tab**: Commit history list. Tap commit → diff in slide-up panel.
|
||||
**Mobile Tools tab**: Instance cards stacked, full width.
|
||||
**Mobile Settings tab**: Same as desktop, scrollable.
|
||||
|
||||
## Routing
|
||||
|
||||
```tsx
|
||||
// router.tsx changes
|
||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="workspaces/:workspaceId" element={<WorkspaceDetailPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId" element={<ProjectDetailPage />} />
|
||||
// Remove old repo-workspace route
|
||||
// <Route path="projects/:projectId" element={<RepoWorkspace />} /> — DELETED
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | Fetches | Polling |
|
||||
|---|---|---|
|
||||
| `useWorkspace(id)` | `GET /workspaces/{id}` | No |
|
||||
| `useWorkspaceFiles(id, path?)` | `GET /workspaces/{id}/files` | No |
|
||||
| `useWorkspaceFileContent(id, path?)` | `GET /workspaces/{id}/files/content` | No |
|
||||
| `useWorkspaceGitStatus(id)` | `GET /workspaces/{id}/git/status` | 10s when visible |
|
||||
| `useWorkspaceGitHistory(id)` | `GET /workspaces/{id}/git/history` | No |
|
||||
| `useWorkspaceInstances(id)` | `GET /workspaces/{id}/instances` | 10s when visible |
|
||||
| `useProjectsEnriched()` | `GET /projects/` | 30s |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Backend
|
||||
- Unit: FileService.list_directory, read_file, write_file
|
||||
- Unit: GitOperations.status, commit, push, pull, history
|
||||
- Integration: GET/POST /workspaces/{id}/files
|
||||
- Integration: /workspaces/{id}/git/* endpoints
|
||||
- Integration: /workspaces/{id}/instances
|
||||
- Integration: Enriched /projects/ response
|
||||
|
||||
### Frontend
|
||||
- Component: WorkspaceFilePanel renders file tree + viewer
|
||||
- Component: GitToolbar expands/collapses, commits
|
||||
- Component: WorkspaceToolsPanel shows empty state + modal
|
||||
- Component: ProjectCard renders repos + workspace chips
|
||||
- Hook: useWorkspaceGitStatus polls correctly
|
||||
- Hook: useProjectsEnriched caches correctly
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Multi-file search/replace
|
||||
- Real-time collaborative editing
|
||||
- Workspace backup/restore
|
||||
- Git merge conflict UI
|
||||
- Terminal inside workspace page
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New Files (Backend)
|
||||
- `apps/api/src/api/workspace_files.py`
|
||||
- `apps/api/src/api/workspace_git.py`
|
||||
- `apps/api/src/api/workspace_instances.py`
|
||||
- `apps/api/src/services/file_service.py`
|
||||
- `apps/api/src/services/git_operations.py`
|
||||
- `apps/api/tests/integration/test_workspace_files.py`
|
||||
- `apps/api/tests/integration/test_workspace_git.py`
|
||||
- `apps/api/tests/unit/test_file_service.py`
|
||||
- `apps/api/tests/unit/test_git_operations.py`
|
||||
|
||||
### Modified Files (Backend)
|
||||
- `apps/api/src/api/workspaces.py` (add repo_id to POST, enrich responses)
|
||||
- `apps/api/src/api/projects.py` (enriched list response)
|
||||
- `apps/api/src/main.py` (register new routers)
|
||||
|
||||
### New Files (Frontend)
|
||||
- `apps/web/src/pages/workspace-detail.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-header.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-tabs.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-file-panel.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-git-panel.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-tools-panel.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-settings-panel.tsx`
|
||||
- `apps/web/src/components/workspace/git-toolbar.tsx`
|
||||
- `apps/web/src/components/workspace/file-tree.tsx`
|
||||
- `apps/web/src/components/workspace/file-viewer.tsx`
|
||||
- `apps/web/src/components/project/project-card.tsx`
|
||||
- `apps/web/src/components/project/repo-section.tsx`
|
||||
- `apps/web/src/components/project/workspace-chip.tsx`
|
||||
- `apps/web/src/components/project/new-workspace-inline.tsx`
|
||||
- `apps/web/src/hooks/use-workspace-files.ts`
|
||||
- `apps/web/src/hooks/use-workspace-git.ts`
|
||||
- `apps/web/src/hooks/use-workspace-instances.ts`
|
||||
- `apps/web/src/hooks/use-projects-enriched.ts`
|
||||
- `apps/web/src/api/workspace-files.ts`
|
||||
- `apps/web/src/api/workspace-git.ts`
|
||||
- `apps/web/src/api/workspace-instances.ts`
|
||||
|
||||
### Modified Files (Frontend)
|
||||
- `apps/web/src/pages/projects.tsx` (full rewrite)
|
||||
- `apps/web/src/pages/workspaces.tsx` (link to detail)
|
||||
- `apps/web/src/components/app-shell.tsx` (nav order)
|
||||
- `apps/web/src/router.tsx` (routes)
|
||||
- `apps/web/src/api/projects.ts` (enriched response types)
|
||||
|
||||
### Deleted Files
|
||||
- `apps/web/src/pages/repo-workspace.tsx`
|
||||
- `apps/web/src/components/workspace-header.tsx`
|
||||
- `apps/web/src/components/git-toolbar.tsx` (old standalone version)
|
||||
- `apps/web/src/components/instance-list.tsx` (replaced by workspace-tools-panel)
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# Proposal: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Proposal** |
|
||||
| Based on | [Working Copies Spec](../working-copies/spec.md) |
|
||||
| Next | Spec |
|
||||
|
||||
## Problem
|
||||
|
||||
The current UI has two competing "workspace" concepts:
|
||||
|
||||
1. **Old "Repository Workspace"** (`repo-workspace.tsx`): A file browser + editor + git toolbar view tied to a repository. This was the default view when opening a project. It reads files from the repo path directly and offers quick editing.
|
||||
2. **New "Workspace"** (`workspaces.tsx`): A list of persistent writable clones that tool instances mount. These are first-class entities with their own lifecycle.
|
||||
|
||||
These two concepts confuse users. The old workspace is redundant now that workspaces are persistent clones — users should work inside a workspace, not directly on the repo.
|
||||
|
||||
Additionally:
|
||||
- The Projects page only shows a list of projects with "Open Workspace" links — no visibility into repos or workspaces
|
||||
- Tool instances are spawned from the repo-workspace view, not from the workspace view
|
||||
- Mobile layout of the old workspace is cramped and not well-suited for the new paradigm
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the old "Repository Workspace" with a **Workspace-First** navigation model:
|
||||
|
||||
### New Information Architecture
|
||||
|
||||
```
|
||||
Projects
|
||||
└── Project Card (inline repos + workspaces)
|
||||
└── Repo: "my-app"
|
||||
├── Workspace: "main" → /workspaces/{id}
|
||||
├── Workspace: "feature-auth" → /workspaces/{id}
|
||||
└── [+ New Workspace]
|
||||
Workspaces
|
||||
└── All Workspaces (grid/list)
|
||||
└── Workspace Card → /workspaces/{id}
|
||||
```
|
||||
|
||||
### Workspace Detail Page (`/workspaces/:workspaceId`)
|
||||
|
||||
The workspace detail page is the primary work surface. It replaces the old repo-workspace:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ {project} / {repo} / {workspace-name} [Start Tool ▼] │
|
||||
├──────────────┬───────────────────────────────────────────────┤
|
||||
│ │ Tabs: [Files] [Git] [Tools] │
|
||||
│ File Tree ├───────────────────────────────────────────────┤
|
||||
│ (workspace │ │
|
||||
│ clone) │ {active tab content} │
|
||||
│ │ │
|
||||
│ 📁 src/ │ │
|
||||
│ 📄 README │ │
|
||||
│ │ │
|
||||
├──────────────┤ │
|
||||
│ Git Status │ │
|
||||
│ (compact) │ │
|
||||
└──────────────┴───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Panels (collapsible, IDE-style):**
|
||||
- **Files**: File tree from workspace clone path + file viewer/editor
|
||||
- **Git**: Commit panel, branch selector, push/pull/fetch actions (operating on workspace clone)
|
||||
- **Tools**: List of active tool instances on this workspace + spawn new tool
|
||||
|
||||
**Start Tool**: Inline modal (not page navigation) to spawn a tool instance on this workspace.
|
||||
|
||||
### Projects Page Refresh
|
||||
|
||||
Project cards now show:
|
||||
- Project name + description
|
||||
- Repositories (accordion/list)
|
||||
- For each repo: its workspaces as clickable chips/cards
|
||||
- "New Workspace" button per repo
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ My Project │
|
||||
│ A web application │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Repositories: │
|
||||
│ │
|
||||
│ ▼ my-app (git@github.com:...) │
|
||||
│ ┌─────────┐ ┌─────────────┐ [+ New] │
|
||||
│ │ main │ │ feature-auth│ │
|
||||
│ │ ● 2 │ │ ● 0 │ │
|
||||
│ └─────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ▶ api-service │
|
||||
│ ┌─────────┐ [+ New] │
|
||||
│ │ main │ │
|
||||
│ └─────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Mobile Layout
|
||||
|
||||
Bottom tab bar (4 tabs):
|
||||
- **Files**: Full-screen file tree + viewer
|
||||
- **Git**: Compact commit panel + action buttons
|
||||
- **Tools**: Instance list + spawn button
|
||||
- **Menu**: Workspace switcher, settings
|
||||
|
||||
Swipe between tabs. File tree is always accessible.
|
||||
|
||||
### Deleted
|
||||
|
||||
- `pages/repo-workspace.tsx` — old repository workspace (file browser + editor on repo path)
|
||||
- Route `/projects/:projectId` → now shows project detail, not file browser
|
||||
- Old workspace header component
|
||||
- Git toolbar component (replaced by panel in workspace detail)
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- [ ] New workspace detail page (`/workspaces/:workspaceId`)
|
||||
- [ ] File browser reading from workspace clone path
|
||||
- [ ] File viewer/editor for workspace files
|
||||
- [ ] Git operations on workspace clone (status, commit, push, pull, fetch, branch)
|
||||
- [ ] Tool instance list per workspace
|
||||
- [ ] Inline tool spawn modal
|
||||
- [ ] Collapsible IDE-style panels (desktop)
|
||||
- [ ] Bottom tab bar layout (mobile)
|
||||
- [ ] Projects page refresh (inline repos + workspaces)
|
||||
- [ ] Workspace list page improvements (link to detail page)
|
||||
- [ ] Backend: file endpoints for workspace path
|
||||
- [ ] Backend: git endpoints for workspace path
|
||||
- [ ] Delete old `repo-workspace.tsx` and related components
|
||||
- [ ] Update routing
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Git history / diff view (deferred, can reuse existing page)
|
||||
- Workspace sharing between users
|
||||
- Advanced IDE features (search, multi-file edit)
|
||||
- Auto-sync on schedule
|
||||
- Terminal integration inside workspace page
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Question | Answer |
|
||||
|---|---|---|
|
||||
| 1 | Projects page → what happens on "Open"? | **A** — Show project detail with repos + workspaces inline |
|
||||
| 2 | Workspace page layout? | **C** — Collapsible panels, IDE-style |
|
||||
| 3 | File browser source? | Workspace clone path (`/data/working-copies/{repo-id}/{name}/`) |
|
||||
| 4 | Git actions scope? | Workspace clone |
|
||||
| 5 | Tool spawning? | Inline modal on workspace page |
|
||||
| 6 | Mobile layout? | Bottom tab bar (Files / Git / Tools / Menu), swipeable |
|
||||
| 7 | Old workspace fallback? | **No fallback** — delete immediately |
|
||||
| 8 | Projects page detail level? | Inline workspace cards on project page |
|
||||
|
||||
## Open Questions for Spec
|
||||
|
||||
1. Should the workspace detail page URL be `/workspaces/:id` or nested under project/repo?
|
||||
2. Should we keep the sidebar Workspaces nav entry, or rely on Projects → Workspace flow?
|
||||
3. How does "New Workspace" flow work from Projects page — inline form or navigate to create page?
|
||||
4. Should workspace detail show repo remote URL and allow switching branches?
|
||||
5. What happens when a workspace has no tool instances yet — show empty state or prompt to spawn?
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Users confused by navigation change | Keep "Workspaces" in sidebar, add breadcrumbs |
|
||||
| Large frontend refactor | Break into 3 PRs: backend endpoints, workspace detail page, projects refresh |
|
||||
| Mobile layout complexity | Prototype with CSS grid first, test on actual device |
|
||||
| Git operations on workspace path | Reuse existing git service, just change the path argument |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Old `repo-workspace.tsx` is deleted
|
||||
- [ ] `/projects/:id` shows project detail with repos and workspaces
|
||||
- [ ] `/workspaces/:id` shows workspace detail with Files, Git, Tools panels
|
||||
- [ ] File browser reads from workspace clone path
|
||||
- [ ] Git commit/push/pull work on workspace clone
|
||||
- [ ] Tool spawn modal creates instance with workspace mounted
|
||||
- [ ] Mobile layout uses bottom tabs
|
||||
- [ ] All existing tests pass (or updated)
|
||||
- [ ] ruff clean, TypeScript clean, eslint clean
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
# Spec: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Spec** |
|
||||
| Based on | [Proposal](proposal.md) |
|
||||
| Next | Design |
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the old "Repository Workspace" (direct repo file browser) with a **Workspace-First** model. The workspace detail page becomes the primary work surface. Projects page shows inline repos + workspaces. Old `repo-workspace.tsx` is deleted.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Question | Answer |
|
||||
|---|---|---|
|
||||
| 1 | URL structure | `/workspaces/:id` (flat) |
|
||||
| 2 | Sidebar nav order | Workspaces → Projects |
|
||||
| 3 | New Workspace flow | Inline form on project page |
|
||||
| 4 | Branch switching | Dropdown in workspace header |
|
||||
| 5 | Empty tool state | "Start a tool" prompt card |
|
||||
| 6 | Mobile tabs | Files / Git / Tools / Settings |
|
||||
| 7 | Git toolbar | Collapsible top bar on Files tab |
|
||||
| 8 | Git tab content | History, diff, full commit log |
|
||||
|
||||
## User Flows
|
||||
|
||||
### Flow 1: Open a Project
|
||||
|
||||
1. User clicks "Projects" in sidebar
|
||||
2. Sees project cards with inline repositories
|
||||
3. Each repo shows its workspaces as clickable cards
|
||||
4. User clicks a workspace → navigates to `/workspaces/:id`
|
||||
|
||||
### Flow 2: Work in a Workspace
|
||||
|
||||
1. User is on `/workspaces/:id`
|
||||
2. **Files tab** (default): File tree (left) + file viewer/editor (right). Git toolbar at top.
|
||||
3. User edits a file, commits via git toolbar
|
||||
4. **Git tab**: Full history, diff view, detailed commit log
|
||||
5. **Tools tab**: See running instances, click "Start Tool" → inline modal
|
||||
6. **Settings tab**: Sync workspace, rename, delete
|
||||
|
||||
### Flow 3: Create a Workspace
|
||||
|
||||
1. User on Projects page, expands a repo
|
||||
2. Clicks "+ New Workspace" next to a repo
|
||||
3. Inline form appears: name input, branch dropdown
|
||||
4. Submits → workspace created, appears in list
|
||||
|
||||
### Flow 4: Start a Tool
|
||||
|
||||
1. User on workspace detail, Tools tab
|
||||
2. If no instances: "Start a tool on this workspace" card
|
||||
3. If instances: list of cards + "Start Another" button
|
||||
4. Click → inline modal: tool type picker, config profile (optional)
|
||||
5. Submit → instance created, appears in list with status
|
||||
|
||||
## Backend API
|
||||
|
||||
### New Endpoints (workspace-scoped)
|
||||
|
||||
All endpoints operate on the workspace clone path (`workspace.path`).
|
||||
|
||||
```
|
||||
# Files
|
||||
GET /workspaces/{workspace_id}/files?path=&branch=
|
||||
→ List directory entries
|
||||
GET /workspaces/{workspace_id}/files/content?path=&branch=
|
||||
→ Get file content
|
||||
POST /workspaces/{workspace_id}/files/content
|
||||
Body: { path, content, message }
|
||||
→ Commit file change
|
||||
|
||||
# Git
|
||||
GET /workspaces/{workspace_id}/git/status
|
||||
→ { modified, added, deleted, untracked, branch }
|
||||
GET /workspaces/{workspace_id}/git/branches
|
||||
→ { branches, default_branch, current_branch }
|
||||
POST /workspaces/{workspace_id}/git/commit
|
||||
Body: { message, files? }
|
||||
→ Commit staged changes
|
||||
POST /workspaces/{workspace_id}/git/push
|
||||
→ Push current branch
|
||||
POST /workspaces/{workspace_id}/git/pull
|
||||
→ Pull current branch
|
||||
POST /workspaces/{workspace_id}/git/fetch
|
||||
→ Fetch from origin
|
||||
POST /workspaces/{workspace_id}/git/checkout
|
||||
Body: { branch }
|
||||
→ Switch branch
|
||||
GET /workspaces/{workspace_id}/git/history
|
||||
Query: ?path=&limit=50
|
||||
→ Commit history for file or entire repo
|
||||
|
||||
# Tools (instances on this workspace)
|
||||
GET /workspaces/{workspace_id}/instances
|
||||
→ List tool instances using this workspace
|
||||
POST /workspaces/{workspace_id}/instances
|
||||
Body: { tool_type_id, display_name?, config_profile_id? }
|
||||
→ Create instance on this workspace
|
||||
```
|
||||
|
||||
### Existing Endpoints (unchanged)
|
||||
|
||||
```
|
||||
GET /workspaces/
|
||||
POST /workspaces/ (body: { repo_id, name, branch })
|
||||
DELETE /workspaces/{id}
|
||||
POST /workspaces/{id}/sync
|
||||
PATCH /workspaces/{id}
|
||||
```
|
||||
|
||||
Note: `POST /workspaces/` now accepts `repo_id` directly instead of nested under `/projects/{pid}/repositories/{rid}/workspaces`.
|
||||
|
||||
### Modified Endpoints
|
||||
|
||||
```
|
||||
GET /projects/
|
||||
→ Now includes `repositories` array with `workspaces` sub-array
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
No changes. Existing `workspaces` table is sufficient.
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
```
|
||||
/ → Dashboard (unchanged)
|
||||
/workspaces → All workspaces list (refreshed)
|
||||
/workspaces/:id → Workspace detail (NEW, replaces repo-workspace)
|
||||
/projects → Projects list (refreshed)
|
||||
/projects/:id → Project detail with repos + workspaces (NEW)
|
||||
/sessions → Sessions list (unchanged)
|
||||
/settings → Settings (unchanged)
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
### WorkspaceDetailPage (`/workspaces/:id`)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Breadcrumb: Projects > {project} > {repo} > {workspace} │
|
||||
│ [Branch ▼ main] [Sync] [Start Tool] [Settings] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Tab bar: [Files] [Git] [Tools] [Settings] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ {Active Tab Content} │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Files Tab (default)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Git Toolbar (collapsible) │
|
||||
│ [Modified: 3] [Staged: 2] [Commit ▼] [Push] [Pull] [Fetch] │
|
||||
├──────────────┬───────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ File Tree │ File Viewer / Editor │
|
||||
│ (workspace │ │
|
||||
│ path) │ Breadcrumbs: src > utils > helpers.ts │
|
||||
│ │ │
|
||||
│ 📁 src/ │ [Edit] [History] │
|
||||
│ 📄 README │ │
|
||||
│ │ export function ... │
|
||||
│ │ │
|
||||
└──────────────┴───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Git Toolbar**: Collapsible bar above file content. Shows:
|
||||
- Status counters: Modified, Added, Deleted, Untracked
|
||||
- Commit button (with message input when expanded)
|
||||
- Push, Pull, Fetch buttons
|
||||
- Branch selector dropdown
|
||||
|
||||
#### Git Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Branch: [main ▼] [New Branch] [Merge] [Compare] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Commit History │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ ● abc123 Fix auth middleware │ │
|
||||
│ │ ● def456 Add user profile page │ │
|
||||
│ │ ● 789abc Initial commit │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Show Diff] [Checkout] [Revert] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Tools Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Active Tool Instances │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ [+ Start Tool] │
|
||||
│ │ Code Server │ │ Terminal │ │
|
||||
│ │ ● Running │ │ ● Stopped │ │
|
||||
│ │ [Open] [Stop│ │ [Start] [×] │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ─ or ─ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ No tools running on this workspace │ │
|
||||
│ │ Start a tool to begin coding │ │
|
||||
│ │ [Start Tool] │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Settings Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ Name: [my-feature-branch ] │
|
||||
│ Branch: main (tracks origin/main) │
|
||||
│ Path: /data/working-copies/{repo-id}/{name} │
|
||||
│ Created: 2024-01-15 │
|
||||
│ Last Sync: 2024-01-20 14:32 │
|
||||
│ │
|
||||
│ [Rename] [Sync Now] [Delete Workspace] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### ProjectsPage (`/projects`)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Projects [+ New Project] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ My Web App │ │
|
||||
│ │ A full-stack application │ │
|
||||
│ ├────────────────────────────────────────────────────┤ │
|
||||
│ │ Repositories: │ │
|
||||
│ │ │ │
|
||||
│ │ ▼ frontend (git@github.com:me/frontend.git) │ │
|
||||
│ │ ┌──────────┐ ┌─────────────┐ [+ New Workspace] │ │
|
||||
│ │ │ main │ │ feature-ui │ │ │
|
||||
│ │ │ ● 2 inst │ │ ● 0 inst │ │ │
|
||||
│ │ └──────────┘ └─────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ▶ backend (git@github.com:me/backend.git) │ │
|
||||
│ │ ┌──────────┐ [+ New Workspace] │ │
|
||||
│ │ │ main │ │ │
|
||||
│ │ │ ● 1 inst │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ [Edit Project] [Delete] │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Workspace Card**: Small card showing:
|
||||
- Name
|
||||
- Status badge (ready/syncing/error)
|
||||
- Instance count (dot + number)
|
||||
- Click navigates to `/workspaces/:id`
|
||||
|
||||
**New Workspace Button**: Inline form on click:
|
||||
```
|
||||
[Name: __________] [Branch: main ▼] [Create] [Cancel]
|
||||
```
|
||||
|
||||
### Mobile Layout
|
||||
|
||||
Bottom tab bar (4 tabs, always visible):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ {Tab Content - full screen} │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
├────────────────────────────────────┤
|
||||
│ 📁 Files 🔀 Git 🛠 Tools ⚙ Settings│
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Files tab**: File tree full screen, tap file → viewer overlay
|
||||
**Git tab**: Commit history list, tap commit → diff overlay
|
||||
**Tools tab**: Instance cards stacked vertically
|
||||
**Settings tab**: Same as desktop settings, scrollable
|
||||
|
||||
## State & Data Flow
|
||||
|
||||
### Workspace Detail Page
|
||||
|
||||
```
|
||||
useWorkspace(workspaceId) → fetch /workspaces/{id}
|
||||
useWorkspaceFiles(workspaceId, path?, branch?) → fetch /workspaces/{id}/files
|
||||
useWorkspaceGitStatus(workspaceId) → fetch /workspaces/{id}/git/status
|
||||
useWorkspaceInstances(workspaceId) → fetch /workspaces/{id}/instances
|
||||
```
|
||||
|
||||
All hooks poll/refetch on:
|
||||
- Tab switch
|
||||
- User action (commit, push, etc.)
|
||||
- 30s background refresh
|
||||
|
||||
### Projects Page
|
||||
|
||||
```
|
||||
useProjects() → fetch /projects/
|
||||
useProjectWorkspaces(projectId) → derived from project.repositories.workspaces
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | UX |
|
||||
|---|---|
|
||||
| Workspace not found | 404 page with "Workspace not found" + link to workspaces |
|
||||
| Git operation fails | Toast with git stderr, retry button |
|
||||
| File read fails | "File not found" in viewer, check if on correct branch |
|
||||
| No tool types available | "No tools configured" + link to Tool Workshop |
|
||||
| Workspace path missing | "Workspace files not found — try syncing" |
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Tab bar: `role="tablist"`, keyboard arrow navigation
|
||||
- File tree: `role="tree"`, arrow key expansion
|
||||
- Git toolbar: All buttons have `aria-label`
|
||||
- Focus management: Modal traps focus, returns on close
|
||||
|
||||
## Performance
|
||||
|
||||
- File tree: Virtualized for repos > 1000 files
|
||||
- Git history: Paginated (50 commits per page)
|
||||
- Image files: Lazy loaded in viewer
|
||||
- Polling: 30s for instances, 10s for git status when visible
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `repo-workspace.tsx` and related components deleted
|
||||
- [ ] `/projects/:id` route shows project detail, not file browser
|
||||
- [ ] `/workspaces/:id` route shows workspace detail page
|
||||
- [ ] File browser reads from workspace clone path
|
||||
- [ ] File viewer/editor works on workspace files
|
||||
- [ ] Git toolbar on Files tab supports commit/push/pull/fetch
|
||||
- [ ] Git tab shows commit history with diff
|
||||
- [ ] Tools tab lists instances + spawn modal
|
||||
- [ ] Settings tab shows workspace info + rename/sync/delete
|
||||
- [ ] Projects page shows inline repos + workspace cards
|
||||
- [ ] Inline "New Workspace" form on project page
|
||||
- [ ] Mobile: 4-tab bottom navigation
|
||||
- [ ] All existing tests pass or updated
|
||||
- [ ] ruff clean, TypeScript clean, eslint clean
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
# Tasks: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Tasks** |
|
||||
| Based on | [Design](design.md) |
|
||||
| Next | Apply |
|
||||
|
||||
## PR Breakdown
|
||||
|
||||
### PR-1: Backend — Workspace File, Git & Instance Endpoints
|
||||
**Scope**: All new backend endpoints for workspace-scoped operations
|
||||
**Est. lines**: ~900 backend, ~400 tests
|
||||
**Files touched**: 10 new, 3 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [x] Create `FileService` (`apps/api/src/services/file_service.py`)
|
||||
2. [x] Create `GitOperations` service (`apps/api/src/services/git_operations.py`)
|
||||
3. [x] Create `workspace_files` API router (`apps/api/src/api/workspace_files.py`)
|
||||
4. [x] Create `workspace_git` API router (`apps/api/src/api/workspace_git.py`)
|
||||
5. [x] Create `workspace_instances` API router (`apps/api/src/api/workspace_instances.py`)
|
||||
6. [x] Register new routers in `main.py`
|
||||
7. [x] Update `workspaces.py` POST to accept `repo_id` directly
|
||||
8. [x] Enrich `projects.py` list response with repos + workspaces
|
||||
9. [x] Write unit tests for FileService
|
||||
10. [x] Write unit tests for GitOperations
|
||||
11. [x] Write integration tests for workspace file endpoints
|
||||
12. [x] Write integration tests for workspace git endpoints
|
||||
13. [x] Write integration tests for workspace instance endpoints
|
||||
|
||||
### PR-2: Frontend — Workspace Detail Page
|
||||
**Scope**: Workspace detail page with 4 tabs, replaces old repo-workspace
|
||||
**Est. lines**: ~1,400 frontend, ~300 tests
|
||||
**Files touched**: 14 new, 3 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [x] Create `useWorkspaceFiles` hook
|
||||
2. [x] Create `useWorkspaceGit` hook
|
||||
3. [x] Create `useWorkspaceInstances` hook
|
||||
4. [x] Create workspace API clients (`workspace-files.ts`, `workspace-git.ts`, `workspace-instances.ts`)
|
||||
5. [x] Create `WorkspaceHeader` component (inline in WorkspaceDetailPage)
|
||||
6. [x] Create `WorkspaceTabs` component (inline in WorkspaceDetailPage)
|
||||
7. [x] Create `WorkspaceFilePanel` component (inline in WorkspaceDetailPage)
|
||||
8. [x] Create `GitToolbar` component (inline in Files tab)
|
||||
9. [x] Create `WorkspaceGitPanel` component (inline in WorkspaceDetailPage)
|
||||
10. [x] Create `WorkspaceToolsPanel` component (inline in WorkspaceDetailPage)
|
||||
11. [x] Create `WorkspaceSettingsPanel` component (inline in WorkspaceDetailPage)
|
||||
12. [x] Create `WorkspaceDetailPage` page
|
||||
13. [x] Add `/workspaces/:id` route
|
||||
14. [x] Delete `repo-workspace.tsx` and related components
|
||||
15. [x] Write component tests for WorkspaceDetailPage
|
||||
|
||||
### PR-3: Frontend — Projects Page Refresh & Routing
|
||||
**Scope**: Projects page with inline repos + workspaces, mobile layout
|
||||
**Est. lines**: ~800 frontend, ~200 tests
|
||||
**Files touched**: 5 new, 5 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [x] Update `projects.ts` API client for enriched response
|
||||
2. [x] Create `useProjects` hook with enriched response
|
||||
3. [x] Create `ProjectCard` component
|
||||
4. [x] Inline repo section in ProjectCard
|
||||
5. [x] Inline workspace chips in ProjectCard
|
||||
6. [x] Inline new workspace form in ProjectCard
|
||||
7. [x] Rewrite `ProjectsPage`
|
||||
8. [x] Inline project detail on ProjectsPage (mobile + desktop)
|
||||
9. [x] Update `AppShell` nav order (Workspaces → Projects)
|
||||
10. [x] Update `WorkspacesPage` / WorkspaceCard to link to detail
|
||||
11. [x] Add mobile tab bar to workspace detail
|
||||
12. [x] Remove old repo-workspace route and components
|
||||
13. [x] Write component tests for ProjectCard
|
||||
14. [ ] Write component tests for RepoSection
|
||||
15. [ ] Write tests for NewWorkspaceInline
|
||||
|
||||
## Acceptance Criteria (All PRs)
|
||||
|
||||
- [x] Old `repo-workspace.tsx` is deleted
|
||||
- [x] `/projects/:id` shows project detail with repos + workspaces (inline on ProjectsPage)
|
||||
- [x] `/workspaces/:id` shows workspace detail with 4 tabs
|
||||
- [x] File browser reads from workspace clone path
|
||||
- [x] File viewer/editor works on workspace files
|
||||
- [x] Git toolbar on Files tab supports commit/push/pull/fetch
|
||||
- [x] Git tab shows commit history
|
||||
- [x] Tools tab lists instances + spawn modal (or empty prompt)
|
||||
- [x] Settings tab shows workspace info + rename/sync/delete
|
||||
- [x] Projects page shows inline repos + workspace cards
|
||||
- [x] Inline "New Workspace" form on project page
|
||||
- [x] Mobile: 4-tab bottom navigation
|
||||
- [x] All existing tests pass or updated
|
||||
- [ ] ruff clean (ruff not available in current environment; py_compile passed)
|
||||
- [x] TypeScript compilation clean
|
||||
- [x] eslint clean
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
PR-1 (Backend endpoints)
|
||||
→ PR-2 (Workspace detail page)
|
||||
→ PR-3 (Projects refresh + routing)
|
||||
```
|
||||
|
||||
Each PR depends on the previous. No parallel work.
|
||||
|
||||
## Verification Steps per PR
|
||||
|
||||
### PR-1
|
||||
```bash
|
||||
cd apps/api
|
||||
pytest tests/unit/test_file_service.py tests/unit/test_git_operations.py -v
|
||||
pytest tests/integration/test_workspace_files.py tests/integration/test_workspace_git.py tests/integration/test_workspace_instances.py -v
|
||||
python -m ruff check src/services/file_service.py src/services/git_operations.py src/api/workspace_*.py
|
||||
```
|
||||
|
||||
### PR-2
|
||||
```bash
|
||||
cd apps/web
|
||||
npx tsc --noEmit
|
||||
npx eslint src/pages/workspace-detail.tsx src/components/workspace/
|
||||
npm run test -- --run workspace-detail
|
||||
```
|
||||
|
||||
### PR-3
|
||||
```bash
|
||||
cd apps/web
|
||||
npx tsc --noEmit
|
||||
npx eslint src/pages/projects.tsx src/components/project/
|
||||
npm run test -- --run projects
|
||||
```
|
||||
Reference in New Issue
Block a user