# 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 ``, `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 `` above terminal area - Renders one `` 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.