singularity-forge/web/components/gsd/status-bar.tsx
Tom Boucher 5b0c24a92c 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>
2026-03-25 00:07:39 -06:00

163 lines
6.9 KiB
TypeScript

"use client"
import { useEffect, useState, useCallback } from "react"
import { GitBranch, Cpu, DollarSign, Clock, Zap, AlertTriangle, Wifi, Info, LifeBuoy } from "lucide-react"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import {
buildProjectUrl,
getCurrentBranch,
getCurrentScopeLabel,
getLiveAutoDashboard,
getLiveWorkspaceIndex,
getModelLabel,
getStatusPresentation,
getVisibleWorkspaceError,
useGSDWorkspaceState,
} from "@/lib/gsd-workspace-store"
import {
formatCost as formatProjectCost,
formatDuration as formatProjectDuration,
formatTokenCount,
type ProjectTotals,
} from "@/lib/visualizer-types"
import { ScopeBadgeInline } from "@/components/gsd/scope-badge"
import { authFetch } from "@/lib/auth"
function toneClass(tone: ReturnType<typeof getStatusPresentation>["tone"]): string {
switch (tone) {
case "success":
return "text-success"
case "warning":
return "text-warning"
case "danger":
return "text-destructive"
default:
return "text-muted-foreground"
}
}
export function StatusBar() {
const workspace = useGSDWorkspaceState()
const status = getStatusPresentation(workspace)
const liveWorkspace = getLiveWorkspaceIndex(workspace)
const auto = getLiveAutoDashboard(workspace)
const branch = getCurrentBranch(liveWorkspace) ?? "project scope"
const model = getModelLabel(workspace.boot?.bridge)
const unitLabel = auto?.currentUnit?.id ?? getCurrentScopeLabel(liveWorkspace)
const visibleError = getVisibleWorkspaceError(workspace)
const titleOverride = workspace.titleOverride?.trim() || null
const statusTexts = workspace.statusTexts
const recoverySummary = workspace.live.recoverySummary
const validationCount = getLiveWorkspaceIndex(workspace)?.validationIssues.length ?? 0
const statusTextEntries = Object.entries(statusTexts)
const latestStatusText = statusTextEntries.length > 0 ? statusTextEntries[statusTextEntries.length - 1][1] : null
const isConnecting = workspace.bootStatus === "idle" || workspace.bootStatus === "loading"
const projectCwd = workspace.boot?.project.cwd
// ── Project-level totals from visualizer API ──
const [projectTotals, setProjectTotals] = useState<ProjectTotals | null>(null)
const fetchProjectTotals = useCallback(async () => {
try {
const resp = await authFetch(buildProjectUrl("/api/visualizer", projectCwd))
if (!resp.ok) return
const json = await resp.json()
if (json.totals) setProjectTotals(json.totals)
} catch {
// Silently ignore — status bar is non-critical
}
}, [projectCwd])
useEffect(() => {
const timeout = window.setTimeout(() => {
void fetchProjectTotals()
}, 0)
const interval = window.setInterval(() => {
void fetchProjectTotals()
}, 30_000)
return () => {
window.clearTimeout(timeout)
window.clearInterval(interval)
}
}, [fetchProjectTotals])
return (
<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="hidden sm:flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3 w-3" />
{isConnecting ? (
<Skeleton className="h-3 w-20" />
) : (
<span className="font-mono">{branch}</span>
)}
</div>
<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" />
) : (
<span className="font-mono">{model}</span>
)}
</div>
{!isConnecting && (
<div className="hidden max-w-xs items-center gap-1.5 truncate text-muted-foreground xl:flex" data-testid="status-bar-retry-compaction">
<LifeBuoy className="h-3 w-3 shrink-0" />
<span className="truncate">
{recoverySummary.retryInProgress ? `Retry ${Math.max(1, recoverySummary.retryAttempt)}` : recoverySummary.isCompacting ? "Compacting" : recoverySummary.freshness}
</span>
</div>
)}
{!isConnecting && (
<div
className={cn("hidden items-center gap-1.5 xl:flex", validationCount > 0 ? "text-warning" : "text-muted-foreground")}
data-testid="status-bar-validation-count"
>
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>{validationCount} issue{validationCount === 1 ? "" : "s"}</span>
</div>
)}
{!isConnecting && visibleError && (
<div className="hidden max-w-sm items-center gap-1.5 truncate text-destructive lg:flex" data-testid="status-bar-error">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span className="truncate">{visibleError}</span>
</div>
)}
{!isConnecting && titleOverride && (
<div className="hidden max-w-xs items-center gap-1.5 truncate text-foreground/80 xl:flex" data-testid="status-bar-title-override">
<Info className="h-3 w-3 shrink-0" />
<span className="truncate" title={titleOverride}>{titleOverride}</span>
</div>
)}
{!isConnecting && latestStatusText && !visibleError && (
<div className="hidden max-w-xs items-center gap-1.5 truncate text-muted-foreground lg:flex" data-testid="status-bar-extension-status">
<Info className="h-3 w-3 shrink-0" />
<span className="truncate">{latestStatusText}</span>
</div>
)}
</div>
<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="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>
<div className="flex items-center gap-1.5 text-muted-foreground">
<DollarSign className="h-3 w-3" />
{isConnecting ? <Skeleton className="h-3 w-10" /> : <span>{formatProjectCost(projectTotals?.cost ?? auto?.totalCost ?? 0)}</span>}
</div>
<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>
</div>
)
}