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:
parent
3522b54618
commit
5b0c24a92c
7 changed files with 394 additions and 38 deletions
144
src/tests/web-responsive.test.ts
Normal file
144
src/tests/web-responsive.test.ts
Normal 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')
|
||||
})
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue