mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix(desktop): expand workbench panels into tabs (#730)
Tested: cd desktop && bun run test -- --run src/components/workbench/WorkbenchPanel.test.tsx Tested: cd desktop && bun run test -- --run src/components/layout/ContentRouter.test.tsx Tested: cd desktop && bun run test -- --run src/stores/tabStore.test.ts Tested: bun run check:desktop Confidence: high Scope-risk: moderate
This commit is contained in:
parent
a4799ae5ea
commit
f5a97ac21c
@ -42,10 +42,16 @@ vi.mock('../../pages/TraceList', () => ({
|
||||
TraceList: () => <div data-testid="trace-list" />,
|
||||
}))
|
||||
|
||||
vi.mock('../workbench/WorkbenchTab', () => ({
|
||||
WorkbenchTab: ({ sessionId, tabId }: { sessionId: string; tabId: string }) => (
|
||||
<div data-testid="workbench-tab">workbench:{sessionId}:{tabId}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { ContentRouter } from './ContentRouter'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
describe('ContentRouter terminal tabs', () => {
|
||||
describe('ContentRouter tab surfaces', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
previewBridgeMock.close.mockClear()
|
||||
@ -148,6 +154,24 @@ describe('ContentRouter terminal tabs', () => {
|
||||
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders workbench tabs as main content instead of mounting the chat session surface', () => {
|
||||
useTabStore.setState({
|
||||
tabs: [{
|
||||
sessionId: '__workbench__session-1',
|
||||
title: 'Workbench',
|
||||
type: 'workbench',
|
||||
status: 'idle',
|
||||
workbenchSessionId: 'session-1',
|
||||
}],
|
||||
activeTabId: '__workbench__session-1',
|
||||
})
|
||||
|
||||
render(<ContentRouter />)
|
||||
|
||||
expect(screen.getByTestId('workbench-tab')).toHaveTextContent('workbench:session-1:__workbench__session-1')
|
||||
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the native preview when switching from a chat session to settings', async () => {
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
|
||||
@ -7,6 +7,7 @@ import { Settings } from '../../pages/Settings'
|
||||
import { TerminalSettings } from '../../pages/TerminalSettings'
|
||||
import { TraceList } from '../../pages/TraceList'
|
||||
import { TraceSession } from '../../pages/TraceSession'
|
||||
import { WorkbenchTab } from '../workbench/WorkbenchTab'
|
||||
import { previewBridge } from '../../lib/previewBridge'
|
||||
|
||||
export function ContentRouter() {
|
||||
@ -16,7 +17,7 @@ export function ContentRouter() {
|
||||
const terminalTabs = tabs.filter((tab) => tab.type === 'terminal')
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTabType === 'session') return
|
||||
if (activeTabType === 'session' || activeTabType === 'workbench') return
|
||||
void previewBridge.close()
|
||||
}, [activeTabType])
|
||||
|
||||
@ -32,6 +33,11 @@ export function ContentRouter() {
|
||||
page = traceSessionId ? <TraceSession sessionId={traceSessionId} /> : <EmptySession />
|
||||
} else if (activeTabType === 'traces') {
|
||||
page = <TraceList />
|
||||
} else if (activeTabType === 'workbench') {
|
||||
const workbenchTab = tabs.find((t) => t.sessionId === activeTabId)
|
||||
page = workbenchTab?.workbenchSessionId
|
||||
? <WorkbenchTab tabId={activeTabId} sessionId={workbenchTab.workbenchSessionId} />
|
||||
: <EmptySession />
|
||||
} else if (activeTabType !== 'terminal') {
|
||||
page = <ActiveSession />
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
TERMINAL_TAB_PREFIX,
|
||||
TRACE_LIST_TAB_ID,
|
||||
TRACE_TAB_PREFIX,
|
||||
WORKBENCH_TAB_PREFIX,
|
||||
useTabStore,
|
||||
type Tab,
|
||||
} from '../../stores/tabStore'
|
||||
@ -44,7 +45,8 @@ function isSessionTabId(tabId: string | null) {
|
||||
tabId !== SCHEDULED_TAB_ID &&
|
||||
tabId !== TRACE_LIST_TAB_ID &&
|
||||
!tabId.startsWith(TERMINAL_TAB_PREFIX) &&
|
||||
!tabId.startsWith(TRACE_TAB_PREFIX)
|
||||
!tabId.startsWith(TRACE_TAB_PREFIX) &&
|
||||
!tabId.startsWith(WORKBENCH_TAB_PREFIX)
|
||||
}
|
||||
|
||||
export function TabBar() {
|
||||
@ -549,6 +551,9 @@ const TabItem = forwardRef<HTMLDivElement, {
|
||||
{tab.type === 'terminal' && (
|
||||
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">terminal</span>
|
||||
)}
|
||||
{tab.type === 'workbench' && (
|
||||
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">view_sidebar</span>
|
||||
)}
|
||||
|
||||
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
{tab.title || 'Untitled'}
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../workspace/WorkspacePanel', () => ({
|
||||
WorkspacePanel: ({ sessionId, embedded }: { sessionId: string; embedded?: boolean }) => (
|
||||
<div data-testid="workspace-panel" data-embedded={embedded ? 'true' : 'false'}>
|
||||
WorkspacePanel: ({ sessionId, embedded, forceVisible }: { sessionId: string; embedded?: boolean; forceVisible?: boolean }) => (
|
||||
<div
|
||||
data-testid="workspace-panel"
|
||||
data-embedded={embedded ? 'true' : 'false'}
|
||||
data-force-visible={forceVisible ? 'true' : 'false'}
|
||||
>
|
||||
workspace:{sessionId}
|
||||
</div>
|
||||
),
|
||||
@ -20,12 +26,14 @@ import { WorkbenchPanel } from './WorkbenchPanel'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
const SESSION_ID = 'workbench-session'
|
||||
|
||||
beforeEach(() => {
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||
useTabStore.setState(useTabStore.getInitialState(), true)
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useWorkspacePanelStore.getState().openPanel(SESSION_ID)
|
||||
})
|
||||
@ -34,6 +42,7 @@ afterEach(() => {
|
||||
cleanup()
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||
useTabStore.setState(useTabStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('WorkbenchPanel', () => {
|
||||
@ -95,4 +104,35 @@ describe('WorkbenchPanel', () => {
|
||||
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('the expand button promotes the current workbench into a main content tab', () => {
|
||||
useWorkspacePanelStore.getState().setMode(SESSION_ID, 'browser')
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand panel' }))
|
||||
|
||||
expect(useTabStore.getState().activeTabId).toBe(`__workbench__${SESSION_ID}`)
|
||||
expect(useTabStore.getState().tabs).toEqual([
|
||||
{
|
||||
sessionId: `__workbench__${SESSION_ID}`,
|
||||
title: 'Workbench',
|
||||
type: 'workbench',
|
||||
status: 'idle',
|
||||
workbenchSessionId: SESSION_ID,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the tab variant without a nested expand action', () => {
|
||||
const handleClose = vi.fn()
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} variant="tab" onClose={handleClose} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Expand panel' })).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workspace-panel')).toHaveAttribute('data-force-visible', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { FolderOpen, Globe, X } from 'lucide-react'
|
||||
import { FolderOpen, Globe, Maximize2, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
useWorkspacePanelStore,
|
||||
type WorkbenchMode,
|
||||
} from '../../stores/workspacePanelStore'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { WorkspacePanel } from '../workspace/WorkspacePanel'
|
||||
import { BrowserSurface } from '../browser/BrowserSurface'
|
||||
|
||||
type WorkbenchPanelProps = {
|
||||
sessionId: string
|
||||
variant?: 'panel' | 'tab'
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const MODE_ITEMS: ReadonlyArray<{
|
||||
@ -26,12 +29,13 @@ const MODE_ITEMS: ReadonlyArray<{
|
||||
* browser surface behind a single per-session mode switch (file ↔ browser),
|
||||
* sharing the panel's open state and width via {@link useWorkspacePanelStore}.
|
||||
*/
|
||||
export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) {
|
||||
export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: WorkbenchPanelProps) {
|
||||
const t = useTranslation()
|
||||
const mode = useWorkspacePanelStore((state) => state.getMode(sessionId))
|
||||
const setMode = useWorkspacePanelStore((state) => state.setMode)
|
||||
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
|
||||
const ensureBlankBrowser = useBrowserPanelStore((state) => state.ensureBlank)
|
||||
const isTabVariant = variant === 'tab'
|
||||
|
||||
const handleModeSelect = (nextMode: WorkbenchMode) => {
|
||||
if (nextMode === 'browser') {
|
||||
@ -40,6 +44,18 @@ export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) {
|
||||
setMode(sessionId, nextMode)
|
||||
}
|
||||
|
||||
const handleExpand = () => {
|
||||
useTabStore.getState().openWorkbenchTab(sessionId, t('workbench.tabTitle'))
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (onClose) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
closePanel(sessionId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5">
|
||||
@ -70,21 +86,34 @@ export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workbench.close')}
|
||||
onClick={() => closePanel(sessionId)}
|
||||
className="ml-auto inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<X size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{!isTabVariant && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workbench.expand')}
|
||||
title={t('workbench.expand')}
|
||||
onClick={handleExpand}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<Maximize2 size={15} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workbench.close')}
|
||||
onClick={handleClose}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<X size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{mode === 'browser' ? (
|
||||
<BrowserSurface sessionId={sessionId} />
|
||||
) : (
|
||||
<WorkspacePanel sessionId={sessionId} embedded />
|
||||
<WorkspacePanel sessionId={sessionId} embedded forceVisible={isTabVariant} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
30
desktop/src/components/workbench/WorkbenchTab.tsx
Normal file
30
desktop/src/components/workbench/WorkbenchTab.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { WorkbenchPanel } from './WorkbenchPanel'
|
||||
|
||||
type WorkbenchTabProps = {
|
||||
tabId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export function WorkbenchTab({ tabId, sessionId }: WorkbenchTabProps) {
|
||||
const mode = useWorkspacePanelStore((state) => state.getMode(sessionId))
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'browser') {
|
||||
useBrowserPanelStore.getState().ensureBlank(sessionId)
|
||||
}
|
||||
}, [mode, sessionId])
|
||||
|
||||
return (
|
||||
<div data-testid="workbench-tab" className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
|
||||
<WorkbenchPanel
|
||||
sessionId={sessionId}
|
||||
variant="tab"
|
||||
onClose={() => useTabStore.getState().closeTab(tabId)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -38,6 +38,11 @@ type WorkspacePanelProps = {
|
||||
* button so the panel header doesn't render a duplicate close control.
|
||||
*/
|
||||
embedded?: boolean
|
||||
/**
|
||||
* Main-content workbench tabs reuse the same workspace preview UI without
|
||||
* depending on the right-side panel's open bit.
|
||||
*/
|
||||
forceVisible?: boolean
|
||||
}
|
||||
|
||||
type TreeNodeProps = {
|
||||
@ -912,7 +917,7 @@ function TreeNode({
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelProps) {
|
||||
export function WorkspacePanel({ sessionId, embedded = false, forceVisible = false }: WorkspacePanelProps) {
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((state) => state.addToast)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
@ -946,6 +951,7 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
|
||||
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
|
||||
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||
const chatState = useChatStore((state) => state.sessions[sessionId]?.chatState ?? 'idle')
|
||||
const shouldRender = forceVisible || isOpen
|
||||
const refreshLifecycleRef = useRef({
|
||||
sessionId,
|
||||
isOpen: false,
|
||||
@ -986,25 +992,25 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
|
||||
useEffect(() => {
|
||||
const previous = refreshLifecycleRef.current
|
||||
const sessionChanged = previous.sessionId !== sessionId
|
||||
const opened = isOpen && (sessionChanged || !previous.isOpen)
|
||||
const opened = shouldRender && (sessionChanged || !previous.isOpen)
|
||||
const completedTurn =
|
||||
isOpen &&
|
||||
shouldRender &&
|
||||
!sessionChanged &&
|
||||
previous.chatState !== 'idle' &&
|
||||
chatState === 'idle'
|
||||
|
||||
refreshLifecycleRef.current = { sessionId, isOpen, chatState }
|
||||
refreshLifecycleRef.current = { sessionId, isOpen: shouldRender, chatState }
|
||||
|
||||
const shouldRefreshOnOpen = opened
|
||||
const shouldRefreshAfterCompletedTurn = completedTurn && chatState === 'idle'
|
||||
if ((!shouldRefreshOnOpen && !shouldRefreshAfterCompletedTurn) || statusLoading) return
|
||||
void loadStatus(sessionId)
|
||||
}, [chatState, isOpen, loadStatus, sessionId, statusLoading])
|
||||
}, [chatState, loadStatus, sessionId, shouldRender, statusLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isNavigatorVisible || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
|
||||
if (!shouldRender || !isNavigatorVisible || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
|
||||
void loadTree(sessionId, '')
|
||||
}, [activeView, isNavigatorVisible, isOpen, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId])
|
||||
}, [activeView, isNavigatorVisible, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId, shouldRender])
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewTabContextMenu && !fileContextMenu) return
|
||||
@ -1028,7 +1034,7 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
|
||||
}
|
||||
}, [isNavigatorVisible])
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!shouldRender) return null
|
||||
|
||||
const panelWidth = hasPreviewTabs ? width : Math.min(width, 520)
|
||||
const panelMaxWidth = hasPreviewTabs ? 'min(62%, calc(100% - 328px))' : '36%'
|
||||
|
||||
@ -141,7 +141,9 @@ export const en = {
|
||||
'workbench.modeSwitch': 'Workbench mode',
|
||||
'workbench.modeWorkspace': 'Files',
|
||||
'workbench.modeBrowser': 'Browser',
|
||||
'workbench.expand': 'Expand panel',
|
||||
'workbench.close': 'Close',
|
||||
'workbench.tabTitle': 'Workbench',
|
||||
'workspace.closeTab': 'Close tab',
|
||||
'workspace.preview': 'Preview',
|
||||
'workspace.previewEmpty': 'Select a file to preview.',
|
||||
|
||||
@ -143,7 +143,9 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'workbench.modeSwitch': 'ワークベンチモード',
|
||||
'workbench.modeWorkspace': 'ファイル',
|
||||
'workbench.modeBrowser': 'ブラウザ',
|
||||
'workbench.expand': 'パネルを展開',
|
||||
'workbench.close': '閉じる',
|
||||
'workbench.tabTitle': 'ワークベンチ',
|
||||
'workspace.closeTab': 'タブを閉じる',
|
||||
'workspace.preview': 'プレビュー',
|
||||
'workspace.previewEmpty': 'プレビューするファイルを選択してください。',
|
||||
|
||||
@ -143,7 +143,9 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'workbench.modeSwitch': '워크벤치 모드',
|
||||
'workbench.modeWorkspace': '파일',
|
||||
'workbench.modeBrowser': '브라우저',
|
||||
'workbench.expand': '패널 펼치기',
|
||||
'workbench.close': '닫기',
|
||||
'workbench.tabTitle': '워크벤치',
|
||||
'workspace.closeTab': '탭 닫기',
|
||||
'workspace.preview': '미리 보기',
|
||||
'workspace.previewEmpty': '미리 볼 파일을 선택하세요.',
|
||||
|
||||
@ -143,7 +143,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workbench.modeSwitch': '工作臺模式',
|
||||
'workbench.modeWorkspace': '檔案',
|
||||
'workbench.modeBrowser': '瀏覽器',
|
||||
'workbench.expand': '展開面板',
|
||||
'workbench.close': '關閉',
|
||||
'workbench.tabTitle': '工作臺',
|
||||
'workspace.closeTab': '關閉標籤',
|
||||
'workspace.preview': '預覽',
|
||||
'workspace.previewEmpty': '選擇一個檔案進行預覽。',
|
||||
|
||||
@ -143,7 +143,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workbench.modeSwitch': '工作台模式',
|
||||
'workbench.modeWorkspace': '文件',
|
||||
'workbench.modeBrowser': '浏览器',
|
||||
'workbench.expand': '展开面板',
|
||||
'workbench.close': '关闭',
|
||||
'workbench.tabTitle': '工作台',
|
||||
'workspace.closeTab': '关闭标签',
|
||||
'workspace.preview': '预览',
|
||||
'workspace.previewEmpty': '选择一个文件进行预览。',
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
SETTINGS_TAB_ID,
|
||||
TERMINAL_TAB_PREFIX,
|
||||
TRACE_TAB_PREFIX,
|
||||
WORKBENCH_TAB_PREFIX,
|
||||
useTabStore,
|
||||
type TabType,
|
||||
} from '../stores/tabStore'
|
||||
@ -47,7 +48,8 @@ function isSessionTabState(activeTabId: string | null, activeTabType: TabType |
|
||||
return activeTabId !== SETTINGS_TAB_ID &&
|
||||
activeTabId !== SCHEDULED_TAB_ID &&
|
||||
!activeTabId.startsWith(TERMINAL_TAB_PREFIX) &&
|
||||
!activeTabId.startsWith(TRACE_TAB_PREFIX)
|
||||
!activeTabId.startsWith(TRACE_TAB_PREFIX) &&
|
||||
!activeTabId.startsWith(WORKBENCH_TAB_PREFIX)
|
||||
}
|
||||
|
||||
function getSessionTerminalCwd(session: SessionListItem | undefined) {
|
||||
|
||||
@ -44,6 +44,28 @@ describe('tabStore', () => {
|
||||
expect(useTabStore.getState().activeTabId).toBe(tabId)
|
||||
})
|
||||
|
||||
it('opens one ephemeral workbench tab per source session', () => {
|
||||
const firstTabId = useTabStore.getState().openWorkbenchTab('session-1', 'Workbench')
|
||||
const secondTabId = useTabStore.getState().openWorkbenchTab('session-1', 'Workbench')
|
||||
|
||||
expect(firstTabId).toBe('__workbench__session-1')
|
||||
expect(secondTabId).toBe(firstTabId)
|
||||
expect(useTabStore.getState().tabs).toEqual([
|
||||
{
|
||||
sessionId: '__workbench__session-1',
|
||||
title: 'Workbench',
|
||||
type: 'workbench',
|
||||
status: 'idle',
|
||||
workbenchSessionId: 'session-1',
|
||||
},
|
||||
])
|
||||
expect(useTabStore.getState().activeTabId).toBe('__workbench__session-1')
|
||||
expect(localStorage.getItem('cc-haha-open-tabs')).toBe(JSON.stringify({
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not let async tab restore overwrite tabs opened while restore is in flight', async () => {
|
||||
let resolveSessions: (value: unknown) => void = () => {}
|
||||
vi.mocked(sessionsApi.list).mockReturnValueOnce(new Promise((resolve) => {
|
||||
|
||||
@ -10,8 +10,9 @@ export const SCHEDULED_TAB_ID = '__scheduled__'
|
||||
export const TRACE_LIST_TAB_ID = '__traces__'
|
||||
export const TERMINAL_TAB_PREFIX = '__terminal__'
|
||||
export const TRACE_TAB_PREFIX = '__trace__'
|
||||
export const WORKBENCH_TAB_PREFIX = '__workbench__'
|
||||
|
||||
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces'
|
||||
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces' | 'workbench'
|
||||
|
||||
export type Tab = {
|
||||
sessionId: string
|
||||
@ -21,6 +22,7 @@ export type Tab = {
|
||||
terminalCwd?: string
|
||||
terminalRuntimeId?: string
|
||||
traceSessionId?: string
|
||||
workbenchSessionId?: string
|
||||
}
|
||||
|
||||
type TabPersistence = {
|
||||
@ -36,6 +38,7 @@ type TabStore = {
|
||||
openTracesTab: (title?: string) => string
|
||||
openTraceTab: (sessionId: string, title?: string) => string
|
||||
openTerminalTab: (cwd?: string, terminalRuntimeId?: string) => string
|
||||
openWorkbenchTab: (sessionId: string, title?: string) => string
|
||||
closeTab: (sessionId: string) => void
|
||||
setActiveTab: (sessionId: string) => void
|
||||
updateTabTitle: (sessionId: string, title: string) => void
|
||||
@ -141,6 +144,33 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
return sessionId
|
||||
},
|
||||
|
||||
openWorkbenchTab: (sessionId, title = 'Workbench') => {
|
||||
const tabId = `${WORKBENCH_TAB_PREFIX}${sessionId}`
|
||||
const { tabs } = get()
|
||||
const existing = tabs.find((tab) => tab.sessionId === tabId)
|
||||
const tab: Tab = {
|
||||
sessionId: tabId,
|
||||
title,
|
||||
type: 'workbench',
|
||||
status: 'idle',
|
||||
workbenchSessionId: sessionId,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
set({
|
||||
tabs: tabs.map((current) => current.sessionId === tabId ? tab : current),
|
||||
activeTabId: tabId,
|
||||
})
|
||||
} else {
|
||||
set({
|
||||
tabs: [...tabs, tab],
|
||||
activeTabId: tabId,
|
||||
})
|
||||
}
|
||||
get().saveTabs()
|
||||
return tabId
|
||||
},
|
||||
|
||||
closeTab: (sessionId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const index = tabs.findIndex((t) => t.sessionId === sessionId)
|
||||
@ -210,7 +240,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
|
||||
saveTabs: () => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal')
|
||||
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal' && tab.type !== 'workbench')
|
||||
const data: TabPersistence = {
|
||||
openTabs: persistableTabs.map((t) => ({
|
||||
sessionId: t.sessionId,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user