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.
17 KiB
SDD Tasks: Multi-Session Terminal UX
Review Workload Forecast
| Field | Value |
|---|---|
| Estimated changed lines | ~1,400–1,600 (new ~900, modified ~600–700) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1: DB + Backend Core → PR 2: Backend API + Tests → PR 3: Frontend + Tests |
| Delivery strategy | auto-chain |
| Chain strategy | stacked-to-main |
Decision needed before apply: Yes
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
Task Overview
| # | Task | PR | Est. Lines | Dependencies |
|---|---|---|---|---|
| 1 | Database schema and Alembic migration | 1 | ~80 | None |
| 2 | TerminalManager multi-session core | 1 | ~250 | Task 1 |
| 3 | TerminalSession name and status fields | 1 | ~40 | Task 2 |
| 4 | WebSocket routing and backward-compat alias | 2 | ~200 | Task 2 |
| 5 | REST endpoints for session CRUD | 2 | ~180 | Task 2, 4 |
| 6 | Frontend API client and useTerminalSessions hook |
3 | ~180 | Task 5 |
| 7 | TerminalComponent sessionId support |
3 | ~100 | Task 4, 6 |
| 8 | TerminalSessionTabs UI component |
3 | ~220 | Task 6 |
| 9 | TerminalPage multi-session orchestration and fullscreen |
3 | ~200 | Task 7, 8 |
| 10 | Mobile terminal integration | 3 | ~100 | Task 8, 9 |
| 11 | Backend integration tests | 2 | ~250 | Task 4, 5 |
| 12 | Frontend component tests | 3 | ~150 | Task 8, 9, 10 |
PR 1: Database + Backend Core
Task 1: Database Schema and Alembic Migration
Scope: Create the terminal_sessions metadata table and corresponding Alembic migration.
Files to create:
apps/api/src/models/terminal_session.pyapps/api/alembic/versions/XXXX_add_terminal_sessions_table.py
Files to modify:
apps/api/src/main.py— import new model so Alembic autogenerate discovers it
Acceptance Criteria:
TerminalSessionModelextendsBase,UUIDPrimaryKeyMixin,TimestampMixin- Columns:
instance_id(UUID, FKtool_instances.idON DELETE CASCADE, indexed),name(String 255, nullable),status(String 50, default"active"),created_at(DateTime TZ, non-nullable),last_activity_at(DateTime TZ, nullable),closed_at(DateTime TZ, nullable) - Migration is reversible (
downgradedrops table + index) make migrateapplies successfully in local dev
Testing (TDD):
- RED: Write a migration metadata test asserting the new table exists in
Base.metadataand has expected columns - GREEN: Create model and migration
- Run
pytest tests/integration/test_models.pyor equivalent to verify table registration
Task 2: TerminalManager Multi-Session Core
Scope: Refactor TerminalManager to support up to 5 concurrent sessions per instance using composite keys.
Files to modify:
apps/api/src/services/terminal_manager.py
Acceptance Criteria:
self._sessionskeyed by(instance_id: str, session_id: str)create_session(instance_id, container_id, startup_command=None, name=None):- Generates UUID
session_id - Enforces max 5 active sessions per instance (raise
MaxSessionsExceededError/ HTTP 409) - Inserts
TerminalSessionModelDB row (fire-and-forget async task acceptable) - Returns
TerminalSession
- Generates UUID
get_or_create_session(instance_id, container_id, ...)preserved for backward compatibility; uses"default"session_idget_session(instance_id, session_id)returns session orNoneget_sessions_for_instance(instance_id)returns list of in-memory sessionsclose_session(instance_id, session_id): kills PTY, removes from_sessions, updates DBstatus=closed,closed_at=now()reset_session(instance_id, container_id, session_id=None): ifsession_idomitted, resets"default"sessionattach_websocketonly closes existing WebSockets within the same(instance_id, session_id)_cleanup_idle_sessionsuses composite keys and updates DB status on cleanup- Idle timeout (30 min) and buffer replay behavior preserved
Testing (TDD):
- RED: Create
apps/api/tests/services/test_terminal_manager_multi.pywith tests:test_create_session_increases_counttest_create_session_enforces_max_5test_get_sessions_for_instance_filters_by_instancetest_close_session_removes_from_dict_and_updates_dbtest_attach_websocket_only_closes_same_sessiontest_default_session_keyed_separatelytest_idle_cleanup_updates_db_status
- GREEN: Implement
TerminalManagerchanges - Run
make test-unit
Task 3: TerminalSession Name and Status Fields
Scope: Add runtime name and status tracking to TerminalSession.
Files to modify:
apps/api/src/services/terminal_session.py
Acceptance Criteria:
__init__accepts optionalname; auto-generates"Session N"if omitted (N = per-instance counter)self.namestored as runtime attributeself.statusenum-like string:"active","resetting","closed"reset()setsstatus="resetting"during transition,"active"after restartclose()setsstatus="closed"- No breaking changes to existing
TerminalSessionbehavior
Testing (TDD):
- RED: Extend
test_terminal_manager_multi.pyor addtest_terminal_session_name_and_status.pycovering auto-naming, status transitions, and reset/close side effects - GREEN: Implement fields and transitions
- Run
make test-unit
PR 2: Backend API + Tests
Task 4: WebSocket Routing and Backward-Compat Alias
Scope: Add session-scoped WebSocket route, extract shared handler, preserve legacy alias.
Files to modify:
apps/api/src/api/terminal.py
Files to create:
apps/api/tests/api/test_terminal_ws_multi.py
Acceptance Criteria:
- New route:
@router.websocket("/ws/tool-instances/{instance_id}/terminal/{session_id}") - Existing route
@router.websocket("/ws/tool-instances/{instance_id}/terminal")preserved; callsget_or_create_session(...)for"default"session - Extract
async def _handle_terminal_websocket(websocket, instance_id, session_id, db_session)containing shared auth/validation/I/O loop logic - Both routes call
_handle_terminal_websocket - Auth/validation logic unchanged (cookie-based, ownership check, running status)
resetcontrol message scoped to the current session only (viaSessionRefupdate)- On unknown
session_id, close WS with code4004"Session not found"
Testing (TDD):
- RED: Write
test_terminal_ws_multi.py:test_specific_session_websocket_connectstest_default_session_alias_creates_defaulttest_concurrent_sessions_isolated_outputtest_reset_control_message_scoped_to_sessiontest_unknown_session_id_returns_4004
- GREEN: Implement routes and shared handler
- Run
pytest tests/api/test_terminal_ws_multi.py
Task 5: REST Endpoints for Session CRUD
Scope: Add REST endpoints for listing, creating, closing, resetting, and renaming sessions.
Files to modify:
apps/api/src/api/terminal.py
Acceptance Criteria:
GET /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions- Returns
{ sessions: [...] }withid,name,status,has_websockets,created_at,last_activity_at has_websocketsqueried live fromTerminalManager
- Returns
POST .../terminal/sessions— body{ name?: string }- Returns
201with{ id, name, status, created_at } - Returns
409if max 5 reached
- Returns
DELETE .../terminal/sessions/{sid}— returns{ status: "closed", session_id }POST .../terminal/sessions/{sid}/reset— returns{ id, name, status }POST .../terminal/sessions/{sid}/rename— body{ name: string }, returns{ id, name }- Existing
POST .../terminal/resetpreserved as alias for default session reset - All endpoints validate auth, ownership, and running instance status
Testing (TDD):
- RED: Add integration tests in
test_terminal_ws_multi.pyor newtest_terminal_rest.py:test_list_sessions_returns_db_and_live_statetest_create_session_201test_create_session_409_at_maxtest_close_session_200test_reset_session_200test_rename_session_200test_legacy_reset_alias_still_works
- GREEN: Implement endpoints
- Run
make test-integration
Task 6: Frontend API Client and useTerminalSessions Hook
Scope: Add frontend REST client functions and the central session state hook.
Files to create:
apps/web/src/api/terminal.ts(new file for terminal-specific API calls)apps/web/src/hooks/use-terminal-sessions.ts
Files to modify:
apps/web/src/api/sessions.ts— optional, or keep terminal API separate
Acceptance Criteria:
- API functions:
listTerminalSessions,createTerminalSession,closeTerminalSession,resetTerminalSession,renameTerminalSession useTerminalSessions(instanceId: string)hook:- Loads sessions on mount; auto-creates one if list is empty
- Exposes
sessions,activeSessionId,setActiveSessionId - Exposes
createSession,closeSession,renameSession,resetSessionwith optimistic UI updates - Handles 409 errors (max sessions) gracefully
- Refetches after reset/rename to stay in sync
Testing (TDD):
- RED: Write hook unit tests mocking API client:
test_loads_sessions_on_mounttest_auto_creates_session_if_emptytest_close_session_removes_from_statetest_create_session_enforces_max_5_error
- GREEN: Implement hook and API client
- Run
cd apps/web && npm test
PR 3: Frontend + Tests
Task 7: TerminalComponent sessionId Support
Scope: Update TerminalComponent to accept an optional sessionId and route WS accordingly.
Files to modify:
apps/web/src/components/terminal.tsx
Acceptance Criteria:
- New optional prop
sessionId?: string - WS URL constructed as:
/ws/tool-instances/{instanceId}/terminal/{sessionId}ifsessionIdprovided/ws/tool-instances/{instanceId}/terminalif omitted (backward compat)
- Reset button sends
{"type": "reset"}to the correct session's WS - Component still supports all existing props and mobile behavior
onTerminalReadycallback still works; parent can differentiate sessions by key
Testing (TDD):
- RED: Add/update
terminal.test.tsx(or similar) to assert WS URL includessessionIdwhen provided - GREEN: Implement prop and URL logic
- Run
cd apps/web && npm test
Task 8: TerminalSessionTabs UI Component
Scope: Build the tab bar for desktop and mobile.
Files to create:
apps/web/src/components/terminal-session-tabs.tsxapps/web/src/components/terminal-session-tabs.test.tsx
Acceptance Criteria:
- Props interface:
sessions,activeSessionId,onSelect,onClose,onCreate,onRename,isMobile? - Desktop: horizontal tab strip above terminal, overflow scroll with fade indicator
- Mobile: compact tabs integrated into auto-hide chrome, horizontal swipe scroll
- Each tab shows: name, status dot (connecting/connected/disconnected/error), close button (×) on hover/active
- Double-click to rename: inline
<input>,Enterto confirm,Escapeto cancel, blur confirms - New session button (+) at right end; disabled when 5 sessions exist
- Close confirmation: lightweight inline confirm tooltip (not modal)
- Accessible:
role="tablist",role="tab", keyboard navigation
Testing (TDD):
- RED: Write
terminal-session-tabs.test.tsx:test_renders_all_tabstest_click_tab_calls_onSelecttest_close_button_calls_onClosetest_double_click_enables_renametest_plus_disabled_at_max_sessionstest_status_dot_reflects_connection_state
- GREEN: Implement component
- Run
cd apps/web && npm test
Task 9: TerminalPage Multi-Session Orchestration, Fullscreen, and Shortcuts
Scope: Rewrite TerminalPage to manage multiple mounted terminals, fullscreen mode, and keyboard shortcuts.
Files to modify:
apps/web/src/pages/terminal.tsx
Acceptance Criteria:
- Uses
useTerminalSessionshook - Renders
<TerminalSessionTabs />above terminal area - Renders one
<TerminalComponent />per session; inactive sessions hidden viadisplay: none(preserves scrollback and WS) - On tab switch, active terminal calls
fitAddon.fit()via ref +useEffecton visibility - Fullscreen toggle:
Ctrl+Shift+Ftoggles.fullscreenclass- Desktop: hides page header; tab strip becomes minimal overlay (auto-hides after 3s, reappears on mouse move)
- Mobile: hides header, tab strip, special keys; floating handle reveals chrome
- Exit via
Escor UI button
- Keyboard shortcuts (registered in
useEffectonkeydown):Alt+Shift+N— new sessionAlt+Shift+W— close current sessionAlt+Shift+←/Alt+Shift+→— prev/next sessionAlt+Shift+R— reset current session- All use
preventDefault()only for the exact combo; no browser overrides
- Closing last session auto-creates a new default session
Testing (TDD):
- RED: Add
terminal-page.test.tsx:test_creates_default_session_on_empty_loadtest_switching_tabs_hides_inactive_terminalstest_fullscreen_toggle_adds_classtest_keyboard_shortcut_creates_sessiontest_close_last_session_auto_creates_default
- GREEN: Implement page orchestration
- Run
cd apps/web && npm test
Task 10: Mobile Terminal Integration
Scope: Integrate session tabs into mobile terminal wrapper and update header.
Files to modify:
apps/web/src/components/mobile-terminal-wrapper.tsxapps/web/src/components/mobile-terminal-header.tsx
Acceptance Criteria:
MobileTerminalWrapperaccepts session-related props fromTerminalPageand passes them toTerminalSessionTabsMobileTerminalHeaderdisplaysactiveSession.nameinstead of generic"Terminal"- Tab strip shares
useAutoHidebehavior with header (tapping terminal toggles visibility) - Special keys strip remains functional; no z-index conflicts with tabs
- Fullscreen on mobile correctly hides/shows all chrome layers
Testing (TDD):
- RED: Add/update mobile wrapper tests:
test_renders_session_tabstest_header_shows_session_nametest_auto_hide_applies_to_tabs
- GREEN: Implement mobile integration
- Run
cd apps/web && npm test
Task 11: Backend Integration Tests
Scope: Complete backend test coverage for multi-session WebSocket and REST behavior.
Files to create / modify:
apps/api/tests/services/test_terminal_manager_multi.py(finalize)apps/api/tests/api/test_terminal_ws_multi.py(finalize)
Acceptance Criteria:
- All tests from Tasks 2, 4, 5 pass
- Additional integration tests:
test_list_sessions_after_api_restart_shows_db_metadata(simulates restart by clearing in-memory dict)test_two_websockets_on_same_session_receive_same_outputtest_idle_cleanup_per_session_not_global
make testpasses (unit + integration)
Testing (TDD):
- These are the GREEN/TRIANGULATE phases for earlier backend tasks; ensure coverage is comprehensive
Task 12: Frontend Component Tests
Scope: Finalize frontend test coverage for tabs, page, and hook.
Files to create / modify:
apps/web/src/components/terminal-session-tabs.test.tsx(finalize)apps/web/src/hooks/use-terminal-sessions.test.ts(new, if not created earlier)apps/web/src/pages/terminal.test.tsx(new)
Acceptance Criteria:
- Tab component tests cover rendering, selection, close, rename, and max-session disable
- Hook tests cover load, create, close, error handling
- Page tests cover session lifecycle, fullscreen, and keyboard shortcuts
cd apps/web && npm testpasses
Testing (TDD):
- Finalize RED→GREEN→TRIANGULATE for all frontend tasks
Risks and Mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Resource exhaustion (5× docker exec per instance) | Medium | High | Max 5 enforced in create_session. Idle timeout (30 min) applies per session. |
| Mobile UX degraded by tab bar + special keys strip | Medium | Medium | Auto-hide shared between tabs and header. Compact tab design. Overflow scroll. |
| Concurrent WS policy closes wrong session's sockets | Medium | High | Explicit unit test: attach_websocket must only affect same (instance_id, session_id). |
| DB writes on hot path (activity tracking) | Low | Medium | last_activity_at updates are fire-and-forget async tasks; do not block I/O loop. |
| Frontend performance with 5 mounted xterm.js instances | Low | Medium | Max 5 sessions. Inactive terminals use display: none (not unmounted). xterm.js GPU acceleration handles this. |
| Default session alias ambiguity | Low | Low | Document that /terminal maps to "default". Future deprecation can migrate to explicit IDs. |
| Browser shortcut conflicts | Low | Medium | Use Alt+Shift+* instead of Ctrl+Shift+W/N. Only preventDefault() on exact matching combos. |
Rollback Plan
- PR 1 rollback: Alembic downgrade removes
terminal_sessionstable. OldTerminalManagercode is fully replaced, so reverting PR 1 requires reverting all subsequent PRs. - PR 2 rollback: Revert API changes. Legacy
/terminalWS route andPOST .../terminal/resetcontinue to work; new/terminal/{session_id}returns 404 but no clients call it until PR 3 is deployed. - PR 3 rollback: Revert frontend. Users see old single-session UI. Backend
/terminalalias continues to serve them.
Because PRs are stacked, rolling back PR 2 or PR 1 requires rolling back all dependent PRs above it.