# 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 = "" down_revision = "" 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 `` 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
{!isFullscreen && (
...
)}
{sessions.map((s) => (
handleCloseSession(s.id)} isMobile={isMobile} // ... other props />
))}
``` ### 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([]); const [activeSessionId, setActiveSessionId] = useState(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 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 (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 . │ ▼ 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. |