diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 12080ce1..2f0a6ae4 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -308,6 +308,12 @@ app.whenReady().then(async () => { }, }; } + // data:/blob: URLs cannot be opened externally (macOS shows a broken + // "choose application" dialog). Deny without shell.openExternal — the + // renderer should preview these in-app instead. + if (url.startsWith('data:') || url.startsWith('blob:')) { + return { action: 'deny' }; + } // All other external URLs → system browser shell.openExternal(url); return { action: 'deny' }; diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index 00610187..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; @@ -368,13 +377,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 +394,7 @@ export function App() { return () => document.removeEventListener('keydown', onKey, true); }, [ isMobile, + page, toggleLeftCollapsed, toggleRightPanel, toggleTerminalPanel, @@ -412,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) { @@ -439,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); @@ -693,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'} /> @@ -759,7 +874,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/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} > + )} +

+ {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..5ca19a45 100644 --- a/packages/web-ui/src/components/ShortcutsHelpModal.tsx +++ b/packages/web-ui/src/components/ShortcutsHelpModal.tsx @@ -5,13 +5,33 @@ 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', 'navigation', 'tasks', 'team', 'search', 'rightPanel', 'terminal', 'help', +]; -export function ShortcutsHelpModal({ open, onClose }: { open: boolean; onClose: () => void }) { +function pageScope(page: PageId | undefined): 'team' | 'work' | 'deliverables' | 'store' | 'any' { + if (page === PAGE.TEAM) return 'team'; + if (page === PAGE.WORK) return 'work'; + if (page === PAGE.DELIVERABLES) return 'deliverables'; + if (page === PAGE.STORE) return 'store'; + 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 +72,14 @@ 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'; + // Always show global shortcuts; also show the current page's locals. + // On Overview/other pages (scope=any), only show `any` — not every page-local list. + if (p === 'any') return true; + return p === scope; + }); if (items.length === 0) return null; return (
@@ -66,7 +93,7 @@ export function ShortcutsHelpModal({ open, onClose }: { open: boolean; onClose: {t(s.labelKey ?? s.label, { defaultValue: s.label })} - {formatShortcutKeys(s.keys, isMac)} + {formatShortcutKeys(s.keys, isMac, s.bare)} ))} diff --git a/packages/web-ui/src/components/Sidebar.tsx b/packages/web-ui/src/components/Sidebar.tsx index 8817f177..437e76a8 100644 --- a/packages/web-ui/src/components/Sidebar.tsx +++ b/packages/web-ui/src/components/Sidebar.tsx @@ -11,6 +11,9 @@ interface Props { collapsed?: boolean; onToggleCollapse?: () => void; onLogout?: () => void; + /** Keyboard L0 focus highlight (j/k while pane is l0). */ + keyboardFocusPageId?: string | null; + keyboardPaneActive?: boolean; } function Icon({ d, size = 18 }: { d: string; size?: number }) { @@ -21,7 +24,10 @@ function Icon({ d, size = 18 }: { d: string; size?: number }) { ); } -export function Sidebar({ currentPage, onNavigate, authUser, collapsed, onToggleCollapse, onLogout }: Props) { +export function Sidebar({ + currentPage, onNavigate, authUser, collapsed, onToggleCollapse, onLogout, + keyboardFocusPageId, keyboardPaneActive, +}: Props) { const { t } = useTranslation(['nav', 'common']); return ( @@ -70,15 +76,19 @@ export function Sidebar({ currentPage, onNavigate, authUser, collapsed, onToggle ); } const isActive = currentPage === item.id; + const isKbFocus = keyboardPaneActive && keyboardFocusPageId === item.id; return (
{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 (
-
); } diff --git a/packages/web-ui/src/pages/Work.tsx b/packages/web-ui/src/pages/Work.tsx index 944c3136..0de722dd 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,8 +3109,9 @@ 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'}`} + 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/25 border-l-brand-500 ring-1 ring-inset ring-brand-500/35' : 'active:bg-surface-elevated/50'}`} >
{typeBadge} @@ -3143,11 +3153,12 @@ function BacklogRowView({ row, idx, dragIdx, agentMap, projMap, onTaskClick, onR return (
onRowDragStart(e, idx)} onDragEnd={onRowDragEnd} onClick={() => row.kind === 'task' ? onTaskClick(row.data) : onReqClick(row.data)} - className={`flex items-center gap-2 px-6 py-2 border-b border-border-default/40 cursor-pointer transition-colors border-l-2 ${GROUP_ACCENT[row.group] ?? 'border-l-gray-700'} ${dragIdx === idx ? 'opacity-40' : ''} ${selected ? 'bg-brand-500/10 border-l-brand-500 hover:bg-brand-500/15' : 'hover:bg-surface-elevated/50'}`} + className={`flex items-center gap-2 px-6 py-2 border-b border-border-default/40 cursor-pointer transition-colors border-l-2 ${GROUP_ACCENT[row.group] ?? 'border-l-gray-700'} ${dragIdx === idx ? 'opacity-40' : ''} ${selected ? 'bg-brand-500/25 border-l-brand-500 ring-1 ring-inset ring-brand-500/35 hover:bg-brand-500/30' : 'hover:bg-surface-elevated/50'}`} >
{typeBadge}
@@ -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,325 @@ 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 — matches visual order (mixed req+task by status/time). + const navigableItems = useMemo(() => { + const tasks = filterTasks(Object.values(board).flat()); + type NavItem = { kind: 'req'; data: RequirementInfo } | { kind: 'task'; data: TaskInfo }; + const items: NavItem[] = []; + + if (boardType === 'kanban') { + // Column order, then newest-first within each column (same as kanban render). + for (const col of BOARD_COLUMNS_BASE) { + const colTasks = col.statuses.flatMap(s => tasks.filter(t => t.status === s)); + const colReqs = filteredReqs.filter(r => REQ_COLUMN_MAP[r.status] === col.id); + type Mixed = + | { kind: 'req'; data: RequirementInfo; time: number } + | { kind: 'task'; data: TaskInfo; time: number }; + const mixed: Mixed[] = [ + ...colReqs.map(r => ({ kind: 'req' as const, data: r, time: new Date(r.updatedAt ?? r.createdAt).getTime() })), + ...colTasks.map(t => ({ kind: 'task' as const, data: t, time: new Date(t.updatedAt ?? t.createdAt ?? 0).getTime() })), + ]; + mixed.sort((a, b) => b.time - a.time); + for (const m of mixed) { + if (m.kind === 'req') items.push({ kind: 'req', data: m.data }); + else items.push({ kind: 'task', data: m.data }); + } + } + return items; + } + + // Backlog (and fallback): status group order, then updatedAt desc — same as BacklogTable. + type Row = + | { kind: 'req'; data: RequirementInfo; groupOrder: number; time: number } + | { kind: 'task'; data: TaskInfo; groupOrder: number; time: number }; + const rows: Row[] = []; + for (const t of tasks) { + const group = taskToGroup(t.status); + rows.push({ + kind: 'task', data: t, + groupOrder: GROUP_ORDER[group] ?? 5, + time: new Date(t.updatedAt ?? t.createdAt ?? 0).getTime(), + }); + } + for (const r of filteredReqs) { + const group = REQ_COLUMN_MAP[r.status] ?? 'todo'; + rows.push({ + kind: 'req', data: r, + groupOrder: GROUP_ORDER[group] ?? 5, + time: new Date(r.updatedAt ?? r.createdAt).getTime(), + }); + } + rows.sort((a, b) => { + if (a.groupOrder !== b.groupOrder) return a.groupOrder - b.groupOrder; + return b.time - a.time; + }); + return rows.map((r): NavItem => + r.kind === 'req' ? { kind: 'req', data: r.data } : { kind: 'task', data: r.data }, + ); + }, [board, filteredReqs, boardType, 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]); + + // Entering L1 from L0 (App H/L): expand project rail and focus it. + const keyboardPane = layout?.keyboardPane ?? 'content'; + useEffect(() => { + if (previewMode || isMobile || !isActive) return; + if (keyboardPane !== 'l1') return; + if (l1CollapsedRef.current) { + setL1CollapsedPersisted(false); + layout?.setLeftCollapsed(false); + } + setNavFocus('projects'); + }, [keyboardPane, previewMode, isMobile, isActive, setL1CollapsedPersisted, layout]); + + // Tasks-page keyboard: Cmd+J/L, Ctrl+Tab, H/L focus panes (content↔L1↔L0), j/k navigation + useEffect(() => { + if (previewMode || isMobile || !isActive) return; + const isMac = navigator.platform.toUpperCase().includes('MAC'); + const onKey = (e: KeyboardEvent) => { + // L0 app-rail owns j/k/h/l while focused + if (layout?.keyboardPane === 'l0') return; + + 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; + + const bare = e.key.length === 1 ? e.key.toLowerCase() : e.key; + + // H / ← : items → L1 → L0 + // L / → / Tab : L1 → items + if (bare === 'h' || bare === 'ArrowLeft') { + if (l1CollapsedRef.current) { + e.preventDefault(); + setL1CollapsedPersisted(false); + layout?.setLeftCollapsed(false); + setNavFocus('projects'); + layout?.setKeyboardPane('l1'); + return; + } + if (navFocusRef.current === 'items') { + e.preventDefault(); + setNavFocus('projects'); + layout?.setKeyboardPane('l1'); + return; + } + // Already on L1 → focus app rail (L0) + e.preventDefault(); + layout?.setL0FocusPageId(PAGE.WORK); + layout?.setLeftCollapsed(false); + layout?.setKeyboardPane('l0'); + return; + } + if ( + (bare === 'l' || bare === 'ArrowRight' || (bare === 'Tab' && !e.shiftKey)) + && !l1CollapsedRef.current + && navFocusRef.current === 'projects' + ) { + e.preventDefault(); + setNavFocus('items'); + layout?.setKeyboardPane('content'); + return; + } + + const move = bare === 'j' || bare === 'ArrowDown' ? 1 + : bare === 'k' || bare === 'ArrowUp' ? -1 + : 0; + if (!move) return; + + const focus = l1CollapsedRef.current ? 'items' : navFocusRef.current; + if (focus === 'projects') { + e.preventDefault(); + layout?.setKeyboardPane('l1'); + 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(); + layout?.setKeyboardPane('content'); + 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, setL1CollapsedPersisted, layout, + ]); 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 +5614,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 +5652,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 +5669,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 +5697,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au )} {/* View toggle */} -
+
{(['backlog', 'kanban', 'dag', 'workflows'] as const).map(v => (
{/* Actions */} -
- +
@@ -5284,44 +5716,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 +5771,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,12 +5917,12 @@ 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 ${needsReview ? 'bg-amber-500/[0.06] border-amber-500/30 ring-1 ring-amber-500/15' : 'bg-surface-elevated/80 hover:bg-surface-elevated border-border-default/50 hover:border-brand-400/40'} - ${isSelected ? 'ring-2 ring-brand-500/50 border-brand-500/40' : ''}`}> + ${isSelected ? '!bg-brand-500/25 !border-brand-500/50 ring-1 ring-brand-500/40' : ''}`}>
{t('work:task.requirementShort')} @@ -5615,7 +5968,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 ${ @@ -5626,7 +5979,7 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au : isSchedTask ? 'bg-blue-500/[0.04] border-blue-500/20 hover:border-blue-400/40 cursor-pointer' : 'bg-surface-elevated/80 hover:bg-surface-elevated border-border-default/50 hover:border-brand-400/40 cursor-grab active:cursor-grabbing' - } ${isSelected ? 'ring-2 ring-brand-500/50 border-brand-500/40' : ''}`}> + } ${isSelected ? '!bg-brand-500/25 !border-brand-500/50 ring-1 ring-brand-500/40' : ''}`}>
{isSchedTask && } @@ -5678,17 +6031,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 +6095,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 +6119,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 +6137,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 +6160,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 +6172,15 @@ export function WorkPage({ authUser, previewMode, previewData }: { authUser?: Au onProjectClick={toggleProjectFilter} previewMode={previewMode} /> - ) : null} + ) : ( +
+
+

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

+

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

+ +
+
+ )}
)} @@ -6000,8 +6399,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 && ( - - )} -
+ ); })}
diff --git a/packages/web-ui/src/routes.ts b/packages/web-ui/src/routes.ts index d696bd91..11affebf 100644 --- a/packages/web-ui/src/routes.ts +++ b/packages/web-ui/src/routes.ts @@ -131,6 +131,23 @@ export const SIDEBAR_NAV: Array<{ id: PageId; label: string; section: string }> { id: PAGE.SETTINGS, label: 'Settings', section: 'system' }, ]; +/** Pages that expose an L1 pane for H/L keyboard navigation with the app rail. */ +export const PAGES_WITH_L1: ReadonlySet = new Set([ + PAGE.TEAM, + PAGE.WORK, + PAGE.DELIVERABLES, + PAGE.STORE, + PAGE.SETTINGS, +]); + +/** + * Sidebar entries that participate in L0 j/k focus. + * Excludes notifications (popover). Settings is included — entering it focuses its L1 tab rail. + */ +export const L0_NAV_PAGES: PageId[] = SIDEBAR_NAV + .filter(i => i.id !== PAGE.NOTIFICATIONS) + .map(i => i.id); + // ── Mobile bottom nav structure ───────────────────────────────────────────── export type MobileTabId = PageId;