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