From 7b841bac66cc12557a7c44ad70d7d434f21a1106 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 5 Aug 2026 17:10:00 +0800 Subject: [PATCH] feat: keep JK/HL pane focus across L2, Cmd+B, and pointer clicks Claim keyboard panes on click, blur the composer when clicking outside, and stop Cmd+B / L0 navigation from dumping focus into content so vim nav stays usable on Team L1/L2 and other L1 rails. Co-authored-by: Cursor --- packages/web-ui/src/App.tsx | 8 +- .../web-ui/src/components/ChatTeamSidebar.tsx | 17 ++- .../web-ui/src/components/ProjectSidebar.tsx | 5 + packages/web-ui/src/components/Sidebar.tsx | 2 +- .../web-ui/src/components/TeamDetailPanel.tsx | 115 +++++++++++++++++- .../web-ui/src/contexts/LayoutContext.tsx | 50 +++++++- packages/web-ui/src/lib/keyboard-shortcuts.ts | 3 +- packages/web-ui/src/locales/en/common.json | 3 +- packages/web-ui/src/locales/zh-CN/common.json | 3 +- packages/web-ui/src/pages/Deliverables.tsx | 12 +- packages/web-ui/src/pages/Settings.tsx | 2 +- packages/web-ui/src/pages/Store.tsx | 5 +- packages/web-ui/src/pages/Team.tsx | 79 ++++++++++-- packages/web-ui/src/pages/Work.tsx | 21 +++- 14 files changed, 286 insertions(+), 39 deletions(-) diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index 30f5fb68..0e2bee8f 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -806,7 +806,13 @@ export function App() { > { navigate(p); setSidebarOpen(false); setKeyboardPane?.('content'); }} + onNavigate={(p) => { + navigate(p); + setSidebarOpen(false); + // Clicking the app rail claims L0 — never dump into content (that kills JK/HL). + setKeyboardPane?.('l0'); + setL0FocusPageId?.(p); + }} authUser={authUser} collapsed={sidebar.collapsed} onToggleCollapse={sidebar.toggle} diff --git a/packages/web-ui/src/components/ChatTeamSidebar.tsx b/packages/web-ui/src/components/ChatTeamSidebar.tsx index 6a8bbb6a..e83195e1 100644 --- a/packages/web-ui/src/components/ChatTeamSidebar.tsx +++ b/packages/web-ui/src/components/ChatTeamSidebar.tsx @@ -70,6 +70,8 @@ interface ChatTeamSidebarProps { previewMode?: boolean; /** Keyboard focus is on this L1 pane (H/L navigation). */ focused?: boolean; + /** When true, L from L1 enters Team L2 instead of jumping to chat content. */ + l2Available?: boolean; } // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -215,6 +217,7 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({ initialLoading, previewMode, focused, + l2Available, }: ChatTeamSidebarProps) { const { t } = useTranslation(['team', 'common']); const isMobile = useIsMobile(); @@ -1027,19 +1030,19 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({ }); }, [onSelectDm, onSelectChannel, onSelectAgent, onSelectTeam]); - // L1 keyboard: j/k move roster, H → L0, L → content (chat) + // L1 keyboard: j/k move roster, H → L0, L → L2 (if open) or content useEffect(() => { if (previewMode || isMobile || !isActive || hidden) return; const onKey = (e: KeyboardEvent) => { const pane = layout?.keyboardPane ?? 'content'; - if (pane === 'l0') return; + if (pane === 'l0' || pane === 'l2') return; if (e.metaKey || e.ctrlKey || e.altKey) return; if (isEditableTarget(e.target)) return; const bare = e.key.length === 1 ? e.key.toLowerCase() : e.key; if (bare === 'h' || bare === 'ArrowLeft') { - if (pane !== 'l1') return; // Team.tsx handles content → L1 + if (pane !== 'l1') return; // Team.tsx / L2 handle other panes e.preventDefault(); e.stopPropagation(); layout?.setL0FocusPageId(PAGE.TEAM); @@ -1051,7 +1054,9 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({ if (pane !== 'l1') return; e.preventDefault(); e.stopPropagation(); - layout?.setKeyboardPane('content'); + // Only enter L2 when it exists. Never dump into chat content — + // deepest pane keeps JK focus (L would leave the user without JK). + if (l2Available) layout?.setKeyboardPane('l2'); return; } @@ -1088,7 +1093,7 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({ document.addEventListener('keydown', onKey, true); return () => document.removeEventListener('keydown', onKey, true); }, [ - previewMode, isMobile, isActive, hidden, layout, focused, + previewMode, isMobile, isActive, hidden, layout, focused, l2Available, chatMode, activeDmUserId, authUser?.id, selectedAgent, activeChannel, selectedTeamId, activateL1Item, ]); @@ -1100,7 +1105,7 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({ return ( <> -
+
{/* Header with title + manage button */}
{isMobile && } diff --git a/packages/web-ui/src/components/ProjectSidebar.tsx b/packages/web-ui/src/components/ProjectSidebar.tsx index 199b5b11..2ed53efd 100644 --- a/packages/web-ui/src/components/ProjectSidebar.tsx +++ b/packages/web-ui/src/components/ProjectSidebar.tsx @@ -16,6 +16,8 @@ export interface ProjectSidebarProps { onResizeStart?: (e: React.MouseEvent) => void; hidden?: boolean; focused?: boolean; + /** Called when the user pointer-activates this L1 rail (in addition to data-keyboard-pane). */ + onActivate?: () => void; } export function ProjectSidebar({ @@ -32,6 +34,7 @@ export function ProjectSidebar({ onResizeStart, hidden, focused, + onActivate, }: ProjectSidebarProps) { const { t } = useTranslation(['work', 'common']); const allIsSelected = allSelected ?? selectedProjectId == null; @@ -39,6 +42,8 @@ export function ProjectSidebar({ return ( <>
onActivate?.()} className="bg-surface-primary flex flex-col shrink-0 border-r border-border-default/60" style={hidden ? { display: 'none' } : width != null ? { width } : undefined} > diff --git a/packages/web-ui/src/components/Sidebar.tsx b/packages/web-ui/src/components/Sidebar.tsx index 437e76a8..f0044b36 100644 --- a/packages/web-ui/src/components/Sidebar.tsx +++ b/packages/web-ui/src/components/Sidebar.tsx @@ -31,7 +31,7 @@ export function Sidebar({ const { t } = useTranslation(['nav', 'common']); return ( -