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:
parent
049ca42d85
commit
ffef8d4a79
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue