62d1bdc462
- Add TerminalSessionTabs component with status dots, rename, close, max-5 limit - Add 7 component tests for tab rendering, selection, close, rename - TerminalComponent: sessionId prop, forwardRef with fit() method - TerminalPage: multi-session orchestration, tab switching, auto-create default - Fullscreen mode: Alt+Shift+F toggle, auto-hide tabs, Esc exit - Keyboard shortcuts: Alt+Shift+N/W/ArrowLeft/ArrowRight/R - Add CSS for tabs, fullscreen, mobile responsive - Update useTerminalSessions hook for session CRUD - terminal_manager.py: lookup by internal session_id fallback Quality gates: tsc --noEmit clean, vitest (7/7 new tests passed), pytest (182 passed)
868 lines
35 KiB
Markdown
868 lines
35 KiB
Markdown
# 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. |
|