"use client" 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/sf/sidebar" import { ShellTerminal } from "@/components/sf/shell-terminal" import { Dashboard } from "@/components/sf/dashboard" import { Roadmap } from "@/components/sf/roadmap" import { FilesView } from "@/components/sf/files-view" import { ActivityView } from "@/components/sf/activity-view" import { VisualizerView } from "@/components/sf/visualizer-view" import { StatusBar } from "@/components/sf/status-bar" import { DualTerminal } from "@/components/sf/dual-terminal" import { FocusedPanel } from "@/components/sf/focused-panel" import { OnboardingGate } from "@/components/sf/onboarding-gate" import { CommandSurface } from "@/components/sf/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/sf-workspace-store" import { ChatMode } from "@/components/sf/chat-mode" import { ScopeBadge } from "@/components/sf/scope-badge" import { Badge } from "@/components/ui/badge" import { ProjectsPanel, ProjectSelectionGate } from "@/components/sf/projects-view" import { UpdateBanner } from "@/components/sf/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 [mobileNavOpen, setMobileNavOpen] = useState(false) const [mobileMilestoneOpen, setMobileMilestoneOpen] = 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]) // Reset viewRestored when projectPath changes so the restore effect can // fire for the newly-selected project (fixes #2711: tab reset on switch). const prevProjectPath = useRef(projectPath) useEffect(() => { if (prevProjectPath.current !== projectPath) { prevProjectPath.current = projectPath setViewRestored(false) } }, [projectPath]) // 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]) // 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) 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" // --- Unauthenticated gate --- // Render a clear recovery screen before any workspace chrome is mounted so // users who open a manually-typed URL (no #token= fragment) get actionable // guidance instead of a cascade of 401 errors. if (workspace.bootStatus === "unauthenticated") { return (
GSD GSD

Authentication Required

This workspace requires an auth token. Copy the full URL from your terminal (including the{" "} #token=…{" "} part) or restart with{" "} gsd --web.

) } return (
{/* Mobile hamburger menu */}
GSD GSD beta
/ {isConnecting ? ( ) : ( <> {projectLabel} {titleOverride && ( {titleOverride} )} )}
{/* Hidden status marker for test instrumentation */} {status.label} {isConnecting ? : }
{!isConnecting && visibleError && (
{visibleError}
)} {/* Mobile navigation drawer */} {mobileNavOpen && (
setMobileNavOpen(false)} data-testid="mobile-nav-overlay" /> )}
{} : handleViewChange} isConnecting={isConnecting} mobile />
{/* Mobile milestone drawer */} {mobileMilestoneOpen && (
setMobileMilestoneOpen(false)} data-testid="mobile-milestone-overlay" /> )} {!isWelcomeState && (
setMobileMilestoneOpen(false)} />
)}
{/* Desktop sidebar — hidden on mobile */}
{} : handleViewChange} isConnecting={isConnecting} />
{isConnecting ? ( ) : ( <> {activeView === "dashboard" && ( setIsTerminalExpanded(true)} /> )} {activeView === "power" && } {activeView === "roadmap" && } {activeView === "files" && } {activeView === "activity" && } {activeView === "visualize" && } {activeView === "chat" && } )}
{activeView !== "power" && activeView !== "chat" && (
{/* Drag handle + toggle header — entire bar is clickable */}
{ 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) }} >
Terminal {isTerminalExpanded ? "▼" : "▲"}
{/* Terminal content */}
)}
{/* Resizable milestone sidebar — hidden on mobile, hidden during project welcome */} {!isWelcomeState && !sidebarCollapsed && (
{/* Thin visible border */}
{/* Wide invisible grab area overlapping the border */}
)}
{!isWelcomeState && (sidebarCollapsed ? ( setSidebarCollapsed(false)} /> ) : ( setSidebarCollapsed(true)} /> ))}
{/* Desktop status bar — hidden on mobile */}
{/* Mobile bottom bar — quick access to milestones + status */} {!isWelcomeState && (
{status.label} {scopeLabel}
)}
) } export function GSDAppShell() { // Extract the auth token from the URL fragment on first render. // Must happen before any API calls fire. getAuthToken() return ( ) } 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. // IMPORTANT: pagehide fires both on real page unload AND on mobile/Safari // tab switches (bfcache entry). When event.persisted is true the page is // being cached for later reuse — the server must stay alive. Only send // the shutdown beacon when the page is truly being discarded. useEffect(() => { const handlePageHide = (event: PageTransitionEvent) => { if (event.persisted) { // Page is entering bfcache (tab switch, app backgrounding) — keep // the server alive so PTY sessions survive. return } // 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 } return ( ) }