feat(web): make web UI mobile responsive (#2354)

* feat(web): make web UI mobile responsive

Fixes #2274

Add mobile-first responsive design to the GSD web UI:
- Viewport meta tag via Next.js Viewport export
- Collapsible sidebar as slide-out drawer on mobile with hamburger menu
- Milestone explorer as right-side drawer on mobile with bottom bar toggle
- Responsive header: hide project label, scope badge, beta badge on small screens
- Dashboard: responsive grid (1col mobile -> 2col sm -> 4col xl), responsive padding
- Status bar: hide secondary info on small screens, responsive text sizing
- Touch-friendly 44px minimum tap targets on mobile nav items
- Mobile CSS utilities in globals.css (overlay, drawer transitions)
- 19 structural tests verifying responsive classes exist in key components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: retrigger after stale check

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-25 02:07:39 -04:00 committed by GitHub
parent 3522b54618
commit 5b0c24a92c
7 changed files with 394 additions and 38 deletions

View file

@ -0,0 +1,144 @@
/**
* Structural tests verifying mobile-responsive CSS classes exist in key web UI components.
*
* These tests read the source files and assert that responsive Tailwind classes
* (md:, sm:, lg:, xl:) and mobile-specific markup are present where expected.
*/
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const WEB_ROOT = resolve(import.meta.dirname, '../../web')
function readComponent(relativePath: string): string {
return readFileSync(resolve(WEB_ROOT, relativePath), 'utf-8')
}
// ── layout.tsx ──────────────────────────────────────────────────────────────
test('layout.tsx exports a Viewport with device-width', () => {
const src = readComponent('app/layout.tsx')
assert.ok(src.includes("Viewport"), 'should import Viewport type from next')
assert.ok(src.includes("device-width"), 'should set width to device-width')
assert.ok(src.includes("maximumScale"), 'should set maximumScale for mobile')
})
// ── app-shell.tsx ───────────────────────────────────────────────────────────
test('app-shell.tsx has a mobile hamburger menu toggle', () => {
const src = readComponent('components/gsd/app-shell.tsx')
assert.ok(src.includes('mobile-nav-toggle'), 'should have mobile-nav-toggle test id')
assert.ok(src.includes('Menu'), 'should import Menu icon for hamburger')
})
test('app-shell.tsx hides desktop sidebar on mobile with md:flex', () => {
const src = readComponent('components/gsd/app-shell.tsx')
// The desktop sidebar wrapper should use hidden + md:flex
assert.ok(src.includes('hidden md:flex'), 'desktop sidebar should be hidden on mobile')
})
test('app-shell.tsx has a mobile nav drawer', () => {
const src = readComponent('components/gsd/app-shell.tsx')
assert.ok(src.includes('mobile-nav-drawer'), 'should have mobile-nav-drawer test id')
assert.ok(src.includes('mobile-nav-overlay'), 'should have mobile-nav-overlay test id')
})
test('app-shell.tsx has a mobile milestone drawer', () => {
const src = readComponent('components/gsd/app-shell.tsx')
assert.ok(src.includes('mobile-milestone-drawer'), 'should have mobile-milestone-drawer test id')
assert.ok(src.includes('mobile-milestone-toggle'), 'should have mobile-milestone-toggle test id')
})
test('app-shell.tsx has a mobile bottom bar', () => {
const src = readComponent('components/gsd/app-shell.tsx')
assert.ok(src.includes('mobile-bottom-bar'), 'should have mobile-bottom-bar test id')
})
test('app-shell.tsx header uses responsive padding', () => {
const src = readComponent('components/gsd/app-shell.tsx')
assert.ok(src.includes('md:px-4'), 'header should have responsive horizontal padding')
})
test('app-shell.tsx hides project label on small screens', () => {
const src = readComponent('components/gsd/app-shell.tsx')
assert.ok(src.includes('hidden sm:inline'), 'project label should be hidden on mobile')
})
test('app-shell.tsx hides desktop milestone sidebar on mobile', () => {
const src = readComponent('components/gsd/app-shell.tsx')
// The milestone sidebar resize handle should be hidden on mobile
assert.ok(
src.includes('hidden md:flex') || src.includes('hidden md:block'),
'milestone sidebar should be hidden on mobile',
)
})
// ── sidebar.tsx ──────────────────────────────────────────────────────────────
test('sidebar.tsx supports a mobile prop', () => {
const src = readComponent('components/gsd/sidebar.tsx')
assert.ok(src.includes('mobile?:'), 'Sidebar should accept a mobile prop')
assert.ok(src.includes('mobile?: boolean'), 'mobile prop should be boolean')
})
test('sidebar.tsx has a MobileNavPanel with touch-friendly targets', () => {
const src = readComponent('components/gsd/sidebar.tsx')
assert.ok(src.includes('mobile-nav-panel'), 'should have mobile-nav-panel test id')
assert.ok(src.includes('min-h-[44px]'), 'nav items should have 44px minimum touch target height')
})
// ── dashboard.tsx ───────────────────────────────────────────────────────────
test('dashboard.tsx has responsive grid for metric cards', () => {
const src = readComponent('components/gsd/dashboard.tsx')
assert.ok(src.includes('sm:grid-cols-2'), 'metric grid should stack to 2 cols on sm')
assert.ok(src.includes('xl:grid-cols-4'), 'metric grid should expand to 4 cols on xl')
})
test('dashboard.tsx has responsive padding on content area', () => {
const src = readComponent('components/gsd/dashboard.tsx')
assert.ok(src.includes('md:p-6'), 'content area should have responsive padding')
})
test('dashboard.tsx has responsive header padding', () => {
const src = readComponent('components/gsd/dashboard.tsx')
assert.ok(src.includes('md:px-6'), 'dashboard header should have responsive horizontal padding')
})
// ── status-bar.tsx ──────────────────────────────────────────────────────────
test('status-bar.tsx hides branch info on small screens', () => {
const src = readComponent('components/gsd/status-bar.tsx')
// Branch info should be hidden on mobile
assert.ok(
src.includes('hidden sm:flex'),
'branch info should use hidden sm:flex for responsive display',
)
})
test('status-bar.tsx has responsive text sizing', () => {
const src = readComponent('components/gsd/status-bar.tsx')
assert.ok(src.includes('md:text-xs'), 'status bar should have responsive text size')
})
test('status-bar.tsx has responsive gap spacing', () => {
const src = readComponent('components/gsd/status-bar.tsx')
assert.ok(src.includes('md:gap-4'), 'status bar should have responsive gap')
})
// ── globals.css ─────────────────────────────────────────────────────────────
test('globals.css has mobile touch target styles', () => {
const src = readComponent('../web/app/globals.css')
assert.ok(src.includes('max-width: 767px'), 'should have a mobile media query')
assert.ok(src.includes('mobile-touch-target'), 'should define mobile-touch-target class')
assert.ok(src.includes('min-height: 44px'), 'touch targets should be at least 44px')
})
test('globals.css has mobile sidebar drawer styles', () => {
const src = readComponent('../web/app/globals.css')
assert.ok(src.includes('mobile-sidebar-drawer'), 'should define mobile-sidebar-drawer class')
assert.ok(src.includes('mobile-sidebar-overlay'), 'should define mobile-sidebar-overlay class')
})

View file

@ -146,6 +146,39 @@
}
}
/* ── Mobile responsive: touch targets & safe areas ── */
@media (max-width: 767px) {
/* Ensure touch targets meet 44px minimum */
.mobile-touch-target {
min-height: 44px;
min-width: 44px;
}
/* Mobile overlay for sidebar drawer */
.mobile-sidebar-overlay {
position: fixed;
inset: 0;
z-index: 40;
background: oklch(0 0 0 / 0.5);
}
/* Mobile sidebar drawer */
.mobile-sidebar-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 50;
width: 260px;
transform: translateX(-100%);
transition: transform 200ms ease-out;
}
.mobile-sidebar-drawer.open {
transform: translateX(0);
}
}
/* ── File viewer: Shiki code blocks ── */
.file-viewer-code pre {
margin: 0;

View file

@ -1,4 +1,4 @@
import type { Metadata } from 'next'
import type { Metadata, Viewport } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import { Toaster } from '@/components/ui/sonner'
import { ThemeProvider } from '@/components/theme-provider'
@ -36,6 +36,13 @@ export const metadata: Metadata = {
},
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export default function RootLayout({
children,
}: Readonly<{

View file

@ -2,6 +2,7 @@
import Image from "next/image"
import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from "react"
import { Menu, X } from "lucide-react"
import { Sidebar, MilestoneExplorer, CollapsedMilestoneSidebar } from "@/components/gsd/sidebar"
import { ShellTerminal } from "@/components/gsd/shell-terminal"
import { Dashboard } from "@/components/gsd/dashboard"
@ -57,6 +58,8 @@ function WorkspaceChrome() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [viewRestored, setViewRestored] = useState(false)
const [projectsPanelOpen, setProjectsPanelOpen] = useState(false)
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false)
const workspace = useGSDWorkspaceState()
const { refreshBoot } = useGSDWorkspaceActions()
@ -122,8 +125,10 @@ function WorkspaceChrome() {
document.title = titleOverride ? `${titleOverride} · ${base}` : base
}, [titleOverride, projectLabel])
// Close mobile nav on view change
const handleViewChange = useCallback((view: string) => {
setActiveView(view)
setMobileNavOpen(false)
}, [])
// Listen for cross-component file navigation events (e.g. sidebar task clicks)
@ -232,8 +237,17 @@ function WorkspaceChrome() {
return (
<div className="relative flex h-screen flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border bg-card px-4">
<div className="flex items-center gap-3">
<header className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border bg-card px-2 md:px-4">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
{/* Mobile hamburger menu */}
<button
className="flex md:hidden h-10 w-10 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label={mobileNavOpen ? "Close navigation" : "Open navigation"}
data-testid="mobile-nav-toggle"
>
{mobileNavOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<div className="flex items-center gap-2">
<Image
src="/logo-black.svg"
@ -249,12 +263,12 @@ function WorkspaceChrome() {
height={16}
className="shrink-0 h-4 w-auto hidden dark:block"
/>
<Badge variant="outline" className="text-[10px] rounded-full border-foreground/15 bg-accent/40 text-muted-foreground font-normal">
<Badge variant="outline" className="hidden sm:inline-flex text-[10px] rounded-full border-foreground/15 bg-accent/40 text-muted-foreground font-normal">
beta
</Badge>
</div>
<span className="text-2xl font-thin text-muted-foreground/50 leading-none select-none">/</span>
<span className="text-sm text-muted-foreground" data-testid="workspace-project-cwd" title={projectPath ?? undefined}>
<span className="hidden sm:inline text-2xl font-thin text-muted-foreground/50 leading-none select-none">/</span>
<span className="hidden sm:inline text-sm text-muted-foreground truncate" data-testid="workspace-project-cwd" title={projectPath ?? undefined}>
{isConnecting ? (
<Skeleton className="inline-block h-4 w-28 align-middle" />
) : (
@ -274,11 +288,11 @@ function WorkspaceChrome() {
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 md:gap-3">
{/* Hidden status marker for test instrumentation */}
<span className="sr-only" data-testid="workspace-connection-status">{status.label}</span>
<span
className="text-xs text-muted-foreground"
className="hidden sm:inline text-xs text-muted-foreground"
data-testid="workspace-scope-label"
>
{isConnecting ? <Skeleton className="inline-block h-3.5 w-40 align-middle" /> : <ScopeBadge label={scopeLabel} size="sm" />}
@ -307,8 +321,53 @@ function WorkspaceChrome() {
</div>
)}
{/* Mobile navigation drawer */}
{mobileNavOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setMobileNavOpen(false)}
data-testid="mobile-nav-overlay"
/>
)}
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-64 transform bg-sidebar border-r border-border transition-transform duration-200 ease-out md:hidden",
mobileNavOpen ? "translate-x-0" : "-translate-x-full",
)}
data-testid="mobile-nav-drawer"
>
<Sidebar activeView={activeView} onViewChange={isConnecting ? () => {} : handleViewChange} isConnecting={isConnecting} mobile />
</div>
{/* Mobile milestone drawer */}
{mobileMilestoneOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setMobileMilestoneOpen(false)}
data-testid="mobile-milestone-overlay"
/>
)}
{!isWelcomeState && (
<div
className={cn(
"fixed inset-y-0 right-0 z-50 w-72 transform bg-sidebar border-l border-border transition-transform duration-200 ease-out md:hidden",
mobileMilestoneOpen ? "translate-x-0" : "translate-x-full",
)}
data-testid="mobile-milestone-drawer"
>
<MilestoneExplorer
isConnecting={isConnecting}
width={288}
onCollapse={() => setMobileMilestoneOpen(false)}
/>
</div>
)}
<div className="flex flex-1 overflow-hidden">
<Sidebar activeView={activeView} onViewChange={isConnecting ? () => {} : handleViewChange} isConnecting={isConnecting} />
{/* Desktop sidebar — hidden on mobile */}
<div className="hidden md:flex">
<Sidebar activeView={activeView} onViewChange={isConnecting ? () => {} : handleViewChange} isConnecting={isConnecting} />
</div>
<div className="flex flex-1 flex-col overflow-hidden">
<div
@ -384,10 +443,10 @@ function WorkspaceChrome() {
)}
</div>
{/* Resizable milestone sidebar — hidden during project welcome */}
{/* Resizable milestone sidebar — hidden on mobile, hidden during project welcome */}
{!isWelcomeState && !sidebarCollapsed && (
<div
className="relative flex h-full items-stretch"
className="relative hidden md:flex h-full items-stretch"
style={{ flexShrink: 0 }}
>
{/* Thin visible border */}
@ -399,18 +458,42 @@ function WorkspaceChrome() {
/>
</div>
)}
{!isWelcomeState && (sidebarCollapsed ? (
<CollapsedMilestoneSidebar onExpand={() => setSidebarCollapsed(false)} />
) : (
<MilestoneExplorer
isConnecting={isConnecting}
width={sidebarWidth}
onCollapse={() => setSidebarCollapsed(true)}
/>
))}
<div className="hidden md:flex">
{!isWelcomeState && (sidebarCollapsed ? (
<CollapsedMilestoneSidebar onExpand={() => setSidebarCollapsed(false)} />
) : (
<MilestoneExplorer
isConnecting={isConnecting}
width={sidebarWidth}
onCollapse={() => setSidebarCollapsed(true)}
/>
))}
</div>
</div>
<StatusBar />
{/* Desktop status bar — hidden on mobile */}
<div className="hidden md:block">
<StatusBar />
</div>
{/* Mobile bottom bar — quick access to milestones + status */}
{!isWelcomeState && (
<div className="flex md:hidden h-12 items-center justify-between border-t border-border bg-card px-3" data-testid="mobile-bottom-bar">
<div className="flex items-center gap-2 text-xs text-muted-foreground truncate">
<span className="sr-only" data-testid="workspace-connection-status-mobile">{status.label}</span>
<span className={cn("h-2 w-2 rounded-full shrink-0", status.tone === "success" ? "bg-success" : status.tone === "warning" ? "bg-warning" : status.tone === "danger" ? "bg-destructive" : "bg-muted-foreground")} />
<span className="truncate">{scopeLabel}</span>
</div>
<button
onClick={() => setMobileMilestoneOpen(!mobileMilestoneOpen)}
className="flex h-10 items-center gap-2 rounded-md px-3 text-xs font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
data-testid="mobile-milestone-toggle"
>
Milestones
</button>
</div>
)}
<ProjectsPanel open={projectsPanelOpen} onOpenChange={setProjectsPanelOpen} />
<CommandSurface />
<FocusedPanel />

View file

@ -181,18 +181,18 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Dashboard</h1>
<div className="flex items-center justify-between border-b border-border px-3 py-2 md:px-6 md:py-3">
<div className="flex items-center gap-2 min-w-0">
<h1 className="text-base md:text-lg font-semibold shrink-0">Dashboard</h1>
{!isConnecting && scopeLabel && (
<>
<span className="text-lg font-thin text-muted-foreground/40 select-none">/</span>
<ScopeBadge label={scopeLabel} size="sm" />
<span className="hidden sm:inline text-lg font-thin text-muted-foreground/40 select-none">/</span>
<span className="hidden sm:inline"><ScopeBadge label={scopeLabel} size="sm" /></span>
</>
)}
{isConnecting && <Skeleton className="h-4 w-40" />}
</div>
<div className="flex items-center gap-3" data-testid="dashboard-action-bar">
<div className="flex items-center gap-2 md:gap-3" data-testid="dashboard-action-bar">
{isConnecting ? (
<>
<Skeleton className="h-8 w-40 rounded-md" />
@ -220,8 +220,8 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="flex-1 overflow-y-auto p-3 md:p-6">
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-md border border-border bg-card p-4" data-testid="dashboard-current-unit">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">

View file

@ -698,12 +698,101 @@ interface SidebarProps {
activeView: string
onViewChange: (view: string) => void
isConnecting?: boolean
mobile?: boolean
}
export function Sidebar({ activeView, onViewChange, isConnecting = false }: SidebarProps) {
export function Sidebar({ activeView, onViewChange, isConnecting = false, mobile = false }: SidebarProps) {
if (mobile) {
return <MobileNavPanel activeView={activeView} onViewChange={onViewChange} isConnecting={isConnecting} />
}
return (
<div className="flex h-full">
<NavRail activeView={activeView} onViewChange={onViewChange} isConnecting={isConnecting} />
</div>
)
}
/* ─── Mobile Nav Panel (full-width labels for touch) ─── */
function MobileNavPanel({ activeView, onViewChange, isConnecting = false }: NavRailProps) {
const { openCommandSurface } = useGSDWorkspaceActions()
const { theme, setTheme } = useTheme()
const cycleTheme = () => {
if (theme === "system") setTheme("light")
else if (theme === "light") setTheme("dark")
else setTheme("system")
}
const themeLabel = theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System"
const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor
const navItems = [
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
{ id: "power", label: "Power Mode", icon: Columns2 },
{ id: "chat", label: "Chat", icon: MessagesSquare },
{ id: "roadmap", label: "Roadmap", icon: MapIcon },
{ id: "files", label: "Files", icon: Folder },
{ id: "activity", label: "Activity", icon: Activity },
{ id: "visualize", label: "Visualize", icon: BarChart3 },
]
return (
<div className="flex h-full flex-col bg-sidebar pt-14" data-testid="mobile-nav-panel">
<div className="flex-1 overflow-y-auto px-2 py-2">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => onViewChange(item.id)}
disabled={isConnecting}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm font-medium transition-colors min-h-[44px]",
isConnecting
? "cursor-not-allowed text-muted-foreground/30"
: activeView === item.id
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<item.icon className="h-5 w-5 shrink-0" />
{item.label}
</button>
))}
</div>
<div className="border-t border-border px-2 py-2 space-y-1">
<button
onClick={() => window.dispatchEvent(new CustomEvent("gsd:open-projects"))}
disabled={isConnecting}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors min-h-[44px]"
>
<FolderKanban className="h-5 w-5 shrink-0" />
Projects
</button>
<button
onClick={() => !isConnecting && openCommandSurface("git", { source: "sidebar" })}
disabled={isConnecting}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors min-h-[44px]"
>
<GitBranch className="h-5 w-5 shrink-0" />
Git
</button>
<button
onClick={() => !isConnecting && openCommandSurface("settings", { source: "sidebar" })}
disabled={isConnecting}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors min-h-[44px]"
>
<Settings className="h-5 w-5 shrink-0" />
Settings
</button>
<button
onClick={() => !isConnecting && cycleTheme()}
disabled={isConnecting}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors min-h-[44px]"
>
<ThemeIcon className="h-5 w-5 shrink-0" />
Theme: {themeLabel}
</button>
</div>
</div>
)
}

