feat: add multi-terminal workbar and resizable sidebars
The assistant workbar now uses persistent application tabs with a permanent catalog entry point and supports multiple local or managed SSH terminals bound to the current project. Terminal sessions flow through the Main, preload, and renderer boundaries with bounded output, resize, lifecycle, and cleanup behavior. The main sidebar and Magic Notes list now support pointer and keyboard resizing. Magic Notes can also hide and restore its list, remembers the chosen layout, and stacks its panes when the available width is narrow. Closing a terminal tab ends its Shell session. Restored terminal tabs do not restart prior Shell processes and require an explicit reconnect. Release note: 助手工作栏现可通过“+”打开多个本机或当前远程项目终端;主侧栏和魔法笔记列表支持拖拽调宽,魔法笔记列表可隐藏并记住布局。
This commit is contained in:
@@ -18,6 +18,9 @@ import type {
|
||||
} from '../../shared/contracts'
|
||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
||||
import type {
|
||||
TerminalSnapshot
|
||||
} from '../../shared/terminal-contracts'
|
||||
import {
|
||||
loadBrandingPreferences,
|
||||
saveBrandingPreferences
|
||||
@@ -99,6 +102,12 @@ vi.mock('./ActivityPanel', async (importOriginal) => {
|
||||
return importOriginal<typeof import('./ActivityPanel')>()
|
||||
})
|
||||
|
||||
vi.mock('./TerminalPanel', () => ({
|
||||
TerminalPanel: ({ title }: { title?: string }) => (
|
||||
<section aria-label={`用户终端:${title ?? '终端'}`} />
|
||||
)
|
||||
}))
|
||||
|
||||
import App from './App'
|
||||
import { loadActivityRecords } from './activity-store'
|
||||
import { changeUiLocale } from './i18n'
|
||||
@@ -155,6 +164,20 @@ const project = {
|
||||
updatedAt: '2026-07-31T00:00:00.000Z'
|
||||
}
|
||||
|
||||
const terminalSnapshot: TerminalSnapshot = {
|
||||
sessionId: '00000000-0000-4000-8000-000000000901',
|
||||
target: { type: 'local' },
|
||||
targetLabel: '本机',
|
||||
title: '终端 1',
|
||||
state: 'running',
|
||||
shell: 'PowerShell 7',
|
||||
workingDirectory: 'C:\\Users\\test',
|
||||
size: { cols: 80, rows: 24 },
|
||||
lastSequence: 0,
|
||||
exit: null,
|
||||
error: null
|
||||
}
|
||||
|
||||
const api: DesktopApi = {
|
||||
app: {
|
||||
getInfo: vi.fn(async () => ({
|
||||
@@ -231,6 +254,23 @@ const api: DesktopApi = {
|
||||
}
|
||||
})
|
||||
},
|
||||
terminal: {
|
||||
create: vi.fn(async (request) => ({
|
||||
...terminalSnapshot,
|
||||
target: request.target,
|
||||
size: { cols: request.cols, rows: request.rows }
|
||||
})),
|
||||
write: vi.fn(async () => undefined),
|
||||
resize: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => ({
|
||||
...terminalSnapshot,
|
||||
state: 'exited' as const,
|
||||
exit: { exitCode: 0, signal: null }
|
||||
})),
|
||||
getSnapshot: vi.fn(async () => terminalSnapshot),
|
||||
ack: vi.fn(async () => undefined),
|
||||
onEvent: vi.fn(() => () => {})
|
||||
},
|
||||
speech: {
|
||||
transcribe: vi.fn(async () => ({ text: '本地语音结果' })),
|
||||
cancel: vi.fn(async () => true)
|
||||
@@ -5462,6 +5502,35 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens multiple current-project terminals without a second target picker', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||
const add = await screen.findByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
for (let count = 1; count <= 2; count += 1) {
|
||||
fireEvent.click(add)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /打开当前项目的用户终端/u
|
||||
})
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('tab', {
|
||||
name: `终端 ${count}`
|
||||
})
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
}
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开工作栏应用' })
|
||||
).toBeVisible()
|
||||
expect(
|
||||
screen.getAllByRole('tab', { name: /^终端 \d+$/u })
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('opens workspace entries from their row actions', async () => {
|
||||
vi.mocked(api.workspace.listDirectory).mockResolvedValue({
|
||||
path: '',
|
||||
@@ -5683,7 +5752,7 @@ describe('App', () => {
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce()
|
||||
)
|
||||
@@ -9567,7 +9636,7 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes ignored heartbeat suggestions from the navigation badge', async () => {
|
||||
@@ -9907,6 +9976,88 @@ describe('App', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resizes and remembers the docked primary sidebar width', () => {
|
||||
const originalWidth = window.innerWidth
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1200
|
||||
})
|
||||
const { container, unmount } = render(<App />)
|
||||
|
||||
try {
|
||||
const sidebar =
|
||||
container.querySelector<HTMLElement>('.sidebar')
|
||||
const separator = screen.getByRole('separator', {
|
||||
name: '调整主侧栏宽度'
|
||||
})
|
||||
const setPointerCapture = vi.fn()
|
||||
const releasePointerCapture = vi.fn()
|
||||
Object.defineProperties(separator, {
|
||||
setPointerCapture: { value: setPointerCapture },
|
||||
hasPointerCapture: { value: () => true },
|
||||
releasePointerCapture: { value: releasePointerCapture }
|
||||
})
|
||||
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('278px')
|
||||
expect(separator).toHaveAttribute('aria-valuemin', '220')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '420')
|
||||
|
||||
fireEvent.pointerDown(separator, {
|
||||
button: 0,
|
||||
clientX: 320,
|
||||
pointerId: 17
|
||||
})
|
||||
fireEvent.pointerMove(separator, {
|
||||
clientX: 400,
|
||||
pointerId: 17
|
||||
})
|
||||
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(17)
|
||||
expect(sidebar).toHaveClass('sidebar--resizing')
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('400px')
|
||||
|
||||
fireEvent.pointerUp(separator, { pointerId: 17 })
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(17)
|
||||
expect(sidebar).not.toHaveClass('sidebar--resizing')
|
||||
expect(
|
||||
localStorage.getItem('goodbuddy.primary-sidebar-width.v1')
|
||||
).toBe('400')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'Home' })
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('220px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'ArrowRight' })
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('236px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('420px')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '420')
|
||||
|
||||
unmount()
|
||||
const persistedRender = render(<App />)
|
||||
expect(
|
||||
persistedRender.container
|
||||
.querySelector<HTMLElement>('.sidebar')
|
||||
?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('420px')
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: originalWidth
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('docks the assistant workbar without obscuring a narrow workspace', async () => {
|
||||
const originalWidth = window.innerWidth
|
||||
const { container } = render(<App />)
|
||||
@@ -9994,7 +10145,7 @@ describe('App', () => {
|
||||
)
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
).toBeInTheDocument()
|
||||
} finally {
|
||||
delete api.updates
|
||||
}
|
||||
@@ -10206,7 +10357,7 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^专家角色:/u })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
+247
-24
@@ -558,9 +558,66 @@ const storageKey = 'goodbuddy.conversations.v1'
|
||||
|
||||
const activeProjectStorageKey = 'goodbuddy.active-project.v1'
|
||||
|
||||
const primarySidebarWidthStorageKey =
|
||||
'goodbuddy.primary-sidebar-width.v1'
|
||||
const defaultPrimarySidebarWidth = 278
|
||||
const compactPrimarySidebarWidth = 236
|
||||
const minimumPrimarySidebarWidth = 220
|
||||
const maximumPrimarySidebarWidth = 420
|
||||
const minimumPrimaryWorkspaceWidth = 480
|
||||
const primarySidebarKeyboardResizeStep = 16
|
||||
|
||||
const maxMessageContentLength = 1_000_000
|
||||
const maxMessageBlocks = 500
|
||||
|
||||
function getPrimarySidebarWidthLimits(viewportWidth: number): {
|
||||
minimum: number
|
||||
maximum: number
|
||||
} {
|
||||
const availableWidth = Math.max(0, Math.floor(viewportWidth))
|
||||
return {
|
||||
minimum: minimumPrimarySidebarWidth,
|
||||
maximum: Math.max(
|
||||
minimumPrimarySidebarWidth,
|
||||
Math.min(
|
||||
maximumPrimarySidebarWidth,
|
||||
availableWidth - minimumPrimaryWorkspaceWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function clampPrimarySidebarWidth(
|
||||
width: number,
|
||||
viewportWidth: number
|
||||
): number {
|
||||
const limits = getPrimarySidebarWidthLimits(viewportWidth)
|
||||
return Math.min(
|
||||
limits.maximum,
|
||||
Math.max(limits.minimum, Math.round(width))
|
||||
)
|
||||
}
|
||||
|
||||
function loadPrimarySidebarWidth(): number {
|
||||
const fallback =
|
||||
window.innerWidth <= 1020
|
||||
? compactPrimarySidebarWidth
|
||||
: defaultPrimarySidebarWidth
|
||||
try {
|
||||
const persistedWidth = Number(
|
||||
localStorage.getItem(primarySidebarWidthStorageKey)
|
||||
)
|
||||
return clampPrimarySidebarWidth(
|
||||
Number.isFinite(persistedWidth) && persistedWidth > 0
|
||||
? persistedWidth
|
||||
: fallback,
|
||||
window.innerWidth
|
||||
)
|
||||
} catch {
|
||||
return clampPrimarySidebarWidth(fallback, window.innerWidth)
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessageContentBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
type: 'text' | 'reasoning',
|
||||
@@ -2298,6 +2355,11 @@ function App(): React.JSX.Element {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(
|
||||
() => window.innerWidth >= 900
|
||||
)
|
||||
const [primarySidebarWidth, setPrimarySidebarWidth] = useState(
|
||||
loadPrimarySidebarWidth
|
||||
)
|
||||
const [primarySidebarResizing, setPrimarySidebarResizing] =
|
||||
useState(false)
|
||||
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
|
||||
() => window.innerWidth >= 1280
|
||||
)
|
||||
@@ -2647,6 +2709,10 @@ function App(): React.JSX.Element {
|
||||
>({})
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const livePrimarySidebarWidthRef = useRef(primarySidebarWidth)
|
||||
const primarySidebarResizePointerIdRef = useRef<number | undefined>(
|
||||
undefined
|
||||
)
|
||||
const assistantSidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const conversationActionTriggerRefs = useRef(
|
||||
new Map<string, HTMLButtonElement>()
|
||||
@@ -2673,6 +2739,71 @@ function App(): React.JSX.Element {
|
||||
setSidebarOpen(false)
|
||||
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
|
||||
}, [])
|
||||
const resizePrimarySidebarFromClientX = useCallback(
|
||||
(clientX: number, commit: boolean): void => {
|
||||
const width = clampPrimarySidebarWidth(
|
||||
clientX,
|
||||
window.innerWidth
|
||||
)
|
||||
livePrimarySidebarWidthRef.current = width
|
||||
if (commit) {
|
||||
setPrimarySidebarWidth(width)
|
||||
return
|
||||
}
|
||||
sidebarRef.current?.style.setProperty(
|
||||
'--primary-sidebar-width',
|
||||
`${width}px`
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const finishPrimarySidebarResize = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (
|
||||
primarySidebarResizePointerIdRef.current !== event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
primarySidebarResizePointerIdRef.current = undefined
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
setPrimarySidebarWidth(livePrimarySidebarWidthRef.current)
|
||||
setPrimarySidebarResizing(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const resizePrimarySidebarWithKeyboard = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (narrowWindow || !sidebarOpen) {
|
||||
return
|
||||
}
|
||||
const limits = getPrimarySidebarWidthLimits(window.innerWidth)
|
||||
const nextWidth =
|
||||
event.key === 'Home'
|
||||
? limits.minimum
|
||||
: event.key === 'End'
|
||||
? limits.maximum
|
||||
: event.key === 'ArrowLeft'
|
||||
? primarySidebarWidth -
|
||||
primarySidebarKeyboardResizeStep
|
||||
: event.key === 'ArrowRight'
|
||||
? primarySidebarWidth +
|
||||
primarySidebarKeyboardResizeStep
|
||||
: undefined
|
||||
if (nextWidth === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const width = clampPrimarySidebarWidth(
|
||||
nextWidth,
|
||||
window.innerWidth
|
||||
)
|
||||
livePrimarySidebarWidthRef.current = width
|
||||
setPrimarySidebarWidth(width)
|
||||
},
|
||||
[narrowWindow, primarySidebarWidth, sidebarOpen]
|
||||
)
|
||||
const navigateFromSidebar = useCallback(
|
||||
(nextView: WorkspaceView, trigger: HTMLElement): void => {
|
||||
if (viewRef.current !== 'settings' && nextView === 'settings') {
|
||||
@@ -2788,12 +2919,30 @@ function App(): React.JSX.Element {
|
||||
return () => window.clearInterval(interval)
|
||||
}, [activeId, assistantTasks, knowledgeOperationCount, view])
|
||||
|
||||
useEffect(() => {
|
||||
livePrimarySidebarWidthRef.current = primarySidebarWidth
|
||||
try {
|
||||
localStorage.setItem(
|
||||
primarySidebarWidthStorageKey,
|
||||
String(primarySidebarWidth)
|
||||
)
|
||||
} catch {
|
||||
// The current width remains usable when browser storage is unavailable.
|
||||
}
|
||||
}, [primarySidebarWidth])
|
||||
|
||||
useEffect(() => {
|
||||
const collapseSidebarAtNarrowWidth = (): void => {
|
||||
const narrow = window.innerWidth < 900
|
||||
setNarrowWindow(narrow)
|
||||
if (narrow) {
|
||||
setSidebarOpen(false)
|
||||
setPrimarySidebarResizing(false)
|
||||
primarySidebarResizePointerIdRef.current = undefined
|
||||
} else {
|
||||
setPrimarySidebarWidth((current) =>
|
||||
clampPrimarySidebarWidth(current, window.innerWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', collapseSidebarAtNarrowWidth)
|
||||
@@ -8033,6 +8182,9 @@ function App(): React.JSX.Element {
|
||||
])
|
||||
|
||||
const mainSidebarOpen = narrowWindow && sidebarOpen
|
||||
const canResizePrimarySidebar = sidebarOpen && !narrowWindow
|
||||
const primarySidebarWidthLimits =
|
||||
getPrimarySidebarWidthLimits(window.innerWidth)
|
||||
const backgroundIsolated = mainSidebarOpen
|
||||
const runtimeState =
|
||||
runtimeSwitching ||
|
||||
@@ -8060,7 +8212,17 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
aria-hidden={!sidebarOpen}
|
||||
aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
|
||||
className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
|
||||
className={
|
||||
sidebarOpen
|
||||
? `sidebar${
|
||||
primarySidebarResizing &&
|
||||
canResizePrimarySidebar
|
||||
? ' sidebar--resizing'
|
||||
: ''
|
||||
}`
|
||||
: 'sidebar sidebar--closed'
|
||||
}
|
||||
id="primary-sidebar"
|
||||
inert={!sidebarOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (mainSidebarOpen) {
|
||||
@@ -8069,6 +8231,11 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
ref={sidebarRef}
|
||||
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
|
||||
style={
|
||||
{
|
||||
'--primary-sidebar-width': `${primarySidebarWidth}px`
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<BrandLockup
|
||||
className="brand"
|
||||
@@ -8757,6 +8924,65 @@ function App(): React.JSX.Element {
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
aria-controls="primary-sidebar"
|
||||
aria-disabled={!canResizePrimarySidebar}
|
||||
aria-label={t('sidebar.resizeAriaLabel')}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={primarySidebarWidthLimits.maximum}
|
||||
aria-valuemin={primarySidebarWidthLimits.minimum}
|
||||
aria-valuenow={primarySidebarWidth}
|
||||
aria-valuetext={t('sidebar.resizeValue', {
|
||||
width: primarySidebarWidth
|
||||
})}
|
||||
className="primary-sidebar-resize-handle"
|
||||
onKeyDown={resizePrimarySidebarWithKeyboard}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (
|
||||
primarySidebarResizePointerIdRef.current ===
|
||||
event.pointerId
|
||||
) {
|
||||
primarySidebarResizePointerIdRef.current = undefined
|
||||
setPrimarySidebarWidth(
|
||||
livePrimarySidebarWidthRef.current
|
||||
)
|
||||
setPrimarySidebarResizing(false)
|
||||
}
|
||||
}}
|
||||
onPointerCancel={finishPrimarySidebarResize}
|
||||
onPointerDown={(event) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
!canResizePrimarySidebar
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
primarySidebarResizePointerIdRef.current = event.pointerId
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
resizePrimarySidebarFromClientX(event.clientX, true)
|
||||
setPrimarySidebarResizing(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (
|
||||
primarySidebarResizePointerIdRef.current !==
|
||||
event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!canResizePrimarySidebar) {
|
||||
finishPrimarySidebarResize(event)
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
resizePrimarySidebarFromClientX(event.clientX, false)
|
||||
}}
|
||||
onPointerUp={finishPrimarySidebarResize}
|
||||
role="separator"
|
||||
tabIndex={canResizePrimarySidebar ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
{sidebarOpen && (
|
||||
<button
|
||||
aria-label={t('sidebar.close')}
|
||||
@@ -8882,26 +9108,24 @@ function App(): React.JSX.Element {
|
||||
</header>
|
||||
|
||||
<div className="app-content">
|
||||
{view === 'chat' && (
|
||||
<button
|
||||
aria-controls="assistant-sidebar"
|
||||
aria-expanded={assistantSidebarOpen}
|
||||
aria-label={t('topbar.toggleAssistantSidebar')}
|
||||
className="icon-button assistant-sidebar-toggle"
|
||||
onClick={() =>
|
||||
setAssistantSidebarOpen((current) => !current)
|
||||
}
|
||||
ref={assistantSidebarToggleRef}
|
||||
title={t('topbar.toggleAssistantSidebar')}
|
||||
type="button"
|
||||
>
|
||||
{assistantSidebarOpen ? (
|
||||
<PanelRightClose aria-hidden="true" size={18} />
|
||||
) : (
|
||||
<PanelRightOpen aria-hidden="true" size={18} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-controls="assistant-sidebar"
|
||||
aria-expanded={assistantSidebarOpen}
|
||||
aria-label={t('topbar.toggleAssistantSidebar')}
|
||||
className="icon-button assistant-sidebar-toggle"
|
||||
onClick={() =>
|
||||
setAssistantSidebarOpen((current) => !current)
|
||||
}
|
||||
ref={assistantSidebarToggleRef}
|
||||
title={t('topbar.toggleAssistantSidebar')}
|
||||
type="button"
|
||||
>
|
||||
{assistantSidebarOpen ? (
|
||||
<PanelRightClose aria-hidden="true" size={18} />
|
||||
) : (
|
||||
<PanelRightOpen aria-hidden="true" size={18} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<main
|
||||
aria-hidden={backgroundIsolated ? 'true' : undefined}
|
||||
@@ -10602,6 +10826,7 @@ function App(): React.JSX.Element {
|
||||
artifacts={sidebarArtifacts}
|
||||
browserState={browserStates[activeId]}
|
||||
conversationTitles={conversationTitles}
|
||||
currentProject={activeProject}
|
||||
onCreateCustomTask={() => openCustomTaskDialog('current')}
|
||||
schedules={assistantSchedules}
|
||||
selectedTaskId={selectedAssistantTaskId}
|
||||
@@ -10682,9 +10907,7 @@ function App(): React.JSX.Element {
|
||||
onOpenWorkspaceEntry={openWorkspaceEntry}
|
||||
onRefreshChanges={refreshWorkspaceChanges}
|
||||
onTabChange={setAssistantSidebarTab}
|
||||
open={
|
||||
assistantSidebarOpen && view === 'chat'
|
||||
}
|
||||
open={assistantSidebarOpen}
|
||||
restoreFocusRef={assistantSidebarToggleRef}
|
||||
tab={assistantSidebarTab}
|
||||
workspaceChanges={workspaceChanges}
|
||||
|
||||
@@ -198,6 +198,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
|
||||
const onNotify = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
analysisEventListener = undefined
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
@@ -528,6 +529,155 @@ describe('MagicNotesWorkspace', () => {
|
||||
expect(pane).toBeVisible()
|
||||
})
|
||||
|
||||
it('hides, restores, and remembers the left list pane', async () => {
|
||||
const firstRender = render(
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
const pane = await screen.findByLabelText('笔记列表')
|
||||
const hideButton = screen.getByRole('button', {
|
||||
name: '隐藏左侧列表'
|
||||
})
|
||||
expect(hideButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(pane).toBeVisible()
|
||||
|
||||
fireEvent.click(hideButton)
|
||||
expect(pane).not.toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('separator', {
|
||||
name: '调整笔记列表与编辑区宽度'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem('goodbuddy.magic-notes-layout.v1') ?? '{}'
|
||||
)
|
||||
).toMatchObject({ listPaneOpen: false })
|
||||
|
||||
firstRender.unmount()
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
const restoredPane = await screen.findByLabelText('笔记列表')
|
||||
expect(restoredPane).not.toBeVisible()
|
||||
|
||||
const showButton = screen.getByRole('button', {
|
||||
name: '显示左侧列表'
|
||||
})
|
||||
expect(showButton).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(showButton)
|
||||
expect(restoredPane).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('separator', {
|
||||
name: '调整笔记列表与编辑区宽度'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resizes the left list pane with pointer and keyboard controls', async () => {
|
||||
const originalInnerWidth = window.innerWidth
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1200
|
||||
})
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
|
||||
try {
|
||||
await screen.findByText('记录正文')
|
||||
const separator = screen.getByRole('separator', {
|
||||
name: '调整笔记列表与编辑区宽度'
|
||||
})
|
||||
const layout =
|
||||
separator.closest('.magic-notes-layout') as HTMLElement
|
||||
const setPointerCapture = vi.fn()
|
||||
const releasePointerCapture = vi.fn()
|
||||
Object.defineProperties(separator, {
|
||||
setPointerCapture: { value: setPointerCapture },
|
||||
hasPointerCapture: { value: () => true },
|
||||
releasePointerCapture: { value: releasePointerCapture }
|
||||
})
|
||||
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '220')
|
||||
expect(separator).toHaveAttribute('aria-valuemin', '180')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '360')
|
||||
|
||||
fireEvent.pointerDown(separator, {
|
||||
button: 0,
|
||||
clientX: 220,
|
||||
pointerId: 13
|
||||
})
|
||||
fireEvent.pointerMove(separator, {
|
||||
clientX: 350,
|
||||
pointerId: 13
|
||||
})
|
||||
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(13)
|
||||
expect(layout).toHaveClass('magic-notes-layout--resizing')
|
||||
expect(layout).toHaveClass('magic-notes-layout--list-resizing')
|
||||
expect(
|
||||
layout.style.getPropertyValue('--magic-notes-list-width')
|
||||
).toBe('350px')
|
||||
|
||||
fireEvent.pointerUp(separator, { pointerId: 13 })
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(13)
|
||||
expect(layout).not.toHaveClass('magic-notes-layout--resizing')
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(
|
||||
'goodbuddy.magic-notes-layout.v1'
|
||||
) ?? '{}'
|
||||
)
|
||||
).toMatchObject({ listPaneWidth: 350 })
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'Home' })
|
||||
expect(
|
||||
layout.style.getPropertyValue('--magic-notes-list-width')
|
||||
).toBe('180px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'ArrowRight' })
|
||||
expect(
|
||||
layout.style.getPropertyValue('--magic-notes-list-width')
|
||||
).toBe('196px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(
|
||||
layout.style.getPropertyValue('--magic-notes-list-width')
|
||||
).toBe('360px')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '360')
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: originalInnerWidth
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('disables horizontal pane resizing in the narrow stacked layout', async () => {
|
||||
const originalInnerWidth = window.innerWidth
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 700
|
||||
})
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
|
||||
try {
|
||||
await screen.findByText('记录正文')
|
||||
const listSeparator = screen.getByRole('separator', {
|
||||
name: '调整笔记列表与编辑区宽度'
|
||||
})
|
||||
expect(listSeparator).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(listSeparator).toHaveAttribute('tabindex', '-1')
|
||||
const aiSeparator = screen.getByRole('separator', {
|
||||
name: '调整编辑区与 AI 评论宽度'
|
||||
})
|
||||
expect(aiSeparator).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(aiSeparator).toHaveAttribute('tabindex', '-1')
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: originalInnerWidth
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a concise empty state before a note has AI comments', async () => {
|
||||
let resolveDetail:
|
||||
| ((value: MagicNoteDetail) => void)
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
FolderTree,
|
||||
Lightbulb,
|
||||
ListTodo,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Pin,
|
||||
@@ -73,23 +75,107 @@ type DraftSwitchTarget =
|
||||
const defaultAiPaneWidth = 280
|
||||
const minimumAiPaneWidth = 240
|
||||
const maximumAiPaneWidth = 520
|
||||
const magicNotesListPaneWidth = 220
|
||||
const defaultMagicNotesListPaneWidth = 220
|
||||
const minimumMagicNotesListPaneWidth = 180
|
||||
const maximumMagicNotesListPaneWidth = 360
|
||||
const minimumMagicNotesEditorWidth = 300
|
||||
const magicNotesResizeHandleWidth = 9
|
||||
const aiPaneKeyboardResizeStep = 16
|
||||
const magicNotesPaneKeyboardResizeStep = 16
|
||||
const magicNotesLayoutStorageKey =
|
||||
'goodbuddy.magic-notes-layout.v1'
|
||||
|
||||
function getAiPaneWidthLimits(layoutWidth: number): {
|
||||
type MagicNotesLayoutPreferences = {
|
||||
listPaneOpen: boolean
|
||||
listPaneWidth: number
|
||||
aiPaneOpen: boolean
|
||||
aiPaneWidth: number
|
||||
}
|
||||
|
||||
function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
|
||||
const defaults = {
|
||||
listPaneOpen: true,
|
||||
listPaneWidth: defaultMagicNotesListPaneWidth,
|
||||
aiPaneOpen: true,
|
||||
aiPaneWidth: defaultAiPaneWidth
|
||||
}
|
||||
try {
|
||||
const value = localStorage.getItem(magicNotesLayoutStorageKey)
|
||||
if (!value || value.length > 10_000) {
|
||||
return defaults
|
||||
}
|
||||
const parsed = JSON.parse(value) as Partial<
|
||||
MagicNotesLayoutPreferences
|
||||
>
|
||||
return {
|
||||
listPaneOpen: parsed.listPaneOpen !== false,
|
||||
listPaneWidth:
|
||||
typeof parsed.listPaneWidth === 'number' &&
|
||||
Number.isFinite(parsed.listPaneWidth)
|
||||
? Math.min(
|
||||
maximumMagicNotesListPaneWidth,
|
||||
Math.max(
|
||||
minimumMagicNotesListPaneWidth,
|
||||
parsed.listPaneWidth
|
||||
)
|
||||
)
|
||||
: defaults.listPaneWidth,
|
||||
aiPaneOpen: parsed.aiPaneOpen !== false,
|
||||
aiPaneWidth:
|
||||
typeof parsed.aiPaneWidth === 'number' &&
|
||||
Number.isFinite(parsed.aiPaneWidth)
|
||||
? Math.min(
|
||||
maximumAiPaneWidth,
|
||||
Math.max(minimumAiPaneWidth, parsed.aiPaneWidth)
|
||||
)
|
||||
: defaults.aiPaneWidth
|
||||
}
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
function persistMagicNotesLayoutPreferences(
|
||||
preferences: MagicNotesLayoutPreferences
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
magicNotesLayoutStorageKey,
|
||||
JSON.stringify(preferences)
|
||||
)
|
||||
} catch {
|
||||
// The in-memory layout remains usable when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
type MagicNotesPaneWidthLimits = {
|
||||
minimum: number
|
||||
maximum: number
|
||||
} {
|
||||
}
|
||||
|
||||
function getMagicNotesPaneWidthLimits({
|
||||
adjacentPaneOpen,
|
||||
adjacentPaneWidth,
|
||||
layoutWidth,
|
||||
maximum,
|
||||
minimum
|
||||
}: {
|
||||
adjacentPaneOpen: boolean
|
||||
adjacentPaneWidth: number
|
||||
layoutWidth: number
|
||||
maximum: number
|
||||
minimum: number
|
||||
}): MagicNotesPaneWidthLimits {
|
||||
const reservedAdjacentWidth = adjacentPaneOpen
|
||||
? adjacentPaneWidth + magicNotesResizeHandleWidth
|
||||
: 0
|
||||
return {
|
||||
minimum: minimumAiPaneWidth,
|
||||
minimum,
|
||||
maximum: Math.max(
|
||||
minimumAiPaneWidth,
|
||||
minimum,
|
||||
Math.min(
|
||||
maximumAiPaneWidth,
|
||||
maximum,
|
||||
layoutWidth -
|
||||
magicNotesListPaneWidth -
|
||||
reservedAdjacentWidth -
|
||||
minimumMagicNotesEditorWidth -
|
||||
magicNotesResizeHandleWidth
|
||||
)
|
||||
@@ -97,9 +183,73 @@ function getAiPaneWidthLimits(layoutWidth: number): {
|
||||
}
|
||||
}
|
||||
|
||||
function clampAiPaneWidth(width: number, layoutWidth: number): number {
|
||||
const limits = getAiPaneWidthLimits(layoutWidth)
|
||||
return Math.min(limits.maximum, Math.max(limits.minimum, width))
|
||||
function clampMagicNotesPaneWidth(
|
||||
width: number,
|
||||
limits: MagicNotesPaneWidthLimits
|
||||
): number {
|
||||
return Math.round(
|
||||
Math.min(limits.maximum, Math.max(limits.minimum, width))
|
||||
)
|
||||
}
|
||||
|
||||
function getAiPaneWidthLimits(
|
||||
layoutWidth: number,
|
||||
listPaneWidth: number,
|
||||
listPaneOpen: boolean
|
||||
): MagicNotesPaneWidthLimits {
|
||||
return getMagicNotesPaneWidthLimits({
|
||||
adjacentPaneOpen: listPaneOpen,
|
||||
adjacentPaneWidth: listPaneWidth,
|
||||
layoutWidth,
|
||||
maximum: maximumAiPaneWidth,
|
||||
minimum: minimumAiPaneWidth
|
||||
})
|
||||
}
|
||||
|
||||
function clampAiPaneWidth(
|
||||
width: number,
|
||||
layoutWidth: number,
|
||||
listPaneWidth: number,
|
||||
listPaneOpen: boolean
|
||||
): number {
|
||||
return clampMagicNotesPaneWidth(
|
||||
width,
|
||||
getAiPaneWidthLimits(
|
||||
layoutWidth,
|
||||
listPaneWidth,
|
||||
listPaneOpen
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getListPaneWidthLimits(
|
||||
layoutWidth: number,
|
||||
aiPaneWidth: number,
|
||||
aiPaneOpen: boolean
|
||||
): MagicNotesPaneWidthLimits {
|
||||
return getMagicNotesPaneWidthLimits({
|
||||
adjacentPaneOpen: aiPaneOpen,
|
||||
adjacentPaneWidth: aiPaneWidth,
|
||||
layoutWidth,
|
||||
maximum: maximumMagicNotesListPaneWidth,
|
||||
minimum: minimumMagicNotesListPaneWidth
|
||||
})
|
||||
}
|
||||
|
||||
function clampListPaneWidth(
|
||||
width: number,
|
||||
layoutWidth: number,
|
||||
aiPaneWidth: number,
|
||||
aiPaneOpen: boolean
|
||||
): number {
|
||||
return clampMagicNotesPaneWidth(
|
||||
width,
|
||||
getListPaneWidthLimits(
|
||||
layoutWidth,
|
||||
aiPaneWidth,
|
||||
aiPaneOpen
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function noteSummary(note: MagicNoteDetail): MagicNoteSummary {
|
||||
@@ -349,8 +499,23 @@ export function MagicNotesWorkspace({
|
||||
const [composerKey, setComposerKey] = useState(0)
|
||||
const [editingEntry, setEditingEntry] = useState<MagicNoteEntry>()
|
||||
const [deletingEntryId, setDeletingEntryId] = useState('')
|
||||
const [aiPaneOpen, setAiPaneOpen] = useState(true)
|
||||
const [aiPaneWidth, setAiPaneWidth] = useState(defaultAiPaneWidth)
|
||||
const initialLayoutPreferences = useMemo(
|
||||
() => loadMagicNotesLayoutPreferences(),
|
||||
[]
|
||||
)
|
||||
const [listPaneOpen, setListPaneOpen] = useState(
|
||||
initialLayoutPreferences.listPaneOpen
|
||||
)
|
||||
const [listPaneWidth, setListPaneWidth] = useState(
|
||||
initialLayoutPreferences.listPaneWidth
|
||||
)
|
||||
const [listPaneResizing, setListPaneResizing] = useState(false)
|
||||
const [aiPaneOpen, setAiPaneOpen] = useState(
|
||||
initialLayoutPreferences.aiPaneOpen
|
||||
)
|
||||
const [aiPaneWidth, setAiPaneWidth] = useState(
|
||||
initialLayoutPreferences.aiPaneWidth
|
||||
)
|
||||
const [aiPaneResizing, setAiPaneResizing] = useState(false)
|
||||
const [magicNotesLayoutWidth, setMagicNotesLayoutWidth] = useState(
|
||||
window.innerWidth
|
||||
@@ -392,7 +557,9 @@ export function MagicNotesWorkspace({
|
||||
const draftAnalysisContextRef = useRef(0)
|
||||
const lastDraftAnalysisStartedAtRef = useRef(0)
|
||||
const magicNotesLayoutRef = useRef<HTMLDivElement>(null)
|
||||
const liveAiPaneWidthRef = useRef(defaultAiPaneWidth)
|
||||
const liveListPaneWidthRef = useRef(listPaneWidth)
|
||||
const listResizePointerIdRef = useRef<number | undefined>(undefined)
|
||||
const liveAiPaneWidthRef = useRef(aiPaneWidth)
|
||||
const aiResizePointerIdRef = useRef<number | undefined>(undefined)
|
||||
const composerRef = useRef<HTMLDivElement>(null)
|
||||
const continueEditingRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -426,12 +593,14 @@ export function MagicNotesWorkspace({
|
||||
)
|
||||
|
||||
const getLayoutBounds = useCallback((): {
|
||||
left: number
|
||||
width: number
|
||||
right: number
|
||||
} => {
|
||||
const bounds = magicNotesLayoutRef.current?.getBoundingClientRect()
|
||||
const width = bounds?.width || window.innerWidth
|
||||
return {
|
||||
left: bounds?.left ?? 0,
|
||||
width,
|
||||
right: bounds?.right || width
|
||||
}
|
||||
@@ -442,7 +611,9 @@ export function MagicNotesWorkspace({
|
||||
const bounds = getLayoutBounds()
|
||||
const width = clampAiPaneWidth(
|
||||
bounds.right - clientX,
|
||||
bounds.width
|
||||
bounds.width,
|
||||
liveListPaneWidthRef.current,
|
||||
listPaneOpen
|
||||
)
|
||||
liveAiPaneWidthRef.current = width
|
||||
if (commit) {
|
||||
@@ -454,7 +625,7 @@ export function MagicNotesWorkspace({
|
||||
`${width}px`
|
||||
)
|
||||
},
|
||||
[getLayoutBounds]
|
||||
[getLayoutBounds, listPaneOpen]
|
||||
)
|
||||
|
||||
const finishAiPaneResize = useCallback(
|
||||
@@ -475,26 +646,106 @@ export function MagicNotesWorkspace({
|
||||
const resizeAiPaneWithKeyboard = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
const bounds = getLayoutBounds()
|
||||
const limits = getAiPaneWidthLimits(bounds.width)
|
||||
const limits = getAiPaneWidthLimits(
|
||||
bounds.width,
|
||||
listPaneWidth,
|
||||
listPaneOpen
|
||||
)
|
||||
const nextWidth =
|
||||
event.key === 'Home'
|
||||
? limits.minimum
|
||||
: event.key === 'End'
|
||||
? limits.maximum
|
||||
: event.key === 'ArrowLeft'
|
||||
? aiPaneWidth + aiPaneKeyboardResizeStep
|
||||
? aiPaneWidth + magicNotesPaneKeyboardResizeStep
|
||||
: event.key === 'ArrowRight'
|
||||
? aiPaneWidth - aiPaneKeyboardResizeStep
|
||||
? aiPaneWidth - magicNotesPaneKeyboardResizeStep
|
||||
: undefined
|
||||
if (nextWidth === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const width = clampAiPaneWidth(nextWidth, bounds.width)
|
||||
const width = clampAiPaneWidth(
|
||||
nextWidth,
|
||||
bounds.width,
|
||||
listPaneWidth,
|
||||
listPaneOpen
|
||||
)
|
||||
liveAiPaneWidthRef.current = width
|
||||
setAiPaneWidth(width)
|
||||
},
|
||||
[aiPaneWidth, getLayoutBounds]
|
||||
[aiPaneWidth, getLayoutBounds, listPaneOpen, listPaneWidth]
|
||||
)
|
||||
|
||||
const resizeListPaneFromClientX = useCallback(
|
||||
(clientX: number, commit: boolean): void => {
|
||||
const bounds = getLayoutBounds()
|
||||
const width = clampListPaneWidth(
|
||||
clientX - bounds.left,
|
||||
bounds.width,
|
||||
liveAiPaneWidthRef.current,
|
||||
aiPaneOpen
|
||||
)
|
||||
liveListPaneWidthRef.current = width
|
||||
if (commit) {
|
||||
setListPaneWidth(width)
|
||||
return
|
||||
}
|
||||
magicNotesLayoutRef.current?.style.setProperty(
|
||||
'--magic-notes-list-width',
|
||||
`${width}px`
|
||||
)
|
||||
},
|
||||
[aiPaneOpen, getLayoutBounds]
|
||||
)
|
||||
|
||||
const finishListPaneResize = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (listResizePointerIdRef.current !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
listResizePointerIdRef.current = undefined
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
setListPaneWidth(liveListPaneWidthRef.current)
|
||||
setListPaneResizing(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const resizeListPaneWithKeyboard = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
const bounds = getLayoutBounds()
|
||||
const limits = getListPaneWidthLimits(
|
||||
bounds.width,
|
||||
aiPaneWidth,
|
||||
aiPaneOpen
|
||||
)
|
||||
const nextWidth =
|
||||
event.key === 'Home'
|
||||
? limits.minimum
|
||||
: event.key === 'End'
|
||||
? limits.maximum
|
||||
: event.key === 'ArrowLeft'
|
||||
? listPaneWidth - magicNotesPaneKeyboardResizeStep
|
||||
: event.key === 'ArrowRight'
|
||||
? listPaneWidth + magicNotesPaneKeyboardResizeStep
|
||||
: undefined
|
||||
if (nextWidth === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const width = clampListPaneWidth(
|
||||
nextWidth,
|
||||
bounds.width,
|
||||
aiPaneWidth,
|
||||
aiPaneOpen
|
||||
)
|
||||
liveListPaneWidthRef.current = width
|
||||
setListPaneWidth(width)
|
||||
},
|
||||
[aiPaneOpen, aiPaneWidth, getLayoutBounds, listPaneWidth]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
@@ -512,33 +763,62 @@ export function MagicNotesWorkspace({
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
persistMagicNotesLayoutPreferences({
|
||||
listPaneOpen,
|
||||
listPaneWidth,
|
||||
aiPaneOpen,
|
||||
aiPaneWidth
|
||||
})
|
||||
}, [aiPaneOpen, aiPaneWidth, listPaneOpen, listPaneWidth])
|
||||
|
||||
useEffect(() => {
|
||||
const layout = magicNotesLayoutRef.current
|
||||
const updateLayoutWidth = (): void => {
|
||||
const width = layout?.getBoundingClientRect().width || window.innerWidth
|
||||
setMagicNotesLayoutWidth(width)
|
||||
if (width > 720) {
|
||||
setListPaneWidth((current) => {
|
||||
const next = clampListPaneWidth(
|
||||
current,
|
||||
width,
|
||||
liveAiPaneWidthRef.current,
|
||||
aiPaneOpen
|
||||
)
|
||||
liveListPaneWidthRef.current = next
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (width > 800) {
|
||||
setAiPaneWidth((current) => {
|
||||
const next = clampAiPaneWidth(current, width)
|
||||
const next = clampAiPaneWidth(
|
||||
current,
|
||||
width,
|
||||
liveListPaneWidthRef.current,
|
||||
listPaneOpen
|
||||
)
|
||||
liveAiPaneWidthRef.current = next
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
updateLayoutWidth()
|
||||
window.addEventListener('resize', updateLayoutWidth)
|
||||
const observer =
|
||||
layout && typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(updateLayoutWidth)
|
||||
: undefined
|
||||
if (layout && observer) {
|
||||
observer.observe(layout)
|
||||
} else {
|
||||
window.addEventListener('resize', updateLayoutWidth)
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateLayoutWidth)
|
||||
if (!observer) {
|
||||
window.removeEventListener('resize', updateLayoutWidth)
|
||||
}
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [])
|
||||
}, [aiPaneOpen, listPaneOpen])
|
||||
|
||||
const notifyError = useCallback(
|
||||
(error: unknown): void =>
|
||||
@@ -1136,8 +1416,24 @@ export function MagicNotesWorkspace({
|
||||
.reverse(),
|
||||
[detail]
|
||||
)
|
||||
const aiPaneWidthLimits = getAiPaneWidthLimits(magicNotesLayoutWidth)
|
||||
const canResizeAiPane = aiPaneOpen && magicNotesLayoutWidth > 800
|
||||
const listPaneWidthLimits = getListPaneWidthLimits(
|
||||
magicNotesLayoutWidth,
|
||||
aiPaneWidth,
|
||||
aiPaneOpen
|
||||
)
|
||||
const aiPaneWidthLimits = getAiPaneWidthLimits(
|
||||
magicNotesLayoutWidth,
|
||||
listPaneWidth,
|
||||
listPaneOpen
|
||||
)
|
||||
const canResizeListPane =
|
||||
listPaneOpen &&
|
||||
magicNotesLayoutWidth > 720 &&
|
||||
listPaneWidthLimits.maximum > listPaneWidthLimits.minimum
|
||||
const canResizeAiPane =
|
||||
aiPaneOpen &&
|
||||
magicNotesLayoutWidth > 800 &&
|
||||
aiPaneWidthLimits.maximum > aiPaneWidthLimits.minimum
|
||||
|
||||
const submitCreateNote = async (): Promise<void> => {
|
||||
const title = newTitle.trim()
|
||||
@@ -1419,6 +1715,24 @@ export function MagicNotesWorkspace({
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
aria-controls="magic-notes-list-pane"
|
||||
aria-expanded={listPaneOpen}
|
||||
className="secondary-button"
|
||||
onClick={() => setListPaneOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{listPaneOpen ? (
|
||||
<PanelLeftClose aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<PanelLeftOpen aria-hidden="true" size={15} />
|
||||
)}
|
||||
{t(
|
||||
listPaneOpen
|
||||
? 'actions.hideListPane'
|
||||
: 'actions.showListPane'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-controls="magic-notes-ai-pane"
|
||||
aria-expanded={aiPaneOpen}
|
||||
@@ -1497,14 +1811,26 @@ export function MagicNotesWorkspace({
|
||||
ref={magicNotesLayoutRef}
|
||||
aria-busy={Boolean(busy)}
|
||||
className={`magic-notes-layout${
|
||||
listPaneOpen ? '' : ' magic-notes-layout--list-hidden'
|
||||
}${
|
||||
aiPaneOpen ? '' : ' magic-notes-layout--ai-hidden'
|
||||
}${
|
||||
aiPaneResizing && canResizeAiPane
|
||||
(listPaneResizing && canResizeListPane) ||
|
||||
(aiPaneResizing && canResizeAiPane)
|
||||
? ' magic-notes-layout--resizing'
|
||||
: ''
|
||||
}${
|
||||
listPaneResizing && canResizeListPane
|
||||
? ' magic-notes-layout--list-resizing'
|
||||
: ''
|
||||
}${
|
||||
aiPaneResizing && canResizeAiPane
|
||||
? ' magic-notes-layout--ai-resizing'
|
||||
: ''
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
'--magic-notes-list-width': `${listPaneWidth}px`,
|
||||
'--magic-notes-ai-width': `${aiPaneWidth}px`
|
||||
} as React.CSSProperties
|
||||
}
|
||||
@@ -1516,6 +1842,8 @@ export function MagicNotesWorkspace({
|
||||
: 'todos.listLabel'
|
||||
)}
|
||||
className="magic-notes-list-pane"
|
||||
hidden={!listPaneOpen}
|
||||
id="magic-notes-list-pane"
|
||||
>
|
||||
<PageTabs
|
||||
ariaLabel={t('page.contentLabel')}
|
||||
@@ -1772,6 +2100,59 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{listPaneOpen && (
|
||||
<div
|
||||
aria-controls="magic-notes-list-pane"
|
||||
aria-disabled={!canResizeListPane}
|
||||
aria-label={t('accessibility.resizeListPane')}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={listPaneWidthLimits.maximum}
|
||||
aria-valuemin={listPaneWidthLimits.minimum}
|
||||
aria-valuenow={listPaneWidth}
|
||||
aria-valuetext={t('accessibility.listPaneWidth', {
|
||||
width: listPaneWidth
|
||||
})}
|
||||
className="magic-notes-list-resize-handle"
|
||||
onKeyDown={resizeListPaneWithKeyboard}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (
|
||||
listResizePointerIdRef.current === event.pointerId
|
||||
) {
|
||||
listResizePointerIdRef.current = undefined
|
||||
setListPaneWidth(liveListPaneWidthRef.current)
|
||||
setListPaneResizing(false)
|
||||
}
|
||||
}}
|
||||
onPointerCancel={finishListPaneResize}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || !canResizeListPane) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
listResizePointerIdRef.current = event.pointerId
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
resizeListPaneFromClientX(event.clientX, true)
|
||||
setListPaneResizing(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (
|
||||
listResizePointerIdRef.current !== event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!canResizeListPane) {
|
||||
finishListPaneResize(event)
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
resizeListPaneFromClientX(event.clientX, false)
|
||||
}}
|
||||
onPointerUp={finishListPaneResize}
|
||||
role="separator"
|
||||
tabIndex={canResizeListPane ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section
|
||||
aria-label={t(
|
||||
libraryView === 'notes'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1400
|
||||
|
||||
@@ -12,11 +12,20 @@ import {
|
||||
ShieldAlert,
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
AssistantProject,
|
||||
WorkspaceChanges,
|
||||
WorkspaceDirectoryListing,
|
||||
WorkspaceFilePreview
|
||||
@@ -32,6 +41,28 @@ import {
|
||||
findTaskSchedule,
|
||||
TaskScheduleActions
|
||||
} from './TaskScheduleActions'
|
||||
import type {
|
||||
TerminalSnapshot
|
||||
} from '../../shared/terminal-contracts'
|
||||
import {
|
||||
WORKBAR_APP_DEFINITIONS,
|
||||
workbarLayoutPreferencesSchema,
|
||||
type WorkbarAppDefinition,
|
||||
type WorkbarTabInstance,
|
||||
type WorkbarTargetRef
|
||||
} from '../../shared/workbar-contracts'
|
||||
import {
|
||||
DEFAULT_WORKBAR_INSTANCES,
|
||||
WorkbarShell,
|
||||
type WorkbarInstanceCreateRequest
|
||||
} from './WorkbarShell'
|
||||
import type { TerminalAdapter } from './TerminalPanel'
|
||||
import './terminal-panel.css'
|
||||
|
||||
const TerminalPanel = lazy(async () => {
|
||||
const module = await import('./TerminalPanel')
|
||||
return { default: module.TerminalPanel }
|
||||
})
|
||||
|
||||
export type AssistantSidebarTab =
|
||||
| 'tasks'
|
||||
@@ -69,6 +100,7 @@ type RightAssistantSidebarProps = {
|
||||
workspaceChanges?: WorkspaceChanges
|
||||
workspaceProjectId?: string
|
||||
browserState?: BrowserLiveState
|
||||
currentProject?: AssistantProject
|
||||
restoreFocusRef?: { current: HTMLElement | null }
|
||||
onInteractBrowser: () => Promise<void>
|
||||
onStopBrowser: () => Promise<void>
|
||||
@@ -108,6 +140,45 @@ const emptyChangedFiles: WorkspaceChanges['files'] = []
|
||||
const defaultSidebarRatio = 0.3
|
||||
const minimumPaneWidth = 300
|
||||
const keyboardResizeStep = 16
|
||||
const workbarStorageKey = 'goodbuddy.workbar-layout.v1'
|
||||
|
||||
function loadPersistedWorkbarLayout(): ReturnType<
|
||||
typeof workbarLayoutPreferencesSchema.parse
|
||||
> | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem(workbarStorageKey)
|
||||
if (!value || value.length > 100_000) {
|
||||
return undefined
|
||||
}
|
||||
return workbarLayoutPreferencesSchema.parse(JSON.parse(value))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function persistWorkbarLayout(
|
||||
instances: readonly WorkbarTabInstance[],
|
||||
activeInstanceId: string | null,
|
||||
expanded: boolean,
|
||||
widthRatio: number
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
workbarStorageKey,
|
||||
JSON.stringify(
|
||||
workbarLayoutPreferencesSchema.parse({
|
||||
instances,
|
||||
activeInstanceId,
|
||||
expanded,
|
||||
dock: 'right',
|
||||
widthRatio
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
// The in-memory workbar remains usable when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function getSidebarWidthLimits(layoutWidth: number): {
|
||||
minimum: number
|
||||
@@ -170,6 +241,7 @@ export function RightAssistantSidebar({
|
||||
workspaceChanges,
|
||||
workspaceProjectId,
|
||||
browserState,
|
||||
currentProject,
|
||||
restoreFocusRef,
|
||||
onInteractBrowser,
|
||||
onStopBrowser,
|
||||
@@ -206,10 +278,68 @@ export function RightAssistantSidebar({
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
const initialLayout = useMemo(
|
||||
() => loadPersistedWorkbarLayout(),
|
||||
[]
|
||||
)
|
||||
const localizedAppDefinitions = useMemo(
|
||||
() =>
|
||||
WORKBAR_APP_DEFINITIONS.map((definition) => {
|
||||
const tabDefinition = tabs.find(
|
||||
(item) => item.id === definition.id
|
||||
)
|
||||
return {
|
||||
...definition,
|
||||
label:
|
||||
tabDefinition?.label ??
|
||||
t('sidebar.tabs.terminal.label'),
|
||||
description:
|
||||
tabDefinition?.description ??
|
||||
t('sidebar.tabs.terminal.description')
|
||||
} satisfies WorkbarAppDefinition
|
||||
}),
|
||||
[t, tabs]
|
||||
)
|
||||
const defaultInstances = useMemo(
|
||||
() =>
|
||||
DEFAULT_WORKBAR_INSTANCES.map((instance) => ({
|
||||
...instance,
|
||||
title:
|
||||
localizedAppDefinitions.find(
|
||||
(definition) => definition.id === instance.appId
|
||||
)?.label ?? instance.title
|
||||
})),
|
||||
[localizedAppDefinitions]
|
||||
)
|
||||
const [workbarInstances, setWorkbarInstances] = useState<
|
||||
WorkbarTabInstance[]
|
||||
>(() =>
|
||||
(initialLayout?.instances ?? defaultInstances).map((instance) =>
|
||||
instance.appId === 'terminal'
|
||||
? instance
|
||||
: {
|
||||
...instance,
|
||||
title:
|
||||
localizedAppDefinitions.find(
|
||||
(definition) => definition.id === instance.appId
|
||||
)?.label ?? instance.title
|
||||
}
|
||||
)
|
||||
)
|
||||
const [activeWorkbarInstanceId, setActiveWorkbarInstanceId] =
|
||||
useState<string | null>(
|
||||
() =>
|
||||
initialLayout?.activeInstanceId ??
|
||||
defaultInstances.find((instance) => instance.appId === tab)?.id ??
|
||||
defaultInstances[0]?.id ??
|
||||
null
|
||||
)
|
||||
const [splitLayoutWidth, setSplitLayoutWidth] = useState(
|
||||
window.innerWidth
|
||||
)
|
||||
const [sidebarRatio, setSidebarRatio] = useState(defaultSidebarRatio)
|
||||
const [sidebarRatio, setSidebarRatio] = useState(
|
||||
initialLayout?.widthRatio ?? defaultSidebarRatio
|
||||
)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const wasOpen = useRef(false)
|
||||
@@ -249,12 +379,41 @@ export function RightAssistantSidebar({
|
||||
'attention' | 'active' | 'paused' | 'finished'
|
||||
>('active')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [terminalSessionIds, setTerminalSessionIds] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
const [terminalSnapshots, setTerminalSnapshots] = useState<
|
||||
Record<string, TerminalSnapshot>
|
||||
>({})
|
||||
const [terminalCloseConfirmation, setTerminalCloseConfirmation] =
|
||||
useState<{
|
||||
instance: WorkbarTabInstance
|
||||
resolve: (accepted: boolean) => void
|
||||
closing: boolean
|
||||
error?: string
|
||||
}>()
|
||||
const terminalCloseCancelRef = useRef<HTMLButtonElement>(null)
|
||||
const lastExternalTabRef = useRef(tab)
|
||||
const artifactPreview =
|
||||
artifacts.find((artifact) => artifact.id === selectedArtifactId)
|
||||
const currentWorkspacePreview =
|
||||
workspacePreview?.projectId === workspaceProjectId
|
||||
? workspacePreview
|
||||
: undefined
|
||||
const terminalAdapter = useMemo<TerminalAdapter>(
|
||||
() => ({
|
||||
create: (request) => window.goodbuddy.terminal.create(request),
|
||||
write: (request) => window.goodbuddy.terminal.write(request),
|
||||
resize: (request) => window.goodbuddy.terminal.resize(request),
|
||||
close: (request) => window.goodbuddy.terminal.close(request),
|
||||
getSnapshot: (request) =>
|
||||
window.goodbuddy.terminal.getSnapshot(request),
|
||||
ack: (request) => window.goodbuddy.terminal.ack(request),
|
||||
subscribe: (listener) =>
|
||||
window.goodbuddy.terminal.onEvent(listener)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth)
|
||||
const canResize =
|
||||
open &&
|
||||
@@ -288,6 +447,209 @@ export function RightAssistantSidebar({
|
||||
[taskFilter, topLevelTasks]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (lastExternalTabRef.current === tab) {
|
||||
return
|
||||
}
|
||||
lastExternalTabRef.current = tab
|
||||
const requested = workbarInstances.find(
|
||||
(instance) => instance.appId === tab
|
||||
)
|
||||
if (requested) {
|
||||
// This effect intentionally mirrors the legacy external tab prop into
|
||||
// the dynamic workbar only when that prop actually changes.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveWorkbarInstanceId(requested.id)
|
||||
}
|
||||
}, [tab, workbarInstances])
|
||||
|
||||
useEffect(() => {
|
||||
persistWorkbarLayout(
|
||||
workbarInstances,
|
||||
activeWorkbarInstanceId,
|
||||
open,
|
||||
sidebarRatio
|
||||
)
|
||||
}, [
|
||||
activeWorkbarInstanceId,
|
||||
open,
|
||||
sidebarRatio,
|
||||
workbarInstances
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalCloseConfirmation && !terminalCloseConfirmation.closing) {
|
||||
terminalCloseCancelRef.current?.focus()
|
||||
}
|
||||
}, [terminalCloseConfirmation])
|
||||
|
||||
const resolveTerminalTarget = useCallback(
|
||||
(): WorkbarTargetRef =>
|
||||
currentProject
|
||||
? { type: 'project', projectId: currentProject.id }
|
||||
: { type: 'local' },
|
||||
[currentProject]
|
||||
)
|
||||
|
||||
const updateActiveWorkbarInstance = useCallback(
|
||||
(instanceId: string): void => {
|
||||
setActiveWorkbarInstanceId(instanceId)
|
||||
const instance = workbarInstances.find(
|
||||
(candidate) => candidate.id === instanceId
|
||||
)
|
||||
if (
|
||||
instance &&
|
||||
instance.appId !== 'terminal'
|
||||
) {
|
||||
onTabChange(instance.appId)
|
||||
}
|
||||
},
|
||||
[onTabChange, workbarInstances]
|
||||
)
|
||||
|
||||
const createWorkbarInstance = useCallback(
|
||||
(request: WorkbarInstanceCreateRequest): void => {
|
||||
const definition = localizedAppDefinitions.find(
|
||||
(candidate) => candidate.id === request.appId
|
||||
)
|
||||
if (!definition) {
|
||||
return
|
||||
}
|
||||
const sameTargetTerminals = workbarInstances.filter(
|
||||
(instance) =>
|
||||
instance.appId === 'terminal' &&
|
||||
JSON.stringify(instance.targetRef) ===
|
||||
JSON.stringify(request.targetRef)
|
||||
).length
|
||||
const instance: WorkbarTabInstance = {
|
||||
id: crypto.randomUUID(),
|
||||
appId: request.appId,
|
||||
title:
|
||||
request.appId === 'terminal'
|
||||
? `${definition.label} ${sameTargetTerminals + 1}`
|
||||
: definition.label,
|
||||
...(request.targetRef
|
||||
? { targetRef: request.targetRef }
|
||||
: {})
|
||||
}
|
||||
setWorkbarInstances((current) => {
|
||||
const afterIndex = request.insertAfterInstanceId
|
||||
? current.findIndex(
|
||||
(candidate) =>
|
||||
candidate.id === request.insertAfterInstanceId
|
||||
)
|
||||
: -1
|
||||
const next = [...current]
|
||||
next.splice(afterIndex >= 0 ? afterIndex + 1 : next.length, 0, instance)
|
||||
return next
|
||||
})
|
||||
setActiveWorkbarInstanceId(instance.id)
|
||||
},
|
||||
[localizedAppDefinitions, workbarInstances]
|
||||
)
|
||||
|
||||
const removeWorkbarInstance = useCallback(
|
||||
(instanceId: string): void => {
|
||||
setWorkbarInstances((current) =>
|
||||
current.filter((instance) => instance.id !== instanceId)
|
||||
)
|
||||
setTerminalSessionIds((current) => {
|
||||
const next = { ...current }
|
||||
delete next[instanceId]
|
||||
return next
|
||||
})
|
||||
setTerminalSnapshots((current) => {
|
||||
const next = { ...current }
|
||||
delete next[instanceId]
|
||||
return next
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const requestCloseWorkbarInstance = useCallback(
|
||||
async (instance: WorkbarTabInstance): Promise<boolean> => {
|
||||
if (instance.appId !== 'terminal') {
|
||||
removeWorkbarInstance(instance.id)
|
||||
return true
|
||||
}
|
||||
const sessionId = terminalSessionIds[instance.id]
|
||||
const snapshot = terminalSnapshots[instance.id]
|
||||
if (
|
||||
sessionId &&
|
||||
(!snapshot ||
|
||||
snapshot.state === 'starting' ||
|
||||
snapshot.state === 'running' ||
|
||||
snapshot.state === 'closing')
|
||||
) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setTerminalCloseConfirmation({
|
||||
instance,
|
||||
resolve,
|
||||
closing: false
|
||||
})
|
||||
})
|
||||
}
|
||||
if (sessionId) {
|
||||
try {
|
||||
await terminalAdapter.close({ sessionId })
|
||||
} catch {
|
||||
// An already-ended session can still be removed from the workbar.
|
||||
}
|
||||
}
|
||||
removeWorkbarInstance(instance.id)
|
||||
return true
|
||||
},
|
||||
[
|
||||
removeWorkbarInstance,
|
||||
terminalAdapter,
|
||||
terminalSessionIds,
|
||||
terminalSnapshots
|
||||
]
|
||||
)
|
||||
|
||||
const confirmTerminalClose = useCallback(async (): Promise<void> => {
|
||||
const confirmation = terminalCloseConfirmation
|
||||
if (!confirmation || confirmation.closing) {
|
||||
return
|
||||
}
|
||||
const sessionId = terminalSessionIds[confirmation.instance.id]
|
||||
setTerminalCloseConfirmation({
|
||||
...confirmation,
|
||||
closing: true,
|
||||
error: undefined
|
||||
})
|
||||
try {
|
||||
if (sessionId) {
|
||||
await terminalAdapter.close({ sessionId })
|
||||
}
|
||||
removeWorkbarInstance(confirmation.instance.id)
|
||||
setTerminalCloseConfirmation(undefined)
|
||||
confirmation.resolve(true)
|
||||
} catch (reason) {
|
||||
setTerminalCloseConfirmation({
|
||||
...confirmation,
|
||||
closing: false,
|
||||
error:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('sidebar.terminal.closeDialog.error')
|
||||
})
|
||||
}
|
||||
}, [
|
||||
removeWorkbarInstance,
|
||||
terminalAdapter,
|
||||
terminalCloseConfirmation,
|
||||
terminalSessionIds,
|
||||
t
|
||||
])
|
||||
|
||||
const cancelTerminalClose = useCallback((): void => {
|
||||
const confirmation = terminalCloseConfirmation
|
||||
setTerminalCloseConfirmation(undefined)
|
||||
confirmation?.resolve(false)
|
||||
}, [terminalCloseConfirmation])
|
||||
|
||||
useEffect(() => {
|
||||
const sidebar = sidebarRef.current
|
||||
const workspace = findWorkspaceSibling(sidebar)
|
||||
@@ -470,36 +832,6 @@ export function RightAssistantSidebar({
|
||||
})
|
||||
}
|
||||
|
||||
const moveTabFocus = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
tabId: AssistantSidebarTab
|
||||
): void => {
|
||||
const index = tabs.findIndex((item) => item.id === tabId)
|
||||
const targetIndex =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? tabs.length - 1
|
||||
: event.key === 'ArrowLeft'
|
||||
? (index - 1 + tabs.length) % tabs.length
|
||||
: event.key === 'ArrowRight'
|
||||
? (index + 1) % tabs.length
|
||||
: -1
|
||||
if (targetIndex < 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const target = tabs[targetIndex]
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
setActionError('')
|
||||
onTabChange(target.id)
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(`assistant-sidebar-tab-${target.id}`)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
@@ -573,57 +905,75 @@ export function RightAssistantSidebar({
|
||||
role="separator"
|
||||
tabIndex={canResize ? 0 : -1}
|
||||
/>
|
||||
<nav
|
||||
aria-label={t('sidebar.categoriesAriaLabel')}
|
||||
className="assistant-sidebar__tabs"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
aria-controls="assistant-sidebar-panel"
|
||||
aria-selected={tab === item.id}
|
||||
className={
|
||||
tab === item.id
|
||||
? 'assistant-sidebar__tab assistant-sidebar__tab--active'
|
||||
: 'assistant-sidebar__tab'
|
||||
}
|
||||
id={`assistant-sidebar-tab-${item.id}`}
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActionError('')
|
||||
onTabChange(item.id)
|
||||
}}
|
||||
onKeyDown={(event) => moveTabFocus(event, item.id)}
|
||||
role="tab"
|
||||
tabIndex={tab === item.id ? 0 : -1}
|
||||
title={item.description}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
{item.id === 'tasks' && approvals.length > 0 && (
|
||||
<span
|
||||
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
|
||||
className="assistant-sidebar__badge"
|
||||
>
|
||||
{approvals.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
aria-labelledby={`assistant-sidebar-tab-${tab}`}
|
||||
className="assistant-sidebar__body"
|
||||
id="assistant-sidebar-panel"
|
||||
role="tabpanel"
|
||||
>
|
||||
<WorkbarShell
|
||||
activeInstanceId={activeWorkbarInstanceId}
|
||||
appDefinitions={localizedAppDefinitions}
|
||||
instances={workbarInstances}
|
||||
onActiveInstanceChange={updateActiveWorkbarInstance}
|
||||
onCloseInstance={requestCloseWorkbarInstance}
|
||||
onCreateInstance={createWorkbarInstance}
|
||||
onResolveTerminalTarget={resolveTerminalTarget}
|
||||
renderTabAdornment={(instance) =>
|
||||
instance.appId === 'tasks' && approvals.length > 0 ? (
|
||||
<span
|
||||
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
|
||||
className="assistant-sidebar__badge"
|
||||
>
|
||||
{approvals.length}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
renderPanel={(instance) => (
|
||||
<div className="assistant-sidebar__body">
|
||||
{actionError ? (
|
||||
<p className="settings-error" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{tab === 'tasks' && (
|
||||
{instance.appId === 'terminal' && instance.targetRef ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<section
|
||||
aria-busy="true"
|
||||
aria-label={t('sidebar.terminal.loading')}
|
||||
className="terminal-panel terminal-panel--loading"
|
||||
>
|
||||
<span>{t('sidebar.terminal.loading')}</span>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
<TerminalPanel
|
||||
adapter={terminalAdapter}
|
||||
onRename={(title) =>
|
||||
setWorkbarInstances((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === instance.id
|
||||
? { ...candidate, title }
|
||||
: candidate
|
||||
)
|
||||
)
|
||||
}
|
||||
onSessionChange={(snapshot) => {
|
||||
setTerminalSessionIds((current) =>
|
||||
current[instance.id] === snapshot.sessionId
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
[instance.id]: snapshot.sessionId
|
||||
}
|
||||
)
|
||||
setTerminalSnapshots((current) => ({
|
||||
...current,
|
||||
[instance.id]: snapshot
|
||||
}))
|
||||
}}
|
||||
sessionId={terminalSessionIds[instance.id]}
|
||||
target={instance.targetRef}
|
||||
title={instance.title}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{instance.appId === 'tasks' && (
|
||||
<section className="assistant-sidebar__section">
|
||||
<h3>
|
||||
<ShieldAlert size={15} />
|
||||
@@ -812,7 +1162,7 @@ export function RightAssistantSidebar({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'workspace' && (
|
||||
{instance.appId === 'workspace' && (
|
||||
currentWorkspacePreview ? (
|
||||
<section
|
||||
aria-busy={currentWorkspacePreview.state === 'loading'}
|
||||
@@ -934,7 +1284,7 @@ export function RightAssistantSidebar({
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'results' && (
|
||||
{instance.appId === 'results' && (
|
||||
artifactPreview ? (
|
||||
<section className="assistant-sidebar__preview">
|
||||
<header>
|
||||
@@ -1043,7 +1393,7 @@ export function RightAssistantSidebar({
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'browser' && (
|
||||
{instance.appId === 'browser' && (
|
||||
<section className="assistant-sidebar__browser">
|
||||
<header>
|
||||
<span>
|
||||
@@ -1149,7 +1499,54 @@ export function RightAssistantSidebar({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{terminalCloseConfirmation ? (
|
||||
<div className="assistant-sidebar__overlay">
|
||||
<section
|
||||
aria-label={t('sidebar.terminal.closeDialog.ariaLabel')}
|
||||
className="assistant-sidebar__terminal-close"
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!terminalCloseConfirmation.closing
|
||||
) {
|
||||
event.preventDefault()
|
||||
cancelTerminalClose()
|
||||
}
|
||||
}}
|
||||
role="alertdialog"
|
||||
>
|
||||
<h2>{t('sidebar.terminal.closeDialog.title')}</h2>
|
||||
<p>{t('sidebar.terminal.closeDialog.description')}</p>
|
||||
{terminalCloseConfirmation.error ? (
|
||||
<p role="alert">{terminalCloseConfirmation.error}</p>
|
||||
) : null}
|
||||
<div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={terminalCloseConfirmation.closing}
|
||||
onClick={cancelTerminalClose}
|
||||
ref={terminalCloseCancelRef}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.terminal.closeDialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={terminalCloseConfirmation.closing}
|
||||
onClick={() => void confirmTerminalClose()}
|
||||
type="button"
|
||||
>
|
||||
{terminalCloseConfirmation.closing
|
||||
? t('sidebar.terminal.closeDialog.closing')
|
||||
: t('sidebar.terminal.closeDialog.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
TerminalEvent,
|
||||
TerminalSnapshot
|
||||
} from '../../shared/terminal-contracts'
|
||||
import {
|
||||
TerminalPanel,
|
||||
resolveTerminalFontFamily,
|
||||
type TerminalAdapter,
|
||||
type TerminalEmulator,
|
||||
type TerminalFactory
|
||||
} from './TerminalPanel'
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: class MockTerminal {} }))
|
||||
vi.mock('@xterm/addon-fit', () => ({ FitAddon: class MockFitAddon {} }))
|
||||
vi.mock('@xterm/addon-search', () => ({
|
||||
SearchAddon: class MockSearchAddon {}
|
||||
}))
|
||||
|
||||
const sessionId = '00000000-0000-4000-8000-000000000201'
|
||||
|
||||
function snapshot(
|
||||
overrides: Partial<TerminalSnapshot> = {}
|
||||
): TerminalSnapshot {
|
||||
return {
|
||||
sessionId,
|
||||
target: { type: 'local' },
|
||||
targetLabel: '本机',
|
||||
title: '终端 · 本机 1',
|
||||
state: 'running',
|
||||
shell: 'PowerShell 7',
|
||||
workingDirectory: 'D:\\workspace',
|
||||
size: { cols: 80, rows: 24 },
|
||||
lastSequence: 0,
|
||||
exit: null,
|
||||
error: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type EmulatorHarness = {
|
||||
factory: TerminalFactory
|
||||
terminal: TerminalEmulator
|
||||
emitInput: (data: string) => void
|
||||
write: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
fit: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function emulatorHarness(): EmulatorHarness {
|
||||
let inputListener: ((data: string) => void) | undefined
|
||||
const write = vi.fn((_: string, callback?: () => void) => callback?.())
|
||||
const dispose = vi.fn()
|
||||
const fit = vi.fn()
|
||||
const terminal: TerminalEmulator = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
open: vi.fn(),
|
||||
onData: vi.fn((listener: (data: string) => void) => {
|
||||
inputListener = listener
|
||||
return { dispose: vi.fn() }
|
||||
}),
|
||||
write,
|
||||
clear: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
getSelection: vi.fn(() => 'selected output'),
|
||||
focus: vi.fn(),
|
||||
dispose
|
||||
}
|
||||
return {
|
||||
terminal,
|
||||
write,
|
||||
dispose,
|
||||
fit,
|
||||
emitInput: (data) => inputListener?.(data),
|
||||
factory: () => ({
|
||||
terminal,
|
||||
fitAddon: { fit },
|
||||
searchAddon: { findNext: vi.fn(() => true) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type AdapterHarness = {
|
||||
adapter: TerminalAdapter
|
||||
emit: (event: TerminalEvent) => void
|
||||
}
|
||||
|
||||
function adapterHarness(
|
||||
createResult: TerminalSnapshot = snapshot()
|
||||
): AdapterHarness {
|
||||
let eventListener: ((event: TerminalEvent) => void) | undefined
|
||||
const adapter: TerminalAdapter = {
|
||||
create: vi.fn(async () => createResult),
|
||||
write: vi.fn(async () => undefined),
|
||||
resize: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () =>
|
||||
snapshot({ state: 'exited', exit: { exitCode: 0, signal: null } })
|
||||
),
|
||||
getSnapshot: vi.fn(async () => createResult),
|
||||
ack: vi.fn(async () => undefined),
|
||||
subscribe: vi.fn((listener) => {
|
||||
eventListener = listener
|
||||
return vi.fn()
|
||||
})
|
||||
}
|
||||
return {
|
||||
adapter,
|
||||
emit: (event) => eventListener?.(event)
|
||||
}
|
||||
}
|
||||
|
||||
type ResizeObserverHarness = {
|
||||
trigger: (width?: number, height?: number) => void
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserverHarness
|
||||
|
||||
beforeEach(() => {
|
||||
const disconnect = vi.fn()
|
||||
class MockResizeObserver {
|
||||
private readonly callback: ResizeObserverCallback
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback
|
||||
resizeObserver = {
|
||||
disconnect,
|
||||
trigger: (width = 800, height = 480) =>
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
contentRect: {
|
||||
width,
|
||||
height
|
||||
} as DOMRectReadOnly
|
||||
} as ResizeObserverEntry
|
||||
],
|
||||
this as unknown as ResizeObserver
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect = disconnect
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.documentElement.style.removeProperty('--font-family-mono')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('TerminalPanel', () => {
|
||||
it('creates only after the first valid fitted size and forwards input', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
|
||||
resizeObserver.trigger(0, 0)
|
||||
expect(backend.adapter.create).not.toHaveBeenCalled()
|
||||
resizeObserver.trigger()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledWith({
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
)
|
||||
expect(screen.getByText('PowerShell 7')).toBeInTheDocument()
|
||||
|
||||
emulator.emitInput('Get-Location\r')
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.write).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
data: 'Get-Location\r'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplicates resize, consumes ordered events, and ACKs output', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
resizeObserver.trigger(700, 400)
|
||||
resizeObserver.trigger(600, 300)
|
||||
expect(backend.adapter.resize).not.toHaveBeenCalled()
|
||||
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
type: 'output',
|
||||
data: 'hello'
|
||||
})
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
type: 'output',
|
||||
data: 'duplicate'
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(emulator.write).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.any(Function)
|
||||
)
|
||||
)
|
||||
expect(emulator.write).not.toHaveBeenCalledWith(
|
||||
'duplicate',
|
||||
expect.anything()
|
||||
)
|
||||
Object.defineProperty(emulator.terminal, 'cols', { value: 100 })
|
||||
Object.defineProperty(emulator.terminal, 'rows', { value: 30 })
|
||||
resizeObserver.trigger()
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.resize).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(backend.adapter.resize).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
cols: 100,
|
||||
rows: 30
|
||||
})
|
||||
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 2,
|
||||
type: 'state',
|
||||
state: 'interrupted'
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.ack).toHaveBeenLastCalledWith({
|
||||
sessionId,
|
||||
sequence: 2
|
||||
})
|
||||
)
|
||||
expect(screen.getAllByText('连接已中断')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unsubscribes and destroys the emulator without closing the session', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
const unsubscribe = vi.fn()
|
||||
vi.mocked(backend.adapter.subscribe).mockImplementation((listener) => {
|
||||
void listener
|
||||
return unsubscribe
|
||||
})
|
||||
const view = render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
sessionId={sessionId}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.getSnapshot).toHaveBeenCalledWith({
|
||||
sessionId
|
||||
})
|
||||
)
|
||||
|
||||
view.unmount()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(emulator.dispose).toHaveBeenCalledOnce()
|
||||
expect(resizeObserver.disconnect).toHaveBeenCalledOnce()
|
||||
expect(backend.adapter.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves session closing to the workbar tab action', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
sessionId={sessionId}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
await screen.findByText('PowerShell 7')
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '关闭终端会话' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(backend.adapter.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders and ACKs startup events included in the create snapshot sequence', async () => {
|
||||
const backend = adapterHarness(snapshot({ lastSequence: 2 }))
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
type: 'output',
|
||||
data: 'startup prompt'
|
||||
})
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 2,
|
||||
type: 'state',
|
||||
state: 'running'
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(emulator.write).toHaveBeenCalledWith(
|
||||
'startup prompt',
|
||||
expect.any(Function)
|
||||
)
|
||||
)
|
||||
expect(backend.adapter.ack).toHaveBeenLastCalledWith({
|
||||
sessionId,
|
||||
sequence: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one emulator when callback identities change', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
const factory = vi.fn(emulator.factory)
|
||||
const firstCallback = vi.fn()
|
||||
const view = render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
onSessionChange={firstCallback}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
const latestCallback = vi.fn()
|
||||
view.rerender(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
onSessionChange={latestCallback}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={factory}
|
||||
/>
|
||||
)
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
type: 'state',
|
||||
state: 'interrupted'
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(latestCallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: 'interrupted' })
|
||||
)
|
||||
)
|
||||
expect(factory).toHaveBeenCalledOnce()
|
||||
expect(emulator.dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a session whose create result arrives after unmount', async () => {
|
||||
let resolveCreate:
|
||||
| ((value: TerminalSnapshot) => void)
|
||||
| undefined
|
||||
const backend = adapterHarness()
|
||||
vi.mocked(backend.adapter.create).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveCreate = resolve
|
||||
})
|
||||
)
|
||||
const emulator = emulatorHarness()
|
||||
const view = render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
view.unmount()
|
||||
resolveCreate?.(snapshot())
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.close).toHaveBeenCalledWith({ sessionId })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the resolved cross-platform monospace font stack', () => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--font-family-mono',
|
||||
'"Test Mono", monospace'
|
||||
)
|
||||
expect(resolveTerminalFontFamily()).toBe(
|
||||
'"Test Mono", monospace'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps creation failures visible and allows retrying', async () => {
|
||||
const backend = adapterHarness()
|
||||
vi.mocked(backend.adapter.create)
|
||||
.mockRejectedValueOnce(new Error('Shell 启动失败'))
|
||||
.mockResolvedValueOnce(snapshot())
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Shell 启动失败'
|
||||
)
|
||||
expect(screen.getAllByText('连接失败')).not.toHaveLength(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(await screen.findAllByText('已连接')).not.toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,786 @@
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import {
|
||||
Clipboard,
|
||||
Copy,
|
||||
Eraser,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import type {
|
||||
TerminalEvent,
|
||||
TerminalSessionId,
|
||||
TerminalSnapshot,
|
||||
TerminalTarget
|
||||
} from '../../shared/terminal-contracts'
|
||||
import './terminal-panel.css'
|
||||
|
||||
export type TerminalAdapter = Omit<DesktopApi['terminal'], 'onEvent'> & {
|
||||
subscribe: DesktopApi['terminal']['onEvent']
|
||||
}
|
||||
|
||||
type Disposable = { dispose: () => void }
|
||||
|
||||
export type TerminalEmulator = {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
open: (element: HTMLElement) => void
|
||||
onData: (listener: (data: string) => void) => Disposable
|
||||
write: (data: string, callback?: () => void) => void
|
||||
clear: () => void
|
||||
selectAll: () => void
|
||||
getSelection: () => string
|
||||
focus: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export type TerminalFitAddon = {
|
||||
fit: () => void
|
||||
}
|
||||
|
||||
export type TerminalSearchAddon = {
|
||||
findNext: (term: string) => boolean
|
||||
}
|
||||
|
||||
export type TerminalFactoryResult = {
|
||||
terminal: TerminalEmulator
|
||||
fitAddon: TerminalFitAddon
|
||||
searchAddon: TerminalSearchAddon
|
||||
activateRenderer?: () => void
|
||||
}
|
||||
|
||||
export type TerminalFactory = () => TerminalFactoryResult
|
||||
|
||||
export type TerminalPanelProps = {
|
||||
adapter: TerminalAdapter
|
||||
target: TerminalTarget
|
||||
sessionId?: TerminalSessionId
|
||||
title?: string
|
||||
terminalFactory?: TerminalFactory
|
||||
onSessionChange?: (snapshot: TerminalSnapshot) => void
|
||||
onRename?: (title: string) => void
|
||||
}
|
||||
|
||||
const announcedStates = new Set<TerminalSnapshot['state']>([
|
||||
'running',
|
||||
'exited',
|
||||
'interrupted',
|
||||
'failed'
|
||||
])
|
||||
|
||||
const fallbackTerminalFontFamily = [
|
||||
'"Cascadia Mono"',
|
||||
'"Cascadia Code"',
|
||||
'"SFMono-Regular"',
|
||||
'Menlo',
|
||||
'Monaco',
|
||||
'Consolas',
|
||||
'"Noto Sans Mono"',
|
||||
'"DejaVu Sans Mono"',
|
||||
'"Liberation Mono"',
|
||||
'monospace'
|
||||
].join(', ')
|
||||
|
||||
export function resolveTerminalFontFamily(): string {
|
||||
const configured = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--font-family-mono')
|
||||
.trim()
|
||||
return configured || fallbackTerminalFontFamily
|
||||
}
|
||||
|
||||
function createDefaultTerminal(): TerminalFactoryResult {
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
scrollback: 10_000,
|
||||
fontFamily: resolveTerminalFontFamily(),
|
||||
fontSize: 13,
|
||||
rescaleOverlappingGlyphs: true
|
||||
})
|
||||
const fitAddon = new FitAddon()
|
||||
const searchAddon = new SearchAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(searchAddon)
|
||||
return {
|
||||
terminal,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
activateRenderer: () => {
|
||||
const webglAddon = new WebglAddon()
|
||||
const contextLossSubscription = webglAddon.onContextLoss(() => {
|
||||
contextLossSubscription.dispose()
|
||||
webglAddon.dispose()
|
||||
})
|
||||
try {
|
||||
terminal.loadAddon(webglAddon)
|
||||
} catch {
|
||||
contextLossSubscription.dispose()
|
||||
webglAddon.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function failureMessage(reason: unknown, fallback: string): string {
|
||||
return reason instanceof Error && reason.message
|
||||
? reason.message
|
||||
: fallback
|
||||
}
|
||||
|
||||
function isValidSize(
|
||||
cols: number,
|
||||
rows: number
|
||||
): boolean {
|
||||
return cols >= 2 && rows >= 1
|
||||
}
|
||||
|
||||
export function TerminalPanel({
|
||||
adapter,
|
||||
target,
|
||||
sessionId,
|
||||
title,
|
||||
terminalFactory = createDefaultTerminal,
|
||||
onSessionChange,
|
||||
onRename
|
||||
}: TerminalPanelProps): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const searchInputId = useId()
|
||||
const titleInputId = useId()
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const emulatorRef = useRef<TerminalEmulator | undefined>(undefined)
|
||||
const fitAddonRef = useRef<TerminalFitAddon | undefined>(undefined)
|
||||
const searchAddonRef = useRef<TerminalSearchAddon | undefined>(
|
||||
undefined
|
||||
)
|
||||
const activeSessionRef = useRef<TerminalSessionId | undefined>(
|
||||
undefined
|
||||
)
|
||||
const snapshotRef = useRef<TerminalSnapshot | undefined>(undefined)
|
||||
const mountedRef = useRef(false)
|
||||
const acceptedSequenceRef = useRef(0)
|
||||
const eventQueueRef = useRef(Promise.resolve())
|
||||
const lastSizeRef = useRef<
|
||||
{ cols: number; rows: number } | undefined
|
||||
>(undefined)
|
||||
const operationGenerationRef = useRef(0)
|
||||
const onSessionChangeRef = useRef(onSessionChange)
|
||||
const onRenameRef = useRef(onRename)
|
||||
const translationRef = useRef(t)
|
||||
const [snapshot, setSnapshot] = useState<
|
||||
TerminalSnapshot | undefined
|
||||
>(undefined)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [draftTitle, setDraftTitle] = useState(title ?? '')
|
||||
const [hasMeasuredSize, setHasMeasuredSize] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onSessionChangeRef.current = onSessionChange
|
||||
onRenameRef.current = onRename
|
||||
translationRef.current = t
|
||||
}, [onRename, onSessionChange, t])
|
||||
|
||||
const publishSnapshot = useCallback((next: TerminalSnapshot): void => {
|
||||
snapshotRef.current = next
|
||||
setSnapshot(next)
|
||||
onSessionChangeRef.current?.(next)
|
||||
if (announcedStates.has(next.state)) {
|
||||
setAnnouncement(
|
||||
translationRef.current(
|
||||
`sidebar.terminal.states.${next.state}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const consumeEvent = useCallback(
|
||||
async (event: TerminalEvent): Promise<void> => {
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
event.sessionId !== activeSessionRef.current ||
|
||||
event.sequence <= acceptedSequenceRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
acceptedSequenceRef.current = event.sequence
|
||||
|
||||
if (event.type === 'output') {
|
||||
await new Promise<void>((resolve) => {
|
||||
emulatorRef.current?.write(event.data, resolve)
|
||||
if (!emulatorRef.current) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const current = snapshotRef.current
|
||||
if (current) {
|
||||
if (event.type === 'state') {
|
||||
publishSnapshot({ ...current, state: event.state })
|
||||
} else if (event.type === 'exit') {
|
||||
publishSnapshot({
|
||||
...current,
|
||||
state: 'exited',
|
||||
exit: event.exit,
|
||||
error: null
|
||||
})
|
||||
} else {
|
||||
publishSnapshot({
|
||||
...current,
|
||||
state: 'failed',
|
||||
error: event.error,
|
||||
exit: null
|
||||
})
|
||||
setError(event.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mountedRef.current) {
|
||||
try {
|
||||
await adapter.ack({
|
||||
sessionId: event.sessionId,
|
||||
sequence: event.sequence
|
||||
})
|
||||
} catch (reason) {
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.ack')
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[adapter, publishSnapshot]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
const {
|
||||
terminal,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
activateRenderer
|
||||
} = terminalFactory()
|
||||
emulatorRef.current = terminal
|
||||
fitAddonRef.current = fitAddon
|
||||
searchAddonRef.current = searchAddon
|
||||
if (hostRef.current) {
|
||||
terminal.open(hostRef.current)
|
||||
activateRenderer?.()
|
||||
}
|
||||
|
||||
const inputSubscription = terminal.onData((data) => {
|
||||
const current = snapshotRef.current
|
||||
if (!current || current.state !== 'running') {
|
||||
return
|
||||
}
|
||||
void adapter
|
||||
.write({ sessionId: current.sessionId, data })
|
||||
.catch((reason: unknown) => {
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.write')
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
const unsubscribe = adapter.subscribe((event) => {
|
||||
eventQueueRef.current = eventQueueRef.current.then(
|
||||
() => consumeEvent(event),
|
||||
() => consumeEvent(event)
|
||||
)
|
||||
})
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
operationGenerationRef.current += 1
|
||||
unsubscribe()
|
||||
inputSubscription.dispose()
|
||||
terminal.dispose()
|
||||
emulatorRef.current = undefined
|
||||
fitAddonRef.current = undefined
|
||||
searchAddonRef.current = undefined
|
||||
}
|
||||
}, [adapter, consumeEvent, terminalFactory])
|
||||
|
||||
const attachSession = useCallback(
|
||||
async (
|
||||
id: TerminalSessionId,
|
||||
generation: number
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const next = await adapter.getSnapshot({ sessionId: id })
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
operationGenerationRef.current !== generation
|
||||
) {
|
||||
return
|
||||
}
|
||||
activeSessionRef.current = next.sessionId
|
||||
acceptedSequenceRef.current = next.lastSequence
|
||||
publishSnapshot(next)
|
||||
setError(next.error?.message)
|
||||
emulatorRef.current?.focus()
|
||||
} catch (reason) {
|
||||
if (
|
||||
mountedRef.current &&
|
||||
operationGenerationRef.current === generation
|
||||
) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.snapshot')
|
||||
)
|
||||
)
|
||||
setAnnouncement(
|
||||
translationRef.current('sidebar.terminal.states.failed')
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[adapter, publishSnapshot]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
const generation = ++operationGenerationRef.current
|
||||
void attachSession(sessionId, generation)
|
||||
}, [attachSession, sessionId])
|
||||
|
||||
const createSession = useCallback(
|
||||
async (cols: number, rows: number): Promise<void> => {
|
||||
const generation = ++operationGenerationRef.current
|
||||
setError(undefined)
|
||||
try {
|
||||
const next = await adapter.create({ target, cols, rows })
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
operationGenerationRef.current !== generation
|
||||
) {
|
||||
try {
|
||||
await adapter.close({ sessionId: next.sessionId })
|
||||
} catch {
|
||||
// The owning tab is gone; Main shutdown cleanup remains the fallback.
|
||||
}
|
||||
return
|
||||
}
|
||||
activeSessionRef.current = next.sessionId
|
||||
acceptedSequenceRef.current = 0
|
||||
publishSnapshot(next)
|
||||
setError(next.error?.message)
|
||||
emulatorRef.current?.focus()
|
||||
} catch (reason) {
|
||||
if (
|
||||
mountedRef.current &&
|
||||
operationGenerationRef.current === generation
|
||||
) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.create')
|
||||
)
|
||||
)
|
||||
setAnnouncement(
|
||||
translationRef.current('sidebar.terminal.states.failed')
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[adapter, publishSnapshot, target]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (!host) {
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (
|
||||
!entry ||
|
||||
entry.contentRect.width <= 0 ||
|
||||
entry.contentRect.height <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
fitAddonRef.current?.fit()
|
||||
const emulator = emulatorRef.current
|
||||
if (
|
||||
!emulator ||
|
||||
!isValidSize(emulator.cols, emulator.rows)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const size = { cols: emulator.cols, rows: emulator.rows }
|
||||
if (
|
||||
lastSizeRef.current?.cols === size.cols &&
|
||||
lastSizeRef.current.rows === size.rows
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastSizeRef.current = size
|
||||
setHasMeasuredSize(true)
|
||||
|
||||
const current = snapshotRef.current
|
||||
if (!current && !sessionId) {
|
||||
void createSession(size.cols, size.rows)
|
||||
return
|
||||
}
|
||||
if (current && current.state === 'running') {
|
||||
void adapter
|
||||
.resize({ sessionId: current.sessionId, ...size })
|
||||
.catch((reason: unknown) => {
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current(
|
||||
'sidebar.terminal.errors.resize'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
observer.observe(host)
|
||||
return () => observer.disconnect()
|
||||
}, [adapter, createSession, sessionId])
|
||||
|
||||
const runSearch = (): void => {
|
||||
if (searchTerm) {
|
||||
searchAddonRef.current?.findNext(searchTerm)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const copySelection = async (): Promise<void> => {
|
||||
const value = emulatorRef.current?.getSelection() ?? ''
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch (reason) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.copy')
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const paste = async (): Promise<void> => {
|
||||
const current = snapshotRef.current
|
||||
if (!current || current.state !== 'running') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await navigator.clipboard.readText()
|
||||
if (data) {
|
||||
await adapter.write({ sessionId: current.sessionId, data })
|
||||
}
|
||||
emulatorRef.current?.focus()
|
||||
} catch (reason) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.paste')
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const reopenSession = async (): Promise<void> => {
|
||||
const size = lastSizeRef.current
|
||||
if (!size) {
|
||||
return
|
||||
}
|
||||
const current = snapshotRef.current
|
||||
if (current) {
|
||||
try {
|
||||
await adapter.close({ sessionId: current.sessionId })
|
||||
} catch (reason) {
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current(
|
||||
'sidebar.terminal.errors.closePrevious'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
emulatorRef.current?.clear()
|
||||
activeSessionRef.current = undefined
|
||||
acceptedSequenceRef.current = 0
|
||||
snapshotRef.current = undefined
|
||||
setSnapshot(undefined)
|
||||
setError(undefined)
|
||||
await createSession(size.cols, size.rows)
|
||||
}
|
||||
|
||||
const submitRename = (): void => {
|
||||
const nextTitle = draftTitle.trim()
|
||||
if (!nextTitle) {
|
||||
return
|
||||
}
|
||||
onRenameRef.current?.(nextTitle)
|
||||
setRenaming(false)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
|
||||
const canReopen =
|
||||
snapshot?.state === 'exited' ||
|
||||
snapshot?.state === 'interrupted' ||
|
||||
snapshot?.state === 'failed'
|
||||
const location =
|
||||
snapshot?.targetLabel ??
|
||||
(target.type === 'local'
|
||||
? t('sidebar.terminal.location.local')
|
||||
: t('sidebar.terminal.location.target'))
|
||||
|
||||
return (
|
||||
<section
|
||||
className="terminal-panel"
|
||||
aria-label={t('sidebar.terminal.ariaLabel', {
|
||||
title: title ?? location
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="terminal-panel__toolbar"
|
||||
role="toolbar"
|
||||
aria-label={t('sidebar.terminal.toolbar.ariaLabel')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.search')}
|
||||
aria-label={t('sidebar.terminal.toolbar.search')}
|
||||
aria-pressed={searchOpen}
|
||||
onClick={() => setSearchOpen((value) => !value)}
|
||||
>
|
||||
<Search aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.copy')}
|
||||
aria-label={t('sidebar.terminal.toolbar.copy')}
|
||||
onClick={() => void copySelection()}
|
||||
>
|
||||
<Copy aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.paste')}
|
||||
aria-label={t('sidebar.terminal.toolbar.paste')}
|
||||
disabled={snapshot?.state !== 'running'}
|
||||
onClick={() => void paste()}
|
||||
>
|
||||
<Clipboard aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.selectAll')}
|
||||
aria-label={t('sidebar.terminal.toolbar.selectAllAriaLabel')}
|
||||
onClick={() => emulatorRef.current?.selectAll()}
|
||||
>
|
||||
{t('sidebar.terminal.toolbar.selectAll')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.clear')}
|
||||
aria-label={t('sidebar.terminal.toolbar.clear')}
|
||||
onClick={() => {
|
||||
emulatorRef.current?.clear()
|
||||
emulatorRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<Eraser aria-hidden="true" />
|
||||
</button>
|
||||
{onRename ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.rename')}
|
||||
aria-label={t('sidebar.terminal.toolbar.rename')}
|
||||
aria-pressed={renaming}
|
||||
onClick={() => {
|
||||
setDraftTitle(title ?? snapshot?.title ?? '')
|
||||
setRenaming(true)
|
||||
}}
|
||||
>
|
||||
<Pencil aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<span className="terminal-panel__toolbar-spacer" />
|
||||
{canReopen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-panel__reopen"
|
||||
onClick={() => void reopenSession()}
|
||||
>
|
||||
<RefreshCw aria-hidden="true" />
|
||||
{snapshot.state === 'interrupted'
|
||||
? t('sidebar.terminal.reopen.interrupted')
|
||||
: t('sidebar.terminal.reopen.default')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{searchOpen ? (
|
||||
<form
|
||||
className="terminal-panel__inline-form"
|
||||
role="search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
runSearch()
|
||||
}}
|
||||
>
|
||||
<label htmlFor={searchInputId}>
|
||||
{t('sidebar.terminal.search.label')}
|
||||
</label>
|
||||
<input
|
||||
id={searchInputId}
|
||||
autoFocus
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setSearchOpen(false)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit">
|
||||
{t('sidebar.terminal.search.next')}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{renaming ? (
|
||||
<form
|
||||
className="terminal-panel__inline-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitRename()
|
||||
}}
|
||||
>
|
||||
<label htmlFor={titleInputId}>
|
||||
{t('sidebar.terminal.rename.label')}
|
||||
</label>
|
||||
<input
|
||||
id={titleInputId}
|
||||
autoFocus
|
||||
value={draftTitle}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setRenaming(false)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit" disabled={!draftTitle.trim()}>
|
||||
{t('sidebar.terminal.rename.save')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setRenaming(false)}>
|
||||
{t('sidebar.terminal.rename.cancel')}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="terminal-panel__error" role="alert">
|
||||
<span>{error}</span>
|
||||
{!snapshot ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMeasuredSize}
|
||||
onClick={() => void reopenSession()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="terminal-panel__emulator"
|
||||
aria-label={t('sidebar.terminal.status.emulatorAriaLabel')}
|
||||
/>
|
||||
|
||||
<footer
|
||||
className="terminal-panel__status"
|
||||
aria-label={t('sidebar.terminal.status.ariaLabel')}
|
||||
>
|
||||
<span title={location}>{location}</span>
|
||||
<span
|
||||
title={
|
||||
snapshot?.workingDirectory ??
|
||||
t('sidebar.terminal.status.readingDirectory')
|
||||
}
|
||||
>
|
||||
{snapshot?.workingDirectory ??
|
||||
t('sidebar.terminal.status.readingDirectory')}
|
||||
</span>
|
||||
<span
|
||||
title={
|
||||
snapshot?.shell ??
|
||||
t('sidebar.terminal.status.readingShell')
|
||||
}
|
||||
>
|
||||
{snapshot?.shell ??
|
||||
t('sidebar.terminal.status.readingShell')}
|
||||
</span>
|
||||
<strong
|
||||
data-state={snapshot?.state ?? (error ? 'failed' : 'starting')}
|
||||
>
|
||||
{snapshot
|
||||
? t(`sidebar.terminal.states.${snapshot.state}`)
|
||||
: error
|
||||
? t('sidebar.terminal.states.failed')
|
||||
: t('sidebar.terminal.states.creating')}
|
||||
{snapshot?.state === 'exited' && snapshot.exit?.exitCode !== null
|
||||
? t('sidebar.terminal.status.exitCode', {
|
||||
code: snapshot.exit?.exitCode
|
||||
})
|
||||
: ''}
|
||||
</strong>
|
||||
</footer>
|
||||
<div className="terminal-panel__announcement" aria-live="polite">
|
||||
{announcement}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within
|
||||
} from '@testing-library/react'
|
||||
import {
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
WorkbarTabInstance,
|
||||
WorkbarTargetRef
|
||||
} from '../../shared/workbar-contracts'
|
||||
import {
|
||||
DEFAULT_WORKBAR_INSTANCES,
|
||||
WorkbarShell,
|
||||
type WorkbarInstanceCreateRequest
|
||||
} from './WorkbarShell'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const terminalOne: WorkbarTabInstance = {
|
||||
id: '20000000-0000-4000-8000-000000000001',
|
||||
appId: 'terminal',
|
||||
title: '终端 · 本机 1',
|
||||
targetRef: { type: 'local' }
|
||||
}
|
||||
|
||||
function ControlledShell({
|
||||
initialInstances = DEFAULT_WORKBAR_INSTANCES,
|
||||
onCreate = vi.fn(),
|
||||
onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
|
||||
type: 'local'
|
||||
}))
|
||||
}: {
|
||||
initialInstances?: readonly WorkbarTabInstance[]
|
||||
onCreate?: (request: WorkbarInstanceCreateRequest) => void
|
||||
onTerminalTarget?: () =>
|
||||
| WorkbarTargetRef
|
||||
| Promise<WorkbarTargetRef>
|
||||
}): ReactNode {
|
||||
const [instances, setInstances] = useState(initialInstances)
|
||||
const [activeId, setActiveId] = useState(
|
||||
initialInstances[0]?.id ?? null
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkbarShell
|
||||
activeInstanceId={activeId}
|
||||
instances={instances}
|
||||
onActiveInstanceChange={setActiveId}
|
||||
onCloseInstance={(instance) =>
|
||||
setInstances((current) =>
|
||||
current.filter((candidate) => candidate.id !== instance.id)
|
||||
)
|
||||
}
|
||||
onCreateInstance={onCreate}
|
||||
onResolveTerminalTarget={onTerminalTarget}
|
||||
renderPanel={(instance) => <p>{instance.title}内容</p>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkbarShell', () => {
|
||||
it('provides four default single-instance tabs and keeps add outside the tablist', () => {
|
||||
render(<ControlledShell />)
|
||||
|
||||
const tablist = screen.getByRole('tablist', {
|
||||
name: '已打开的工作栏应用'
|
||||
})
|
||||
expect(within(tablist).getAllByRole('tab')).toHaveLength(4)
|
||||
expect(
|
||||
within(tablist).queryByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开工作栏应用' })
|
||||
).toBeVisible()
|
||||
expect(tablist.parentElement).toHaveClass(
|
||||
'workbar-shell__tab-scroll'
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '任务中心' }).parentElement
|
||||
).toHaveClass('workbar-shell__tab-item--active')
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '工作区' }).parentElement
|
||||
).not.toHaveClass('workbar-shell__tab-item--active')
|
||||
})
|
||||
|
||||
it('opens the catalog, focuses its first choice, and restores add-button focus on Escape', async () => {
|
||||
render(<ControlledShell />)
|
||||
const addButton = screen.getByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
|
||||
fireEvent.click(addButton)
|
||||
const firstChoice = screen
|
||||
.getByText('任务中心', { selector: 'strong' })
|
||||
.closest('button')!
|
||||
await waitFor(() => expect(firstChoice).toHaveFocus())
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '新建工作栏应用' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(firstChoice, { key: 'Escape' })
|
||||
await waitFor(() => expect(addButton).toHaveFocus())
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: '新建工作栏应用' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '任务中心' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('focuses an existing single instance instead of requesting a duplicate', async () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<ControlledShell onCreate={onCreate} />)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开工作栏应用' })
|
||||
)
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getByText('工作区', { selector: 'strong' })
|
||||
.closest('button')!
|
||||
)
|
||||
|
||||
const workspaceTab = screen.getByRole('tab', {
|
||||
name: '工作区'
|
||||
})
|
||||
await waitFor(() => expect(workspaceTab).toHaveFocus())
|
||||
expect(workspaceTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the current terminal target for every multiple-instance creation', async () => {
|
||||
const onCreate = vi.fn()
|
||||
const onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
|
||||
type: 'local'
|
||||
}))
|
||||
render(
|
||||
<ControlledShell
|
||||
initialInstances={[...DEFAULT_WORKBAR_INSTANCES, terminalOne]}
|
||||
onCreate={onCreate}
|
||||
onTerminalTarget={onTerminalTarget}
|
||||
/>
|
||||
)
|
||||
|
||||
for (let count = 1; count <= 2; count += 1) {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /^终端/u })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(onCreate).toHaveBeenCalledTimes(count)
|
||||
)
|
||||
}
|
||||
|
||||
expect(onTerminalTarget).toHaveBeenCalledTimes(2)
|
||||
expect(onCreate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
appId: 'terminal',
|
||||
targetRef: { type: 'local' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('supports roving Arrow, Home, and End navigation', () => {
|
||||
render(<ControlledShell />)
|
||||
const tasks = screen.getByRole('tab', { name: '任务中心' })
|
||||
|
||||
tasks.focus()
|
||||
fireEvent.keyDown(tasks, { key: 'ArrowRight' })
|
||||
const workspace = screen.getByRole('tab', { name: '工作区' })
|
||||
expect(workspace).toHaveFocus()
|
||||
expect(workspace).toHaveAttribute('tabindex', '0')
|
||||
|
||||
fireEvent.keyDown(workspace, { key: 'End' })
|
||||
const results = screen.getByRole('tab', { name: '成果' })
|
||||
expect(results).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(results, { key: 'Home' })
|
||||
expect(tasks).toHaveFocus()
|
||||
})
|
||||
|
||||
it('closes the active tab, activates its right neighbor, and restores focus', async () => {
|
||||
render(<ControlledShell />)
|
||||
const workspace = screen.getByRole('tab', { name: '工作区' })
|
||||
fireEvent.click(workspace)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭工作区' })
|
||||
)
|
||||
|
||||
const browser = screen.getByRole('tab', { name: '浏览器' })
|
||||
await waitFor(() => expect(browser).toHaveFocus())
|
||||
expect(browser).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: '工作区' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the current active tab focused when a background tab closes', async () => {
|
||||
render(<ControlledShell />)
|
||||
const tasks = screen.getByRole('tab', { name: '任务中心' })
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭成果' })
|
||||
)
|
||||
|
||||
await waitFor(() => expect(tasks).toHaveFocus())
|
||||
expect(tasks).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('shows the empty state while preserving the add command after the last close', async () => {
|
||||
render(
|
||||
<ControlledShell initialInstances={[DEFAULT_WORKBAR_INSTANCES[0]!]} />
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭任务中心' })
|
||||
)
|
||||
|
||||
const addButton = screen.getByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
await waitFor(() => expect(addButton).toHaveFocus())
|
||||
expect(
|
||||
screen.getByText('还没有打开的工作栏应用')
|
||||
).toBeInTheDocument()
|
||||
expect(addButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,555 @@
|
||||
import {
|
||||
ClipboardCheck,
|
||||
FileOutput,
|
||||
FolderTree,
|
||||
Globe2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
type LucideIcon
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
WORKBAR_APP_DEFINITIONS,
|
||||
WORKBAR_LIMITS,
|
||||
type WorkbarAppDefinition,
|
||||
type WorkbarAppId,
|
||||
type WorkbarTabInstance,
|
||||
type WorkbarTargetRef
|
||||
} from '../../shared/workbar-contracts'
|
||||
|
||||
const WORKBAR_APP_ICONS: Record<WorkbarAppId, LucideIcon> = {
|
||||
tasks: ClipboardCheck,
|
||||
workspace: FolderTree,
|
||||
browser: Globe2,
|
||||
results: FileOutput,
|
||||
terminal: SquareTerminal
|
||||
}
|
||||
|
||||
const DEFAULT_INSTANCE_IDS = {
|
||||
tasks: '10000000-0000-4000-8000-000000000001',
|
||||
workspace: '10000000-0000-4000-8000-000000000002',
|
||||
browser: '10000000-0000-4000-8000-000000000003',
|
||||
results: '10000000-0000-4000-8000-000000000004'
|
||||
} as const
|
||||
|
||||
export const DEFAULT_WORKBAR_INSTANCES: readonly WorkbarTabInstance[] =
|
||||
WORKBAR_APP_DEFINITIONS.filter(
|
||||
(definition) => definition.defaultOpen
|
||||
).map((definition) => ({
|
||||
id: DEFAULT_INSTANCE_IDS[definition.id],
|
||||
appId: definition.id,
|
||||
title: definition.label
|
||||
}))
|
||||
|
||||
export type WorkbarInstanceCreateRequest = {
|
||||
appId: WorkbarAppId
|
||||
insertAfterInstanceId: string | null
|
||||
targetRef?: WorkbarTargetRef
|
||||
}
|
||||
|
||||
export type WorkbarShellProps = {
|
||||
instances: readonly WorkbarTabInstance[]
|
||||
activeInstanceId: string | null
|
||||
renderPanel: (instance: WorkbarTabInstance) => ReactNode
|
||||
renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode
|
||||
onActiveInstanceChange: (instanceId: string) => void
|
||||
onCreateInstance: (request: WorkbarInstanceCreateRequest) => void
|
||||
onCloseInstance: (
|
||||
instance: WorkbarTabInstance
|
||||
) => boolean | void | Promise<boolean | void>
|
||||
onResolveTerminalTarget: () =>
|
||||
| WorkbarTargetRef
|
||||
| Promise<WorkbarTargetRef>
|
||||
appDefinitions?: readonly WorkbarAppDefinition[]
|
||||
onResolveUnavailableApp?: (definition: WorkbarAppDefinition) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function joinClassNames(...values: (string | false | undefined)[]): string {
|
||||
return values.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function adjacentIndex(
|
||||
key: string,
|
||||
currentIndex: number,
|
||||
itemCount: number
|
||||
): number | undefined {
|
||||
if (key === 'Home') {
|
||||
return 0
|
||||
}
|
||||
if (key === 'End') {
|
||||
return itemCount - 1
|
||||
}
|
||||
if (key === 'ArrowRight') {
|
||||
return (currentIndex + 1) % itemCount
|
||||
}
|
||||
if (key === 'ArrowLeft') {
|
||||
return (currentIndex - 1 + itemCount) % itemCount
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function WorkbarShell({
|
||||
activeInstanceId,
|
||||
appDefinitions = WORKBAR_APP_DEFINITIONS,
|
||||
className,
|
||||
instances,
|
||||
onActiveInstanceChange,
|
||||
onCloseInstance,
|
||||
onCreateInstance,
|
||||
onResolveUnavailableApp,
|
||||
onResolveTerminalTarget,
|
||||
renderPanel,
|
||||
renderTabAdornment
|
||||
}: WorkbarShellProps): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const idPrefix = useId()
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const catalogRef = useRef<HTMLDivElement>(null)
|
||||
const returnInstanceIdRef = useRef<string | null>(null)
|
||||
const pendingTabFocusRef = useRef<string | null | undefined>(undefined)
|
||||
const focusCreatedInstanceRef = useRef(false)
|
||||
const [catalogOpen, setCatalogOpen] = useState(false)
|
||||
const [creatingAppId, setCreatingAppId] =
|
||||
useState<WorkbarAppId | null>(null)
|
||||
const [targetError, setTargetError] = useState<string | null>(null)
|
||||
|
||||
const activeInstance =
|
||||
instances.find((instance) => instance.id === activeInstanceId) ??
|
||||
null
|
||||
|
||||
const tabId = useCallback(
|
||||
(instanceId: string): string =>
|
||||
`${idPrefix}-tab-${instanceId}`,
|
||||
[idPrefix]
|
||||
)
|
||||
const panelId = (instanceId: string): string =>
|
||||
`${idPrefix}-panel-${instanceId}`
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalogOpen) {
|
||||
return
|
||||
}
|
||||
const firstAvailable =
|
||||
catalogRef.current?.querySelector<HTMLButtonElement>(
|
||||
'[data-workbar-app-choice]:not(:disabled)'
|
||||
)
|
||||
firstAvailable?.focus()
|
||||
}, [catalogOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
focusCreatedInstanceRef.current &&
|
||||
!catalogOpen &&
|
||||
activeInstanceId
|
||||
) {
|
||||
const activeTab = document.getElementById(tabId(activeInstanceId))
|
||||
if (activeTab instanceof HTMLButtonElement) {
|
||||
activeTab.focus()
|
||||
focusCreatedInstanceRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const pendingId = pendingTabFocusRef.current
|
||||
if (pendingId === undefined) {
|
||||
return
|
||||
}
|
||||
if (pendingId === null) {
|
||||
addButtonRef.current?.focus()
|
||||
pendingTabFocusRef.current = undefined
|
||||
return
|
||||
}
|
||||
const tab = document.getElementById(tabId(pendingId))
|
||||
if (tab instanceof HTMLButtonElement) {
|
||||
tab.focus()
|
||||
pendingTabFocusRef.current = undefined
|
||||
}
|
||||
}, [activeInstanceId, catalogOpen, instances, tabId])
|
||||
|
||||
const focusAndActivate = (instanceId: string): void => {
|
||||
onActiveInstanceChange(instanceId)
|
||||
const tab = document.getElementById(tabId(instanceId))
|
||||
if (tab instanceof HTMLButtonElement) {
|
||||
tab.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const closeCatalog = (restoreToAddButton: boolean): void => {
|
||||
setCatalogOpen(false)
|
||||
setCreatingAppId(null)
|
||||
setTargetError(null)
|
||||
const returnInstanceId = returnInstanceIdRef.current
|
||||
if (
|
||||
returnInstanceId &&
|
||||
instances.some((instance) => instance.id === returnInstanceId)
|
||||
) {
|
||||
onActiveInstanceChange(returnInstanceId)
|
||||
}
|
||||
if (restoreToAddButton) {
|
||||
queueMicrotask(() => addButtonRef.current?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
const openCatalog = (): void => {
|
||||
returnInstanceIdRef.current = activeInstanceId
|
||||
setTargetError(null)
|
||||
setCatalogOpen(true)
|
||||
}
|
||||
|
||||
const chooseApp = async (
|
||||
definition: WorkbarAppDefinition
|
||||
): Promise<void> => {
|
||||
if (definition.availability.state === 'unavailable') {
|
||||
return
|
||||
}
|
||||
|
||||
if (definition.instancePolicy === 'single') {
|
||||
const existing = instances.find(
|
||||
(instance) => instance.appId === definition.id
|
||||
)
|
||||
if (existing) {
|
||||
setCatalogOpen(false)
|
||||
setTargetError(null)
|
||||
pendingTabFocusRef.current = existing.id
|
||||
onActiveInstanceChange(existing.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (instances.length >= WORKBAR_LIMITS.maximumOpenInstances) {
|
||||
return
|
||||
}
|
||||
|
||||
const insertAfterInstanceId = activeInstanceId
|
||||
if (definition.id !== 'terminal') {
|
||||
focusCreatedInstanceRef.current = true
|
||||
setCatalogOpen(false)
|
||||
onCreateInstance({
|
||||
appId: definition.id,
|
||||
insertAfterInstanceId
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCreatingAppId('terminal')
|
||||
setTargetError(null)
|
||||
try {
|
||||
const targetRef = await onResolveTerminalTarget()
|
||||
focusCreatedInstanceRef.current = true
|
||||
setCatalogOpen(false)
|
||||
onCreateInstance({
|
||||
appId: 'terminal',
|
||||
insertAfterInstanceId,
|
||||
targetRef
|
||||
})
|
||||
} catch (error) {
|
||||
setTargetError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('sidebar.workbar.targetError')
|
||||
)
|
||||
} finally {
|
||||
setCreatingAppId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabKeyDown = (
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
index: number
|
||||
): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (catalogOpen) {
|
||||
closeCatalog(false)
|
||||
}
|
||||
focusAndActivate(activeInstance?.id ?? instances[index]!.id)
|
||||
return
|
||||
}
|
||||
const nextIndex = adjacentIndex(
|
||||
event.key,
|
||||
index,
|
||||
instances.length
|
||||
)
|
||||
if (nextIndex === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
focusAndActivate(instances[nextIndex]!.id)
|
||||
}
|
||||
|
||||
const handleClose = async (
|
||||
instance: WorkbarTabInstance
|
||||
): Promise<void> => {
|
||||
const closingIndex = instances.findIndex(
|
||||
(candidate) => candidate.id === instance.id
|
||||
)
|
||||
const isActive = instance.id === activeInstanceId
|
||||
const nextActive = isActive
|
||||
? (instances[closingIndex + 1] ??
|
||||
instances[closingIndex - 1] ??
|
||||
null)
|
||||
: activeInstance
|
||||
|
||||
const closeResult = onCloseInstance(instance)
|
||||
const closeAccepted =
|
||||
closeResult &&
|
||||
typeof (closeResult as PromiseLike<boolean | void>).then ===
|
||||
'function'
|
||||
? await closeResult
|
||||
: closeResult
|
||||
if (closeAccepted === false) {
|
||||
return
|
||||
}
|
||||
pendingTabFocusRef.current = nextActive?.id ?? null
|
||||
if (nextActive && nextActive.id !== activeInstanceId) {
|
||||
onActiveInstanceChange(nextActive.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t('sidebar.workbar.ariaLabel')}
|
||||
className={joinClassNames('workbar-shell', className)}
|
||||
>
|
||||
<div className="workbar-shell__tab-row">
|
||||
<div className="workbar-shell__tab-scroll">
|
||||
<div
|
||||
aria-label={t('sidebar.workbar.tablist')}
|
||||
className="workbar-shell__tablist"
|
||||
role="tablist"
|
||||
>
|
||||
{instances.map((instance, index) => {
|
||||
const selected =
|
||||
!catalogOpen && instance.id === activeInstanceId
|
||||
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab-item',
|
||||
selected && 'workbar-shell__tab-item--active'
|
||||
)}
|
||||
key={instance.id}
|
||||
>
|
||||
<button
|
||||
aria-controls={panelId(instance.id)}
|
||||
aria-selected={selected}
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab',
|
||||
selected && 'workbar-shell__tab--active'
|
||||
)}
|
||||
id={tabId(instance.id)}
|
||||
onClick={() => {
|
||||
setCatalogOpen(false)
|
||||
setTargetError(null)
|
||||
onActiveInstanceChange(instance.id)
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, index)
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
title={
|
||||
appDefinitions.find(
|
||||
(application) =>
|
||||
application.id === instance.appId
|
||||
)?.description ?? instance.title
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="workbar-shell__tab-icon"
|
||||
>
|
||||
<AppIcon />
|
||||
</span>
|
||||
<span className="workbar-shell__tab-label">
|
||||
{instance.title}
|
||||
</span>
|
||||
{renderTabAdornment?.(instance)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
className="workbar-shell__tab-close"
|
||||
onClick={() => void handleClose(instance)}
|
||||
title={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-expanded={catalogOpen}
|
||||
aria-label={t('sidebar.workbar.add')}
|
||||
className={joinClassNames(
|
||||
'workbar-shell__add',
|
||||
catalogOpen && 'workbar-shell__add--active'
|
||||
)}
|
||||
onClick={() =>
|
||||
catalogOpen ? closeCatalog(true) : openCatalog()
|
||||
}
|
||||
ref={addButtonRef}
|
||||
title={t('sidebar.workbar.add')}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="workbar-shell__content">
|
||||
{instances.map((instance) => (
|
||||
<div
|
||||
aria-labelledby={tabId(instance.id)}
|
||||
className="workbar-shell__tabpanel"
|
||||
hidden={catalogOpen || instance.id !== activeInstanceId}
|
||||
id={panelId(instance.id)}
|
||||
key={instance.id}
|
||||
role="tabpanel"
|
||||
tabIndex={0}
|
||||
>
|
||||
{renderPanel(instance)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{catalogOpen ? (
|
||||
<div
|
||||
className="workbar-shell__catalog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeCatalog(true)
|
||||
}
|
||||
}}
|
||||
ref={catalogRef}
|
||||
>
|
||||
<div className="workbar-shell__catalog-header">
|
||||
<div>
|
||||
<h2>{t('sidebar.workbar.catalogTitle')}</h2>
|
||||
<p>{t('sidebar.workbar.catalogDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => closeCatalog(true)}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.workbar.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{targetError ? (
|
||||
<p className="workbar-shell__catalog-error" role="alert">
|
||||
{targetError}
|
||||
</p>
|
||||
) : null}
|
||||
<ul
|
||||
aria-label={t('sidebar.workbar.catalog')}
|
||||
className="workbar-shell__catalog-list"
|
||||
>
|
||||
{appDefinitions.map((definition) => {
|
||||
const AppIcon = WORKBAR_APP_ICONS[definition.id]
|
||||
const existing =
|
||||
definition.instancePolicy === 'single' &&
|
||||
instances.some(
|
||||
(instance) => instance.appId === definition.id
|
||||
)
|
||||
const availability = definition.availability
|
||||
const unavailable =
|
||||
availability.state === 'unavailable'
|
||||
const atLimit =
|
||||
!existing &&
|
||||
instances.length >=
|
||||
WORKBAR_LIMITS.maximumOpenInstances
|
||||
const disabled =
|
||||
unavailable ||
|
||||
atLimit ||
|
||||
creatingAppId !== null
|
||||
return (
|
||||
<li
|
||||
className="workbar-shell__catalog-item"
|
||||
key={definition.id}
|
||||
>
|
||||
<button
|
||||
className="workbar-shell__catalog-choice"
|
||||
data-workbar-app-choice
|
||||
disabled={disabled}
|
||||
onClick={() => void chooseApp(definition)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`workbar-shell__app-icon workbar-shell__app-icon--${definition.icon}`}
|
||||
>
|
||||
<AppIcon />
|
||||
</span>
|
||||
<span className="workbar-shell__catalog-copy">
|
||||
<strong>{definition.label}</strong>
|
||||
<span>{definition.description}</span>
|
||||
<small>
|
||||
{existing
|
||||
? t('sidebar.workbar.existing')
|
||||
: definition.instancePolicy === 'multiple'
|
||||
? t('sidebar.workbar.multiple')
|
||||
: t('sidebar.workbar.single')}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
{availability.state === 'unavailable' ? (
|
||||
<div className="workbar-shell__unavailable">
|
||||
<span>{availability.reason}</span>
|
||||
{onResolveUnavailableApp ? (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
onResolveUnavailableApp(definition)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.workbar.resolve')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : atLimit ? (
|
||||
<small className="workbar-shell__limit">
|
||||
{t('sidebar.workbar.limit', {
|
||||
count: WORKBAR_LIMITS.maximumOpenInstances
|
||||
})}
|
||||
</small>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : instances.length === 0 ? (
|
||||
<div className="workbar-shell__empty">
|
||||
<strong>{t('sidebar.workbar.emptyTitle')}</strong>
|
||||
<p>{t('sidebar.workbar.emptyDescription')}</p>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={openCatalog}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.workbar.open')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -62,7 +62,9 @@ export const app = {
|
||||
localWorkspace: 'Local workspace',
|
||||
loading: 'Loading',
|
||||
close: 'Close sidebar',
|
||||
toggle: 'Toggle sidebar'
|
||||
toggle: 'Toggle sidebar',
|
||||
resizeAriaLabel: 'Resize main sidebar',
|
||||
resizeValue: 'Main sidebar, {{width}} pixels'
|
||||
},
|
||||
topbar: {
|
||||
toggleAssistantSidebar: 'Toggle assistant workspace',
|
||||
|
||||
@@ -27,6 +27,8 @@ export const magicNotes = {
|
||||
analyzeAgain: 'Analyze again',
|
||||
analyzing: 'Analyzing…',
|
||||
openSourceNote: 'Open source note',
|
||||
hideListPane: 'Hide left list',
|
||||
showListPane: 'Show left list',
|
||||
hideAiComments: 'Hide AI comments',
|
||||
showAiComments: 'Show AI comments',
|
||||
pinNote: 'Pin note',
|
||||
@@ -161,6 +163,8 @@ export const magicNotes = {
|
||||
'Choose “Analyze with AI” on an entry to show comments here.'
|
||||
},
|
||||
accessibility: {
|
||||
resizeListPane: 'Resize the note list and editor panes',
|
||||
listPaneWidth: 'Left list, {{width}} pixels',
|
||||
resizeAiPane: 'Resize the editor and AI comments panes',
|
||||
aiPaneWidth: 'AI comments pane, {{width}} pixels'
|
||||
},
|
||||
|
||||
@@ -191,6 +191,97 @@ export const workspace = {
|
||||
results: {
|
||||
label: 'Results',
|
||||
description: 'View generated or imported standalone results'
|
||||
},
|
||||
terminal: {
|
||||
label: 'Terminal',
|
||||
description: 'Open a user terminal for the current project'
|
||||
}
|
||||
},
|
||||
workbar: {
|
||||
ariaLabel: 'Workbar applications',
|
||||
tablist: 'Open workbar applications',
|
||||
add: 'Open workbar application',
|
||||
close: 'Close {{title}}',
|
||||
catalogTitle: 'New workbar application',
|
||||
catalogDescription: 'Choose an application to open in the workbar.',
|
||||
cancel: 'Cancel',
|
||||
targetError:
|
||||
'Could not resolve the current project terminal target. Try again.',
|
||||
catalog: 'Workbar application catalog',
|
||||
existing: 'Already open. Selecting switches to the existing instance.',
|
||||
multiple: 'Multiple instances supported',
|
||||
single: 'Single-instance application',
|
||||
resolve: 'Resolve',
|
||||
limit: 'You can open up to {{count}} instances.',
|
||||
emptyTitle: 'No workbar applications are open',
|
||||
emptyDescription: 'Open an application to switch to it here.',
|
||||
open: 'Open application'
|
||||
},
|
||||
terminal: {
|
||||
loading: 'Loading terminal…',
|
||||
ariaLabel: 'User terminal: {{title}}',
|
||||
location: {
|
||||
local: 'Local',
|
||||
target: 'Target'
|
||||
},
|
||||
states: {
|
||||
starting: 'Connecting',
|
||||
running: 'Connected',
|
||||
exited: 'Shell exited',
|
||||
interrupted: 'Connection interrupted',
|
||||
closing: 'Closing',
|
||||
failed: 'Connection failed',
|
||||
creating: 'Creating'
|
||||
},
|
||||
toolbar: {
|
||||
ariaLabel: 'Terminal actions',
|
||||
search: 'Search terminal',
|
||||
copy: 'Copy selection',
|
||||
paste: 'Paste',
|
||||
selectAll: 'Select all',
|
||||
selectAllAriaLabel: 'Select all terminal content',
|
||||
clear: 'Clear terminal',
|
||||
rename: 'Rename terminal'
|
||||
},
|
||||
reopen: {
|
||||
interrupted: 'Reconnect with a new shell',
|
||||
default: 'Reopen'
|
||||
},
|
||||
search: {
|
||||
label: 'Search terminal content',
|
||||
next: 'Find next'
|
||||
},
|
||||
rename: {
|
||||
label: 'Terminal name',
|
||||
save: 'Save name',
|
||||
cancel: 'Cancel'
|
||||
},
|
||||
status: {
|
||||
emulatorAriaLabel: 'Terminal input and output',
|
||||
ariaLabel: 'Terminal status',
|
||||
readingDirectory: 'Reading directory',
|
||||
readingShell: 'Reading shell',
|
||||
exitCode: '(exit code {{code}})'
|
||||
},
|
||||
errors: {
|
||||
ack: 'Could not acknowledge terminal output. Try again.',
|
||||
write: 'Could not write input to the terminal.',
|
||||
snapshot: 'Could not read the terminal session. Reopen it.',
|
||||
create: 'Could not create the terminal. Try again.',
|
||||
resize: 'Could not synchronize the terminal size.',
|
||||
copy: 'Could not copy the terminal selection.',
|
||||
paste: 'Could not paste into the terminal.',
|
||||
closePrevious: 'Could not close the previous terminal. Try again.'
|
||||
},
|
||||
closeDialog: {
|
||||
ariaLabel: 'Close terminal',
|
||||
title: 'Close terminal?',
|
||||
description:
|
||||
'Closing this tab ends the shell and its child processes.',
|
||||
cancel: 'Cancel',
|
||||
closing: 'Closing…',
|
||||
confirm: 'Close terminal',
|
||||
error: 'Could not close the terminal. Try again.'
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
|
||||
@@ -59,7 +59,9 @@ export const app = {
|
||||
localWorkspace: '本地工作区',
|
||||
loading: '加载中',
|
||||
close: '关闭侧栏',
|
||||
toggle: '切换侧栏'
|
||||
toggle: '切换侧栏',
|
||||
resizeAriaLabel: '调整主侧栏宽度',
|
||||
resizeValue: '主侧栏 {{width}} 像素'
|
||||
},
|
||||
topbar: {
|
||||
toggleAssistantSidebar: '切换助手工作栏',
|
||||
|
||||
@@ -23,6 +23,8 @@ export const magicNotes = {
|
||||
analyzeAgain: '重新分析',
|
||||
analyzing: '分析中…',
|
||||
openSourceNote: '打开原笔记修改',
|
||||
hideListPane: '隐藏左侧列表',
|
||||
showListPane: '显示左侧列表',
|
||||
hideAiComments: '隐藏 AI 评论',
|
||||
showAiComments: '显示 AI 评论',
|
||||
pinNote: '置顶笔记',
|
||||
@@ -149,6 +151,8 @@ export const magicNotes = {
|
||||
manualHint: '在记录上点击“AI 分析”,评论会显示在这里。'
|
||||
},
|
||||
accessibility: {
|
||||
resizeListPane: '调整笔记列表与编辑区宽度',
|
||||
listPaneWidth: '左侧列表 {{width}} 像素',
|
||||
resizeAiPane: '调整编辑区与 AI 评论宽度',
|
||||
aiPaneWidth: 'AI 评论栏 {{width}} 像素'
|
||||
},
|
||||
|
||||
@@ -185,6 +185,95 @@ export const workspace = {
|
||||
results: {
|
||||
label: '成果',
|
||||
description: '查看生成或手动导入的独立成果'
|
||||
},
|
||||
terminal: {
|
||||
label: '终端',
|
||||
description: '打开当前项目的用户终端'
|
||||
}
|
||||
},
|
||||
workbar: {
|
||||
ariaLabel: '工作栏应用',
|
||||
tablist: '已打开的工作栏应用',
|
||||
add: '打开工作栏应用',
|
||||
close: '关闭{{title}}',
|
||||
catalogTitle: '新建工作栏应用',
|
||||
catalogDescription: '选择要在工作栏中打开的应用。',
|
||||
cancel: '取消',
|
||||
targetError: '无法确定当前项目的终端目标,请重试。',
|
||||
catalog: '工作栏应用目录',
|
||||
existing: '已打开,选择后切换到现有实例',
|
||||
multiple: '可打开多个实例',
|
||||
single: '单实例应用',
|
||||
resolve: '前往处理',
|
||||
limit: '最多可打开 {{count}} 个实例。',
|
||||
emptyTitle: '还没有打开的工作栏应用',
|
||||
emptyDescription: '打开应用后,可以在这里快速切换和处理内容。',
|
||||
open: '打开应用'
|
||||
},
|
||||
terminal: {
|
||||
loading: '正在加载终端…',
|
||||
ariaLabel: '用户终端:{{title}}',
|
||||
location: {
|
||||
local: '本机',
|
||||
target: '目标'
|
||||
},
|
||||
states: {
|
||||
starting: '正在连接',
|
||||
running: '已连接',
|
||||
exited: 'Shell 已退出',
|
||||
interrupted: '连接已中断',
|
||||
closing: '正在关闭',
|
||||
failed: '连接失败',
|
||||
creating: '正在创建'
|
||||
},
|
||||
toolbar: {
|
||||
ariaLabel: '终端操作',
|
||||
search: '搜索终端',
|
||||
copy: '复制选中内容',
|
||||
paste: '粘贴',
|
||||
selectAll: '全选',
|
||||
selectAllAriaLabel: '全选终端内容',
|
||||
clear: '清屏',
|
||||
rename: '重命名终端'
|
||||
},
|
||||
reopen: {
|
||||
interrupted: '重新连接并新建 Shell',
|
||||
default: '重新打开'
|
||||
},
|
||||
search: {
|
||||
label: '搜索终端内容',
|
||||
next: '查找下一个'
|
||||
},
|
||||
rename: {
|
||||
label: '终端名称',
|
||||
save: '保存名称',
|
||||
cancel: '取消'
|
||||
},
|
||||
status: {
|
||||
emulatorAriaLabel: '终端输入与输出',
|
||||
ariaLabel: '终端状态',
|
||||
readingDirectory: '正在读取目录',
|
||||
readingShell: '正在读取 Shell',
|
||||
exitCode: '(退出码 {{code}})'
|
||||
},
|
||||
errors: {
|
||||
ack: '无法确认终端输出,请重试。',
|
||||
write: '无法向终端写入输入。',
|
||||
snapshot: '无法读取终端会话,请重新打开。',
|
||||
create: '无法创建终端,请重试。',
|
||||
resize: '无法同步终端窗口尺寸。',
|
||||
copy: '无法复制终端选区。',
|
||||
paste: '无法粘贴到终端。',
|
||||
closePrevious: '无法关闭原终端,请重试。'
|
||||
},
|
||||
closeDialog: {
|
||||
ariaLabel: '关闭终端',
|
||||
title: '关闭终端?',
|
||||
description: '关闭此 Tab 将结束 Shell 及其子进程。',
|
||||
cancel: '取消',
|
||||
closing: '正在关闭…',
|
||||
confirm: '关闭终端',
|
||||
error: '无法关闭终端,请重试。'
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
|
||||
+482
-17
@@ -76,7 +76,9 @@
|
||||
"Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC",
|
||||
sans-serif;
|
||||
--font-family-mono:
|
||||
"Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
"Cascadia Mono", "Cascadia Code", "SFMono-Regular", Menlo, Monaco,
|
||||
Consolas, "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono",
|
||||
monospace;
|
||||
font-family: var(--font-family-ui);
|
||||
font-size: var(--font-body);
|
||||
font-optical-sizing: auto;
|
||||
@@ -1250,6 +1252,7 @@
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
container: magic-notes-page / inline-size;
|
||||
}
|
||||
|
||||
.magic-notes-layout {
|
||||
@@ -1261,14 +1264,26 @@
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
container: speech-model-list / inline-size;
|
||||
grid-template-columns:
|
||||
220px minmax(300px, 1fr) 9px
|
||||
var(--magic-notes-list-width, 220px) 9px
|
||||
minmax(300px, 1fr) 9px
|
||||
var(--magic-notes-ai-width, 280px);
|
||||
}
|
||||
|
||||
.magic-notes-layout--ai-hidden {
|
||||
grid-template-columns: 220px minmax(300px, 1fr);
|
||||
grid-template-columns:
|
||||
var(--magic-notes-list-width, 220px) 9px
|
||||
minmax(300px, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-layout--list-hidden {
|
||||
grid-template-columns:
|
||||
minmax(300px, 1fr) 9px
|
||||
var(--magic-notes-ai-width, 280px);
|
||||
}
|
||||
|
||||
.magic-notes-layout--list-hidden.magic-notes-layout--ai-hidden {
|
||||
grid-template-columns: minmax(300px, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-layout--resizing {
|
||||
@@ -1276,21 +1291,34 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle,
|
||||
.magic-notes-ai-resize-handle {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 9px;
|
||||
min-width: 9px;
|
||||
padding: 0;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--surface-subtle) 0 4px,
|
||||
var(--surface-raised) 4px 100%
|
||||
);
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--surface-raised) 0 4px,
|
||||
var(--surface-subtle) 4px 100%
|
||||
);
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle::after,
|
||||
.magic-notes-ai-resize-handle::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -1304,14 +1332,19 @@
|
||||
background var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle:hover::after,
|
||||
.magic-notes-list-resize-handle:focus-visible::after,
|
||||
.magic-notes-ai-resize-handle:hover::after,
|
||||
.magic-notes-ai-resize-handle:focus-visible::after,
|
||||
.magic-notes-layout--resizing
|
||||
.magic-notes-layout--list-resizing
|
||||
> .magic-notes-list-resize-handle::after,
|
||||
.magic-notes-layout--ai-resizing
|
||||
> .magic-notes-ai-resize-handle::after {
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle:focus-visible,
|
||||
.magic-notes-ai-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
@@ -1330,10 +1363,14 @@
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.magic-notes-list-pane[hidden],
|
||||
.magic-notes-ai-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-pane {
|
||||
display: flex;
|
||||
padding: var(--space-4);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@@ -2175,12 +2212,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
@container magic-notes-page (max-width: 800px) {
|
||||
.magic-notes-layout {
|
||||
grid-template-columns: 210px minmax(320px, 1fr);
|
||||
grid-template-columns:
|
||||
var(--magic-notes-list-width, 210px) 9px
|
||||
minmax(320px, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.magic-notes-layout--list-hidden {
|
||||
grid-template-columns: minmax(320px, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
@@ -2193,11 +2236,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 720px) {
|
||||
.magic-notes-page {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@container magic-notes-page (max-width: 720px) {
|
||||
.magic-notes-layout {
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
@@ -2205,6 +2244,10 @@
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-pane {
|
||||
max-height: 240px;
|
||||
border-right: 0;
|
||||
@@ -2321,7 +2364,7 @@ textarea:focus-visible {
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex: 0 0 278px;
|
||||
flex: 0 0 var(--primary-sidebar-width, 278px);
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 20px 14px 14px;
|
||||
@@ -2334,12 +2377,63 @@ textarea:focus-visible {
|
||||
padding 180ms ease;
|
||||
}
|
||||
|
||||
.sidebar--resizing {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar--closed {
|
||||
flex-basis: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar > .brand,
|
||||
.sidebar > .project-switcher,
|
||||
.sidebar > .new-chat,
|
||||
.sidebar > .sidebar-search,
|
||||
.sidebar > .primary-nav,
|
||||
.sidebar > .conversation-list,
|
||||
.sidebar > .sidebar-footer {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.primary-sidebar-resize-handle {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
width: 9px;
|
||||
min-width: 9px;
|
||||
padding: 0;
|
||||
margin-left: -5px;
|
||||
background: transparent;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.primary-sidebar-resize-handle::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 4px;
|
||||
width: 1px;
|
||||
background: var(--border-subtle);
|
||||
content: '';
|
||||
transition:
|
||||
width var(--motion-fast) ease-out,
|
||||
background var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.primary-sidebar-resize-handle:hover::after,
|
||||
.primary-sidebar-resize-handle:focus-visible::after,
|
||||
.sidebar--resizing + .primary-sidebar-resize-handle::after {
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.primary-sidebar-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
@@ -3714,6 +3808,372 @@ button > svg {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.workbar-shell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
padding: 6px 46px 0 8px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--surface-subtle);
|
||||
flex: 0 0 46px;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-scroll {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbar-shell__tablist {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 88px;
|
||||
max-width: 180px;
|
||||
height: 39px;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-control) var(--radius-control) 0 0;
|
||||
background: transparent;
|
||||
transition:
|
||||
border-color var(--motion-fast) ease-out,
|
||||
background var(--motion-fast) ease-out,
|
||||
box-shadow var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-item:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-item--active {
|
||||
border-color: var(--border-default);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 32px 0 var(--space-2);
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-caption);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workbar-shell__tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.workbar-shell__tab--active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-icon {
|
||||
display: grid;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 15px;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-icon > svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.workbar-shell__tab--active .workbar-shell__tab-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-label {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-close,
|
||||
.workbar-shell__add {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-close {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
right: var(--space-1);
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
background: var(--surface-raised);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%) scale(0.86);
|
||||
transition:
|
||||
opacity var(--motion-fast) ease-out,
|
||||
transform var(--motion-fast) ease-out,
|
||||
background var(--motion-fast) ease-out,
|
||||
color var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-close > svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-item:hover .workbar-shell__tab-close,
|
||||
.workbar-shell__tab-item:focus-within .workbar-shell__tab-close {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-close:hover {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.workbar-shell__tab-close {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.workbar-shell__add:hover,
|
||||
.workbar-shell__add--active {
|
||||
background: var(--accent-selected);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__add {
|
||||
margin-bottom: 5px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__content,
|
||||
.workbar-shell__tabpanel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workbar-shell__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workbar-shell__tabpanel {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbar-shell__tabpanel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog,
|
||||
.workbar-shell__empty {
|
||||
min-height: 0;
|
||||
padding: var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-header h2,
|
||||
.workbar-shell__empty strong {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-header p,
|
||||
.workbar-shell__empty p {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-list {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: var(--space-4) 0 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-item {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-choice {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
align-items: flex-start;
|
||||
border-radius: var(--radius-card);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workbar-shell__app-icon {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: var(--space-3);
|
||||
flex: 0 0 32px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__app-icon > svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-choice:hover:not(:disabled) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-choice:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-copy span,
|
||||
.workbar-shell__catalog-copy small,
|
||||
.workbar-shell__unavailable,
|
||||
.workbar-shell__limit {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.workbar-shell__unavailable {
|
||||
display: flex;
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-error {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--danger-border);
|
||||
border-radius: var(--radius-control);
|
||||
margin: var(--space-3) 0 0;
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.workbar-shell__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workbar-shell__empty .primary-button {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.assistant-sidebar__overlay {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
inset: 46px 0 0;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.assistant-sidebar__terminal-close {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
padding: var(--space-6);
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.assistant-sidebar__terminal-close h2,
|
||||
.assistant-sidebar__terminal-close p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.assistant-sidebar__terminal-close > div {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.assistant-sidebar__tabs {
|
||||
display: grid;
|
||||
min-height: 46px;
|
||||
@@ -3767,6 +4227,7 @@ button > svg {
|
||||
}
|
||||
|
||||
.assistant-sidebar__body {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -14829,7 +15290,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.sidebar {
|
||||
flex-basis: 236px;
|
||||
flex-basis: var(--primary-sidebar-width, 236px);
|
||||
}
|
||||
|
||||
.brand,
|
||||
@@ -14877,6 +15338,10 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.primary-sidebar-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
.terminal-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 300px;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.terminal-panel--loading {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar,
|
||||
.terminal-panel__inline-form,
|
||||
.terminal-panel__status,
|
||||
.terminal-panel__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button,
|
||||
.terminal-panel__inline-form button,
|
||||
.terminal-panel__error button {
|
||||
display: inline-flex;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button:hover:not(:disabled),
|
||||
.terminal-panel__inline-form button:hover:not(:disabled),
|
||||
.terminal-panel__error button:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button:focus-visible,
|
||||
.terminal-panel__inline-form button:focus-visible,
|
||||
.terminal-panel__inline-form input:focus-visible,
|
||||
.terminal-panel__error button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button:disabled,
|
||||
.terminal-panel__inline-form button:disabled,
|
||||
.terminal-panel__error button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar .terminal-panel__reopen {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.terminal-panel__inline-form {
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.terminal-panel__inline-form input {
|
||||
min-width: 120px;
|
||||
min-height: 32px;
|
||||
flex: 1 1 160px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.terminal-panel__error {
|
||||
justify-content: space-between;
|
||||
color: var(--danger);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.terminal-panel__emulator {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: var(--space-2);
|
||||
overflow: hidden;
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
.terminal-panel__emulator .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-panel__status {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--border-default);
|
||||
border-bottom: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.terminal-panel__status span {
|
||||
min-width: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-panel__status span + span::before {
|
||||
content: "·";
|
||||
margin-right: var(--space-2);
|
||||
color: var(--border-control);
|
||||
}
|
||||
|
||||
.terminal-panel__status strong {
|
||||
margin-left: auto;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-panel__status strong[data-state="running"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.terminal-panel__status strong[data-state="interrupted"],
|
||||
.terminal-panel__status strong[data-state="failed"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.terminal-panel__status strong[data-state="starting"],
|
||||
.terminal-panel__status strong[data-state="closing"] {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.terminal-panel__announcement {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.terminal-panel__toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-panel__status span {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.terminal-panel__status strong {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user