feat(web): mobile accordion layout — urgency-first kanban on mobile

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 <noreply@anthropic.com>
This commit is contained in:
Ashish Huddar 2026-03-24 18:37:53 +05:30
parent 049ca42d85
commit ffef8d4a79
4 changed files with 208 additions and 16 deletions

View File

@ -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,
}),
});

View File

@ -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 {

View File

@ -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 (
<div
className={`accordion-section${collapsed ? " accordion-section--collapsed" : " accordion-section--expanded"}`}
data-level={level}
>
<button
type="button"
className="accordion-header"
onClick={onToggle}
aria-expanded={!collapsed}
>
<span className="accordion-header__dot" style={{ background: config.color }} />
<span className="accordion-header__label">{config.label}</span>
<span className="accordion-header__count">{sessions.length}</span>
<span className="accordion-header__chevron" aria-hidden="true">
{collapsed ? "▶" : "▼"}
</span>
</button>
<div className="accordion-body">
{sessions.length > 0 && (
<div className="flex flex-col gap-2 p-3">
{sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onSend={onSend}
onKill={onKill}
onMerge={onMerge}
onRestore={onRestore}
/>
))}
</div>
)}
</div>
</div>
);
}
return (
<div className="kanban-column" data-level={level}>
@ -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 &&

View File

@ -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<Record<string, string>>({});
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobile = useMediaQuery(767);
const [expandedLevel, setExpandedLevel] = useState<AttentionLevel | null>(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<string, DashboardSession[]>();
for (const session of sessions) {
@ -417,19 +430,40 @@ export function Dashboard({
<BoardLegendItem label="Ready to land" tone="var(--color-status-ready)" />
</div>
</div>
<div className="kanban-board">
{KANBAN_LEVELS.map((level) => (
<AttentionZone
key={level}
level={level}
sessions={grouped[level]}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
))}
</div>
{isMobile ? (
<div className="accordion-board">
{MOBILE_KANBAN_ORDER.map((level) => (
<AttentionZone
key={level}
level={level}
sessions={grouped[level]}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
collapsed={expandedLevel !== level}
onToggle={() =>
setExpandedLevel((current) => (current === level ? null : level))
}
/>
))}
</div>
) : (
<div className="kanban-board">
{KANBAN_LEVELS.map((level) => (
<AttentionZone
key={level}
level={level}
sessions={grouped[level]}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
))}
</div>
)}
</div>
)}