diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 9ce7301a..cec50aad 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -314,20 +314,32 @@ - 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。 - 五个栏目使用稳定标签并保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。 - 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。 -- 宽窗口可停靠并调整宽度,中等窗口可停靠或覆盖,窄窗口使用全屏或接近全屏抽屉;所有尺寸下均须保留全部栏目入口。 -- 覆盖和抽屉布局打开后焦点进入工作栏并限制在其中,Escape 或背景点击关闭,关闭后焦点 - 返回触发按钮;覆盖期间背景内容必须从指针与辅助技术导航中隔离。宽窗口停靠布局不得 - 获得对话框语义或隔离主工作区。 +- 所有窗口宽度下均停靠在主工作区右侧并参与水平布局回流;展开时压缩主工作区,不能覆盖、 + 遮罩或隔离其内容。分栏计算排除最左侧主导航,主工作区与工作栏采用相同的 `300px` + 最小宽度;拖动保存两者在可用分栏宽度中的比例,任一侧到达最小值后都不得继续侵占。 + 所有可容纳两侧最小值的尺寸均支持指针和键盘调整,并须保留全部栏目入口。 +- 工作栏开关位于窗口关闭按钮下方的当前任务或页面上下文栏最右侧,同一个按钮负责展开与 + 收起并持续暴露状态;图标中的箭头方向必须随当前动作切换,收起状态提示展开,展开状态 + 提示收起。按钮保持普通图标按钮视觉,不通过持续高亮表达展开状态。工作栏内部不再重复 + 关闭按钮。上下文栏应为后续任务状态、范围和高频操作预留空间,不能只作为孤立的按钮容器。 +- 停靠工作栏不得获得对话框语义、焦点陷阱或背景遮罩。关闭后焦点返回同一个工作栏开关; + 主工作区与工作栏在展开期间都保持可达。 +- 对话消息流、输入区和输入区元信息必须在主工作区内部保留对称的左右安全边距。工作栏 + 分割线只能位于主工作区边界之外,不能覆盖、裁切或挤掉输入框边框、发送按钮或右侧边距。 - 终端、宽日志和大型成果可以由用户切换到底部停靠或独立窗口,应用不得因内容变化自动改变用户已选布局。 ### 6.10 应用顶栏与全局操作 应用顶栏用于窗口级状态、侧栏开关和低频全局操作,不承担页面标题或主要导航。顶栏必须保持紧凑,不能与页面内容争夺注意力。 -- 顶栏高度默认为 `58px`,图标按钮使用 `34px × 34px` 点击区域。 +- 无边框窗口使用两层顶部结构:`30px` 窗口标题栏只承载拖动区域和窗口控制;其下 + `46px` 当前任务或页面上下文栏承载侧栏开关、标题、范围、状态和高频操作。普通图标按钮 + 使用 `34px × 34px` 点击区域。 - Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。 - 浅色与深色切换属于持续可用的窗口级操作,直接显示太阳或月亮图标,并通过可访问名称说明将切换到的主题。选择必须持久化,切换不得改变布局。 - 顶栏只直接显示当前任务所需的高频操作。已有侧栏账户设置入口时,不再重复显示 Runtime/设置入口;使用帮助优先放在相关操作附近,而不是为单个帮助项创建“更多”菜单。 +- 助手工作栏开关固定在上下文栏最右侧并与窗口关闭按钮纵向对齐;展开后该开关保持原位并 + 负责收起,不在工作栏标题区域增加第二个关闭入口。 - 只有存在至少两个无法由稳定入口承载的低频全局操作时才增加全局菜单,不为了容纳一个冗余入口而显示省略号按钮。 - 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。 - 使用全局菜单时,菜单项使用 `--font-body`、`14px` 图标和约 `32px` 单项高度;标签使用短名称。菜单保留 `menu`、`menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。 diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md index c1fe85d8..cd409f9f 100644 --- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md +++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md @@ -216,9 +216,16 @@ Runtime ### 6.3 工作栏尺寸 -- 宽窗口:工作栏停靠右侧,支持键盘和指针调整宽度。 -- 中等窗口:可停靠或覆盖主内容,保持用户上次选择。 -- 窄窗口:以全屏或接近全屏抽屉显示。 +- 所有窗口宽度下工作栏都停靠右侧并参与水平布局回流;展开时主工作区同步收缩,不能覆盖 + 或遮罩其内容。 +- 分栏计算排除最左侧主导航,主工作区与工作栏使用相同的 `300px` 最小宽度。拖动记录两侧 + 在可用分栏宽度中的比例;任一侧达到最小值后停止,窗口缩放后按原比例重新计算并再次 + 应用同一最小值约束。 +- 对话内容与输入区始终保留对称的左右安全边距,工作栏边界不得覆盖或裁切输入框、发送 + 按钮及主工作区右侧边距。 +- 唯一展开与收起开关位于窗口关闭按钮下方的当前任务或页面上下文栏最右侧。工作栏内部 + 不重复关闭入口。开关图标中的箭头方向随当前动作切换,按钮不以持续高亮表达展开状态; + 该上下文栏为后续任务状态、范围和高频操作扩展预留位置。 - 终端、宽日志和大型成果允许用户切换到底部停靠或独立窗口。 - 应用只建议适合的布局,不因面板内容自动改变用户已经选择的停靠位置。 @@ -751,6 +758,8 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 ### 17.5 可用性 - [ ] 宽、中、窄窗口均可访问完整能力目录和用户打开的面板实例。 +- [ ] 工作栏在宽、中、窄窗口展开时都推动主工作区回流,不覆盖、遮罩或隔离主内容;唯一 + 开关始终位于当前任务或页面上下文栏最右侧。 - [ ] 仅使用键盘可以选择能力、面板实例、目标、执行空间和控制动作。 - [ ] 状态不只依赖颜色,徽标具有文字或可访问名称。 - [ ] 终端、HTML、监督证据和高频日志具有可访问替代或降噪行为。 diff --git a/docs/roadmap/long-term-assistant-roadmap.md b/docs/roadmap/long-term-assistant-roadmap.md index 954f5302..9d74317c 100644 --- a/docs/roadmap/long-term-assistant-roadmap.md +++ b/docs/roadmap/long-term-assistant-roadmap.md @@ -44,9 +44,12 @@ GoodBuddy 应能够: └──────────────┴──────────────────────────────┴──────────────────────┘ ``` -- 宽窗口:右侧栏固定显示,可拖动宽度。 -- 中等窗口:右侧栏默认折叠,点击后覆盖主工作区右侧。 -- 窄窗口:右侧栏作为全屏抽屉。 +- 所有窗口宽度下右侧栏都停靠显示并推动主工作区水平回流,不覆盖或遮罩主内容。 +- 排除最左侧主导航后,主工作区与右侧栏使用相同的 `300px` 最小宽度,并按用户拖动后的 + 比例分配可用宽度;任一侧到达最小值后停止拖动。 +- 主工作区中的消息流和输入区保留对称的左右安全边距,右侧栏不得覆盖或裁切其右边缘。 +- 唯一展开与收起开关位于窗口关闭按钮下方的当前任务或页面上下文栏最右侧,右侧栏内部 + 不重复关闭入口。 - 右侧栏在对话、知识库和活动视图之间保持状态。 - 知识图谱实体详情复用同一右栏容器,不再维护独立布局。 - 九个标准能力固定可达;能力、连接和数据状态可以变化,但应用不按当前上下文自动隐藏入口。 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index c4342bea..2d041b67 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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() + const { container } = render() 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() + + 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 () => ({ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fd880938..fe8adc73 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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('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 ( -
+
+
+ +
+
- + {view === 'chat' && ( + + )} {(view === 'chat' || cachedWorkspaceViewKeys.has('chat')) && ( @@ -9794,14 +9797,6 @@ function App(): React.JSX.Element { } /> )} - {assistantOverlayOpen && ( -
) } diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index cc87f2e2..5ba5d352 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -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 - onClose?: () => void - overlay?: boolean restoreFocusRef?: { current: HTMLElement | null } } = {}): HTMLElement { render( - 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} - /> +
+
+ 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} + /> +
) - 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', () => { diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index d6c00768..08d25720 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -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 onStopBrowser: () => Promise 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(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(undefined) const [selectedArtifactId, setSelectedArtifactId] = useState() 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} /> -
- {t('sidebar.title')} - -
-