feat: allow multiple terminal tabs per connection
This commit is contained in:
101
docs/superpowers/plans/2026-03-26-terminal-multi-tabs.md
Normal file
101
docs/superpowers/plans/2026-03-26-terminal-multi-tabs.md
Normal file
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -19,19 +19,21 @@ export const useTerminalTabsStore = defineStore('terminalTabs', () => {
|
|||||||
return `tab-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
return `tab-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function openOrFocus(connection: Connection) {
|
function getTabTitle(connection: Connection) {
|
||||||
// 检查是否已存在该连接的标签页
|
const sameConnectionCount = tabs.value.filter(t => t.connectionId === connection.id).length
|
||||||
const existing = tabs.value.find(t => t.connectionId === connection.id)
|
|
||||||
if (existing) {
|
if (sameConnectionCount === 0) {
|
||||||
activate(existing.id)
|
return connection.name
|
||||||
return existing.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建新标签页
|
return `${connection.name} (${sameConnectionCount + 1})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTab(connection: Connection) {
|
||||||
const newTab: TerminalTab = {
|
const newTab: TerminalTab = {
|
||||||
id: generateTabId(),
|
id: generateTabId(),
|
||||||
connectionId: connection.id,
|
connectionId: connection.id,
|
||||||
title: connection.name,
|
title: getTabTitle(connection),
|
||||||
active: true,
|
active: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ export const useTerminalTabsStore = defineStore('terminalTabs', () => {
|
|||||||
tabs,
|
tabs,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
activeTab,
|
activeTab,
|
||||||
openOrFocus,
|
openTab,
|
||||||
activate,
|
activate,
|
||||||
close,
|
close,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,9 +91,9 @@ async function handleDelete(conn: Connection) {
|
|||||||
await store.deleteConnection(conn.id)
|
await store.deleteConnection(conn.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTerminal(conn: Connection) {
|
function openTerminal(conn: Connection) {
|
||||||
tabsStore.openOrFocus(conn)
|
tabsStore.openTab(conn)
|
||||||
router.push('/terminal')
|
router.push('/terminal')
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSftp(conn: Connection) {
|
function openSftp(conn: Connection) {
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ onMounted(async () => {
|
|||||||
await connectionsStore.fetchConnections()
|
await connectionsStore.fetchConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = connectionsStore.getConnection(connectionId.value)
|
const conn = connectionsStore.getConnection(connectionId.value)
|
||||||
if (conn) {
|
if (conn) {
|
||||||
// 打开或聚焦该连接的标签页
|
// 打开新的终端标签页
|
||||||
tabsStore.openOrFocus(conn)
|
tabsStore.openTab(conn)
|
||||||
// 跳转到工作区
|
// 跳转到工作区
|
||||||
router.replace('/terminal')
|
router.replace('/terminal')
|
||||||
} else {
|
} else {
|
||||||
// 连接不存在,返回连接列表
|
// 连接不存在,返回连接列表
|
||||||
router.replace('/connections')
|
router.replace('/connections')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user