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:
mesalogo
2026-09-01 00:49:49 +08:00
parent 73cc55bf75
commit 27055693bf
45 changed files with 8814 additions and 192 deletions
+155 -4
View File
@@ -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
View File
@@ -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)
+409 -28
View File
@@ -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
+479 -82
View File
@@ -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>
)
}
+458
View File
@@ -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)
})
})
+786
View File
@@ -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>
)
}
+240
View File
@@ -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()
})
})
+555
View File
@@ -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>
)
}
+3 -1
View File
@@ -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: {
+3 -1
View File
@@ -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
View File
@@ -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;
+194
View File
@@ -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;
}
}