singularity-forge/web/components/gsd/app-shell.tsx
Tom Boucher f4db25b9b8 fix(web): persist auth token in sessionStorage to survive page refreshes (#1877)
Next.js 16 auto-detects web/proxy.ts as middleware, gating all /api/*
routes behind bearer token validation. The token was only cached in
memory (lost on page refresh) and extracted from the URL hash fragment
(cleared after first extraction). This caused 401 errors on page
refresh and broke the sendBeacon shutdown call which cannot set
custom headers.

Changes:
- Persist the auth token to sessionStorage after extracting from the
  URL fragment so it survives page refreshes within the same tab
- Fall back to sessionStorage when the URL hash is absent (refresh,
  bookmark without hash)
- Pass the auth token as a _token query parameter in the sendBeacon
  shutdown call since sendBeacon cannot set Authorization headers
- Add regression tests for token persistence, sessionStorage fallback,
  and sendBeacon authentication

Fixes #1851

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:25:27 -06:00

468 lines
17 KiB
TypeScript

"use client"
import Image from "next/image"
import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from "react"
import { Sidebar, MilestoneExplorer, CollapsedMilestoneSidebar } from "@/components/gsd/sidebar"
import { ShellTerminal } from "@/components/gsd/shell-terminal"
import { Dashboard } from "@/components/gsd/dashboard"
import { Roadmap } from "@/components/gsd/roadmap"
import { FilesView } from "@/components/gsd/files-view"
import { ActivityView } from "@/components/gsd/activity-view"
import { VisualizerView } from "@/components/gsd/visualizer-view"
import { StatusBar } from "@/components/gsd/status-bar"
import { DualTerminal } from "@/components/gsd/dual-terminal"
import { FocusedPanel } from "@/components/gsd/focused-panel"
import { OnboardingGate } from "@/components/gsd/onboarding-gate"
import { CommandSurface } from "@/components/gsd/command-surface"
import { DevOverridesProvider } from "@/lib/dev-overrides"
import { ProjectStoreManagerProvider, useProjectStoreManager } from "@/lib/project-store-manager"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import {
GSDWorkspaceProvider,
getCurrentScopeLabel,
getProjectDisplayName,
getStatusPresentation,
getVisibleWorkspaceError,
useGSDWorkspaceState,
useGSDWorkspaceActions,
} from "@/lib/gsd-workspace-store"
import { ChatMode } from "@/components/gsd/chat-mode"
import { ScopeBadge } from "@/components/gsd/scope-badge"
import { Badge } from "@/components/ui/badge"
import { ProjectsPanel, ProjectSelectionGate } from "@/components/gsd/projects-view"
import { UpdateBanner } from "@/components/gsd/update-banner"
import { getAuthToken } from "@/lib/auth"
const KNOWN_VIEWS = new Set(["dashboard", "power", "chat", "roadmap", "files", "activity", "visualize"])
function viewStorageKey(projectCwd: string): string {
return `gsd-active-view:${projectCwd}`
}
function WorkspaceChrome() {
const [activeView, setActiveView] = useState("dashboard")
const [isTerminalExpanded, setIsTerminalExpanded] = useState(false)
const [terminalHeight, setTerminalHeight] = useState(300)
const [terminalDragActive, setTerminalDragActive] = useState(false)
const isDraggingTerminal = useRef(false)
const didDragTerminal = useRef(false)
const dragStartY = useRef(0)
const dragStartHeight = useRef(0)
const [sidebarWidth, setSidebarWidth] = useState(256)
const isDraggingSidebar = useRef(false)
const dragStartX = useRef(0)
const dragStartWidth = useRef(0)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [viewRestored, setViewRestored] = useState(false)
const [projectsPanelOpen, setProjectsPanelOpen] = useState(false)
const workspace = useGSDWorkspaceState()
const { refreshBoot } = useGSDWorkspaceActions()
const status = getStatusPresentation(workspace)
const projectPath = workspace.boot?.project.cwd
const projectLabel = getProjectDisplayName(projectPath)
const titleOverride = workspace.titleOverride?.trim() || null
const scopeLabel = getCurrentScopeLabel(workspace.boot?.workspace)
const visibleError = getVisibleWorkspaceError(workspace)
// Restore persisted view once boot provides projectCwd
useEffect(() => {
if (viewRestored || !projectPath) return
const restoreTimer = window.setTimeout(() => {
try {
const stored = sessionStorage.getItem(viewStorageKey(projectPath))
if (stored && KNOWN_VIEWS.has(stored)) {
setActiveView(stored)
}
} catch {
// sessionStorage may be unavailable (e.g. SSR, iframe sandbox)
}
setViewRestored(true)
}, 0)
return () => window.clearTimeout(restoreTimer)
}, [projectPath, viewRestored])
// Persist view changes to sessionStorage
useEffect(() => {
if (!projectPath) return
try {
sessionStorage.setItem(viewStorageKey(projectPath), activeView)
} catch {
// sessionStorage may be unavailable
}
}, [activeView, projectPath])
// Restore sidebar collapsed state from localStorage
useEffect(() => {
const restoreTimer = window.setTimeout(() => {
try {
const stored = localStorage.getItem("gsd-sidebar-collapsed")
if (stored === "true") setSidebarCollapsed(true)
} catch {
// localStorage may be unavailable
}
}, 0)
return () => window.clearTimeout(restoreTimer)
}, [])
// Persist sidebar collapsed state
useEffect(() => {
try {
localStorage.setItem("gsd-sidebar-collapsed", String(sidebarCollapsed))
} catch {
// localStorage may be unavailable
}
}, [sidebarCollapsed])
useEffect(() => {
if (typeof document === "undefined") return
const base = projectLabel ? `GSD - ${projectLabel}` : "GSD"
document.title = titleOverride ? `${titleOverride} · ${base}` : base
}, [titleOverride, projectLabel])
const handleViewChange = useCallback((view: string) => {
setActiveView(view)
}, [])
// Listen for cross-component file navigation events (e.g. sidebar task clicks)
useEffect(() => {
const handler = () => {
setActiveView("files")
}
window.addEventListener("gsd:open-file", handler)
return () => window.removeEventListener("gsd:open-file", handler)
}, [])
// Listen for cross-component view navigation events (e.g. /gsd visualize dispatch)
useEffect(() => {
const handler = (e: CustomEvent<{ view: string }>) => {
if (KNOWN_VIEWS.has(e.detail.view)) {
handleViewChange(e.detail.view)
}
}
window.addEventListener("gsd:navigate-view", handler as EventListener)
return () => window.removeEventListener("gsd:navigate-view", handler as EventListener)
}, [handleViewChange])
// Listen for projects panel toggle (sidebar icon, or programmatic)
useEffect(() => {
const handler = () => setProjectsPanelOpen(true)
window.addEventListener("gsd:open-projects", handler)
return () => window.removeEventListener("gsd:open-projects", handler)
}, [])
// Terminal + sidebar panel drag-to-resize
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDraggingTerminal.current) {
didDragTerminal.current = true
const delta = dragStartY.current - e.clientY
const newHeight = Math.max(150, Math.min(600, dragStartHeight.current + delta))
setTerminalHeight(newHeight)
}
if (isDraggingSidebar.current) {
const delta = dragStartX.current - e.clientX
const newWidth = Math.max(180, Math.min(480, dragStartWidth.current + delta))
setSidebarWidth(newWidth)
}
}
const handleMouseUp = () => {
isDraggingTerminal.current = false
isDraggingSidebar.current = false
setTerminalDragActive(false)
document.body.style.cursor = ""
document.body.style.userSelect = ""
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [])
const handleTerminalDragStart = useCallback(
(e: React.MouseEvent) => {
isDraggingTerminal.current = true
setTerminalDragActive(true)
dragStartY.current = e.clientY
dragStartHeight.current = terminalHeight
document.body.style.cursor = "row-resize"
document.body.style.userSelect = "none"
},
[terminalHeight],
)
const handleSidebarDragStart = useCallback(
(e: React.MouseEvent) => {
isDraggingSidebar.current = true
dragStartX.current = e.clientX
dragStartWidth.current = sidebarWidth
document.body.style.cursor = "col-resize"
document.body.style.userSelect = "none"
},
[sidebarWidth],
)
const retryDisabled = !!workspace.commandInFlight || workspace.onboardingRequestState !== "idle"
const isConnecting = workspace.bootStatus === "idle" || workspace.bootStatus === "loading"
// Persistent loading toast — dismissed the moment boot completes
useEffect(() => {
if (!isConnecting) return
const id = toast.loading("Connecting to workspace…", {
description: "Establishing the live bridge session",
duration: Infinity,
})
return () => {
toast.dismiss(id)
}
}, [isConnecting])
// Detect project welcome state — hide chrome for v1-legacy, brownfield, blank projects
const detection = workspace.boot?.projectDetection
const isWelcomeState =
!isConnecting &&
activeView === "dashboard" &&
detection != null &&
detection.kind !== "active-gsd" &&
detection.kind !== "empty-gsd"
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">
<div className="flex items-center gap-2">
<Image
src="/logo-black.svg"
alt="GSD"
width={57}
height={16}
className="shrink-0 h-4 w-auto dark:hidden"
/>
<Image
src="/logo-white.svg"
alt="GSD"
width={57}
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">
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}>
{isConnecting ? (
<Skeleton className="inline-block h-4 w-28 align-middle" />
) : (
<>
{projectLabel}
{titleOverride && (
<span
className="ml-2 inline-flex items-center rounded-full border border-foreground/15 bg-accent/60 px-2 py-0.5 text-[10px] font-medium text-foreground"
data-testid="workspace-title-override"
title={titleOverride}
>
{titleOverride}
</span>
)}
</>
)}
</span>
</div>
<div className="flex items-center 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"
data-testid="workspace-scope-label"
>
{isConnecting ? <Skeleton className="inline-block h-3.5 w-40 align-middle" /> : <ScopeBadge label={scopeLabel} size="sm" />}
</span>
</div>
</header>
<UpdateBanner />
{!isConnecting && visibleError && (
<div
className="flex items-center gap-3 border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive"
data-testid="workspace-error-banner"
>
<span className="flex-1">{visibleError}</span>
<button
onClick={() => void refreshBoot()}
disabled={retryDisabled}
className={cn(
"flex-shrink-0 rounded border border-destructive/30 bg-background px-2 py-0.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10",
retryDisabled && "cursor-not-allowed opacity-50",
)}
>
Retry
</button>
</div>
)}
<div className="flex flex-1 overflow-hidden">
<Sidebar activeView={activeView} onViewChange={isConnecting ? () => {} : handleViewChange} isConnecting={isConnecting} />
<div className="flex flex-1 flex-col overflow-hidden">
<div
className={cn(
"flex-1 overflow-hidden transition-all",
isTerminalExpanded && "h-1/3",
)}
>
{isConnecting ? (
<Dashboard />
) : (
<>
{activeView === "dashboard" && (
<Dashboard
onSwitchView={handleViewChange}
onExpandTerminal={() => setIsTerminalExpanded(true)}
/>
)}
{activeView === "power" && <DualTerminal />}
{activeView === "roadmap" && <Roadmap />}
{activeView === "files" && <FilesView />}
{activeView === "activity" && <ActivityView />}
{activeView === "visualize" && <VisualizerView />}
{activeView === "chat" && <ChatMode />}
</>
)}
</div>
{activeView !== "power" && activeView !== "chat" && (
<div className="border-t border-border flex flex-col" style={{ flexShrink: 0 }}>
{/* Drag handle + toggle header — entire bar is clickable */}
<div
role="button"
tabIndex={0}
onClick={() => {
if (didDragTerminal.current) {
didDragTerminal.current = false
return
}
if (!isConnecting) setIsTerminalExpanded(!isTerminalExpanded)
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
if (!isConnecting) setIsTerminalExpanded(!isTerminalExpanded)
}
}}
className={cn(
"flex h-8 w-full items-center justify-between bg-card px-3 text-xs select-none transition-colors",
isTerminalExpanded && "cursor-row-resize",
!isTerminalExpanded && !isConnecting && "cursor-pointer hover:bg-muted/50",
isConnecting && "cursor-default",
)}
onMouseDown={(e) => {
if (isTerminalExpanded) handleTerminalDragStart(e)
}}
>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="font-medium text-foreground">Terminal</span>
<span className="text-[10px] text-muted-foreground/50">
{isTerminalExpanded ? "▼" : "▲"}
</span>
</div>
</div>
{/* Terminal content */}
<div
className="overflow-hidden"
style={{ height: isTerminalExpanded ? terminalHeight : 0, transition: terminalDragActive ? "none" : "height 200ms" }}
>
<ShellTerminal className="h-full" projectCwd={workspace.boot?.project.cwd} />
</div>
</div>
)}
</div>
{/* Resizable milestone sidebar — hidden during project welcome */}
{!isWelcomeState && !sidebarCollapsed && (
<div
className="relative flex h-full items-stretch"
style={{ flexShrink: 0 }}
>
{/* Thin visible border */}
<div className="w-px bg-border" />
{/* Wide invisible grab area overlapping the border */}
<div
className="absolute left-[-3px] top-0 bottom-0 w-[7px] cursor-col-resize z-10 hover:bg-muted-foreground/20 transition-colors"
onMouseDown={handleSidebarDragStart}
/>
</div>
)}
{!isWelcomeState && (sidebarCollapsed ? (
<CollapsedMilestoneSidebar onExpand={() => setSidebarCollapsed(false)} />
) : (
<MilestoneExplorer
isConnecting={isConnecting}
width={sidebarWidth}
onCollapse={() => setSidebarCollapsed(true)}
/>
))}
</div>
<StatusBar />
<ProjectsPanel open={projectsPanelOpen} onOpenChange={setProjectsPanelOpen} />
<CommandSurface />
<FocusedPanel />
<OnboardingGate />
</div>
)
}
export function GSDAppShell() {
// Extract the auth token from the URL fragment on first render.
// Must happen before any API calls fire.
getAuthToken()
return (
<ProjectStoreManagerProvider>
<ProjectAwareWorkspace />
</ProjectStoreManagerProvider>
)
}
function ProjectAwareWorkspace() {
const manager = useProjectStoreManager()
const activeProjectCwd = useSyncExternalStore(manager.subscribe, manager.getSnapshot, manager.getSnapshot)
const activeStore = activeProjectCwd ? manager.getActiveStore() : null
// Shut down all projects when the tab actually closes
useEffect(() => {
const handlePageHide = () => {
// sendBeacon cannot set custom headers, so pass the auth token as a
// query parameter instead (the proxy accepts `_token` as a fallback).
const token = getAuthToken()
const url = token ? `/api/shutdown?_token=${token}` : "/api/shutdown"
navigator.sendBeacon(url, "")
}
window.addEventListener("pagehide", handlePageHide)
return () => {
window.removeEventListener("pagehide", handlePageHide)
}
}, [])
// No project selected yet — show project selection gate
if (!activeProjectCwd || !activeStore) {
return <ProjectSelectionGate />
}
return (
<GSDWorkspaceProvider store={activeStore}>
<DevOverridesProvider>
<WorkspaceChrome />
</DevOverridesProvider>
</GSDWorkspaceProvider>
)
}