Move the following completed changes from openspec/changes/ to openspec/changes/archive/2026-06-12-completed-changes-archive/: - multi-session-terminal-ux - reorganize-long-files - working-copies - workspace-first-ui Update parent and archive .pi-map*.md indexes to reflect the move and remove the transient active-changes-archive grouping. openspec/changes/ now contains only the archive/ directory.
35 KiB
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
TerminalComponentopens its own WebSocket to its specificsession_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}/terminalmaps 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(nosession_id),TerminalManagerchecks 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 duringreset()— cleared after new process starts.closed: Set afterclose()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_idis indexed because lookups by instance are frequent (listing sessions, cleanup).nameis nullable; auto-generated names are stored here so they survive page reloads.statustracksactivevsclosed. TheTerminalManagerupdateslast_activity_atwhenever a WebSocket attaches/detaches or I/O occurs.- On API restart, in-memory sessions are lost, but
terminal_sessionsrows 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.Enterto confirm,Escapeto 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.
MobileTerminalHeadergains asessionTabsrender 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
- Mount:
useEffectcallsGET .../terminal/sessions. If no sessions exist, auto-creates one viaPOST. - Active session: Only one tab is visually active. All
TerminalComponentinstances remain mounted but inactive ones use CSSdisplay: noneto preserve xterm.js scrollback and WebSocket connections. - Switch tabs: Updates
activeSessionId. The newly active tab'sTerminalComponenttriggersfitAddon.fit()via a ref callback after becoming visible (using auseEffecton 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 fromTerminalPage. - Renders
TerminalSessionTabsbetweenMobileTerminalHeaderand the terminal content area. - The tab strip auto-hides along with the header (
useAutoHide). MobileTerminalHeadertitle is updated to showactiveSession.nameinstead 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
TerminalPageadds.fullscreenclass.- 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:
Esckey or click exit-fullscreen button.
Mobile Fullscreen
- Same as desktop but also hides
SpecialKeysStripandSpecialKeysPanel. - 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)
- Run Alembic migration to create
terminal_sessionstable. - No code reads from or writes to this table yet. Existing sessions remain purely in-memory.
- Rollback: Alembic downgrade removes table (no data loss risk since table is empty).
Phase 2: Backend API (Backward Compatible)
- Deploy updated
TerminalManagerwith composite key_sessions. - Deploy updated
TerminalSessionwithnamesupport. - Deploy new WebSocket route
/terminal/{session_id}and preserve/terminalalias. - Deploy new REST endpoints (
GET/POST/DELETE .../sessions). - Update DB writes on session lifecycle (create, close, activity update).
- Rollback: Revert code. Old
/terminalendpoint continues to work. New/terminal/{session_id}returns 404, but no clients call it yet.
Phase 3: Frontend (Feature Flag Optional)
- Deploy new components (
TerminalSessionTabs,useTerminalSessions). - Update
TerminalPageandMobileTerminalWrapper. - Update
TerminalComponentto accept optionalsessionIdprop. - If a feature flag is used, enable multi-session UI for beta users first.
- Rollback: Revert frontend. Users see the old single-session UI. Backend
/terminalalias continues to serve them.
Phase 4: Deprecation & Cleanup (Follow-Up Task)
- Monitor usage of the legacy
/terminalWebSocket endpoint andPOST .../terminal/resetREST endpoint. - 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).
- 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. |