From ffef8d4a79d477ebb9faa23ef0490a0be3ad5fa0 Mon Sep 17 00:00:00 2001 From: Ashish Huddar Date: Tue, 24 Mar 2026 18:37:53 +0530 Subject: [PATCH] =?UTF-8?q?feat(web):=20mobile=20accordion=20layout=20?= =?UTF-8?q?=E2=80=94=20urgency-first=20kanban=20on=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace vertical kanban stacking on mobile (<=767px) with a single-open accordion ordered Respond > Merge > Review > Pending > Working. Auto-expands the most urgent non-empty section on load; empty sections render as header-only rows (no dashed placeholder). Desktop layout is unchanged. Added window.matchMedia stub to the Vitest setup file so Dashboard unit tests pass in jsdom. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/__tests__/setup.ts | 18 ++++ packages/web/src/app/globals.css | 92 ++++++++++++++++++- packages/web/src/components/AttentionZone.tsx | 54 +++++++++++ packages/web/src/components/Dashboard.tsx | 60 +++++++++--- 4 files changed, 208 insertions(+), 16 deletions(-) diff --git a/packages/web/src/__tests__/setup.ts b/packages/web/src/__tests__/setup.ts index f149f27ae..533b774d3 100644 --- a/packages/web/src/__tests__/setup.ts +++ b/packages/web/src/__tests__/setup.ts @@ -1 +1,19 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom does not implement window.matchMedia. Provide a minimal stub so +// components that call useMediaQuery (e.g. Dashboard) work in unit tests. +// The stub always returns `false` (non-matching), which keeps tests in the +// desktop/non-mobile rendering path and avoids spurious re-renders. +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 3bf74e6ce..705c4474b 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1818,6 +1818,93 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { /* Card glow effects removed for Linear-clean look */ +/* ── Mobile accordion board ─────────────────────────────────────────── */ + +.accordion-board { + display: flex; + flex-direction: column; + gap: 0; + margin-top: 8px; +} + +.accordion-section { + border: 1px solid var(--color-border-default); + background: var(--color-bg-surface); + overflow: hidden; + transition: background 0.15s ease; +} + +.accordion-section + .accordion-section { + margin-top: 6px; +} + +.accordion-header { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 44px; + padding: 0 14px; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: var(--color-text-primary); + transition: background 0.12s ease; +} + +.accordion-header:hover { + background: var(--color-bg-hover); +} + +.accordion-header__dot { + width: 8px; + height: 8px; + border-radius: 999px; + flex-shrink: 0; +} + +.accordion-header__label { + font-size: 14px; + font-weight: 600; + flex: 1; + color: var(--color-text-primary); +} + +.accordion-header__count { + font-size: 12px; + font-weight: 500; + min-width: 20px; + padding: 2px 6px; + background: color-mix(in srgb, var(--color-border-default) 60%, transparent); + border-radius: 999px; + text-align: center; + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.accordion-header__chevron { + font-size: 10px; + color: var(--color-text-tertiary); + flex-shrink: 0; + transition: transform 0.25s ease; +} + +/* Accordion body: hidden when collapsed, visible when expanded */ +.accordion-body { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.25s ease; +} + +.accordion-section--collapsed .accordion-body { + grid-template-rows: 0fr; +} + +.accordion-section--collapsed .accordion-body > * { + overflow: hidden; +} + @media (max-width: 960px) { .dashboard-hero__content { padding: 12px 14px; @@ -1997,10 +2084,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { max-height: 400px; } + /* Hide the "Attention Board" heading + legend on mobile — accordion provides its own headers */ .board-section-head { - flex-direction: column; - align-items: flex-start; - gap: 8px; + display: none; } .board-section-head__legend { diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index 420b95744..764b40954 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -11,6 +11,10 @@ interface AttentionZoneProps { onKill?: (sessionId: string) => void; onMerge?: (prNumber: number) => void; onRestore?: (sessionId: string) => void; + /** Accordion mode: whether this section is collapsed (mobile only) */ + collapsed?: boolean; + /** Accordion mode: called when the header is tapped to toggle */ + onToggle?: () => void; } const zoneConfig: Record< @@ -56,6 +60,11 @@ const zoneConfig: Record< /** * Kanban column — always renders (even when empty) to preserve * the board shape. Cards scroll independently within each column. + * + * When `collapsed` and `onToggle` are provided the component renders + * in accordion mode (mobile): a 44 px tappable header only, with the + * card list hidden. Empty sections in accordion mode omit the dashed + * placeholder entirely — just the single-line header is shown. */ function AttentionZoneView({ level, @@ -64,8 +73,51 @@ function AttentionZoneView({ onKill, onMerge, onRestore, + collapsed, + onToggle, }: AttentionZoneProps) { const config = zoneConfig[level]; + const isAccordion = onToggle !== undefined; + + if (isAccordion) { + return ( +
+ + +
+ {sessions.length > 0 && ( +
+ {sessions.map((session) => ( + + ))} +
+ )} +
+
+ ); + } return (
@@ -105,6 +157,8 @@ function AttentionZoneView({ function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZoneProps): boolean { return ( prev.level === next.level && + prev.collapsed === next.collapsed && + prev.onToggle === next.onToggle && prev.onSend === next.onSend && prev.onKill === next.onKill && prev.onMerge === next.onMerge && diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index ad67cd9ce..cf0053523 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; import { type DashboardSession, type DashboardStats, @@ -32,6 +33,8 @@ interface DashboardProps { } const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const; +/** Urgency-first order for the mobile accordion (reversed from desktop) */ +const MOBILE_KANBAN_ORDER = ["respond", "merge", "review", "pending", "working"] as const; const EMPTY_ORCHESTRATORS: DashboardOrchestratorLink[] = []; function mergeOrchestrators( @@ -71,6 +74,8 @@ export function Dashboard({ const [spawnErrors, setSpawnErrors] = useState>({}); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const isMobile = useMediaQuery(767); + const [expandedLevel, setExpandedLevel] = useState(null); const showSidebar = projects.length > 1; const allProjectsView = showSidebar && projectId === undefined; @@ -102,6 +107,14 @@ export function Dashboard({ return zones; }, [displaySessions]); + // Auto-expand the most urgent non-empty section when switching to mobile + // or when session data first loads. Only runs when isMobile is true. + useEffect(() => { + if (!isMobile) return; + const firstNonEmpty = MOBILE_KANBAN_ORDER.find((level) => grouped[level].length > 0) ?? null; + setExpandedLevel(firstNonEmpty); + }, [isMobile, grouped]); + const sessionsByProject = useMemo(() => { const groupedSessions = new Map(); for (const session of sessions) { @@ -417,19 +430,40 @@ export function Dashboard({
-
- {KANBAN_LEVELS.map((level) => ( - - ))} -
+ + {isMobile ? ( +
+ {MOBILE_KANBAN_ORDER.map((level) => ( + + setExpandedLevel((current) => (current === level ? null : level)) + } + /> + ))} +
+ ) : ( +
+ {KANBAN_LEVELS.map((level) => ( + + ))} +
+ )} )}