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