View file

@ -83,13 +83,13 @@ export function StatusBar() {
}, [fetchProjectTotals])
return (
<div className="flex h-7 items-center justify-between border-t border-border bg-card px-3 text-xs">
<div className="flex min-w-0 items-center gap-4">
<div className="flex h-7 items-center justify-between border-t border-border bg-card px-2 md:px-3 text-[10px] md:text-xs">
<div className="flex min-w-0 items-center gap-2 md:gap-4">
<div className={`flex items-center gap-1.5 ${toneClass(status.tone)}`}>
<Wifi className="h-3 w-3" />
<span>{status.label}</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="hidden sm:flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3 w-3" />
{isConnecting ? (
<Skeleton className="h-3 w-20" />
@ -97,7 +97,7 @@ export function StatusBar() {
<span className="font-mono">{branch}</span>
)}
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="hidden lg:flex items-center gap-1.5 text-muted-foreground">
<Cpu className="h-3 w-3" />
{isConnecting ? (
<Skeleton className="h-3 w-24" />
@ -141,12 +141,12 @@ export function StatusBar() {
</div>
)}
</div>
<div className="flex min-w-0 items-center gap-4">
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="flex min-w-0 items-center gap-2 md:gap-4">
<div className="hidden sm:flex items-center gap-1.5 text-muted-foreground">
<Clock className="h-3 w-3" />
{isConnecting ? <Skeleton className="h-3 w-8" /> : <span>{formatProjectDuration(projectTotals?.duration ?? auto?.elapsed ?? 0)}</span>}
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="hidden sm:flex items-center gap-1.5 text-muted-foreground">
<Zap className="h-3 w-3" />
{isConnecting ? <Skeleton className="h-3 w-6" /> : <span>{formatTokenCount(projectTotals?.tokens.total ?? auto?.totalTokens ?? 0)}</span>}
</div>
@ -154,7 +154,7 @@ export function StatusBar() {
<DollarSign className="h-3 w-3" />
{isConnecting ? <Skeleton className="h-3 w-10" /> : <span>{formatProjectCost(projectTotals?.cost ?? auto?.totalCost ?? 0)}</span>}
</div>
<span className="max-w-[20rem] truncate text-muted-foreground" data-testid="status-bar-unit">
<span className="hidden sm:inline max-w-[20rem] truncate text-muted-foreground" data-testid="status-bar-unit">
{isConnecting ? <Skeleton className="inline-block h-3 w-28 align-middle" /> : <ScopeBadgeInline label={unitLabel} />}
</span>
</div>