diff --git a/docs/superpowers/plans/2026-03-26-terminal-multi-tabs.md b/docs/superpowers/plans/2026-03-26-terminal-multi-tabs.md new file mode 100644 index 0000000..35a905f --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-terminal-multi-tabs.md @@ -0,0 +1,101 @@ +# Terminal Multi Tabs Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow the connection list to open multiple terminal tabs for the same connection, with each click creating a fresh terminal session. + +**Architecture:** Keep the existing `/terminal` workspace and Pinia-driven tab model, but change terminal tab creation from connection-deduplicated to always-new. Add lightweight title numbering so repeated tabs for the same connection remain distinguishable without changing routing or terminal widget lifecycle. + +**Tech Stack:** Vue 3, TypeScript, Pinia, Vue Router, xterm.js, Vite, vue-tsc + +--- + +## File Structure + +- Modify: `frontend/src/stores/terminalTabs.ts` + - Change tab creation semantics and add repeated-title numbering. +- Modify: `frontend/src/views/ConnectionsView.vue` + - Use the new always-open terminal action from the connection list. +- Verify: `frontend/src/views/TerminalWorkspaceView.vue` + - Confirm current per-tab widget mounting already supports independent sessions. +- Verify: `frontend/src/layouts/MainLayout.vue` + - Confirm terminal sidebar tab rendering needs no structure change beyond new titles. +- Reference: `docs/superpowers/specs/2026-03-26-terminal-multi-tabs-design.md` + - Source of truth for behavior and acceptance criteria. + +### Task 1: Change Terminal Tabs Store to Always Create New Tabs + +**Files:** +- Modify: `frontend/src/stores/terminalTabs.ts` +- Reference: `frontend/src/stores/sftpTabs.ts` + +- [ ] **Step 1: Inspect current tab store behavior** + +Read `frontend/src/stores/terminalTabs.ts` and confirm the existing dedup-by-connection behavior. + +- [ ] **Step 2: Add repeated-title generation** + +Add a helper that counts currently open tabs for the same `connectionId` and returns: + +```ts +function getTabTitle(connection: Connection) { + const sameConnectionCount = tabs.value.filter(t => t.connectionId === connection.id).length + + if (sameConnectionCount === 0) { + return connection.name + } + + return `${connection.name} (${sameConnectionCount + 1})` +} +``` + +- [ ] **Step 3: Replace dedup open logic with always-new creation** + +Change the open action so it always creates a new `TerminalTab` and activates it immediately. + +- [ ] **Step 4: Keep activate/close behavior unchanged** + +Do not change tab activation and close-neighbor behavior except what is necessary to support the new open logic. + +### Task 2: Wire the Connection List to the New Behavior + +**Files:** +- Modify: `frontend/src/views/ConnectionsView.vue` +- Use: `frontend/src/stores/terminalTabs.ts` + +- [ ] **Step 1: Rename the method usage to match semantics** + +Update `openTerminal(conn)` to call the new always-open store action. + +- [ ] **Step 2: Preserve routing behavior** + +Keep `router.push('/terminal')` unchanged after opening the tab. + +### Task 3: Verify Integration and Build + +**Files:** +- Verify: `frontend/src/layouts/MainLayout.vue` +- Verify: `frontend/src/views/TerminalWorkspaceView.vue` + +- [ ] **Step 1: Confirm sidebar rendering already keys by `tab.id`** + +No structural code changes should be required if repeated tabs render correctly with distinct titles. + +- [ ] **Step 2: Run final frontend build** + +Run in `frontend/`: + +```bash +npm run build +``` + +Expected: `vue-tsc -b` and `vite build` both pass. + +- [ ] **Step 3: Manual runtime verification** + +Check: + +1. Same connection opens multiple terminal tabs. +2. Repeated tabs are titled distinctly. +3. Each tab remains an independent terminal session. +4. Close/switch behavior still works. diff --git a/docs/superpowers/specs/2026-03-26-terminal-multi-tabs-design.md b/docs/superpowers/specs/2026-03-26-terminal-multi-tabs-design.md new file mode 100644 index 0000000..244c41c --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-terminal-multi-tabs-design.md @@ -0,0 +1,97 @@ +# 2026-03-26 Terminal Multi Tabs Design + +## Background + +The connection list already opens terminal sessions into the shared terminal workspace at `/terminal`, and the sidebar already renders terminal tabs. However, the current store deduplicates tabs by `connectionId`, so clicking `终端` for the same connection only focuses the existing tab instead of opening a new shell session. + +## Goal + +- Allow users to open multiple terminal tabs from the connection list for the same connection. +- Keep each terminal tab as an independent shell session. +- Preserve the current terminal workspace route and sidebar interaction model. + +## Non-Goals + +- No tab persistence across refresh. +- No route change from `/terminal` to per-tab URLs. +- No generic tab-system refactor shared with SFTP. +- No terminal session restore or reconnect workflow. + +## Current State + +- `frontend/src/stores/terminalTabs.ts` manages terminal tabs. +- `openOrFocus(connection)` reuses an existing tab when `connectionId` matches. +- `frontend/src/views/ConnectionsView.vue` uses that method before routing to `/terminal`. +- `frontend/src/views/TerminalWorkspaceView.vue` already mounts one `TerminalWidget` per tab keyed by `tab.id`, so multiple tabs can coexist as long as the store allows them. + +## Selected Approach + +Keep the existing terminal workspace architecture and change only the terminal tab creation semantics: clicking `终端` always creates a new tab, even when the same connection already has open tabs. + +### 1. Terminal tab creation becomes always-new + +Update `frontend/src/stores/terminalTabs.ts` so the terminal action always creates a new tab entry: + +- remove the `connectionId` dedup behavior from the open action +- generate a fresh `tab.id` every time +- activate the newly created tab immediately + +This preserves the current in-memory tab lifecycle while enabling multiple concurrent sessions to the same host. + +### 2. Distinguishable tab titles for repeated connections + +When the same connection is opened multiple times, sidebar labels must remain distinguishable. + +Recommended title strategy: + +- first tab: `Connection Name` +- second tab: `Connection Name (2)` +- third tab: `Connection Name (3)` + +The sequence is computed from currently open tabs for the same `connectionId`. No persistence is needed. + +### 3. Connections entry behavior + +Update `frontend/src/views/ConnectionsView.vue` so clicking `终端` calls the always-new tab action and routes to `/terminal`. + +Result: + +- each click from the connection list opens a fresh shell session +- users can intentionally keep several terminals open for the same host + +### 4. Terminal workspace behavior stays unchanged + +`frontend/src/views/TerminalWorkspaceView.vue` and `frontend/src/components/TerminalWidget.vue` do not need architectural changes: + +- the workspace already loops through tabs by `tab.id` +- each active tab renders its own `TerminalWidget` +- each widget maintains an independent xterm instance and WebSocket connection + +This matches the desired UX: multiple tabs for the same connection are separate terminal sessions rather than alternate views of one shared session. + +## Behavior Kept Unchanged + +- Terminal route remains `/terminal`. +- Sidebar terminal tab section remains the primary tab switcher. +- Closing and activating adjacent terminal tabs continues to follow the current logic. +- SFTP tab behavior remains unchanged. +- Existing slate/cyan visual language remains unchanged. + +## Acceptance Criteria + +- Clicking `终端` for the same connection multiple times creates multiple terminal tabs. +- Each new terminal tab becomes the active tab. +- Repeated tabs for the same connection are visually distinguishable in the sidebar. +- Switching between tabs preserves each tab's own terminal session output. +- Closing a terminal tab keeps current close/activate behavior intact. +- Opening tabs for different connections continues to work. + +## Verification + +- Run `npm run build` in `frontend/`. +- Manual verification: + 1. Click `终端` on the same connection three times and confirm three tabs appear. + 2. Confirm the sidebar shows distinguishable titles for repeated tabs. + 3. Type different commands in different tabs for the same connection and confirm each tab keeps its own output. + 4. Close active and inactive terminal tabs and confirm focus changes remain correct. + 5. Re-check that opening terminal tabs for different connections still works. diff --git a/frontend/src/stores/terminalTabs.ts b/frontend/src/stores/terminalTabs.ts index bca5153..e0a260e 100644 --- a/frontend/src/stores/terminalTabs.ts +++ b/frontend/src/stores/terminalTabs.ts @@ -19,19 +19,21 @@ export const useTerminalTabsStore = defineStore('terminalTabs', () => { return `tab-${Date.now()}-${Math.random().toString(16).slice(2)}` } - function openOrFocus(connection: Connection) { - // 检查是否已存在该连接的标签页 - const existing = tabs.value.find(t => t.connectionId === connection.id) - if (existing) { - activate(existing.id) - return existing.id + function getTabTitle(connection: Connection) { + const sameConnectionCount = tabs.value.filter(t => t.connectionId === connection.id).length + + if (sameConnectionCount === 0) { + return connection.name } - // 创建新标签页 + return `${connection.name} (${sameConnectionCount + 1})` + } + + function openTab(connection: Connection) { const newTab: TerminalTab = { id: generateTabId(), connectionId: connection.id, - title: connection.name, + title: getTabTitle(connection), active: true, } @@ -67,7 +69,7 @@ export const useTerminalTabsStore = defineStore('terminalTabs', () => { tabs, activeTabId, activeTab, - openOrFocus, + openTab, activate, close, } diff --git a/frontend/src/views/ConnectionsView.vue b/frontend/src/views/ConnectionsView.vue index 2d36de3..aa84a8d 100644 --- a/frontend/src/views/ConnectionsView.vue +++ b/frontend/src/views/ConnectionsView.vue @@ -91,9 +91,9 @@ async function handleDelete(conn: Connection) { await store.deleteConnection(conn.id) } -function openTerminal(conn: Connection) { - tabsStore.openOrFocus(conn) - router.push('/terminal') +function openTerminal(conn: Connection) { + tabsStore.openTab(conn) + router.push('/terminal') } function openSftp(conn: Connection) { diff --git a/frontend/src/views/TerminalView.vue b/frontend/src/views/TerminalView.vue index d3648ba..3cd99d8 100644 --- a/frontend/src/views/TerminalView.vue +++ b/frontend/src/views/TerminalView.vue @@ -17,13 +17,13 @@ onMounted(async () => { await connectionsStore.fetchConnections() } - const conn = connectionsStore.getConnection(connectionId.value) - if (conn) { - // 打开或聚焦该连接的标签页 - tabsStore.openOrFocus(conn) - // 跳转到工作区 - router.replace('/terminal') - } else { + const conn = connectionsStore.getConnection(connectionId.value) + if (conn) { + // 打开新的终端标签页 + tabsStore.openTab(conn) + // 跳转到工作区 + router.replace('/terminal') + } else { // 连接不存在,返回连接列表 router.replace('/connections') }