From a92e7d8a80e4743c25a0529517b5eae98ff7668b Mon Sep 17 00:00:00 2001 From: YiwenZhu Date: Thu, 16 Apr 2026 16:47:47 -0600 Subject: [PATCH 01/10] feat(web): add multi-session grid view with keyboard navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GridView component: 1-6 sessions displayed in adaptive grid (1×1, 2×1, 3×1, 2×2, 3+2, 3×2) or strip mode (all in one row) - Add SessionSearchModal for quickly adding/replacing sessions in grid - Add useGlobalKeyboard hook for global shortcuts (Cmd+;, Cmd+K, Cmd+Shift+F, Cmd+Shift+X, Cmd+1-9, Cmd+', Alt+hjkl) - Grid iframes share localStorage composer drafts (was sessionStorage) - Hide SessionHeader inside iframes to save vertical space - Floating overlay pill in each cell: title, folder, flavor, close btn - Toast notifications filtered per-session in grid iframes - Sidebar forced hidden in grid iframes regardless of viewport width - Route /grid added; grid icon + shortcut hint in sessions header via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- web/src/App.tsx | 10 +- web/src/components/GridView.tsx | 398 ++++++++++++++++++++++ web/src/components/SessionHeader.tsx | 4 +- web/src/components/SessionSearchModal.tsx | 176 ++++++++++ web/src/hooks/useGlobalKeyboard.ts | 102 ++++++ web/src/index.css | 6 + web/src/lib/composer-drafts.ts | 4 +- web/src/router.tsx | 69 +++- 8 files changed, 760 insertions(+), 9 deletions(-) create mode 100644 web/src/components/GridView.tsx create mode 100644 web/src/components/SessionSearchModal.tsx create mode 100644 web/src/hooks/useGlobalKeyboard.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ab0798c2..81228efe4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -230,14 +230,22 @@ function AppInner() { }, []) const handleSseEvent = useCallback(() => {}, []) + const isGridRoute = matchRoute({ to: '/grid' }) const handleToast = useCallback((event: ToastEvent) => { + // In the grid parent frame, suppress all toasts — each iframe handles its own + if (isGridRoute) return + // In grid view iframes, only show toasts for the session this iframe is displaying + const isInIframe = window.self !== window.top + if (isInIframe && event.data.sessionId && selectedSessionId && event.data.sessionId !== selectedSessionId) { + return + } addToast({ title: event.data.title, body: event.data.body, sessionId: event.data.sessionId, url: event.data.url }) - }, [addToast]) + }, [addToast, selectedSessionId, isGridRoute]) const eventSubscription = useMemo(() => { if (selectedSessionId) { diff --git a/web/src/components/GridView.tsx b/web/src/components/GridView.tsx new file mode 100644 index 000000000..562bdd649 --- /dev/null +++ b/web/src/components/GridView.tsx @@ -0,0 +1,398 @@ +import { useState, useCallback, useRef } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useGlobalKeyboard } from '@/hooks/useGlobalKeyboard' +import { SessionSearchModal } from '@/components/SessionSearchModal' +import type { Session } from '@/types/api' + +function getSessionTitle(session: Session): string { + if (session.metadata?.name) return session.metadata.name + if ((session.metadata as any)?.summary?.text) return (session.metadata as any).summary.text + if (session.metadata?.path) { + const parts = session.metadata.path.split('/').filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8) + } + return session.id.slice(0, 8) +} + +function getSessionFolder(session: Session): string { + const path = (session.metadata as any)?.worktree?.basePath ?? session.metadata?.path ?? '' + if (!path) return '' + const parts = path.split('/').filter(Boolean) + if (parts.length <= 1) return parts[0] ?? '' + return `${parts[parts.length - 2]}/${parts[parts.length - 1]}` +} + +function GridIcon() { + return ( + + + + + ) +} + +function BackIcon() { + return ( + + + + ) +} + +function CloseIcon() { + return ( + + + + ) +} + +type Props = { + sessions: Session[] + baseUrl: string + token: string +} + +export function GridView({ sessions, baseUrl, token }: Props) { + const navigate = useNavigate() + const [pinnedIds, setPinnedIds] = useState(() => + sessions.filter(s => s.active).slice(0, 4).map(s => s.id) + ) + const [expandedId, setExpandedId] = useState(null) + const [focusedIdx, setFocusedIdx] = useState(null) + const [isAddOpen, setIsAddOpen] = useState(false) + const [isReplaceOpen, setIsReplaceOpen] = useState(false) + const [replaceTargetIdx, setReplaceTargetIdx] = useState(null) + const [stripMode, setStripMode] = useState(false) + const iframeRefs = useRef<(HTMLIFrameElement | null)[]>([]) + + // ── mutable ref holding latest actions ────────────────────────────────── + // setupIframeKeyboard is registered once per iframe (onLoad); it would + // capture stale closures if we used callbacks directly. Instead we read + // actionsRef.current so the handler always sees fresh state. + const actionsRef = useRef({ + focusIframe: (_n: number) => {}, + moveFocus: (_dir: 'h' | 'j' | 'k' | 'l') => {}, + toggleStrip: () => {}, + goBack: () => {}, + openAddModal: () => {}, + openReplaceModal: () => {}, + closeCurrentCell: () => {}, + }) + + // Rebuild actionsRef on every render so it always closes over current state + actionsRef.current = { + focusIframe(n: number) { + const idx = n - 1 + const iframe = iframeRefs.current[idx] + if (!iframe) return + setFocusedIdx(idx) + try { + iframe.contentWindow?.focus() + const textarea = iframe.contentDocument?.querySelector('textarea') + textarea?.focus() + } catch { iframe.focus() } + }, + toggleStrip() { setStripMode(prev => !prev) }, + goBack() { navigate({ to: '/sessions' }) }, + moveFocus(dir: 'h' | 'j' | 'k' | 'l') { + const total = pinnedIds.length + if (total === 0) return + const current = focusedIdx ?? 0 + // Effective cols for navigation: treat 5-panel as 3-col + const navCols = total <= 1 ? 1 : total === 3 ? 3 : total <= 4 ? 2 : total === 5 ? 3 : 3 + let next = current + if (dir === 'h') next = current % navCols > 0 ? current - 1 : current + else if (dir === 'l') next = current % navCols < navCols - 1 && current + 1 < total ? current + 1 : current + else if (dir === 'k') next = current - navCols >= 0 ? current - navCols : (current - 1 + total) % total + else if (dir === 'j') next = current + navCols < total ? current + navCols : (current + 1) % total + if (next !== current) actionsRef.current.focusIframe(next + 1) + }, + openAddModal() { + setIsAddOpen(true) + }, + // idx: explicit index from iframe handler (-1 = unknown, fall back to focusedIdx) + openReplaceModal(idx?: number) { + const target = idx !== undefined && idx >= 0 ? idx : focusedIdx + setReplaceTargetIdx(target) + setIsReplaceOpen(true) + }, + closeCell(idx?: number) { + const target = idx !== undefined && idx >= 0 ? idx : focusedIdx + if (target === null || target === undefined) return + const id = pinnedIds[target] + if (!id) return + setPinnedIds(prev => prev.filter(p => p !== id)) + setExpandedId(prev => prev === id ? null : prev) + setFocusedIdx(null) + }, + } + // ──────────────────────────────────────────────────────────────────────── + + const addSession = useCallback((id: string) => { + setPinnedIds(prev => prev.includes(id) ? prev : [...prev.slice(-5), id]) + }, []) + + const removeSession = useCallback((id: string) => { + setPinnedIds(prev => prev.filter(p => p !== id)) + setExpandedId(prev => prev === id ? null : prev) + setFocusedIdx(null) + }, []) + + const replaceCell = useCallback((session: Session) => { + if (replaceTargetIdx === null) { + addSession(session.id) + return + } + setPinnedIds(prev => { + const next = [...prev] + next[replaceTargetIdx] = session.id + return next + }) + setFocusedIdx(replaceTargetIdx) + setReplaceTargetIdx(null) + }, [replaceTargetIdx, addSession]) + + // Inject keyboard listener into iframe — uses actionsRef so no stale closures. + // setupIframeKeyboard itself has no deps and is stable forever. + const setupIframeKeyboard = useCallback((iframe: HTMLIFrameElement) => { + const win = iframe.contentWindow + if (!win) return + + const handler = (e: KeyboardEvent) => { + // Alt+h/j/k/l — move focus between grid cells (vim-style) + if (e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey) { + const dir = e.code === 'KeyH' ? 'h' : e.code === 'KeyJ' ? 'j' : e.code === 'KeyK' ? 'k' : e.code === 'KeyL' ? 'l' : null + if (dir) { e.preventDefault(); e.stopPropagation(); actionsRef.current.moveFocus(dir as 'h'|'j'|'k'|'l'); return } + } + if (!e.metaKey) return + // Resolve which cell this iframe is — used for replace/close + const myIdx = iframeRefs.current.findIndex(ref => ref?.contentWindow === win) + // Cmd+; — go back to sessions list + if (e.key === ';' && !e.shiftKey) { + e.preventDefault(); e.stopPropagation() + actionsRef.current.goBack() + return + } + // Cmd+' — toggle strip/grid layout + if (e.key === "'" && !e.shiftKey) { + e.preventDefault(); e.stopPropagation() + actionsRef.current.toggleStrip() + return + } + // Cmd+K — add session to grid + if ((e.key === 'k' || e.key === 'K') && !e.shiftKey) { + e.preventDefault(); e.stopPropagation() + actionsRef.current.openAddModal() + return + } + // Cmd+Shift+F — replace THIS cell + if ((e.key === 'f' || e.key === 'F') && e.shiftKey) { + e.preventDefault(); e.stopPropagation() + actionsRef.current.openReplaceModal(myIdx) + return + } + // Cmd+Shift+X — close THIS cell + if ((e.key === 'x' || e.key === 'X') && e.shiftKey) { + e.preventDefault(); e.stopPropagation() + actionsRef.current.closeCell(myIdx) + return + } + // Cmd+1-9 — focus nth grid cell + const n = parseInt(e.key) + if (n >= 1 && n <= 9) { + e.preventDefault(); e.stopPropagation() + actionsRef.current.focusIframe(n) + } + } + + win.addEventListener('keydown', handler, true) + return () => win.removeEventListener('keydown', handler, true) + }, []) // stable — reads actionsRef.current at call time + + // Parent-frame shortcuts (fires when parent has focus, not inside an iframe) + useGlobalKeyboard(sessions, { + onSelectIndex: (n) => actionsRef.current.focusIframe(n), + onOpenSearch: () => actionsRef.current.openAddModal(), + onReplaceCell: () => actionsRef.current.openReplaceModal(), + onCloseCell: () => actionsRef.current.closeCell(), + onMoveFocus: (dir) => actionsRef.current.moveFocus(dir), + onToggleStrip: () => actionsRef.current.toggleStrip(), + }) + + const pinned = pinnedIds.map(id => sessions.find(s => s.id === id)).filter(Boolean) as Session[] + const unpinned = sessions.filter(s => !pinnedIds.includes(s.id)) + + // Strip mode: all panels in one row; otherwise adaptive grid + const isFiveLayout = !stripMode && pinned.length === 5 + const cols = stripMode + ? pinned.length || 1 + : pinned.length <= 1 ? 1 : pinned.length === 3 ? 3 : pinned.length <= 4 ? 2 : isFiveLayout ? 6 : 3 + const rows = stripMode ? 1 : isFiveLayout ? 2 : Math.ceil(pinned.length / cols) + + // Column span per item index for the 5-panel layout (not used in strip mode) + const getColSpan = (i: number) => isFiveLayout ? (i < 3 ? 2 : 3) : 1 + + const iframeUrl = (sessionId: string) => `/sessions/${sessionId}` + + return ( +
+ {/* Header */} +
+ + + Grid View + + ⌘; back · ⌘K add · ⌘⇧F replace · ⌘⇧X close · ⌘1-{Math.min(pinned.length || 9, 9)} focus · ⌥hjkl move · ⌘' {stripMode ? 'grid' : 'strip'} + + + {unpinned.length > 0 && pinnedIds.length < 6 && ( + + )} +
+ + {/* Grid body */} + {pinned.length === 0 ? ( +
+ + No sessions pinned. + {sessions.length > 0 && ( + Use "+ Add session" or ⌘K to pin sessions to the grid. + )} +
+ ) : expandedId ? ( +
+
+ + + {getSessionTitle(sessions.find(s => s.id === expandedId)!)} + + + {getSessionFolder(sessions.find(s => s.id === expandedId)!)} + +
+