From 46f4316a21d7a43d0083d2bb0ecf20eaf7b0338a Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 5 Aug 2026 11:36:07 +0800 Subject: [PATCH 1/3] feat: redesign Tasks page with project L1 sidebar and local detail shortcuts Move project filtering into a collapsible L1 sidebar (with All first), unify the right detail pane for project/task/requirement, and scope Cmd+J/L to Tasks vs Team Chat. Co-authored-by: Cursor --- packages/web-ui/src/App.tsx | 9 +- .../web-ui/src/components/ProjectSidebar.tsx | 175 ++++ .../src/components/ShortcutsHelpModal.tsx | 26 +- packages/web-ui/src/lib/keyboard-shortcuts.ts | 35 +- packages/web-ui/src/locales/en/common.json | 10 +- packages/web-ui/src/locales/en/work.json | 6 + packages/web-ui/src/locales/zh-CN/common.json | 10 +- packages/web-ui/src/locales/zh-CN/work.json | 6 + packages/web-ui/src/pages/Work.tsx | 853 ++++++++++++------ 9 files changed, 834 insertions(+), 296 deletions(-) create mode 100644 packages/web-ui/src/components/ProjectSidebar.tsx diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index 00610187..51e0dec2 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -368,13 +368,15 @@ export function App() { if (key === 'b') { e.preventDefault(); toggleLeftCollapsed(); - } else if (key === 'l') { + } else if (page === PAGE.TEAM && key === 'l') { + // Team Chat only: browser right panel. Tasks page owns Cmd+L for item detail. // Don't steal Ctrl+L clear-screen from an focused xterm (Cmd+L still toggles on Mac). if (!isMac && isXtermTarget(e.target)) return; if (isMac && isXtermTarget(e.target) && e.ctrlKey) return; e.preventDefault(); toggleRightPanel(); - } else if (key === 'j' && toggleTerminalPanel) { + } else if (page === PAGE.TEAM && key === 'j' && toggleTerminalPanel) { + // Team Chat only: terminal right panel. Tasks page owns Cmd+J for project detail. e.preventDefault(); toggleTerminalPanel(); } @@ -383,6 +385,7 @@ export function App() { return () => document.removeEventListener('keydown', onKey, true); }, [ isMobile, + page, toggleLeftCollapsed, toggleRightPanel, toggleTerminalPanel, @@ -759,7 +762,7 @@ export function App() { setShowSearchModal(false)} currentPage={page} /> )} {!isMobile && ( - setShowShortcutsHelp(false)} /> + setShowShortcutsHelp(false)} page={page} /> )} {/* Edit profile — available from sidebar account menu without leaving the page */} diff --git a/packages/web-ui/src/components/ProjectSidebar.tsx b/packages/web-ui/src/components/ProjectSidebar.tsx new file mode 100644 index 00000000..199b5b11 --- /dev/null +++ b/packages/web-ui/src/components/ProjectSidebar.tsx @@ -0,0 +1,175 @@ +import { useTranslation } from 'react-i18next'; +import type { ProjectInfo } from '../api.ts'; + +export interface ProjectSidebarProps { + projects: ProjectInfo[]; + selectedProjectId: string | null; + /** When true, the "All" row is selected (no single project). */ + allSelected?: boolean; + taskCounts: Record; + totalTaskCount?: number; + onSelectAll?: () => void; + onSelectProject: (projectId: string) => void; + onCreateProject: () => void; + onCollapse?: () => void; + width?: number; + onResizeStart?: (e: React.MouseEvent) => void; + hidden?: boolean; + focused?: boolean; +} + +export function ProjectSidebar({ + projects, + selectedProjectId, + allSelected, + taskCounts, + totalTaskCount, + onSelectAll, + onSelectProject, + onCreateProject, + onCollapse, + width, + onResizeStart, + hidden, + focused, +}: ProjectSidebarProps) { + const { t } = useTranslation(['work', 'common']); + const allIsSelected = allSelected ?? selectedProjectId == null; + + return ( + <> +
+
+ {onCollapse && ( + + )} +

+ {t('work:task.projects', { defaultValue: 'Projects' })} +

+ +
+ +
+ {projects.length === 0 ? ( +
+
+ + + +
+

+ {t('work:task.emptyNoProjectsHint')} +

+ +
+ ) : ( + <> + {onSelectAll && ( + + )} + {projects.map(p => { + const selected = !allIsSelected && p.id === selectedProjectId; + const count = taskCounts[p.id] ?? 0; + const paused = p.status === 'paused'; + return ( + + ); + })} + + )} +
+
+ {!hidden && onResizeStart && ( +
+
+
+ )} + + ); +} diff --git a/packages/web-ui/src/components/ShortcutsHelpModal.tsx b/packages/web-ui/src/components/ShortcutsHelpModal.tsx index c25ddfcb..61e02d60 100644 --- a/packages/web-ui/src/components/ShortcutsHelpModal.tsx +++ b/packages/web-ui/src/components/ShortcutsHelpModal.tsx @@ -5,13 +5,29 @@ import { formatShortcutKeys, type ShortcutGroupId, } from '../lib/keyboard-shortcuts.ts'; +import { PAGE, type PageId } from '../routes.ts'; -const GROUP_ORDER: ShortcutGroupId[] = ['layout', 'search', 'rightPanel', 'terminal', 'help']; +const GROUP_ORDER: ShortcutGroupId[] = ['layout', 'tasks', 'search', 'rightPanel', 'terminal', 'help']; -export function ShortcutsHelpModal({ open, onClose }: { open: boolean; onClose: () => void }) { +function pageScope(page: PageId | undefined): 'team' | 'work' | 'any' { + if (page === PAGE.TEAM) return 'team'; + if (page === PAGE.WORK) return 'work'; + return 'any'; +} + +export function ShortcutsHelpModal({ + open, + onClose, + page, +}: { + open: boolean; + onClose: () => void; + page?: PageId; +}) { const { t } = useTranslation('common'); const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC'); + const scope = pageScope(page); useEffect(() => { if (!open) return; @@ -52,7 +68,11 @@ export function ShortcutsHelpModal({ open, onClose }: { open: boolean; onClose:
{GROUP_ORDER.map(group => { - const items = KEYBOARD_SHORTCUTS.filter(s => s.group === group); + const items = KEYBOARD_SHORTCUTS.filter(s => { + if (s.group !== group) return false; + const p = s.page ?? 'any'; + return p === 'any' || p === scope || scope === 'any'; + }); if (items.length === 0) return null; return (
diff --git a/packages/web-ui/src/lib/keyboard-shortcuts.ts b/packages/web-ui/src/lib/keyboard-shortcuts.ts index 2045ecec..40b73c9f 100644 --- a/packages/web-ui/src/lib/keyboard-shortcuts.ts +++ b/packages/web-ui/src/lib/keyboard-shortcuts.ts @@ -1,4 +1,4 @@ -export type ShortcutGroupId = 'layout' | 'search' | 'rightPanel' | 'terminal' | 'help'; +export type ShortcutGroupId = 'layout' | 'search' | 'rightPanel' | 'terminal' | 'tasks' | 'help'; export type ShortcutDef = { id: string; @@ -8,24 +8,33 @@ export type ShortcutDef = { /** i18n default English label */ label: string; labelKey?: string; + /** When set, shortcut help only shows this entry on the matching page. */ + page?: 'team' | 'work' | 'any'; }; export const KEYBOARD_SHORTCUTS: ShortcutDef[] = [ - { id: 'toggle-left', group: 'layout', keys: ['B'], label: 'Toggle left sidebar', labelKey: 'shortcuts.toggleLeft' }, - { id: 'toggle-browser', group: 'layout', keys: ['L'], label: 'Toggle right panel · Browser', labelKey: 'shortcuts.toggleBrowser' }, - { id: 'toggle-terminal', group: 'layout', keys: ['J'], label: 'Toggle right panel · Terminal', labelKey: 'shortcuts.toggleTerminal' }, - { id: 'search', group: 'search', keys: ['P'], label: 'Global search', labelKey: 'shortcuts.search' }, - { id: 'next-tab', group: 'rightPanel', keys: ['Shift', ']'], label: 'Next right-panel tab', labelKey: 'shortcuts.nextTab' }, - { id: 'prev-tab', group: 'rightPanel', keys: ['Shift', '['], label: 'Previous right-panel tab', labelKey: 'shortcuts.prevTab' }, - { id: 'tab-n', group: 'rightPanel', keys: ['1…9'], label: 'Jump to Nth right-panel tab', labelKey: 'shortcuts.tabN' }, - { id: 'close-tab', group: 'rightPanel', keys: ['W'], label: 'Close right-panel tab', labelKey: 'shortcuts.closeTab' }, - { id: 'new-tab', group: 'rightPanel', keys: ['T'], label: 'New browser tab / shell', labelKey: 'shortcuts.newTab' }, - { id: 'help', group: 'help', keys: ['/'], label: 'Show keyboard shortcuts', labelKey: 'shortcuts.help' }, - { id: 'term-search', group: 'terminal', keys: ['F'], label: 'Search in terminal', labelKey: 'shortcuts.termSearch' }, - { id: 'term-to-chat', group: 'terminal', keys: ['Shift', 'A'], label: 'Add terminal selection to chat', labelKey: 'shortcuts.termToChat' }, + { id: 'toggle-left', group: 'layout', keys: ['B'], label: 'Toggle left sidebar', labelKey: 'shortcuts.toggleLeft', page: 'any' }, + { id: 'toggle-browser', group: 'layout', keys: ['L'], label: 'Toggle right panel · Browser (Team Chat)', labelKey: 'shortcuts.toggleBrowser', page: 'team' }, + { id: 'toggle-terminal', group: 'layout', keys: ['J'], label: 'Toggle right panel · Terminal (Team Chat)', labelKey: 'shortcuts.toggleTerminal', page: 'team' }, + { id: 'toggle-project-detail', group: 'tasks', keys: ['J'], label: 'Toggle project detail (Tasks)', labelKey: 'shortcuts.toggleProjectDetail', page: 'work' }, + { id: 'toggle-item-detail', group: 'tasks', keys: ['L'], label: 'Toggle task / requirement detail (Tasks)', labelKey: 'shortcuts.toggleItemDetail', page: 'work' }, + { id: 'cycle-board', group: 'tasks', keys: ['Ctrl', 'Tab'], label: 'Cycle Tasks views', labelKey: 'shortcuts.cycleBoard', page: 'work' }, + { id: 'nav-jk', group: 'tasks', keys: ['J / K'], label: 'Move selection (projects or items)', labelKey: 'shortcuts.navJk', page: 'work' }, + { id: 'focus-items', group: 'tasks', keys: ['Tab'], label: 'Focus item list from project sidebar', labelKey: 'shortcuts.focusItems', page: 'work' }, + { id: 'search', group: 'search', keys: ['P'], label: 'Global search', labelKey: 'shortcuts.search', page: 'any' }, + { id: 'next-tab', group: 'rightPanel', keys: ['Shift', ']'], label: 'Next right-panel tab', labelKey: 'shortcuts.nextTab', page: 'team' }, + { id: 'prev-tab', group: 'rightPanel', keys: ['Shift', '['], label: 'Previous right-panel tab', labelKey: 'shortcuts.prevTab', page: 'team' }, + { id: 'tab-n', group: 'rightPanel', keys: ['1…9'], label: 'Jump to Nth right-panel tab', labelKey: 'shortcuts.tabN', page: 'team' }, + { id: 'close-tab', group: 'rightPanel', keys: ['W'], label: 'Close right-panel tab', labelKey: 'shortcuts.closeTab', page: 'team' }, + { id: 'new-tab', group: 'rightPanel', keys: ['T'], label: 'New browser tab / shell', labelKey: 'shortcuts.newTab', page: 'team' }, + { id: 'help', group: 'help', keys: ['/'], label: 'Show keyboard shortcuts', labelKey: 'shortcuts.help', page: 'any' }, + { id: 'term-search', group: 'terminal', keys: ['F'], label: 'Search in terminal', labelKey: 'shortcuts.termSearch', page: 'team' }, + { id: 'term-to-chat', group: 'terminal', keys: ['Shift', 'A'], label: 'Add terminal selection to chat', labelKey: 'shortcuts.termToChat', page: 'team' }, ]; export function formatShortcutKeys(keys: string[], isMac: boolean): string { + if (keys[0] === 'Ctrl') return keys.join('+'); + if (keys[0] === 'Tab' || keys[0] === 'J / K') return keys.join(' '); const mod = isMac ? '⌘' : 'Ctrl'; return [mod, ...keys].join(isMac ? '' : '+'); } diff --git a/packages/web-ui/src/locales/en/common.json b/packages/web-ui/src/locales/en/common.json index 65450dd7..64b924d6 100644 --- a/packages/web-ui/src/locales/en/common.json +++ b/packages/web-ui/src/locales/en/common.json @@ -489,14 +489,20 @@ "close": "Close", "groups": { "layout": "Layout", + "tasks": "Tasks", "search": "Search", "rightPanel": "Right panel", "terminal": "Terminal", "help": "Help" }, "toggleLeft": "Toggle left sidebar", - "toggleBrowser": "Toggle right panel · Browser", - "toggleTerminal": "Toggle right panel · Terminal", + "toggleBrowser": "Toggle right panel · Browser (Team Chat)", + "toggleTerminal": "Toggle right panel · Terminal (Team Chat)", + "toggleProjectDetail": "Toggle project detail (Tasks)", + "toggleItemDetail": "Toggle task / requirement detail (Tasks)", + "cycleBoard": "Cycle Backlog / Board / DAG / Workflows", + "navJk": "Move selection · projects or items", + "focusItems": "Focus item list from project sidebar", "search": "Global search", "nextTab": "Next right-panel tab", "prevTab": "Previous right-panel tab", diff --git a/packages/web-ui/src/locales/en/work.json b/packages/web-ui/src/locales/en/work.json index 110a540f..4f8d39c8 100644 --- a/packages/web-ui/src/locales/en/work.json +++ b/packages/web-ui/src/locales/en/work.json @@ -246,6 +246,8 @@ "dropHere": "Drop here", "backToKanban": "Back to Kanban", "projectsCount": "{{count}} Projects", + "projects": "Projects", + "all": "All", "oneProject": "{{name}}", "shortProject": "+ Project", "shortReq": "+ Req", @@ -326,6 +328,10 @@ "emptyNoProjectsHint": "Projects organize your work. Create a project first, then add requirements to describe what you need. Agents will break them into tasks automatically.", "emptyNoReqsTitle": "Ready to add some work", "emptyNoReqsHint": "Create a requirement to tell agents what you need.\nOnce approved, tasks will appear here automatically.", + "emptyFilteredTitle": "No matching work", + "emptyFilteredHint": "Nothing matches the current assignee filter. Clear filters to see all items in this project.", + "emptyDetailTitle": "No item selected", + "emptyDetailHint": "Select a requirement or task from the list, or create one to get started.", "createRequirementCta": "+ Create a requirement", "filtersSheetTitle": "Filters", "clearAll": "Clear all", diff --git a/packages/web-ui/src/locales/zh-CN/common.json b/packages/web-ui/src/locales/zh-CN/common.json index 5f95791d..3bd47980 100644 --- a/packages/web-ui/src/locales/zh-CN/common.json +++ b/packages/web-ui/src/locales/zh-CN/common.json @@ -489,14 +489,20 @@ "close": "关闭", "groups": { "layout": "布局", + "tasks": "任务", "search": "搜索", "rightPanel": "右侧栏", "terminal": "终端", "help": "帮助" }, "toggleLeft": "折叠/展开左侧栏", - "toggleBrowser": "打开/切换右侧栏 · 浏览器", - "toggleTerminal": "打开/切换右侧栏 · 终端", + "toggleBrowser": "打开/切换右侧栏 · 浏览器(团队聊天)", + "toggleTerminal": "打开/切换右侧栏 · 终端(团队聊天)", + "toggleProjectDetail": "打开/切换项目详情(任务页)", + "toggleItemDetail": "打开/切换需求或任务详情(任务页)", + "cycleBoard": "切换待办列表 / 看板 / 依赖图 / 工作流", + "navJk": "上下移动选中项(项目或需求/任务)", + "focusItems": "从项目侧栏切换到列表焦点", "search": "全局搜索", "nextTab": "下一个右侧栏标签页", "prevTab": "上一个右侧栏标签页", diff --git a/packages/web-ui/src/locales/zh-CN/work.json b/packages/web-ui/src/locales/zh-CN/work.json index dc777812..ae222bbc 100644 --- a/packages/web-ui/src/locales/zh-CN/work.json +++ b/packages/web-ui/src/locales/zh-CN/work.json @@ -246,6 +246,8 @@ "dropHere": "拖放到此处", "backToKanban": "返回看板", "projectsCount": "{{count}} 个项目", + "projects": "项目", + "all": "全部", "oneProject": "{{name}}", "shortProject": "+ 项目", "shortReq": "+ 需求", @@ -326,6 +328,10 @@ "emptyNoProjectsHint": "项目用于组织你的工作。先创建一个项目,然后添加需求来描述你需要什么。智能体会自动将需求拆分为任务。", "emptyNoReqsTitle": "准备好添加工作了", "emptyNoReqsHint": "创建需求以说明你需要什么。\n批准后,任务将自动出现在此处。", + "emptyFilteredTitle": "没有匹配的工作项", + "emptyFilteredHint": "当前负责人筛选下没有结果。清除筛选以查看此项目的全部内容。", + "emptyDetailTitle": "未选择条目", + "emptyDetailHint": "从列表中选择一条需求或任务,或新建一条开始工作。", "createRequirementCta": "+ 创建需求", "filtersSheetTitle": "筛选", "clearAll": "全部清除", diff --git a/packages/web-ui/src/pages/Work.tsx b/packages/web-ui/src/pages/Work.tsx index 944c3136..348c3228 100644 --- a/packages/web-ui/src/pages/Work.tsx +++ b/packages/web-ui/src/pages/Work.tsx @@ -10,6 +10,7 @@ import { Avatar } from '../components/Avatar.tsx'; import { TaskDAG } from '../components/TaskDAG.tsx'; import { NewProjectModal } from '../components/NewProjectModal.tsx'; import { CommentInput, type PendingImage } from '../components/CommentInput.tsx'; +import { ProjectSidebar } from '../components/ProjectSidebar.tsx'; import { navBus } from '../navBus.ts'; import { PAGE, resolvePageId, hashPath } from '../routes.ts'; import { useIsMobile } from '../hooks/useIsMobile.ts'; @@ -17,6 +18,14 @@ import { usePageActive } from '../hooks/usePageActive.ts'; import { useResizablePanel } from '../hooks/useResizablePanel.ts'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; import { MobileMenuButton } from '../components/MobileMenuButton.tsx'; +import { useLayout } from '../contexts/LayoutContext.tsx'; +import { isEditableTarget } from '../lib/keyboard-shortcuts.ts'; + +const WORK_L1_COLLAPSED_KEY = 'markus_work_l1_collapsed'; +const BOARD_TYPE_ORDER = ['backlog', 'kanban', 'dag', 'workflows'] as const; +type BoardType = typeof BOARD_TYPE_ORDER[number]; +type RightDetailMode = 'closed' | 'project' | 'item' | 'empty'; +type NavFocus = 'projects' | 'items'; /** * Convert a live `agent:activity_log` WS payload into the generic execution @@ -3100,6 +3109,7 @@ function BacklogRowView({ row, idx, dragIdx, agentMap, projMap, onTaskClick, onR if (isMobile) { return (
row.kind === 'task' ? onTaskClick(row.data) : onReqClick(row.data)} className={`px-3 py-2 border-b border-border-default/40 cursor-pointer transition-colors border-l-2 ${GROUP_ACCENT[row.group] ?? 'border-l-gray-700'} ${selected ? 'bg-brand-500/10 border-l-brand-500' : 'active:bg-surface-elevated/50'}`} > @@ -3143,6 +3153,7 @@ function BacklogRowView({ row, idx, dragIdx, agentMap, projMap, onTaskClick, onR return (
onRowDragStart(e, idx)} onDragEnd={onRowDragEnd} @@ -4301,6 +4312,9 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au const subStatusBadges = useMemo(() => buildSubStatusBadges(t), [t]); const reqStatusBadges = useMemo(() => buildReqStatusBadges(t), [t]); const isMobile = useIsMobile(); + const layout = useLayout(); + const layoutLeftCollapsed = layout?.leftCollapsed ?? false; + const workContainerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); useEffect(() => { @@ -4311,15 +4325,65 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au setContainerWidth(el.clientWidth); return () => ro.disconnect(); }, []); - const availableWidth = containerWidth || window.innerWidth; - const containerMeasured = useRef(false); - const detailPanel = useResizablePanel({ side: 'right', defaultWidth: Math.round(availableWidth / 2), minWidth: 380, maxWidth: Math.round(availableWidth * 0.8), storageKey: 'markus_projects_detail_v4' }); + + // L1 project sidebar — mirrors Team Chat collapse via Cmd+B (layout.leftCollapsed). + const [l1Collapsed, setL1Collapsed] = useState(() => { + if (previewMode) return false; + try { return localStorage.getItem(WORK_L1_COLLAPSED_KEY) === '1'; } + catch { return false; } + }); + const setL1CollapsedPersisted = useCallback((collapsed: boolean) => { + setL1Collapsed(collapsed); + try { localStorage.setItem(WORK_L1_COLLAPSED_KEY, collapsed ? '1' : '0'); } catch { /* ignore */ } + }, []); + const prevLayoutLeftCollapsed = useRef(layoutLeftCollapsed); useEffect(() => { - if (containerWidth > 0 && !containerMeasured.current) { - containerMeasured.current = true; - detailPanel.setWidth(Math.round(containerWidth / 2)); - } - }, [containerWidth]); // eslint-disable-line react-hooks/exhaustive-deps + if (previewMode || isMobile) return; + if (prevLayoutLeftCollapsed.current === layoutLeftCollapsed) return; + prevLayoutLeftCollapsed.current = layoutLeftCollapsed; + setL1Collapsed(layoutLeftCollapsed); + }, [layoutLeftCollapsed, previewMode, isMobile]); + + const projectSidebar = useResizablePanel({ + side: 'left', + defaultWidth: 240, + minWidth: 180, + maxWidth: 360, + storageKey: 'markus_work_l1', + }); + + const RESIZE_HANDLE_W = 6; + const l1RailWidth = (!isMobile && !l1Collapsed) ? projectSidebar.width + RESIZE_HANDLE_W : 0; + const spaceForMainAndDetail = Math.max(0, (containerWidth || window.innerWidth) - l1RailWidth - RESIZE_HANDLE_W - 8); + const evenSplitWidth = Math.max(320, Math.round(spaceForMainAndDetail / 2)); + const [panelWidthPref, setPanelWidthPref] = useState('auto'); + const maxDetailWidth = Math.max(320, spaceForMainAndDetail - 280); + const desiredDetailWidth = panelWidthPref === 'auto' ? Math.min(evenSplitWidth, maxDetailWidth) : panelWidthPref; + const effectiveDetailWidth = Math.max(320, Math.min(desiredDetailWidth, maxDetailWidth)); + const effectiveDetailWidthRef = useRef(effectiveDetailWidth); + effectiveDetailWidthRef.current = effectiveDetailWidth; + const maxDetailWidthRef = useRef(maxDetailWidth); + maxDetailWidthRef.current = maxDetailWidth; + const onDetailResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startW = effectiveDetailWidthRef.current; + const onMove = (ev: MouseEvent) => { + const next = Math.max(320, Math.min(maxDetailWidthRef.current, startW + (startX - ev.clientX))); + setPanelWidthPref(next); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, []); + const [mobileShowDetail, setMobileShowDetail] = useState(false); const mobileShowDetailRef = useRef(mobileShowDetail); mobileShowDetailRef.current = mobileShowDetail; @@ -4327,6 +4391,8 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au const [projects, setProjects] = useState(previewData?.projects ?? []); const [viewMode, setViewMode] = useState('all'); const [selectedProjectId, setSelectedProjectId] = useState(null); + const [rightDetailMode, setRightDetailMode] = useState('closed'); + const [navFocus, setNavFocus] = useState('projects'); const [board, setBoard] = useState>(previewData?.board ?? {}); const [agents, setAgents] = useState(previewData?.agents ?? []); const [teams, setTeams] = useState>([]); @@ -4334,8 +4400,6 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au const [allRequirements, setAllRequirements] = useState(previewData?.allRequirements ?? []); const [loading, setLoading] = useState(previewData ? false : true); const [flash, setFlash] = useState(''); - const [showProjectSettings, setShowProjectSettings] = useState(false); - const [settingsProjectId, setSettingsProjectId] = useState(null); const openCreateReq = useCallback(() => { setReqProjectId(selectedProjectId ?? ''); setShowCreateReq(true); @@ -4365,13 +4429,19 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au } return null; }); + // Sync preview selection into right panel on first paint + useEffect(() => { + if (previewData?.initialSelectedReqId && selectedReq) { + setRightDetailMode('item'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const [agentFilter, setAgentFilter] = useState>(new Set()); const [myTasksOnly, setMyTasksOnly] = useState(false); const [projectFilter, setProjectFilter] = useState>(new Set()); - const savedProjectFilterRef = useRef>(new Set()); const projectFilterRef = useRef>(new Set()); const [dragOverCol, setDragOverCol] = useState(null); - const [boardType, setBoardType] = useState<'backlog' | 'kanban' | 'dag' | 'workflows'>(previewData?.initialBoardType ?? 'backlog'); + const [boardType, setBoardType] = useState(previewData?.initialBoardType ?? 'backlog'); const [dagExpandReqId, setDagExpandReqId] = useState(null); const boardTabs = useMemo(() => [{ id: 'backlog' as const }, { id: 'kanban' as const }, { id: 'dag' as const }, { id: 'workflows' as const }], []); const boardSwipe = useSwipeTabs(boardTabs, boardType, setBoardType); @@ -4398,7 +4468,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au const msg = (m: string) => { setFlash(m); setTimeout(() => setFlash(''), 3000); }; - // Escape closes the top-most open modal/sheet (NewProjectModal handles its own). + // Escape closes the top-most open modal/sheet, then the right detail pane. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; @@ -4406,28 +4476,41 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au else if (showCreateTask) { setShowCreateTask(false); setTaskCreateError(''); } else if (showCreateReq) { setShowCreateReq(false); setReqTitle(''); setReqDesc(''); setReqCreateError(''); } else if (showFilterSheet) setShowFilterSheet(false); + else if (rightDetailMode !== 'closed') { + setRightDetailMode('closed'); + setSelectedTask(null); + setSelectedReq(null); + if (isMobile && mobileShowDetailRef.current) setMobileShowDetail(false); + } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [rejectReqId, showCreateTask, showCreateReq, showFilterSheet]); + }, [rejectReqId, showCreateTask, showCreateReq, showFilterSheet, rightDetailMode, isMobile]); const selectedProject = projects.find(p => p.id === selectedProjectId) ?? null; const selectedProjectTeamId = selectedProject?.teamIds?.[0] ?? null; - const settingsProject = selectedProject ?? projects.find(p => p.id === settingsProjectId) ?? null; - - const closeProjectSettings = useCallback(() => { - setShowProjectSettings(false); - setSettingsProjectId(null); - }, []); + const activeProjects = useMemo( + () => projects.filter(p => p.status !== 'archived'), + [projects], + ); - const openProjectSettings = useCallback((projectId: string) => { - if (showProjectSettings && (selectedProjectId === projectId || settingsProjectId === projectId)) { - closeProjectSettings(); + const openProjectDetail = useCallback(() => { + if (!selectedProjectId) return; + setSelectedTask(null); + setSelectedReq(null); + setRightDetailMode('project'); + setPanelWidthPref('auto'); + if (isMobile) setMobileShowDetail(true); + }, [selectedProjectId, isMobile]); + + const toggleProjectDetail = useCallback(() => { + if (rightDetailMode === 'project') { + setRightDetailMode('closed'); + if (isMobile) setMobileShowDetail(false); return; } - if (!selectedProjectId) setSettingsProjectId(projectId); - setShowProjectSettings(true); - }, [showProjectSettings, selectedProjectId, settingsProjectId, closeProjectSettings]); + openProjectDetail(); + }, [rightDetailMode, openProjectDetail, isMobile]); useEffect(() => { projectFilterRef.current = projectFilter; }, [projectFilter]); @@ -4481,11 +4564,15 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au }, [previewMode, previewData]); const handleSelectTask = useCallback((task: TaskInfo) => { + setNavFocus('items'); setSelectedTask(prev => { - if (prev?.id === task.id) { + if (prev?.id === task.id && rightDetailMode === 'item') { + setRightDetailMode('closed'); if (isMobile && mobileShowDetailRef.current) setMobileShowDetail(false); return null; } + setRightDetailMode('item'); + setPanelWidthPref('auto'); if (isMobile) { setMobileShowDetail(true); history.pushState({ mobileDetail: PAGE.WORK }, '', window.location.hash); @@ -4493,14 +4580,18 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au return task; }); setSelectedReq(null); - }, [isMobile]); + }, [isMobile, rightDetailMode]); const handleSelectReq = useCallback((req: RequirementInfo) => { + setNavFocus('items'); setSelectedReq(prev => { - if (prev?.id === req.id) { + if (prev?.id === req.id && rightDetailMode === 'item') { + setRightDetailMode('closed'); if (isMobile && mobileShowDetailRef.current) setMobileShowDetail(false); return null; } + setRightDetailMode('item'); + setPanelWidthPref('auto'); if (isMobile) { setMobileShowDetail(true); history.pushState({ mobileDetail: PAGE.WORK }, '', window.location.hash); @@ -4508,20 +4599,37 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au return req; }); setSelectedTask(null); - }, [isMobile]); + }, [isMobile, rightDetailMode]); const handleCloseDetail = useCallback(() => { setSelectedTask(null); setSelectedReq(null); + setRightDetailMode('closed'); if (isMobile && mobileShowDetailRef.current) { setMobileShowDetail(false); } }, [isMobile]); + const selectedTaskRef = useRef(selectedTask); + selectedTaskRef.current = selectedTask; + const selectedReqRef = useRef(selectedReq); + selectedReqRef.current = selectedReq; + const rightDetailModeRef = useRef(rightDetailMode); + rightDetailModeRef.current = rightDetailMode; + const navFocusRef = useRef(navFocus); + navFocusRef.current = navFocus; + const l1CollapsedRef = useRef(l1Collapsed); + l1CollapsedRef.current = l1Collapsed; + const boardTypeRef = useRef(boardType); + boardTypeRef.current = boardType; + const handleCloseTask = useCallback(() => { setSelectedTask(null); - if (!selectedReqRef.current && isMobile && mobileShowDetailRef.current) { - setMobileShowDetail(false); + if (!selectedReqRef.current) { + setRightDetailMode('closed'); + if (isMobile && mobileShowDetailRef.current) { + setMobileShowDetail(false); + } } }, [isMobile]); @@ -4532,29 +4640,13 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au setMobileShowDetail(false); setSelectedTask(null); setSelectedReq(null); + setRightDetailMode('closed'); } }; window.addEventListener('popstate', handler); return () => window.removeEventListener('popstate', handler); }, [isMobile]); - const selectedTaskRef = useRef(selectedTask); - selectedTaskRef.current = selectedTask; - const selectedReqRef = useRef(selectedReq); - selectedReqRef.current = selectedReq; - - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return; - const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; - if (selectedTaskRef.current) { setSelectedTask(null); return; } - if (selectedReqRef.current) { setSelectedReq(null); return; } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, []); - useEffect(() => { if (previewMode) return; if (!isActive) return; @@ -4663,13 +4755,13 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au } }, [allRequirements]); - // Ensure a navigated-to item's project is visible in the current filters + // Ensure a navigated-to item's project is selected in L1 const ensureProjectVisible = useCallback((projectId: string | undefined) => { if (!projectId) return; - const pf = projectFilterRef.current; - if (pf.size > 0 && !pf.has(projectId)) { - setProjectFilter(prev => new Set([...prev, projectId])); - } + setSelectedProjectId(projectId); + setViewMode('project'); + setProjectFilter(new Set()); + history.replaceState(null, '', hashPath(PAGE.WORK, projectId)); }, []); const [scrollToComments, setScrollToComments] = useState(false); @@ -4677,12 +4769,18 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au const forceOpenTask = useCallback((task: TaskInfo, opts?: { scrollToComments?: boolean }) => { setSelectedTask(task); setSelectedReq(null); + setRightDetailMode('item'); + setNavFocus('items'); + setPanelWidthPref('auto'); if (opts?.scrollToComments) setScrollToComments(true); if (isMobile) { setMobileShowDetail(true); } }, [isMobile]); const forceOpenReq = useCallback((req: RequirementInfo, opts?: { scrollToComments?: boolean }) => { setSelectedReq(req); setSelectedTask(null); + setRightDetailMode('item'); + setNavFocus('items'); + setPanelWidthPref('auto'); if (opts?.scrollToComments) setScrollToComments(true); if (isMobile) { setMobileShowDetail(true); } }, [isMobile]); @@ -4736,21 +4834,76 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au } }, [allRequirements, forceOpenReq, ensureProjectVisible]); + const selectProject = useCallback((projectId: string, opts?: { keepItemDetail?: boolean }) => { + setProjectFilter(new Set()); + setSelectedProjectId(projectId); + setViewMode('project'); + setNavFocus(l1Collapsed ? 'items' : 'projects'); + if (!opts?.keepItemDetail) { + setSelectedTask(null); + setSelectedReq(null); + setRightDetailMode(prev => (prev === 'closed' ? prev : 'project')); + } + history.replaceState(null, '', hashPath(PAGE.WORK, projectId)); + }, [l1Collapsed]); + + const selectAllProjects = useCallback((opts?: { keepItemDetail?: boolean }) => { + setProjectFilter(new Set()); + setSelectedProjectId(null); + setViewMode('all'); + setNavFocus(l1Collapsed ? 'items' : 'projects'); + if (!opts?.keepItemDetail) { + setSelectedTask(null); + setSelectedReq(null); + // No single project — close project detail; keep empty shell if panel was open. + setRightDetailMode(prev => { + if (prev === 'closed') return prev; + if (prev === 'project') return 'closed'; + return 'empty'; + }); + } + history.replaceState(null, '', hashPath(PAGE.WORK)); + }, [l1Collapsed]); + // Initial project selection from hash / localStorage (runs once on mount) + const didInitProject = useRef(false); useEffect(() => { + if (didInitProject.current) return; const hashParts = window.location.hash.slice(1).split('/'); if (resolvePageId(hashParts[0]) === PAGE.WORK && hashParts[1]) { - selectProject(hashParts[1]); - } else { - const navProjectId = localStorage.getItem('markus_nav_projectId'); - if (navProjectId) { - localStorage.removeItem('markus_nav_projectId'); - selectProject(navProjectId); - } + didInitProject.current = true; + selectProject(hashParts[1], { keepItemDetail: true }); + return; } + const navProjectId = localStorage.getItem('markus_nav_projectId'); + if (navProjectId) { + didInitProject.current = true; + localStorage.removeItem('markus_nav_projectId'); + selectProject(navProjectId, { keepItemDetail: true }); + return; + } + // Default: All projects + didInitProject.current = true; + selectAllProjects({ keepItemDetail: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // If a selected project disappears (deleted/archived), fall back to All. + useEffect(() => { + if (loading) return; + if (!selectedProjectId) { + didInitProject.current = true; + if (viewMode !== 'all') setViewMode('all'); + return; + } + if (activeProjects.some(p => p.id === selectedProjectId)) { + didInitProject.current = true; + return; + } + didInitProject.current = true; + selectAllProjects({ keepItemDetail: true }); + }, [loading, activeProjects, selectedProjectId, selectAllProjects, viewMode]); + // Hash change & custom navigation events const prevHashPageRef = useRef(resolvePageId(window.location.hash.slice(1).split('/')[0])); useEffect(() => { @@ -4758,13 +4911,12 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au const onHashChange = () => { const parts = window.location.hash.slice(1).split('/'); const newPage = resolvePageId(parts[0]); - const wasOnWork = prevHashPageRef.current === PAGE.WORK; prevHashPageRef.current = newPage; if (newPage !== PAGE.WORK) return; - if (newPage === PAGE.WORK && parts[1]) { - selectProject(parts[1]); - } else if (wasOnWork) { - selectAllTasks(); + if (parts[1]) { + selectProject(parts[1], { keepItemDetail: true }); + } else { + selectAllProjects({ keepItemDetail: true }); } }; @@ -4819,31 +4971,12 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au // ── Actions ── - const selectProject = (projectId: string) => { - if (projectFilterRef.current.size > 0) { - savedProjectFilterRef.current = new Set(projectFilterRef.current); - } - setProjectFilter(new Set()); - setSelectedProjectId(projectId); - setViewMode('project'); - setSettingsProjectId(null); - setShowProjectSettings(false); - history.replaceState(null, '', hashPath(PAGE.WORK, projectId)); - }; - - const selectAllTasks = () => { - setProjectFilter(savedProjectFilterRef.current); - setSelectedProjectId(null); - setViewMode('all'); - setSettingsProjectId(null); - setShowProjectSettings(false); - history.replaceState(null, '', hashPath(PAGE.WORK)); - }; - - const handleProjectCreated = () => { + const handleProjectCreated = (project: ProjectInfo) => { setShowCreateProject(false); msg(t('work:task.projectCreated')); - refreshProjects(); + setProjects(prev => (prev.some(p => p.id === project.id) ? prev : [...prev, project])); + selectProject(project.id); + void refreshProjects(); }; const handleDeleteProject = async (id: string) => { @@ -4852,7 +4985,9 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au // deletes appear to fire with no prompt. try { await api.projects.delete(id); - if (selectedProjectId === id) selectAllTasks(); + if (selectedProjectId === id) { + selectAllProjects(); + } msg(t('work:task.projectDeleted')); refreshProjects(); refreshBoard(); } catch (e) { msg(t('work:task.errorGeneric', { message: String(e) })); } @@ -5013,16 +5148,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au }; const toggleProjectFilter = (id: string) => { - if (viewMode === 'project') { - if (selectedProjectId === id) selectAllTasks(); - else selectProject(id); - } else { - setProjectFilter(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); - return next; - }); - } + selectProject(id); }; // ── Filter & display helpers ── @@ -5156,18 +5282,230 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au }, [board]); const totalTaskCount = Object.values(allTaskCounts).reduce((a, b) => a + b, 0); + const projectTaskCount = selectedProjectId ? (allTaskCounts[selectedProjectId] ?? 0) : totalTaskCount; + + // Flat navigable items for j/k (backlog + kanban) + const navigableItems = useMemo(() => { + const tasks = filterTasks(Object.values(board).flat()); + const items: Array<{ kind: 'req'; data: RequirementInfo } | { kind: 'task'; data: TaskInfo }> = []; + for (const r of filteredReqs) items.push({ kind: 'req', data: r }); + for (const t of tasks) items.push({ kind: 'task', data: t }); + return items; + }, [board, filteredReqs, showClosed, statusFilter, selectedProjectId, projectFilter, agentFilter, myTasksOnly, authUser?.id, viewMode]); + + const navigableItemsRef = useRef(navigableItems); + navigableItemsRef.current = navigableItems; + const activeProjectsRef = useRef(activeProjects); + activeProjectsRef.current = activeProjects; + const selectedProjectIdRef = useRef(selectedProjectId); + selectedProjectIdRef.current = selectedProjectId; + + const openItemDetail = useCallback((item: { kind: 'req'; data: RequirementInfo } | { kind: 'task'; data: TaskInfo }) => { + if (item.kind === 'req') { + setSelectedReq(item.data); + setSelectedTask(null); + } else { + setSelectedTask(item.data); + setSelectedReq(null); + } + setRightDetailMode('item'); + setPanelWidthPref('auto'); + setNavFocus('items'); + }, []); + + const openItemDetailOrEmpty = useCallback(() => { + if (rightDetailModeRef.current === 'item' || rightDetailModeRef.current === 'empty') { + setRightDetailMode('closed'); + setSelectedTask(null); + setSelectedReq(null); + return; + } + if (selectedTaskRef.current || selectedReqRef.current) { + setRightDetailMode('item'); + setPanelWidthPref('auto'); + setNavFocus('items'); + return; + } + const items = navigableItemsRef.current; + if (items.length > 0) { + openItemDetail(items[0]!); + return; + } + setSelectedTask(null); + setSelectedReq(null); + setRightDetailMode('empty'); + setPanelWidthPref('auto'); + }, [openItemDetail]); + + // When L1 collapses: force items focus; if project detail open, switch to latest item. + useEffect(() => { + if (isMobile) return; + if (l1Collapsed) { + setNavFocus('items'); + if (rightDetailModeRef.current === 'project') { + if (selectedTaskRef.current || selectedReqRef.current) { + setRightDetailMode('item'); + } else { + const items = navigableItemsRef.current; + if (items.length > 0) openItemDetail(items[0]!); + else setRightDetailMode('closed'); + } + } + } else if (navFocusRef.current === 'items' && rightDetailModeRef.current === 'closed') { + // keep items if user already tabbed; otherwise default projects when reopening L1 + } else if (!l1Collapsed && rightDetailModeRef.current === 'closed') { + setNavFocus('projects'); + } + }, [l1Collapsed, isMobile, openItemDetail]); + + // Tasks-page keyboard: Cmd+J/L, Ctrl+Tab, Tab focus, j/k navigation + useEffect(() => { + if (previewMode || isMobile || !isActive) return; + const isMac = navigator.platform.toUpperCase().includes('MAC'); + const onKey = (e: KeyboardEvent) => { + const mod = isMac ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey); + + // Ctrl+Tab (always Ctrl, even on Mac) cycles board views + if (e.ctrlKey && !e.metaKey && e.key === 'Tab') { + e.preventDefault(); + const order = BOARD_TYPE_ORDER; + const idx = order.indexOf(boardTypeRef.current); + const next = e.shiftKey + ? order[(idx - 1 + order.length) % order.length]! + : order[(idx + 1) % order.length]!; + setBoardType(next); + return; + } + + if (mod && !e.altKey && !e.shiftKey) { + const key = e.key.toLowerCase(); + if (key === 'j') { + e.preventDefault(); + toggleProjectDetail(); + return; + } + if (key === 'l') { + e.preventDefault(); + openItemDetailOrEmpty(); + return; + } + } + + if (mod || e.altKey || e.ctrlKey || e.metaKey) return; + if (isEditableTarget(e.target)) return; + + // Tab: L1 open → switch nav focus from projects to items + if (e.key === 'Tab' && !e.shiftKey && !l1CollapsedRef.current && navFocusRef.current === 'projects') { + e.preventDefault(); + setNavFocus('items'); + return; + } + + const move = e.key === 'j' || e.key === 'ArrowDown' ? 1 + : e.key === 'k' || e.key === 'ArrowUp' ? -1 + : 0; + if (!move) return; + + const focus = l1CollapsedRef.current ? 'items' : navFocusRef.current; + if (focus === 'projects') { + e.preventDefault(); + const list = activeProjectsRef.current; + // Index 0 = All, then each project + const len = list.length + 1; + if (list.length === 0) return; + const cur = selectedProjectIdRef.current == null + ? 0 + : list.findIndex(p => p.id === selectedProjectIdRef.current) + 1; + const base = cur < 0 ? 0 : cur; + const nextIdx = Math.max(0, Math.min(len - 1, base + move)); + if (nextIdx === 0) { + selectAllProjects(); + requestAnimationFrame(() => { + document.querySelector('[data-project-id="__all__"]')?.scrollIntoView({ block: 'nearest' }); + }); + } else { + const next = list[nextIdx - 1]!; + selectProject(next.id); + if (rightDetailModeRef.current !== 'closed') { + setRightDetailMode('project'); + setSelectedTask(null); + setSelectedReq(null); + } + requestAnimationFrame(() => { + document.querySelector(`[data-project-id="${next.id}"]`)?.scrollIntoView({ block: 'nearest' }); + }); + } + return; + } + + // items focus — only backlog / kanban + const bt = boardTypeRef.current; + if (bt !== 'backlog' && bt !== 'kanban') return; + e.preventDefault(); + const items = navigableItemsRef.current; + if (items.length === 0) { + setRightDetailMode(prev => (prev === 'closed' ? 'empty' : prev)); + return; + } + const curId = selectedTaskRef.current?.id ?? selectedReqRef.current?.id ?? null; + let idx = curId + ? items.findIndex(it => it.data.id === curId) + : -1; + if (idx < 0) idx = move > 0 ? -1 : 0; + const nextIdx = Math.max(0, Math.min(items.length - 1, idx + move)); + openItemDetail(items[nextIdx]!); + requestAnimationFrame(() => { + document.querySelector(`[data-work-item-id="${items[nextIdx]!.data.id}"]`)?.scrollIntoView({ block: 'nearest' }); + }); + }; + document.addEventListener('keydown', onKey, true); + return () => document.removeEventListener('keydown', onKey, true); + }, [ + previewMode, isMobile, isActive, toggleProjectDetail, openItemDetailOrEmpty, + selectProject, selectAllProjects, openItemDetail, + ]); if (loading) return
{t('work:task.loadingPage')}
; - const hasDetail = !!(selectedTask || selectedReq); - const dualDetail = !isMobile && !!(selectedTask && selectedReq); + const hasDetail = rightDetailMode !== 'closed' || !!(selectedTask || selectedReq); + const dualDetail = !isMobile && !!(selectedTask && selectedReq) && rightDetailMode === 'item'; + const showRightPanel = !isMobile ? hasDetail : mobileShowDetail; return (
- {/* ── Task Board + Project Context (left panel) ── */} + {/* ── L1 Projects sidebar (desktop) ── */} + {!isMobile && ( + { + selectAllProjects(); + setNavFocus('projects'); + }} + onSelectProject={(id) => { + selectProject(id); + setNavFocus('projects'); + }} + onCreateProject={() => setShowCreateProject(true)} + onCollapse={() => { + const next = !l1Collapsed; + setL1CollapsedPersisted(next); + layout?.setLeftCollapsed(next); + }} + width={projectSidebar.width} + onResizeStart={projectSidebar.onResizeStart} + hidden={l1Collapsed} + focused={navFocus === 'projects' && !l1Collapsed} + /> + )} + + {/* ── Task Board + Project Context (main) ── */}
{/* Flash */} {flash &&
{flash}
} @@ -5181,14 +5519,14 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au {selectedProject ? (
{selectedProject.name} -
) : (

- {projects.length === 1 ? projects[0].name : t('work:task.projectsCount', { count: projects.length })} + {t('work:task.projectsCount', { count: activeProjects.length })}

)}
@@ -5219,15 +5557,15 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au )} - {(projectFilter.size > 0 || agentFilter.size > 0 || myTasksOnly || projects.length > 1 || agents.length > 0) && ( + {(activeProjects.length > 1 || agents.length > 0 || authUser?.id) && ( )}
@@ -5236,25 +5574,25 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au
{/* Project title + settings */} {selectedProject ? ( -
+
{ await api.projects.update(selectedProject.id, { name } as Partial); refreshProjects(); }} className="text-sm font-semibold text-fg-primary" />
) : (

- {projects.length === 1 ? projects[0].name : t('work:task.projectsCount', { count: projects.length })} + {t('work:task.projectsCount', { count: activeProjects.length })}

)} {closedCount > 0 && ( @@ -5264,7 +5602,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au )} {/* View toggle */} -
+
{(['backlog', 'kanban', 'dag', 'workflows'] as const).map(v => (
{/* Actions */} -
- +
@@ -5284,44 +5621,8 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au
)} - {/* Project filter bar — desktop only (hidden in workflows view) */} - {!isMobile && boardType !== 'workflows' && projects.length > 1 && !selectedProjectId && !showProjectSettings && (totalTaskCount > 0 || allRequirements.length > 0) && ( -
- - {sortedProjects.map(p => { - const selected = projectFilter.has(p.id); - const count = allTaskCounts[p.id] ?? 0; - const editingThis = showProjectSettings && settingsProjectId === p.id; - return ( -
- - {selected && ( - - )} -
- ); - })} -
- )} - {/* Team/agent filter bar — desktop only (hidden in workflows view) */} - {!isMobile && boardType !== 'workflows' && (teamFilterItems.length > 0 || authUser?.id) && !showProjectSettings && (totalTaskCount > 0 || allRequirements.length > 0) && ( + {!isMobile && boardType !== 'workflows' && (teamFilterItems.length > 0 || authUser?.id) && (projectTaskCount > 0 || filteredReqs.length > 0 || agentFilter.size > 0 || myTasksOnly) && (
@@ -5375,93 +5676,50 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au
)} - {showProjectSettings && settingsProject ? ( -
- {!selectedProject && ( -
- - { await api.projects.update(settingsProject.id, { name } as Partial); refreshProjects(); }} - className="text-sm font-semibold text-fg-primary truncate min-w-0" - /> + {activeProjects.length === 0 ? ( +
+
+
+
- )} - handleDeleteProject(settingsProject.id)} - onUpdateProject={async (data) => { await api.projects.update(settingsProject.id, data); }} - onRefresh={() => { refreshProjects(); }} - /> -
- ) : totalTaskCount === 0 && filteredReqs.length === 0 && viewMode === 'project' && selectedProject ? ( -
-
-
- handleDeleteProject(selectedProject.id)} - onUpdateProject={async (data) => { await api.projects.update(selectedProject.id, data); }} - onRefresh={() => { refreshProjects(); }} - />
- ) : totalTaskCount === 0 && filteredReqs.length === 0 ? ( + ) : filterTasks(Object.values(board).flat()).length === 0 && filteredReqs.length === 0 ? (
- {projects.length === 0 ? ( -
-
- -
-

{t('work:task.emptyNoProjectsTitle')}

-

{t('work:task.emptyNoProjectsHint')}

- +
+
+
- ) : ( -
-

{t('work:task.emptyNoReqsHint')}

-
- {projects.map(p => ( - - ))} -
-
- -
-
- )} + + ) : ( + <> +

{t('work:task.emptyNoReqsTitle')}

+

{t('work:task.emptyNoReqsHint')}

+
+ + +
+ + )} +
) : boardType === 'backlog' ? (
@@ -5564,7 +5822,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au const creatorName = resolveActorName(req.createdBy, agents, users) ?? req.createdBy.slice(0, 10); const isSelected = selectedReq?.id === req.id; return ( -
onDragStartReq(e, req)} onDragEnd={onDragEnd} onClick={() => handleSelectReq(req)} onKeyDown={e => e.key === 'Enter' && handleSelectReq(req)} className={`group rounded-lg p-2.5 border border-transparent transition-all cursor-grab active:cursor-grabbing @@ -5615,7 +5873,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au return dep && dep.status === 'completed'; })); return ( -
!isApprovalTask && onDragStartTask(e, task)} onDragEnd={onDragEnd} onClick={() => handleSelectTask(task)} onKeyDown={e => e.key === 'Enter' && handleSelectTask(task)} className={`group rounded-lg p-2.5 border border-transparent transition-all ${ @@ -5678,17 +5936,53 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au
{/* Resize handle — desktop only, when detail is open */} - {!isMobile && hasDetail && ( -
+ {!isMobile && showRightPanel && ( +
)} {/* Detail panel(s) */} - {(!isMobile || mobileShowDetail) && hasDetail && ( -
- {dualDetail ? ( + {showRightPanel && ( +
+ {rightDetailMode === 'project' && selectedProject ? ( +
+
+

{t('work:project.settingsTitle')}

+ +
+ t.projectId === selectedProject.id)} + requirements={allRequirements.filter(r => r.projectId === selectedProject.id)} + agents={agents} + onDeleteProject={() => handleDeleteProject(selectedProject.id)} + onUpdateProject={async (data) => { await api.projects.update(selectedProject.id, data); refreshProjects(); }} + onRefresh={() => { refreshProjects(); }} + /> +
+ ) : rightDetailMode === 'empty' ? ( +
+
+
+ +
+

{t('work:task.emptyDetailTitle', { defaultValue: 'No item selected' })}

+

{t('work:task.emptyDetailHint', { defaultValue: 'Select a requirement or task from the list, or create one to get started.' })}

+
+ + +
+ +
+
+ ) : dualDetail ? ( <>
{ setSelectedReq(null); }} + onClose={() => { setSelectedReq(null); if (!selectedTask) handleCloseDetail(); }} onApprove={id => { handleApproveReq(id); }} onReject={id => { setRejectReqId(id); }} onCancel={id => { handleDeleteReq(id); setSelectedReq(null); }} @@ -5706,7 +6000,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au }} onRefresh={handleReqRefresh} authUser={authUser} - onTaskClick={task => { setSelectedTask(task); }} + onTaskClick={task => { setSelectedTask(task); setRightDetailMode('item'); }} onCreateTask={(reqId, projectId) => { setTaskRequirementId(reqId); if (projectId) setTaskProjectId(projectId); @@ -5730,7 +6024,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au authUser={authUser} scrollToComments={scrollToComments} onScrollToCommentsDone={() => setScrollToComments(false)} - onReqClick={req => { setSelectedReq(prev => prev?.id === req.id ? null : req); }} + onReqClick={req => { setSelectedReq(prev => prev?.id === req.id ? null : req); setRightDetailMode('item'); }} onProjectClick={toggleProjectFilter} />
@@ -5748,7 +6042,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au authUser={authUser} scrollToComments={scrollToComments} onScrollToCommentsDone={() => setScrollToComments(false)} - onReqClick={req => { setSelectedReq(prev => prev?.id === req.id ? null : req); }} + onReqClick={req => { setSelectedReq(req); setSelectedTask(null); setRightDetailMode('item'); }} onProjectClick={toggleProjectFilter} /> ) : selectedReq ? ( @@ -5771,6 +6065,8 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au authUser={authUser} onTaskClick={task => { setSelectedTask(task); + setSelectedReq(null); + setRightDetailMode('item'); if (isMobile) { setMobileShowDetail(true); history.pushState({ mobileDetail: PAGE.WORK }, '', window.location.hash); } }} onCreateTask={(reqId, projectId) => { @@ -5781,7 +6077,15 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au onProjectClick={toggleProjectFilter} previewMode={previewMode} /> - ) : null} + ) : ( +
+
+

{t('work:task.emptyDetailTitle')}

+

{t('work:task.emptyDetailHint')}

+ +
+
+ )}
)} @@ -6000,8 +6304,8 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au

{t('work:task.filtersSheetTitle')}

- {(projectFilter.size > 0 || agentFilter.size > 0 || myTasksOnly) && ( - )}
- {projects.length > 1 && ( + {activeProjects.length > 0 && (
{t('work:task.projectsFilterGroup')}
- {sortedProjects.map(p => { - const selected = projectFilter.has(p.id); + + {activeProjects.map(p => { + const selected = selectedProjectId === p.id; const count = allTaskCounts[p.id] ?? 0; return ( -
- - {selected && ( - - )} -
+ ); })}
From 923431fc12ad36d53bc3b34218dfebf9b7e8ae9e Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 5 Aug 2026 16:14:54 +0800 Subject: [PATCH 2/3] feat: add vim-style L0/L1 pane navigation with JK/HL shortcuts Enable app-rail focus on launch for immediate page switching, extend H/L across Team/Tasks/Output/Store/Settings, align Tasks JK with visual list order, and support Settings tab JK with H to exit. Co-authored-by: Cursor --- packages/web-ui/src/App.tsx | 120 +++++++++++++- .../web-ui/src/components/ChatTeamSidebar.tsx | 156 ++++++++++++++++-- .../src/components/ShortcutsHelpModal.tsx | 15 +- packages/web-ui/src/components/Sidebar.tsx | 18 +- .../web-ui/src/contexts/LayoutContext.tsx | 24 +++ packages/web-ui/src/lib/keyboard-shortcuts.ts | 55 ++++-- packages/web-ui/src/locales/en/common.json | 27 ++- packages/web-ui/src/locales/zh-CN/common.json | 27 ++- packages/web-ui/src/navBus.ts | 15 +- packages/web-ui/src/pages/Deliverables.tsx | 68 ++++++-- packages/web-ui/src/pages/Settings.tsx | 74 ++++++++- packages/web-ui/src/pages/Store.tsx | 57 ++++++- packages/web-ui/src/pages/Team.tsx | 37 +++++ packages/web-ui/src/pages/Work.tsx | 127 ++++++++++++-- packages/web-ui/src/routes.ts | 17 ++ 15 files changed, 746 insertions(+), 91 deletions(-) diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index 51e0dec2..30f5fb68 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useState, useCallback, useMemo, useRef, memo, lazy, Suspense } from 'react'; -import { type PageId, PAGE, resolvePageId, getPageFromHash, hashPath, pageToHash, MOBILE_REDIRECTS } from './routes.ts'; +import { type PageId, PAGE, resolvePageId, getPageFromHash, hashPath, pageToHash, MOBILE_REDIRECTS, L0_NAV_PAGES, PAGES_WITH_L1 } from './routes.ts'; import { setNativeBrowserPagePaintAllowed } from './lib/nativeBrowserOverlay.ts'; import { HomePage } from './pages/Home.tsx'; @@ -29,7 +29,7 @@ import { useTranslation } from 'react-i18next'; import { SearchModal } from './components/SearchModal.tsx'; import { ShortcutsHelpModal } from './components/ShortcutsHelpModal.tsx'; import { EditProfileModal } from './components/EditProfileModal.tsx'; -import { isXtermTarget } from './lib/keyboard-shortcuts.ts'; +import { isEditableTarget, isXtermTarget } from './lib/keyboard-shortcuts.ts'; import { knownTerminalIds, rememberTerminalId } from './lib/known-terminals.ts'; const HIDDEN_STYLE: React.CSSProperties = { @@ -136,6 +136,15 @@ export function App() { const layout = useLayout(); const leftCollapsed = layout?.leftCollapsed ?? false; const toggleLeftCollapsed = layout?.toggleLeftCollapsed; + const setLeftCollapsed = layout?.setLeftCollapsed; + const keyboardPane = layout?.keyboardPane ?? 'l0'; + const setKeyboardPane = layout?.setKeyboardPane; + const l0FocusPageId = layout?.l0FocusPageId ?? null; + const setL0FocusPageId = layout?.setL0FocusPageId; + const pageRef = useRef(page); + pageRef.current = page; + /** Page shown before entering Settings — H / Back restores it. */ + const pageBeforeSettingsRef = useRef(PAGE.HOME); const toggleRightPanel = layout?.toggleRightPanel; const toggleTerminalPanel = layout?.toggleTerminalPanel; const cycleRightPanelTab = layout?.cycleRightPanelTab; @@ -415,6 +424,21 @@ export function App() { if (curFull !== pageToHash(curBase)) _savedPageHashes[curBase] = curFull; else delete _savedPageHashes[curBase]; + const fromPage = pageRef.current; + // Remember origin before Settings so H / Back can restore it. + if (normalized === PAGE.SETTINGS && fromPage !== PAGE.SETTINGS) { + pageBeforeSettingsRef.current = fromPage; + navBus.setSettingsReturnPage(fromPage); + // Settings hides the app rail — land on its L1 tab sidebar for JK. + setKeyboardPane?.('l1'); + setL0FocusPageId?.(PAGE.SETTINGS); + } + // Leaving Settings → restore L0 focus so JK works immediately again. + if (fromPage === PAGE.SETTINGS && normalized !== PAGE.SETTINGS) { + setKeyboardPane?.('l0'); + setL0FocusPageId?.(normalized); + } + // Explicit settings tab (e.g. Hub status → Account) must win over a restored // previous settings sub-hash like #settings/appearance. if (normalized === PAGE.SETTINGS && params?.tab) { @@ -442,12 +466,98 @@ export function App() { window.dispatchEvent(new HashChangeEvent('hashchange')); setPage(normalized); setMountedPages(prev => prev.has(normalized) ? prev : new Set([...prev, normalized])); - }, [isMobile]); + }, [isMobile, setKeyboardPane, setL0FocusPageId]); useEffect(() => { navBus.setHandler((p, params) => navigate(p, params)); }, [navigate]); + // While on L0, keep the rail highlight aligned with the active page + // (e.g. Overview selected when the app opens on Overview / when entering L0). + useEffect(() => { + if (keyboardPane !== 'l0') return; + if (page === PAGE.SETTINGS) return; // Settings hides L0; don't steal focus id + setL0FocusPageId?.(page); + }, [keyboardPane, page, setL0FocusPageId]); + + // L0 app-rail + Settings H: j/k switch pages; L enters L1; H leaves Settings. + useEffect(() => { + if (isMobile || !setKeyboardPane || !setL0FocusPageId) return; + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (isEditableTarget(e.target)) return; + + const bare = e.key.length === 1 ? e.key.toLowerCase() : e.key; + + // Settings L1: H / ← returns to the previous page (app rail is hidden here). + if (page === PAGE.SETTINGS && (bare === 'h' || bare === 'ArrowLeft')) { + e.preventDefault(); + e.stopImmediatePropagation(); + navBus.leaveSettings(); + return; + } + + if (keyboardPane !== 'l0') return; + // While on Settings the app rail is hidden — tab JK is handled in Settings.tsx. + if (page === PAGE.SETTINGS) return; + + const focusId = (l0FocusPageId && L0_NAV_PAGES.includes(l0FocusPageId as PageId) + ? l0FocusPageId + : page) as PageId; + const idx = Math.max(0, L0_NAV_PAGES.indexOf(focusId)); + + if (bare === 'j' || bare === 'ArrowDown') { + e.preventDefault(); + e.stopImmediatePropagation(); + const next = L0_NAV_PAGES[Math.min(L0_NAV_PAGES.length - 1, idx + 1)]!; + setL0FocusPageId(next); + if (next !== page) navigate(next); + requestAnimationFrame(() => { + document.querySelector(`[data-l0-page-id="${next}"]`)?.scrollIntoView({ block: 'nearest' }); + }); + return; + } + if (bare === 'k' || bare === 'ArrowUp') { + e.preventDefault(); + e.stopImmediatePropagation(); + const next = L0_NAV_PAGES[Math.max(0, idx - 1)]!; + setL0FocusPageId(next); + if (next !== page) navigate(next); + requestAnimationFrame(() => { + document.querySelector(`[data-l0-page-id="${next}"]`)?.scrollIntoView({ block: 'nearest' }); + }); + return; + } + if (bare === 'l' || bare === 'ArrowRight' || bare === 'Enter') { + const target = (l0FocusPageId && L0_NAV_PAGES.includes(l0FocusPageId as PageId) + ? l0FocusPageId + : page) as PageId; + // L only enters L1 — page switch is already done by j/k. + if (!PAGES_WITH_L1.has(target)) { + e.preventDefault(); + e.stopImmediatePropagation(); + return; + } + e.preventDefault(); + e.stopImmediatePropagation(); + if (target !== page) navigate(target); + setLeftCollapsed?.(false); + setKeyboardPane('l1'); + return; + } + // H on L0: no further left pane + if (bare === 'h' || bare === 'ArrowLeft') { + e.preventDefault(); + e.stopImmediatePropagation(); + } + }; + document.addEventListener('keydown', onKey, true); + return () => document.removeEventListener('keydown', onKey, true); + }, [ + isMobile, keyboardPane, l0FocusPageId, page, navigate, + setKeyboardPane, setL0FocusPageId, setLeftCollapsed, + ]); + useEffect(() => { const openEdit = () => setShowEditProfile(true); window.addEventListener('markus:open-edit-profile', openEdit); @@ -696,11 +806,13 @@ export function App() { > { navigate(p); setSidebarOpen(false); }} + onNavigate={(p) => { navigate(p); setSidebarOpen(false); setKeyboardPane?.('content'); }} authUser={authUser} collapsed={sidebar.collapsed} onToggleCollapse={sidebar.toggle} onLogout={() => { api.auth.logout().catch(() => {}); clearHubAuth(); setAuthUser(null); }} + keyboardFocusPageId={l0FocusPageId} + keyboardPaneActive={keyboardPane === 'l0'} />
diff --git a/packages/web-ui/src/components/ChatTeamSidebar.tsx b/packages/web-ui/src/components/ChatTeamSidebar.tsx index c106057d..6a8bbb6a 100644 --- a/packages/web-ui/src/components/ChatTeamSidebar.tsx +++ b/packages/web-ui/src/components/ChatTeamSidebar.tsx @@ -16,6 +16,8 @@ import { OpenClawImportModal, } from './TeamModals.tsx'; import { Avatar } from './Avatar.tsx'; +import { useLayout } from '../contexts/LayoutContext.tsx'; +import { isEditableTarget } from '../lib/keyboard-shortcuts.ts'; // Module-level cache so last-message previews survive unmount/remount cycles on mobile let _lastMsgCache: Map = new Map(); @@ -24,6 +26,12 @@ let _lastMsgCache: Map = new Map(); type ChatMode = 'channel' | 'direct' | 'dm'; +type L1NavItem = + | { kind: 'dm'; id: string; userId: string } + | { kind: 'channel'; id: string; channelKey: string } + | { kind: 'agent'; id: string; agentId: string } + | { kind: 'team'; id: string; teamId: string }; + interface ChatTeamSidebarProps { authUser?: AuthUser; agents: AgentInfo[]; @@ -60,6 +68,8 @@ interface ChatTeamSidebarProps { initialLoading?: boolean; /** When true, auto-scroll to the Teams section on mount */ previewMode?: boolean; + /** Keyboard focus is on this L1 pane (H/L navigation). */ + focused?: boolean; } // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -204,10 +214,12 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({ width, onResizeStart, hidden, onCollapse, initialLoading, previewMode, + focused, }: ChatTeamSidebarProps) { const { t } = useTranslation(['team', 'common']); const isMobile = useIsMobile(); const isActive = usePageActive(PAGE.TEAM); + const layout = useLayout(); const isAdmin = authUser?.role === 'owner' || authUser?.role === 'admin'; const externalMarkusIds = useMemo(() => new Set(externalAgents.map(ea => ea.markusAgentId).filter(Boolean) as string[]), [externalAgents]); @@ -661,6 +673,7 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({ onPointerUp={handlePointerUp} >
{group.items.map(item => ( - diff --git a/packages/web-ui/src/pages/Store.tsx b/packages/web-ui/src/pages/Store.tsx index bb3625f6..45a316b0 100644 --- a/packages/web-ui/src/pages/Store.tsx +++ b/packages/web-ui/src/pages/Store.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { TemplateMarketplace, installHubItem } from './TemplateMarketplace.tsx'; import { TeamsStore } from './TeamsStore.tsx'; @@ -7,6 +7,10 @@ import { InstalledStore } from './InstalledStore.tsx'; import { StoreDiscovery } from './StoreDiscovery.tsx'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; import { useIsMobile } from '../hooks/useIsMobile.ts'; +import { usePageActive } from '../hooks/usePageActive.ts'; +import { useLayout } from '../contexts/LayoutContext.tsx'; +import { isEditableTarget } from '../lib/keyboard-shortcuts.ts'; +import { PAGE } from '../routes.ts'; import { hubApi, type AuthUser, type HubItem } from '../api.ts'; import type { AssetType } from '../lib/assetIdentity.ts'; @@ -162,8 +166,52 @@ export function StorePage({ authUser }: { authUser?: AuthUser }) { const [initial] = useState(peekInstallDeepLink); const [activeTab, setActiveTab] = useState(initial.tab); const isMobile = useIsMobile(); + const isActive = usePageActive(PAGE.STORE); + const layout = useLayout(); + const keyboardPane = layout?.keyboardPane ?? 'content'; const swipe = useSwipeTabs(tabs, activeTab, setActiveTab); const [highlightItemId, setHighlightItemId] = useState(initial.installId); + const activeTabRef = useRef(activeTab); + activeTabRef.current = activeTab; + + // Store L1 = tab rail. H → L0; j/k switch tabs; L focuses L1. + useEffect(() => { + if (isMobile || !isActive) return; + const onKey = (e: KeyboardEvent) => { + if (layout?.keyboardPane === 'l0') 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') { + e.preventDefault(); + layout?.setL0FocusPageId(PAGE.STORE); + layout?.setLeftCollapsed(false); + layout?.setKeyboardPane('l0'); + return; + } + if (bare === 'l' || bare === 'ArrowRight') { + e.preventDefault(); + layout?.setKeyboardPane('l1'); + return; + } + + const move = bare === 'j' || bare === 'ArrowDown' ? 1 + : bare === 'k' || bare === 'ArrowUp' ? -1 + : 0; + if (!move) return; + e.preventDefault(); + layout?.setKeyboardPane('l1'); + const idx = tabs.findIndex(tab => tab.id === activeTabRef.current); + const next = tabs[Math.max(0, Math.min(tabs.length - 1, idx + move))]!; + setActiveTab(next.id); + requestAnimationFrame(() => { + document.querySelector(`[data-store-tab-id="${next.id}"]`)?.scrollIntoView({ block: 'nearest' }); + }); + }; + document.addEventListener('keydown', onKey, true); + return () => document.removeEventListener('keydown', onKey, true); + }, [isMobile, isActive, layout]); // Consume after mount (once). Do not clear in useState init — StrictMode double-invokes it. useEffect(() => { @@ -241,17 +289,20 @@ export function StorePage({ authUser }: { authUser?: AuthUser }) { return (
-