mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat: Keep terminal workflows inside desktop tabs
The desktop terminal already supported independent PTY sessions, but it only lived inside settings. This change promotes it to a first-class tab workflow so users can open multiple host terminals without leaving the chat-oriented desktop layout. Constraint: Tauri terminal sessions are process-backed and must stay mounted while switching tabs. Rejected: Reuse the settings terminal as a navigated page only | it cannot support multiple independent terminal tabs. Rejected: Hide inactive xterm panes with display none | xterm lost visible output after tab switches during E2E. Confidence: high Scope-risk: moderate Directive: Keep inactive terminal panes mounted and avoid display none unless xterm repaint behavior is reverified. Tested: bun run test src/components/layout/ContentRouter.test.tsx src/components/layout/Sidebar.test.tsx src/components/layout/TabBar.test.tsx src/pages/TerminalSettings.test.tsx Tested: bun run lint Tested: bun run build Tested: ./scripts/build-macos-arm64.sh Tested: Computer Use E2E against build-artifacts/macos-arm64 app for multiple terminals, command output retention, tab switching, and terminal cleanup Not-tested: Intel macOS package
This commit is contained in:
parent
e0abea6c46
commit
3d3dfdad76
76
desktop/src/components/layout/ContentRouter.test.tsx
Normal file
76
desktop/src/components/layout/ContentRouter.test.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../pages/EmptySession', () => ({
|
||||
EmptySession: () => <div data-testid="empty-session" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../pages/ActiveSession', () => ({
|
||||
ActiveSession: () => <div data-testid="active-session" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../pages/ScheduledTasks', () => ({
|
||||
ScheduledTasks: () => <div data-testid="scheduled-tasks" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../pages/Settings', () => ({
|
||||
Settings: () => <div data-testid="settings-page" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../pages/TerminalSettings', () => ({
|
||||
TerminalSettings: ({ active, onNewTerminal, testId }: { active: boolean; onNewTerminal: () => void; testId: string }) => (
|
||||
<div data-active={active ? 'true' : 'false'} data-testid={testId}>
|
||||
<button type="button" onClick={onNewTerminal}>New Terminal</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { ContentRouter } from './ContentRouter'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
describe('ContentRouter terminal tabs', () => {
|
||||
afterEach(() => {
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
})
|
||||
|
||||
it('renders the active terminal tab as main content', () => {
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }],
|
||||
activeTabId: '__terminal__1',
|
||||
})
|
||||
|
||||
render(<ContentRouter />)
|
||||
|
||||
expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-active', 'true')
|
||||
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps terminal tabs mounted while chat content is active', () => {
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' },
|
||||
{ sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' },
|
||||
],
|
||||
activeTabId: 'session-1',
|
||||
})
|
||||
|
||||
render(<ContentRouter />)
|
||||
|
||||
expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-active', 'false')
|
||||
expect(screen.getByTestId('active-session')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('can open another terminal tab from a terminal page', () => {
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }],
|
||||
activeTabId: '__terminal__1',
|
||||
})
|
||||
|
||||
render(<ContentRouter />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New Terminal' }))
|
||||
|
||||
expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')).toHaveLength(2)
|
||||
expect(useTabStore.getState().activeTabId).not.toBe('__terminal__1')
|
||||
})
|
||||
})
|
||||
@ -1,27 +1,56 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { EmptySession } from '../../pages/EmptySession'
|
||||
import { ActiveSession } from '../../pages/ActiveSession'
|
||||
import { ScheduledTasks } from '../../pages/ScheduledTasks'
|
||||
import { Settings } from '../../pages/Settings'
|
||||
import { TerminalSettings } from '../../pages/TerminalSettings'
|
||||
|
||||
export function ContentRouter() {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const activeTabType = useTabStore((s) => s.tabs.find((t) => t.sessionId === s.activeTabId)?.type)
|
||||
const tabs = useTabStore((s) => s.tabs)
|
||||
const activeTabType = tabs.find((t) => t.sessionId === activeTabId)?.type
|
||||
const terminalTabs = tabs.filter((tab) => tab.type === 'terminal')
|
||||
|
||||
// No tabs open — show empty session
|
||||
let page: ReactNode = null
|
||||
if (!activeTabId || !activeTabType) {
|
||||
return <EmptySession />
|
||||
page = <EmptySession />
|
||||
} else if (activeTabType === 'settings') {
|
||||
page = <Settings />
|
||||
} else if (activeTabType === 'scheduled') {
|
||||
page = <ScheduledTasks />
|
||||
} else if (activeTabType !== 'terminal') {
|
||||
page = <ActiveSession />
|
||||
}
|
||||
|
||||
// Special tabs
|
||||
if (activeTabType === 'settings') {
|
||||
return <Settings />
|
||||
}
|
||||
|
||||
if (activeTabType === 'scheduled') {
|
||||
return <ScheduledTasks />
|
||||
}
|
||||
|
||||
// Session tab — ActiveSession handles both regular and member sessions
|
||||
return <ActiveSession />
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{page && (
|
||||
<div className="absolute inset-0 z-10 flex min-h-0 flex-col overflow-hidden">
|
||||
{page}
|
||||
</div>
|
||||
)}
|
||||
{terminalTabs.map((tab) => {
|
||||
const active = tab.sessionId === activeTabId
|
||||
const visible = activeTabType === 'terminal' && active
|
||||
return (
|
||||
<div
|
||||
key={tab.sessionId}
|
||||
aria-hidden={!visible}
|
||||
data-testid={`terminal-tab-panel-${tab.sessionId}`}
|
||||
className={`absolute inset-0 flex min-h-0 flex-col overflow-hidden ${
|
||||
visible ? 'z-20 opacity-100' : 'pointer-events-none z-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<TerminalSettings
|
||||
active={active}
|
||||
workspace
|
||||
testId={`terminal-host-${tab.sessionId}`}
|
||||
onNewTerminal={() => useTabStore.getState().openTerminalTab()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ vi.mock('../../i18n', () => ({
|
||||
const translations: Record<string, string> = {
|
||||
'sidebar.newSession': 'New Session',
|
||||
'sidebar.scheduled': 'Scheduled',
|
||||
'sidebar.terminal': 'Terminal',
|
||||
'sidebar.settings': 'Settings',
|
||||
'sidebar.searchPlaceholder': 'Search sessions',
|
||||
'sidebar.noSessions': 'No sessions',
|
||||
@ -104,6 +105,26 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region')
|
||||
})
|
||||
|
||||
it('opens each terminal click as a first-class app tab', () => {
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Terminal' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Terminal' }))
|
||||
|
||||
const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')
|
||||
expect(terminalTabs).toHaveLength(2)
|
||||
expect(terminalTabs.map((tab) => tab.title)).toEqual(['Terminal 1', 'Terminal 2'])
|
||||
expect(useTabStore.getState().activeTabId).toBe(terminalTabs[1]!.sessionId)
|
||||
|
||||
useTabStore.getState().closeTab(terminalTabs[0]!.sessionId)
|
||||
useTabStore.getState().openTerminalTab()
|
||||
|
||||
expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal').map((tab) => tab.title)).toEqual([
|
||||
'Terminal 2',
|
||||
'Terminal 3',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows a toast when session creation fails', async () => {
|
||||
createSession.mockRejectedValue(new Error('boom'))
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ export function Sidebar() {
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type)
|
||||
const closeTab = useTabStore((s) => s.closeTab)
|
||||
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@ -202,6 +203,15 @@ export function Sidebar() {
|
||||
>
|
||||
{t('sidebar.scheduled')}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
active={activeTabType === 'terminal'}
|
||||
collapsed={!sidebarOpen}
|
||||
label={t('sidebar.terminal')}
|
||||
onClick={() => useTabStore.getState().openTerminalTab()}
|
||||
icon={<span className="material-symbols-outlined text-[18px]">terminal</span>}
|
||||
>
|
||||
{t('sidebar.terminal')}
|
||||
</NavItem>
|
||||
</div>
|
||||
|
||||
{sidebarOpen ? (
|
||||
|
||||
@ -333,4 +333,34 @@ describe('TabBar', () => {
|
||||
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-2'])
|
||||
expect(useTabStore.getState().activeTabId).toBe('tab-2')
|
||||
})
|
||||
|
||||
it('closes terminal tabs without disconnecting chat sessions', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
|
||||
const disconnectSession = vi.fn()
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' },
|
||||
],
|
||||
activeTabId: '__terminal__1',
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {},
|
||||
disconnectSession,
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.getByText('terminal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Close Terminal 1'))
|
||||
|
||||
expect(disconnectSession).not.toHaveBeenCalled()
|
||||
expect(useTabStore.getState().tabs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -77,7 +77,8 @@ export function TabBar() {
|
||||
const handleClose = (sessionId: string) => {
|
||||
// Special tabs can always be closed directly
|
||||
const tab = tabs.find((t) => t.sessionId === sessionId)
|
||||
if (tab && tab.type !== 'session') {
|
||||
if (!tab) return
|
||||
if (tab.type !== 'session') {
|
||||
closeTab(sessionId)
|
||||
return
|
||||
}
|
||||
@ -101,39 +102,38 @@ export function TabBar() {
|
||||
|
||||
const handleCloseOthers = (sessionId: string) => {
|
||||
setContextMenu(null)
|
||||
const otherIds = tabs.filter((t) => t.sessionId !== sessionId).map((t) => t.sessionId)
|
||||
for (const id of otherIds) {
|
||||
disconnectSession(id)
|
||||
closeTab(id)
|
||||
const otherTabs = tabs.filter((t) => t.sessionId !== sessionId)
|
||||
for (const tab of otherTabs) {
|
||||
if (tab.type === 'session') disconnectSession(tab.sessionId)
|
||||
closeTab(tab.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseLeft = (sessionId: string) => {
|
||||
setContextMenu(null)
|
||||
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
|
||||
const leftIds = tabs.slice(0, idx).map((t) => t.sessionId)
|
||||
for (const id of leftIds) {
|
||||
disconnectSession(id)
|
||||
closeTab(id)
|
||||
const leftTabs = tabs.slice(0, idx)
|
||||
for (const tab of leftTabs) {
|
||||
if (tab.type === 'session') disconnectSession(tab.sessionId)
|
||||
closeTab(tab.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseRight = (sessionId: string) => {
|
||||
setContextMenu(null)
|
||||
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
|
||||
const rightIds = tabs.slice(idx + 1).map((t) => t.sessionId)
|
||||
for (const id of rightIds) {
|
||||
disconnectSession(id)
|
||||
closeTab(id)
|
||||
const rightTabs = tabs.slice(idx + 1)
|
||||
for (const tab of rightTabs) {
|
||||
if (tab.type === 'session') disconnectSession(tab.sessionId)
|
||||
closeTab(tab.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseAll = () => {
|
||||
setContextMenu(null)
|
||||
const allIds = tabs.map((t) => t.sessionId)
|
||||
for (const id of allIds) {
|
||||
disconnectSession(id)
|
||||
closeTab(id)
|
||||
for (const tab of tabs) {
|
||||
if (tab.type === 'session') disconnectSession(tab.sessionId)
|
||||
closeTab(tab.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -402,6 +402,9 @@ const TabItem = forwardRef<HTMLDivElement, {
|
||||
{tab.type === 'scheduled' && (
|
||||
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">schedule</span>
|
||||
)}
|
||||
{tab.type === 'terminal' && (
|
||||
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">terminal</span>
|
||||
)}
|
||||
|
||||
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
{tab.title || 'Untitled'}
|
||||
|
||||
@ -18,6 +18,7 @@ export const en = {
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
'sidebar.newSession': 'New session',
|
||||
'sidebar.scheduled': 'Scheduled',
|
||||
'sidebar.terminal': 'Terminal',
|
||||
'sidebar.settings': 'Settings',
|
||||
'sidebar.searchPlaceholder': 'Search sessions...',
|
||||
'sidebar.noSessions': 'No sessions yet',
|
||||
@ -70,6 +71,7 @@ export const en = {
|
||||
'settings.terminal.status.exited': 'Exited',
|
||||
'settings.terminal.status.error': 'Error',
|
||||
'settings.terminal.status.unavailable': 'Unavailable',
|
||||
'terminal.newTab': 'New Terminal',
|
||||
|
||||
// Settings > Claude Official Login
|
||||
'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.',
|
||||
|
||||
@ -20,6 +20,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
'sidebar.newSession': '新建会话',
|
||||
'sidebar.scheduled': '定时任务',
|
||||
'sidebar.terminal': '终端',
|
||||
'sidebar.settings': '设置',
|
||||
'sidebar.searchPlaceholder': '搜索会话...',
|
||||
'sidebar.noSessions': '暂无会话',
|
||||
@ -72,6 +73,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.terminal.status.exited': '已退出',
|
||||
'settings.terminal.status.error': '错误',
|
||||
'settings.terminal.status.unavailable': '不可用',
|
||||
'terminal.newTab': '新建终端',
|
||||
|
||||
// Settings > Claude Official Login
|
||||
'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
|
||||
const terminalMocks = vi.hoisted(() => {
|
||||
const terminalInstance = {
|
||||
@ -54,6 +55,7 @@ import { TerminalSettings } from './TerminalSettings'
|
||||
|
||||
describe('TerminalSettings', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
terminalMocks.available = false
|
||||
terminalMocks.spawn.mockReset()
|
||||
terminalMocks.write.mockReset()
|
||||
|
||||
@ -15,7 +15,19 @@ const STATUS_LABEL_KEYS: Record<TerminalStatus, TranslationKey> = {
|
||||
unavailable: 'settings.terminal.status.unavailable',
|
||||
}
|
||||
|
||||
export function TerminalSettings() {
|
||||
type TerminalSettingsProps = {
|
||||
active?: boolean
|
||||
onNewTerminal?: () => void
|
||||
testId?: string
|
||||
workspace?: boolean
|
||||
}
|
||||
|
||||
export function TerminalSettings({
|
||||
active = true,
|
||||
onNewTerminal,
|
||||
testId = 'settings-terminal-host',
|
||||
workspace = false,
|
||||
}: TerminalSettingsProps = {}) {
|
||||
const t = useTranslation()
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const terminalRef = useRef<XTermTerminal | null>(null)
|
||||
@ -168,13 +180,19 @@ export function TerminalSettings() {
|
||||
}
|
||||
}, [resizeSession, startTerminal])
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
requestAnimationFrame(() => resizeSession())
|
||||
}
|
||||
}, [active, resizeSession])
|
||||
|
||||
const clearTerminal = () => {
|
||||
terminalRef.current?.clear()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[620px] flex-col overflow-hidden">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className={`flex h-full flex-col overflow-hidden ${workspace ? 'min-h-0 bg-[var(--color-surface)] px-5 py-4' : 'min-h-[620px]'}`}>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.terminal.title')}
|
||||
@ -184,6 +202,16 @@ export function TerminalSettings() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onNewTerminal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewTerminal}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">add</span>
|
||||
{t('terminal.newTab')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearTerminal}
|
||||
@ -247,7 +275,7 @@ export function TerminalSettings() {
|
||||
</div>
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-testid="settings-terminal-host"
|
||||
data-testid={testId}
|
||||
className="settings-terminal-host h-[calc(100%-2rem)] w-full overflow-hidden p-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -5,8 +5,9 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
|
||||
|
||||
export const SETTINGS_TAB_ID = '__settings__'
|
||||
export const SCHEDULED_TAB_ID = '__scheduled__'
|
||||
export const TERMINAL_TAB_PREFIX = '__terminal__'
|
||||
|
||||
export type TabType = 'session' | 'settings' | 'scheduled'
|
||||
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal'
|
||||
|
||||
export type Tab = {
|
||||
sessionId: string
|
||||
@ -25,6 +26,7 @@ type TabStore = {
|
||||
activeTabId: string | null
|
||||
|
||||
openTab: (sessionId: string, title: string, type?: TabType) => void
|
||||
openTerminalTab: () => string
|
||||
closeTab: (sessionId: string) => void
|
||||
setActiveTab: (sessionId: string) => void
|
||||
updateTabTitle: (sessionId: string, title: string) => void
|
||||
@ -54,6 +56,22 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
get().saveTabs()
|
||||
},
|
||||
|
||||
openTerminalTab: () => {
|
||||
const { tabs } = get()
|
||||
const nextIndex = Math.max(
|
||||
0,
|
||||
...tabs
|
||||
.filter((tab) => tab.type === 'terminal')
|
||||
.map((tab) => {
|
||||
const match = /^Terminal (\d+)$/.exec(tab.title)
|
||||
return match ? Number(match[1]) : 0
|
||||
}),
|
||||
) + 1
|
||||
const sessionId = `${TERMINAL_TAB_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
get().openTab(sessionId, `Terminal ${nextIndex}`, 'terminal')
|
||||
return sessionId
|
||||
},
|
||||
|
||||
closeTab: (sessionId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const index = tabs.findIndex((t) => t.sessionId === sessionId)
|
||||
@ -118,9 +136,12 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
|
||||
saveTabs: () => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal')
|
||||
const data: TabPersistence = {
|
||||
openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title, type: t.type })),
|
||||
activeTabId,
|
||||
openTabs: persistableTabs.map((t) => ({ sessionId: t.sessionId, title: t.title, type: t.type })),
|
||||
activeTabId: activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId)
|
||||
? activeTabId
|
||||
: (persistableTabs[0]?.sessionId ?? null),
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data))
|
||||
@ -142,6 +163,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
.filter((t) => {
|
||||
// Special tabs are always valid
|
||||
if (t.type === 'settings' || t.type === 'scheduled') return true
|
||||
if (t.type === 'terminal') return false
|
||||
// Session tabs must exist on server
|
||||
return existingIds.has(t.sessionId)
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user