fix: keep assistant workbar docked and resizable

The right assistant workbar could switch to an overlay, duplicate its close control, and let resizing crowd or clip the chat composer. It now uses one directional toggle below the window controls, remains a docked sibling, and preserves equal 300px minimums for the chat and workbar with symmetric composer gutters.

Release note: 修复右侧助手工作栏遮挡聊天区及拖动边界不一致的问题;工作栏现在使用单一方向按钮,并在左右区域达到相同最小宽度后停止拖动。
This commit is contained in:
mesalogo
2026-08-20 23:58:48 +08:00
parent a0977645a3
commit b29f2d7b0d
11 changed files with 472 additions and 322 deletions
+85 -18
View File
@@ -1138,9 +1138,7 @@ describe('App', () => {
})
)
fireEvent.click(
within(assistantSidebar).getByRole('button', {
name: 'Close assistant workspace'
})
screen.getByLabelText('Toggle assistant workspace')
)
fireEvent.click(screen.getByLabelText('Project settings'))
@@ -5119,7 +5117,7 @@ describe('App', () => {
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId)
)
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
selectProjectOption(secondProject.name)
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(
@@ -8011,23 +8009,52 @@ describe('App', () => {
})
it('opens the global assistant sidebar and switches work tabs', async () => {
render(<App />)
const { container } = render(<App />)
const sidebar = screen.getByLabelText('助手工作栏')
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
const assistantTrigger =
screen.getByLabelText('切换助手工作栏')
expect(
container.querySelector('.topbar')
).toContainElement(assistantTrigger)
expect(assistantTrigger).toHaveClass('assistant-sidebar-toggle')
expect(assistantTrigger).not.toHaveClass('icon-button--active')
expect(assistantTrigger).toHaveAttribute('aria-expanded', 'false')
expect(assistantTrigger).toHaveAttribute(
'aria-controls',
'assistant-sidebar'
)
expect(
assistantTrigger.querySelector('.lucide-panel-right-open')
).toBeInTheDocument()
expect(
assistantTrigger.querySelector('.lucide-panel-right-close')
).not.toBeInTheDocument()
expect(
container.querySelector('.window-titlebar')
).toContainElement(screen.getByLabelText('关闭窗口'))
expect(
screen.queryByLabelText('关闭助手工作栏')
).not.toBeInTheDocument()
fireEvent.click(assistantTrigger)
expect(sidebar).toHaveClass('assistant-sidebar--open')
expect(sidebar).toHaveAttribute('role', 'dialog')
expect(sidebar).toHaveAttribute('aria-modal', 'true')
expect(sidebar).toHaveAttribute('id', 'assistant-sidebar')
expect(sidebar).toHaveAttribute('role', 'complementary')
expect(sidebar).not.toHaveAttribute('aria-modal')
expect(assistantTrigger).toHaveAttribute('aria-expanded', 'true')
expect(assistantTrigger).not.toHaveClass('icon-button--active')
expect(
assistantTrigger.querySelector('.lucide-panel-right-close')
).toBeInTheDocument()
expect(
assistantTrigger.querySelector('.lucide-panel-right-open')
).not.toBeInTheDocument()
const main = document.querySelector('main')
expect(main).toHaveAttribute('inert')
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '任务中心' })
).toHaveFocus()
)
expect(main).not.toHaveAttribute('inert')
expect(
container.querySelector('.assistant-sidebar-backdrop')
).not.toBeInTheDocument()
expect(
screen.getByRole('tab', { name: '任务中心' })
@@ -8056,14 +8083,14 @@ describe('App', () => {
expect(
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
fireEvent.click(assistantTrigger)
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
expect(assistantTrigger).toHaveAttribute('aria-expanded', 'false')
expect(
assistantTrigger.querySelector('.lucide-panel-right-open')
).toBeInTheDocument()
expect(main).not.toHaveAttribute('inert')
await waitFor(() => expect(assistantTrigger).toHaveFocus())
fireEvent.click(assistantTrigger)
fireEvent.keyDown(sidebar, { key: 'Escape' })
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
})
it('keeps completed chat replies out of the results sidebar', async () => {
@@ -8507,6 +8534,46 @@ describe('App', () => {
}
})
it('docks the assistant workbar without obscuring a narrow workspace', async () => {
const originalWidth = window.innerWidth
const { container } = render(<App />)
try {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 680
})
window.dispatchEvent(new Event('resize'))
await waitFor(() =>
expect(container.querySelector('.sidebar'))
.toHaveClass('sidebar--closed')
)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
const workbar = screen.getByRole('complementary', {
name: '助手工作栏'
})
expect(workbar).toHaveClass('assistant-sidebar--open')
expect(workbar.parentElement).toHaveClass('app-shell')
expect(
workbar.parentElement?.querySelector('main.workspace')
).toBeInTheDocument()
expect(workbar).not.toHaveAttribute('aria-modal')
expect(container.querySelector('main')).not.toHaveAttribute(
'inert'
)
expect(
container.querySelector('.assistant-sidebar-backdrop')
).not.toBeInTheDocument()
} finally {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: originalWidth
})
}
})
it('opens Magic Notes as a global first-class workspace', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
+37 -43
View File
@@ -31,6 +31,7 @@ import {
Settings,
RefreshCw,
ShieldCheck,
PanelRightClose,
PanelRightOpen,
Sparkles,
Square,
@@ -2180,8 +2181,6 @@ function App(): React.JSX.Element {
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
() => window.innerWidth >= 1280
)
const [assistantSidebarOverlay, setAssistantSidebarOverlay] =
useState(() => window.innerWidth < 1280)
const [assistantSidebarTab, setAssistantSidebarTab] =
useState<AssistantSidebarTab>('tasks')
const [browserStates, setBrowserStates] = useState<
@@ -2605,7 +2604,6 @@ function App(): React.JSX.Element {
const collapseSidebarAtNarrowWidth = (): void => {
const narrow = window.innerWidth < 900
setNarrowWindow(narrow)
setAssistantSidebarOverlay(window.innerWidth < 1280)
if (narrow) {
setSidebarOpen(false)
}
@@ -7387,23 +7385,23 @@ function App(): React.JSX.Element {
runtimeSettings
])
const assistantOverlayOpen =
assistantSidebarOverlay &&
assistantSidebarOpen &&
view === 'chat'
const mainSidebarOpen = narrowWindow && sidebarOpen
const backgroundIsolated = mainSidebarOpen || assistantOverlayOpen
const backgroundIsolated = mainSidebarOpen
return (
<div className="app-shell">
<div className="app-frame">
<header className="window-titlebar">
<WindowControls onError={handleWindowControlError} />
</header>
<div className="app-shell">
<aside
aria-label={
narrowWindow && sidebarOpen ? t('sidebar.label') : undefined
}
aria-hidden={!sidebarOpen || assistantOverlayOpen}
aria-hidden={!sidebarOpen}
aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
inert={!sidebarOpen || assistantOverlayOpen}
inert={!sidebarOpen}
onKeyDown={(event) => {
if (mainSidebarOpen) {
trapTabFocus(event, sidebarRef.current)
@@ -8052,7 +8050,13 @@ function App(): React.JSX.Element {
className="workspace"
inert={backgroundIsolated}
>
<header className="topbar">
<header
className={
view === 'chat'
? 'topbar topbar--with-assistant-toggle'
: 'topbar'
}
>
<button
className="icon-button sidebar-toggle"
type="button"
@@ -8133,24 +8137,6 @@ function App(): React.JSX.Element {
</span>
)}
</span>
{view === 'chat' && (
<button
aria-label={t('topbar.toggleAssistantSidebar')}
aria-pressed={assistantSidebarOpen}
className={
assistantSidebarOpen
? 'icon-button icon-button--active'
: 'icon-button'
}
onClick={() =>
setAssistantSidebarOpen((current) => !current)
}
ref={assistantSidebarToggleRef}
type="button"
>
<PanelRightOpen size={18} />
</button>
)}
<button
aria-label={
resolvedAppearanceTheme === 'dark'
@@ -8174,9 +8160,26 @@ function App(): React.JSX.Element {
)}
</button>
</div>
<WindowControls
onError={handleWindowControlError}
/>
{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>
)}
</header>
{(view === 'chat' || cachedWorkspaceViewKeys.has('chat')) && (
@@ -9794,14 +9797,6 @@ function App(): React.JSX.Element {
}
/>
)}
{assistantOverlayOpen && (
<button
aria-label={tWorkspace('sidebar.dismissOverlay')}
className="assistant-sidebar-backdrop"
onClick={() => setAssistantSidebarOpen(false)}
type="button"
/>
)}
<RightAssistantSidebar
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
@@ -9815,7 +9810,6 @@ function App(): React.JSX.Element {
selectedTaskId={selectedAssistantTaskId}
tasks={productAssistantTasks}
projectNames={projectNames}
onClose={() => setAssistantSidebarOpen(false)}
onInteractBrowser={async () => {
if (!activeId) {
return
@@ -9895,12 +9889,12 @@ function App(): React.JSX.Element {
open={
assistantSidebarOpen && view === 'chat'
}
overlay={assistantSidebarOverlay}
restoreFocusRef={assistantSidebarToggleRef}
tab={assistantSidebarTab}
workspaceChanges={workspaceChanges}
workspaceProjectId={activeProjectId || undefined}
/>
</div>
</div>
)
}
@@ -25,63 +25,60 @@ function renderSidebar({
tab = 'context',
artifacts = [],
onLoadArtifact = vi.fn(async () => undefined),
onClose = vi.fn(),
overlay = false,
restoreFocusRef
}: {
tab?: AssistantSidebarTab
artifacts?: SidebarArtifact[]
onLoadArtifact?: (artifactId: string) => Promise<void>
onClose?: () => void
overlay?: boolean
restoreFocusRef?: { current: HTMLElement | null }
} = {}): HTMLElement {
render(
<RightAssistantSidebar
approvals={[]}
artifacts={artifacts}
attachments={[]}
enabledLibraries={[]}
memories={[]}
schedules={[]}
tasks={[]}
conversationTitles={new Map()}
projectNames={new Map()}
onClose={onClose}
onCreateCustomTask={vi.fn()}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
path,
entries: [],
truncated: false
}))}
onLoadArtifact={onLoadArtifact}
onLoadWorkspaceFile={vi.fn()}
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
onInteractBrowser={vi.fn(async () => undefined)}
onRefreshChanges={vi.fn(async () => undefined)}
onRemoveAttachment={vi.fn()}
onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()}
onRunSchedule={vi.fn(async () => undefined)}
onSetScheduleEnabled={vi.fn(async () => undefined)}
onOpenTask={vi.fn()}
onStopBrowser={vi.fn(async () => undefined)}
onTabChange={vi.fn()}
open
overlay={overlay}
restoreFocusRef={restoreFocusRef}
tab={tab}
/>
<div>
<main className="workspace" />
<RightAssistantSidebar
approvals={[]}
artifacts={artifacts}
attachments={[]}
enabledLibraries={[]}
memories={[]}
schedules={[]}
tasks={[]}
conversationTitles={new Map()}
projectNames={new Map()}
onCreateCustomTask={vi.fn()}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
path,
entries: [],
truncated: false
}))}
onLoadArtifact={onLoadArtifact}
onLoadWorkspaceFile={vi.fn()}
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
onInteractBrowser={vi.fn(async () => undefined)}
onRefreshChanges={vi.fn(async () => undefined)}
onRemoveAttachment={vi.fn()}
onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()}
onRunSchedule={vi.fn(async () => undefined)}
onSetScheduleEnabled={vi.fn(async () => undefined)}
onOpenTask={vi.fn()}
onStopBrowser={vi.fn(async () => undefined)}
onTabChange={vi.fn()}
open
restoreFocusRef={restoreFocusRef}
tab={tab}
/>
</div>
)
return screen.getByRole(overlay ? 'dialog' : 'complementary', {
return screen.getByRole('complementary', {
name: '助手工作栏'
})
}
describe('RightAssistantSidebar resizing', () => {
it('resizes with pointer capture and preserves the minimum app width', () => {
it('resizes with pointer capture and preserves equal pane minima', () => {
const sidebar = renderSidebar()
const separator = screen.getByRole('separator', {
name: '调整助手工作栏宽度'
@@ -108,7 +105,9 @@ describe('RightAssistantSidebar resizing', () => {
expect(sidebar).toHaveClass('assistant-sidebar--resizing')
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('880px')
).toBe('1100px')
expect(separator).toHaveAttribute('aria-valuemin', '300')
expect(separator).toHaveAttribute('aria-valuemax', '1100')
fireEvent.pointerUp(separator, { pointerId: 7 })
expect(releasePointerCapture).toHaveBeenCalledWith(7)
@@ -124,8 +123,8 @@ describe('RightAssistantSidebar resizing', () => {
fireEvent.keyDown(separator, { key: 'ArrowLeft' })
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('366px')
expect(separator).toHaveAttribute('aria-valuenow', '366')
).toBe('436px')
expect(separator).toHaveAttribute('aria-valuenow', '436')
fireEvent.keyDown(separator, { key: 'Home' })
expect(
@@ -135,10 +134,10 @@ describe('RightAssistantSidebar resizing', () => {
fireEvent.keyDown(separator, { key: 'End' })
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('880px')
).toBe('1100px')
})
it('remains resizable when the sidebar overlays a medium window', () => {
it('remains resizable when the sidebar docks in a medium window', () => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1024
@@ -169,6 +168,69 @@ describe('RightAssistantSidebar resizing', () => {
).toBe('424px')
})
it('keeps the equal minimum dock resizable in a narrow window', () => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 680
})
const sidebar = renderSidebar()
const separator = screen.getByRole('separator', {
name: '调整助手工作栏宽度'
})
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('300px')
expect(sidebar).not.toHaveAttribute('aria-modal')
expect(separator).toHaveAttribute('tabindex', '0')
expect(separator).toHaveAttribute('aria-valuemin', '300')
expect(separator).toHaveAttribute('aria-valuemax', '380')
fireEvent.keyDown(separator, { key: 'End' })
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('380px')
})
it('excludes the primary sidebar from the equal pane limits', async () => {
const getBoundingClientRect = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: HTMLElement) {
const [left, width] = this.classList.contains('workspace')
? [278, 630]
: this.classList.contains('assistant-sidebar')
? [908, 270]
: [0, 0]
return {
bottom: 700,
height: 700,
left,
right: left + width,
top: 0,
width,
x: left,
y: 0,
toJSON: () => ({})
}
})
try {
const sidebar = renderSidebar()
const separator = screen.getByRole('separator', {
name: '调整助手工作栏宽度'
})
await waitFor(() =>
expect(separator).toHaveAttribute('aria-valuemax', '600')
)
fireEvent.keyDown(separator, { key: 'End' })
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('600px')
} finally {
getBoundingClientRect.mockRestore()
}
})
it('exposes the task center and four reusable work surfaces', () => {
renderSidebar()
@@ -180,25 +242,11 @@ describe('RightAssistantSidebar resizing', () => {
).not.toBeInTheDocument()
})
it('treats overlay mode as a focus-trapped dismissible dialog', async () => {
const onClose = vi.fn()
const trigger = document.createElement('button')
document.body.append(trigger)
const sidebar = renderSidebar({
onClose,
overlay: true,
restoreFocusRef: { current: trigger }
})
it('keeps the docked sidebar non-modal', () => {
const sidebar = renderSidebar()
expect(sidebar).toHaveAttribute('aria-modal', 'true')
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '上下文' })
).toHaveFocus()
)
fireEvent.keyDown(sidebar, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
trigger.remove()
expect(sidebar).not.toHaveAttribute('aria-modal')
expect(sidebar).toHaveAttribute('role', 'complementary')
})
it('keeps the product Task index in the task center', () => {
+138 -85
View File
@@ -8,7 +8,6 @@ import {
FolderTree,
ClockFading,
Monitor,
PanelRightClose,
Plus,
RefreshCw,
ShieldAlert,
@@ -33,7 +32,6 @@ import type {
KnowledgeLibrary
} from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives'
import {
findTaskSchedule,
@@ -80,9 +78,7 @@ type RightAssistantSidebarProps = {
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
browserState?: BrowserLiveState
overlay?: boolean
restoreFocusRef?: { current: HTMLElement | null }
onClose: () => void
onInteractBrowser: () => Promise<void>
onStopBrowser: () => Promise<void>
onCreateCustomTask: () => void
@@ -120,29 +116,58 @@ const tabIds: AssistantSidebarTab[] = [
'results'
]
const emptyChangedFiles: WorkspaceChanges['files'] = []
const defaultSidebarWidth = 350
const minimumSidebarWidth = 300
const minimumRemainingAppWidth = 520
const compactSidebarBreakpoint = 720
const defaultSidebarRatio = 0.3
const minimumPaneWidth = 300
const keyboardResizeStep = 16
function getSidebarWidthLimits(viewportWidth: number): {
function getSidebarWidthLimits(layoutWidth: number): {
minimum: number
maximum: number
} {
const boundedLayoutWidth = Math.max(0, Math.floor(layoutWidth))
const minimum = Math.min(
minimumPaneWidth,
Math.floor(boundedLayoutWidth / 2)
)
return {
minimum: minimumSidebarWidth,
maximum: Math.max(
minimumSidebarWidth,
viewportWidth - minimumRemainingAppWidth
)
minimum,
maximum: Math.max(minimum, boundedLayoutWidth - minimum)
}
}
function clampSidebarWidth(width: number, viewportWidth: number): number {
const limits = getSidebarWidthLimits(viewportWidth)
function clampSidebarWidth(width: number, layoutWidth: number): number {
const limits = getSidebarWidthLimits(layoutWidth)
return Math.min(limits.maximum, Math.max(limits.minimum, width))
}
function findWorkspaceSibling(
sidebar: HTMLElement | null
): HTMLElement | undefined {
const parent = sidebar?.parentElement
if (!parent) {
return undefined
}
return Array.from(parent.children).find(
(element): element is HTMLElement =>
element instanceof HTMLElement &&
element.classList.contains('workspace')
)
}
function measureSplitLayoutWidth(
sidebar: HTMLElement | null,
fallbackWidth: number
): number {
const workspace = findWorkspaceSibling(sidebar)
if (!sidebar || !workspace) {
return fallbackWidth
}
const measuredWidth =
workspace.getBoundingClientRect().width +
sidebar.getBoundingClientRect().width
return measuredWidth > 0 ? measuredWidth : fallbackWidth
}
export function RightAssistantSidebar({
open,
tab,
@@ -159,9 +184,7 @@ export function RightAssistantSidebar({
workspaceChanges,
workspaceProjectId,
browserState,
overlay = false,
restoreFocusRef,
onClose,
onInteractBrowser,
onStopBrowser,
onCreateCustomTask,
@@ -198,13 +221,18 @@ export function RightAssistantSidebar({
})),
[t]
)
const [viewportWidth, setViewportWidth] = useState(window.innerWidth)
const [sidebarWidth, setSidebarWidth] = useState(defaultSidebarWidth)
const [splitLayoutWidth, setSplitLayoutWidth] = useState(
window.innerWidth
)
const [sidebarRatio, setSidebarRatio] = useState(defaultSidebarRatio)
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLElement>(null)
const wasOpen = useRef(false)
const wasOverlayOpen = useRef(false)
const liveSidebarWidth = useRef(defaultSidebarWidth)
const sidebarWidth = clampSidebarWidth(
splitLayoutWidth * sidebarRatio,
splitLayoutWidth
)
const liveSidebarWidth = useRef(sidebarWidth)
const resizePointerId = useRef<number | undefined>(undefined)
const [selectedArtifactId, setSelectedArtifactId] = useState<string>()
const [workspacePreview, setWorkspacePreview] = useState<
@@ -245,9 +273,10 @@ export function RightAssistantSidebar({
workspacePreview?.projectId === workspaceProjectId
? workspacePreview
: undefined
const sidebarWidthLimits = getSidebarWidthLimits(viewportWidth)
const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth)
const canResize =
open && viewportWidth >= compactSidebarBreakpoint
open &&
sidebarWidthLimits.maximum > sidebarWidthLimits.minimum
const topLevelTasks = useMemo(
() => tasks.filter((task) => !task.parentTaskId),
[tasks]
@@ -278,51 +307,74 @@ export function RightAssistantSidebar({
)
useEffect(() => {
const handleViewportResize = (): void => {
setViewportWidth(window.innerWidth)
setSidebarWidth((currentWidth) => {
const width = clampSidebarWidth(
currentWidth,
window.innerWidth
)
liveSidebarWidth.current = width
return width
})
const sidebar = sidebarRef.current
const workspace = findWorkspaceSibling(sidebar)
const handleLayoutResize = (): void => {
const width = measureSplitLayoutWidth(
sidebar,
window.innerWidth
)
setSplitLayoutWidth((currentWidth) =>
Math.abs(currentWidth - width) >= 0.5
? width
: currentWidth
)
}
window.addEventListener('resize', handleViewportResize)
return () => window.removeEventListener('resize', handleViewportResize)
handleLayoutResize()
window.addEventListener('resize', handleLayoutResize)
const resizeObserver =
typeof ResizeObserver === 'undefined'
? undefined
: new ResizeObserver(handleLayoutResize)
if (resizeObserver && workspace && sidebar) {
resizeObserver.observe(workspace)
resizeObserver.observe(sidebar)
}
return () => {
resizeObserver?.disconnect()
window.removeEventListener('resize', handleLayoutResize)
}
}, [])
useEffect(() => {
if (open && overlay && !wasOverlayOpen.current) {
const focusFrame = requestAnimationFrame(() => {
document
.getElementById(`assistant-sidebar-tab-${tab}`)
?.focus()
})
wasOpen.current = open
wasOverlayOpen.current = true
return () => cancelAnimationFrame(focusFrame)
if (!isResizing) {
liveSidebarWidth.current = sidebarWidth
}
}, [isResizing, sidebarWidth])
useEffect(() => {
if (!open && wasOpen.current) {
requestAnimationFrame(() => restoreFocusRef?.current?.focus())
}
wasOpen.current = open
wasOverlayOpen.current = open && overlay
}, [open, overlay, restoreFocusRef, tab])
}, [open, restoreFocusRef])
const resizeFromClientX = (
clientX: number,
commit: boolean
): void => {
const layoutWidth = measureSplitLayoutWidth(
sidebarRef.current,
splitLayoutWidth
)
if (Math.abs(splitLayoutWidth - layoutWidth) >= 0.5) {
setSplitLayoutWidth(layoutWidth)
}
const sidebarBounds = sidebarRef.current?.getBoundingClientRect()
const layoutRight =
sidebarBounds && sidebarBounds.width > 0
? sidebarBounds.right
: window.innerWidth
const width = clampSidebarWidth(
window.innerWidth - clientX,
window.innerWidth
layoutRight - clientX,
layoutWidth
)
liveSidebarWidth.current = width
if (commit) {
setSidebarWidth(width)
setSidebarRatio(
layoutWidth > 0 ? width / layoutWidth : defaultSidebarRatio
)
return
}
sidebarRef.current?.style.setProperty(
@@ -341,7 +393,15 @@ export function RightAssistantSidebar({
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
setSidebarWidth(liveSidebarWidth.current)
const layoutWidth = measureSplitLayoutWidth(
sidebarRef.current,
splitLayoutWidth
)
setSidebarRatio(
layoutWidth > 0
? liveSidebarWidth.current / layoutWidth
: defaultSidebarRatio
)
setIsResizing(false)
}
@@ -351,7 +411,14 @@ export function RightAssistantSidebar({
if (!canResize) {
return
}
const limits = getSidebarWidthLimits(window.innerWidth)
const layoutWidth = measureSplitLayoutWidth(
sidebarRef.current,
splitLayoutWidth
)
if (Math.abs(splitLayoutWidth - layoutWidth) >= 0.5) {
setSplitLayoutWidth(layoutWidth)
}
const limits = getSidebarWidthLimits(layoutWidth)
const nextWidth =
event.key === 'Home'
? limits.minimum
@@ -367,8 +434,9 @@ export function RightAssistantSidebar({
return
}
event.preventDefault()
setSidebarWidth(
clampSidebarWidth(nextWidth, window.innerWidth)
const width = clampSidebarWidth(nextWidth, layoutWidth)
setSidebarRatio(
layoutWidth > 0 ? width / layoutWidth : defaultSidebarRatio
)
}
@@ -455,28 +523,17 @@ export function RightAssistantSidebar({
ref={sidebarRef}
aria-label={t('sidebar.ariaLabel')}
aria-hidden={!open}
aria-modal={open && overlay ? 'true' : undefined}
className={
open
? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}`
: 'assistant-sidebar'
}
id="assistant-sidebar"
inert={!open}
onKeyDown={(event) => {
if (!open || !overlay) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, sidebarRef.current)
}}
role={open && overlay ? 'dialog' : undefined}
role="complementary"
style={
{
'--assistant-sidebar-width': `${sidebarWidth}px`
'--assistant-sidebar-width': `${Math.round(sidebarWidth)}px`
} as React.CSSProperties
}
>
@@ -484,11 +541,11 @@ export function RightAssistantSidebar({
aria-controls="assistant-sidebar-panel"
aria-label={t('sidebar.resizeAriaLabel')}
aria-orientation="vertical"
aria-valuemax={sidebarWidthLimits.maximum}
aria-valuemin={sidebarWidthLimits.minimum}
aria-valuenow={sidebarWidth}
aria-valuemax={Math.round(sidebarWidthLimits.maximum)}
aria-valuemin={Math.round(sidebarWidthLimits.minimum)}
aria-valuenow={Math.round(sidebarWidth)}
aria-valuetext={t('sidebar.resizeValue', {
width: sidebarWidth
width: Math.round(sidebarWidth)
})}
aria-disabled={!canResize}
className="assistant-sidebar__resize-handle"
@@ -496,7 +553,15 @@ export function RightAssistantSidebar({
onLostPointerCapture={(event) => {
if (resizePointerId.current === event.pointerId) {
resizePointerId.current = undefined
setSidebarWidth(liveSidebarWidth.current)
const layoutWidth = measureSplitLayoutWidth(
sidebarRef.current,
splitLayoutWidth
)
setSidebarRatio(
layoutWidth > 0
? liveSidebarWidth.current / layoutWidth
: defaultSidebarRatio
)
setIsResizing(false)
}
}}
@@ -526,18 +591,6 @@ export function RightAssistantSidebar({
role="separator"
tabIndex={canResize ? 0 : -1}
/>
<header className="assistant-sidebar__header">
<strong>{t('sidebar.title')}</strong>
<button
aria-label={t('sidebar.close')}
className="icon-button"
onClick={onClose}
type="button"
>
<PanelRightClose size={17} />
</button>
</header>
<nav
aria-label={t('sidebar.categoriesAriaLabel')}
className="assistant-sidebar__tabs"
@@ -158,7 +158,7 @@ describe('WorkspacePrimitives', () => {
expect(screen.getByLabelText('项目 + 全局')).toBeInTheDocument()
})
it('keeps forced-color and narrow assistant overrides authoritative', () => {
it('keeps forced-color and docked split contracts authoritative', () => {
const forcedColorsStart = stylesheet.indexOf(
'@media (forced-colors: active)'
)
@@ -185,15 +185,12 @@ describe('WorkspacePrimitives', () => {
/\.heartbeat-center__meter\s*\{[^}]*forced-color-adjust:\s*none;/u
)
const narrowAssistant = stylesheet.slice(
stylesheet.indexOf('@media (max-width: 719px)'),
stylesheet.indexOf(
'@media (max-width: 720px)',
stylesheet.indexOf('@media (max-width: 719px)')
)
expect(stylesheet).toMatch(
/\.assistant-sidebar--open\s*\{[^}]*width:\s*var\(--assistant-sidebar-width,\s*30%\);[^}]*flex-basis:\s*var\(--assistant-sidebar-width,\s*30%\);/u
)
expect(narrowAssistant).toMatch(
/\.assistant-sidebar--open\s*\{[^}]*width:\s*calc\(100vw - 16px\);[^}]*flex-basis:\s*calc\(100vw - 16px\);/u
expect(stylesheet).not.toContain('@media (max-width: 719px)')
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*min-width:\s*0;[^}]*padding:\s*var\(--space-3\)\s*max\(var\(--page-gutter\),\s*calc\(\(100% - var\(--content-reading\)\) \/ 2\)\)\s*var\(--space-2\);/u
)
})
@@ -72,9 +72,6 @@ export const workspace = {
ariaLabel: 'Assistant workspace',
resizeAriaLabel: 'Resize assistant workspace',
resizeValue: '{{width}} pixels',
title: 'Workspace',
close: 'Close assistant workspace',
dismissOverlay: 'Dismiss assistant workspace overlay',
categoriesAriaLabel: 'Workspace categories',
tabs: {
tasks: {
@@ -69,9 +69,6 @@ export const workspace = {
ariaLabel: '助手工作栏',
resizeAriaLabel: '调整助手工作栏宽度',
resizeValue: '{{width}} 像素',
title: '工作栏',
close: '关闭助手工作栏',
dismissOverlay: '关闭助手工作栏背景',
categoriesAriaLabel: '工作栏分类',
tabs: {
tasks: {
+58 -85
View File
@@ -2257,11 +2257,36 @@ textarea:focus-visible {
outline-offset: 2px;
}
.app-frame {
position: relative;
display: grid;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
background: var(--surface-canvas);
color: var(--text-primary);
grid-template-rows: 30px minmax(0, 1fr);
}
.window-titlebar {
display: flex;
min-width: 0;
align-items: stretch;
justify-content: flex-end;
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-subtle);
-webkit-app-region: drag;
user-select: none;
}
.app-shell {
position: relative;
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
background: var(--surface-canvas);
color: var(--text-primary);
}
@@ -2952,7 +2977,7 @@ textarea:focus-visible {
flex: 1;
overflow: hidden;
background: var(--surface-raised);
grid-template-rows: 58px minmax(0, 1fr) auto;
grid-template-rows: 46px minmax(0, 1fr);
}
.workspace-route-cache {
@@ -2978,6 +3003,10 @@ textarea:focus-visible {
user-select: none;
}
.topbar--with-assistant-toggle {
padding-right: 46px;
}
.topbar :is(button, input, select),
.topbar__actions,
.window-controls {
@@ -2989,7 +3018,7 @@ textarea:focus-visible {
height: 100%;
flex: 0 0 auto;
align-self: stretch;
margin-left: 8px;
-webkit-app-region: no-drag;
}
.window-control {
@@ -3074,8 +3103,8 @@ button > svg {
}
.assistant-sidebar--open {
width: var(--assistant-sidebar-width, 350px);
flex-basis: var(--assistant-sidebar-width, 350px);
width: var(--assistant-sidebar-width, 30%);
flex-basis: var(--assistant-sidebar-width, 30%);
border-left-width: 1px;
opacity: 1;
}
@@ -3123,30 +3152,28 @@ button > svg {
outline-offset: -2px;
}
.assistant-sidebar__header {
display: flex;
height: 58px;
flex: 0 0 58px;
align-items: center;
justify-content: space-between;
padding: 0 14px 0 18px;
border-bottom: 1px solid var(--border-subtle);
}
.assistant-sidebar__header strong {
font-size: 13px;
}
.assistant-sidebar__tabs {
display: grid;
padding: 8px;
min-height: 46px;
padding: 6px 46px 6px 8px;
border-bottom: 1px solid var(--border-subtle);
flex: 0 0 46px;
align-items: center;
gap: 3px;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.assistant-sidebar-toggle {
position: absolute;
z-index: 3;
top: 6px;
right: 6px;
-webkit-app-region: no-drag;
}
.assistant-sidebar__tab {
position: relative;
min-height: 32px;
padding: 7px 2px;
border-radius: 7px;
background: transparent;
@@ -5111,6 +5138,9 @@ button > svg {
}
.composer-wrap {
width: 100%;
max-width: 100%;
min-width: 0;
padding:
var(--space-3)
max(var(--page-gutter), calc((100% - var(--content-reading)) / 2))
@@ -5119,6 +5149,9 @@ button > svg {
}
.composer {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 0;
border: 1px solid var(--border-control);
border-radius: var(--radius-card);
@@ -5563,6 +5596,8 @@ button > svg {
.composer__toolbar {
display: flex;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 56px;
padding: 10px var(--space-3);
@@ -12527,7 +12562,7 @@ details.settings-section > :not(summary) + :not(summary) {
--shadow-dialog: 0 18px 48px rgb(0 4 10 / 48%);
}
:root[data-theme='dark'] .app-shell {
:root[data-theme='dark'] :where(.app-frame, .app-shell) {
background: var(--surface-canvas);
color: var(--text-primary);
}
@@ -12544,6 +12579,7 @@ details.settings-section > :not(summary) + :not(summary) {
:root[data-theme='dark'] :where(
.sidebar,
.window-titlebar,
.topbar,
.assistant-sidebar,
.project-create-card,
@@ -12566,6 +12602,7 @@ details.settings-section > :not(summary) + :not(summary) {
:root[data-theme='dark'] :where(
.sidebar,
.window-titlebar,
.topbar
) {
background: var(--surface-subtle);
@@ -12623,9 +12660,9 @@ details.settings-section > :not(summary) + :not(summary) {
:root[data-theme='dark'] :where(
.primary-nav,
.sidebar-footer,
.window-titlebar,
.topbar,
.assistant-sidebar,
.assistant-sidebar__header,
.assistant-sidebar__tabs,
.assistant-sidebar__context,
.assistant-sidebar__preview > header,
@@ -12944,38 +12981,6 @@ details.settings-section > :not(summary) + :not(summary) {
}
}
@media (max-width: 1279px) {
.assistant-sidebar-backdrop {
position: absolute;
z-index: 25;
display: block;
padding: 0;
border: 0;
background: var(--overlay-backdrop);
inset: 58px 0 0;
}
.assistant-sidebar {
position: absolute;
z-index: 30;
top: 58px;
right: 0;
bottom: 0;
box-shadow: -12px 0 30px rgb(0 0 0 / 10%);
}
.assistant-sidebar--open {
width: min(
var(--assistant-sidebar-width, 350px),
calc(100vw - 36px)
);
flex-basis: min(
var(--assistant-sidebar-width, 350px),
calc(100vw - 36px)
);
}
}
@media (max-width: 1020px) {
.sidebar {
flex-basis: 236px;
@@ -12991,17 +12996,6 @@ details.settings-section > :not(summary) + :not(summary) {
min-width: 206px;
}
.assistant-sidebar--open {
width: min(
var(--assistant-sidebar-width, 350px),
calc(100vw - 36px)
);
flex-basis: min(
var(--assistant-sidebar-width, 350px),
calc(100vw - 36px)
);
}
.settings-page .settings-panel__body {
display: grid;
overflow: hidden;
@@ -13062,27 +13056,6 @@ details.settings-section > :not(summary) + :not(summary) {
}
}
@media (max-width: 719px) {
.assistant-sidebar {
top: 0;
z-index: 45;
}
.assistant-sidebar--open {
width: calc(100vw - 16px);
flex-basis: calc(100vw - 16px);
}
.assistant-sidebar-backdrop {
z-index: 40;
inset: 0;
}
.assistant-sidebar__resize-handle {
display: none;
}
}
@media (max-width: 720px) {
.release-notes-backdrop {
padding: var(--space-4);