Files
headquarter/openspec/changes/multi-session-terminal-ux/design.md
T
alex 62d1bdc462 feat: multi-session terminal frontend UI + tests (PR 3)
- 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)
2026-05-28 13:35:45 +02:00

35 KiB
Raw Blame History

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

# 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

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

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)

@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)

@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:

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)

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

"""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)

{
  "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

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

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

<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

interface TerminalProps {
  instanceId: string;
  sessionId?: string;  // NEW: omitted → uses default session (backward compat)
  // ... existing props
}

WebSocket URL

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)

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:

{
  "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:

{ "name": "Optional Custom Name" }

Response 201:

{
  "id": "uuid",
  "name": "Session 2",
  "status": "active",
  "created_at": "2026-05-28T10:00:00Z"
}

Response 409: (max sessions reached)

{ "detail": "Maximum of 5 terminal sessions reached for this instance" }

DELETE /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}

Response 200:

{ "status": "closed", "session_id": "uuid" }

POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}/reset

Response 200:

{
  "id": "uuid",
  "name": "Session 1",
  "status": "active"
}

POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}/rename

Request body:

{ "name": "New Name" }

Response 200:

{ "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.