Skip to content

Commit bdd8439

Browse files
committed
fix(home): make the chat/resource panel divider keyboard-adjustable
The resource panel divider (moved into the shared ChatPanelLayout this release, identical to main's home.tsx) was pointer-only: a separator with no tabIndex, key handling, or aria-value*, so keyboard users could not focus or resize it. Mirror the file text-editor split: the divider is now a focusable separator with ArrowLeft/ArrowRight steps and Home/End. Both separators now read keys through one readSeparatorKey helper (modifier and IME guard included). Keyboard widths go through the same MIN/max clamps as the drag (keyboardPanelWidth next to panelWidthAt), land without the width transition like the window-resize clamp (shared writeWidthInstantly), are ignored during a live drag, and claim the resource view like a pointer resize. aria-valuenow/min/ max are written imperatively on focus, key, and drag end, preserving the hook's zero-render resize design. Focus-visible outline matches the text-editor split.
1 parent 03b847a commit bdd8439

11 files changed

Lines changed: 463 additions & 133 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx‎

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { cn, toast } from '@sim/emcn'
1818
import { formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste'
1919
import type { editor as MonacoEditorTypes } from 'monaco-editor'
2020
import dynamic from 'next/dynamic'
21+
import { readSeparatorKey } from '@/lib/core/utils/separator-keys'
2122
import {
2223
buildFileSelectionLabel,
2324
truncateSelectionText,
@@ -627,27 +628,17 @@ export const TextEditor = memo(function TextEditor({
627628
}, [isResizing])
628629

629630
const handleSplitKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
630-
if (
631-
event.altKey ||
632-
event.ctrlKey ||
633-
event.metaKey ||
634-
event.shiftKey ||
635-
event.nativeEvent.isComposing ||
636-
event.keyCode === 229
637-
) {
638-
return
639-
}
640-
const { key } = event
641-
if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'Home' && key !== 'End') return
631+
const key = readSeparatorKey(event)
632+
if (!key) return
642633
event.preventDefault()
643634
event.stopPropagation()
644-
if (key === 'Home' || key === 'End') {
645-
setSplitPct(key === 'Home' ? SPLIT_MIN_PCT : SPLIT_MAX_PCT)
635+
if (key === 'min' || key === 'max') {
636+
setSplitPct(key === 'min' ? SPLIT_MIN_PCT : SPLIT_MAX_PCT)
646637
return
647638
}
648639
const container = containerRef.current
649640
const isRtl = container !== null && getComputedStyle(container).direction === 'rtl'
650-
const delta = (key === 'ArrowLeft' ? -1 : 1) * (isRtl ? -1 : 1) * SPLIT_KEYBOARD_STEP_PCT
641+
const delta = (key === 'left' ? -1 : 1) * (isRtl ? -1 : 1) * SPLIT_KEYBOARD_STEP_PCT
651642
setSplitPct((current) => Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, current + delta)))
652643
}
653644

‎apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.test.tsx‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ it.each([1, 2])(
6767
activityCount={count}
6868
onToggle={toggle}
6969
onResize={vi.fn()}
70+
onResizeKeyDown={vi.fn()}
71+
onResizeFocus={vi.fn()}
7072
panel={<div>Resources</div>}
7173
>
7274
<div>Conversation</div>
@@ -87,3 +89,39 @@ it.each([1, 2])(
8789
expect(container.textContent).toContain('Resources')
8890
}
8991
)
92+
93+
it('exposes the divider as a focusable separator routing keys and focus to the owner', async () => {
94+
const resize = vi.fn()
95+
const keyDown = vi.fn()
96+
const focus = vi.fn()
97+
await act(async () =>
98+
root.render(
99+
<ChatPanelLayout
100+
collapsed={false}
101+
label='resource view'
102+
onToggle={vi.fn()}
103+
onResize={resize}
104+
onResizeKeyDown={keyDown}
105+
onResizeFocus={focus}
106+
panel={<div>Resources</div>}
107+
>
108+
<div>Conversation</div>
109+
</ChatPanelLayout>
110+
)
111+
)
112+
document.body.appendChild(container)
113+
const separator = container.querySelector<HTMLDivElement>('[role="separator"]')!
114+
expect(separator.getAttribute('aria-label')).toBe('Resize resource view')
115+
expect(separator.getAttribute('aria-orientation')).toBe('vertical')
116+
expect(separator.tabIndex).toBe(0)
117+
await act(async () => separator.focus())
118+
expect(document.activeElement).toBe(separator)
119+
expect(focus).toHaveBeenCalledOnce()
120+
await act(async () => {
121+
separator.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
122+
})
123+
expect(keyDown).toHaveBeenCalledOnce()
124+
expect(keyDown.mock.calls[0][0].key).toBe('ArrowLeft')
125+
expect(resize).not.toHaveBeenCalled()
126+
container.remove()
127+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
'use client'
22

3-
import type { PointerEventHandler, ReactNode, Ref } from 'react'
3+
import type {
4+
FocusEventHandler,
5+
KeyboardEventHandler,
6+
PointerEventHandler,
7+
ReactNode,
8+
Ref,
9+
} from 'react'
410
import { Button, cn } from '@sim/emcn'
511
import { PanelLeft } from '@sim/emcn/icons'
612
import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
@@ -13,6 +19,9 @@ interface ChatPanelLayoutProps {
1319
activityCount?: number
1420
onToggle: () => void
1521
onResize: PointerEventHandler<HTMLDivElement>
22+
onResizeKeyDown: KeyboardEventHandler<HTMLDivElement>
23+
/** Reports the panel's current width and bounds on the divider's `aria-value*`. */
24+
onResizeFocus: FocusEventHandler<HTMLDivElement>
1625
}
1726

1827
/** Shared resize handle and collapse control for resources and Search results. */
@@ -24,6 +33,8 @@ export function ChatPanelLayout({
2433
activityCount = 0,
2534
onToggle,
2635
onResize,
36+
onResizeKeyDown,
37+
onResizeFocus,
2738
}: ChatPanelLayoutProps) {
2839
const toggleLabel = `${collapsed ? 'Expand' : 'Collapse'} ${label}${
2940
collapsed && activityCount > 0
@@ -38,11 +49,14 @@ export function ChatPanelLayout({
3849
{!collapsed && (
3950
<div className='relative z-20 w-0 flex-none'>
4051
<div
41-
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize'
52+
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--selection)]'
4253
role='separator'
54+
tabIndex={0}
4355
aria-orientation='vertical'
4456
aria-label={`Resize ${label}`}
4557
onPointerDown={onResize}
58+
onKeyDown={onResizeKeyDown}
59+
onFocus={onResizeFocus}
4660
/>
4761
</div>
4862
)}

‎apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export function ChatResourcePanel({
5959
expandResource,
6060
mothershipRef,
6161
handleResourceResizePointerDown,
62+
handleResourceResizeKeyDown,
63+
handleResourceResizeFocus,
6264
handleResourceInteraction,
6365
} = panel
6466
const summarize = useCallback(
@@ -75,6 +77,8 @@ export function ChatResourcePanel({
7577
activityCount={resourceActivityIds.size}
7678
onToggle={isResourceCollapsed ? expandResource : collapseResource}
7779
onResize={handleResourceResizePointerDown}
80+
onResizeKeyDown={handleResourceResizeKeyDown}
81+
onResizeFocus={handleResourceResizeFocus}
7882
panel={
7983
<MothershipResourcesProvider
8084
selectResource={selectResourceFromUser}

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.ts‎

Lines changed: 0 additions & 106 deletions
This file was deleted.

0 commit comments

Comments
 (0)