wip: rename gsd-parser dir + exports, fix native package.json

- packages/native/src/gsd-parser → packages/native/src/forge-parser
- Update packages/native/package.json exports: ./gsd-parser → ./forge-parser
- Update packages/native/src/index.ts imports: ./gsd-parser → ./forge-parser

Build in progress: native tsc output missing submodule dists (fd, text, image, etc).
This is a pre-existing issue with the build system, not caused by rebrand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ace-pm 2026-04-15 14:22:21 +02:00
parent 434bb527c4
commit 83feadb4e1
73 changed files with 197 additions and 27419 deletions

View file

@ -60,9 +60,9 @@
"types": "./dist/diff/index.d.ts",
"default": "./dist/diff/index.js"
},
"./gsd-parser": {
"types": "./dist/gsd-parser/index.d.ts",
"default": "./dist/gsd-parser/index.js"
"./forge-parser": {
"types": "./dist/forge-parser/index.d.ts",
"default": "./dist/forge-parser/index.js"
},
"./highlight": {
"types": "./dist/highlight/index.d.ts",

View file

@ -113,7 +113,7 @@ export {
extractAllSections,
batchParseGsdFiles,
parseRoadmapFile,
} from "./gsd-parser/index.js";
} from "./forge-parser/index.js";
export type {
BatchParseResult,
FrontmatterResult,
@ -122,7 +122,7 @@ export type {
NativeRoadmapSlice,
ParsedGsdFile,
SectionResult,
} from "./gsd-parser/index.js";
} from "./forge-parser/index.js";
export { truncateTail, truncateHead, truncateOutput } from "./truncate/index.js";
export type { TruncateResult, TruncateOutputResult } from "./truncate/index.js";

View file

@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
test("shared/mod.ts has no import from "@sf-run/pi-tui", () => {
test('shared/mod.ts has no import from "@sf-run/pi-tui"', () => {
const src = readFileSync(join(__dirname, "../../shared/mod.ts"), "utf-8");
assert.ok(!src.includes("@sf-run/pi-tui"), "mod.ts must not import "@sf-run/pi-tui");
assert.ok(!src.includes("@sf-run/pi-tui"), 'mod.ts must not import "@sf-run/pi-tui"');
});

View file

@ -2,8 +2,8 @@
import dynamic from "next/dynamic"
const GSDAppShell = dynamic(
() => import("@/components/gsd/app-shell").then((mod) => mod.GSDAppShell),
const SFAppShell = dynamic(
() => import("@/components/sf/app-shell").then((mod) => mod.SFAppShell),
{
ssr: false,
loading: () => (
@ -15,5 +15,5 @@ const GSDAppShell = dynamic(
)
export default function Page() {
return <GSDAppShell />
return <SFAppShell />
}

View file

@ -1,78 +0,0 @@
"use client"
import { CheckCircle2, Play, Clock, Terminal, AlertCircle } from "lucide-react"
import { cn } from "@/lib/utils"
import { useGSDWorkspaceState, type TerminalLineType } from "@/lib/gsd-workspace-store"
function EventIcon({ type }: { type: TerminalLineType }) {
const baseClass = "h-4 w-4"
switch (type) {
case "system":
return <Clock className={cn(baseClass, "text-info")} />
case "success":
return <CheckCircle2 className={cn(baseClass, "text-success")} />
case "error":
return <AlertCircle className={cn(baseClass, "text-destructive")} />
case "output":
return <Terminal className={cn(baseClass, "text-foreground")} />
case "input":
return <Play className={cn(baseClass, "text-warning")} />
default:
return <Clock className={cn(baseClass, "text-muted-foreground")} />
}
}
export function ActivityView() {
const workspace = useGSDWorkspaceState()
const terminalLines = workspace.terminalLines ?? []
// Show most recent events first
const reversedLines = [...terminalLines].reverse()
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold">Activity Log</h1>
<p className="text-sm text-muted-foreground">
Execution history and git operations
</p>
</div>
<div className="flex-1 overflow-y-auto">
{reversedLines.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No activity yet. Events will appear here once the workspace is active.
</div>
) : (
<div className="relative px-6 py-4">
{/* Timeline line */}
<div className="absolute left-10 top-6 bottom-6 w-px bg-border" />
<div className="space-y-4">
{reversedLines.map((line) => (
<div key={line.id} className="relative flex gap-4">
{/* Timeline dot */}
<div className="relative z-10 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border border-border bg-card">
<EventIcon type={line.type} />
</div>
{/* Content */}
<div className="flex-1 pt-0.5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">{line.content}</p>
</div>
<span className="flex-shrink-0 font-mono text-xs text-muted-foreground">
{line.timestamp}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -1,605 +0,0 @@
"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/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 [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 (
<div className="flex h-dvh flex-col items-center justify-center gap-6 bg-background p-8 text-center">
<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"
/>
<div className="flex flex-col items-center gap-2">
<h1 className="text-lg font-semibold text-foreground">Authentication Required</h1>
<p className="max-w-sm text-sm text-muted-foreground">
This workspace requires an auth token. Copy the full URL from your terminal
(including the{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">#token=</code>{" "}
part) or restart with{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">gsd --web</code>.
</p>
</div>
</div>
)
}
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-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"
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="hidden sm:inline-flex text-[10px] rounded-full border-foreground/15 bg-accent/40 text-muted-foreground font-normal">
beta
</Badge>
</div>
<span className="hidden sm:inline text-2xl font-thin text-muted-foreground 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" />
) : (
<>
{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-2 md:gap-3">
{/* Hidden status marker for test instrumentation */}
<span className="sr-only" data-testid="workspace-connection-status">{status.label}</span>
<span
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" />}
</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>
)}
{/* 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">
{/* 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
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">
{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 on mobile, hidden during project welcome */}
{!isWelcomeState && !sidebarCollapsed && (
<div
className="relative hidden md: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>
)}
<div className="hidden md:flex">
{!isWelcomeState && (sidebarCollapsed ? (
<CollapsedMilestoneSidebar onExpand={() => setSidebarCollapsed(false)} />
) : (
<MilestoneExplorer
isConnecting={isConnecting}
width={sidebarWidth}
onCollapse={() => setSidebarCollapsed(true)}
/>
))}
</div>
</div>
{/* 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 />
<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.
// 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 <ProjectSelectionGate />
}
return (
<GSDWorkspaceProvider store={activeStore}>
<DevOverridesProvider>
<WorkspaceChrome />
</DevOverridesProvider>
</GSDWorkspaceProvider>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,221 +0,0 @@
"use client"
import { useMemo } from "react"
import dynamic from "next/dynamic"
import { useTheme } from "next-themes"
import { Loader2 } from "lucide-react"
import { createTheme } from "@uiw/codemirror-themes"
import { tags as t } from "@lezer/highlight"
import { loadLanguage, type LanguageName } from "@uiw/codemirror-extensions-langs"
import { EditorView } from "@codemirror/view"
import { cn } from "@/lib/utils"
/* ── Dynamic import (no SSR — CodeMirror needs browser DOM) ── */
const ReactCodeMirror = dynamic(() => import("@uiw/react-codemirror"), {
ssr: false,
loading: () => (
<div className="flex h-full min-h-[120px] items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
),
})
/* ── Syntax highlighting styles ── */
const darkStyles = [
{ tag: [t.comment, t.lineComment, t.blockComment], color: "#6a737d" },
{ tag: [t.keyword], color: "#ff7b72" },
{ tag: [t.operator], color: "#79c0ff" },
{ tag: [t.string, t.special(t.string)], color: "#a5d6ff" },
{ tag: [t.number, t.bool, t.null], color: "#79c0ff" },
{ tag: [t.variableName], color: "#c9d1d9" },
{ tag: [t.definition(t.variableName)], color: "#d2a8ff" },
{ tag: [t.function(t.variableName)], color: "#d2a8ff" },
{ tag: [t.typeName, t.className], color: "#ffa657" },
{ tag: [t.propertyName], color: "#79c0ff" },
{ tag: [t.definition(t.propertyName)], color: "#c9d1d9" },
{ tag: [t.bracket], color: "#8b949e" },
{ tag: [t.punctuation], color: "#8b949e" },
{ tag: [t.tagName], color: "#7ee787" },
{ tag: [t.attributeName], color: "#79c0ff" },
{ tag: [t.attributeValue], color: "#a5d6ff" },
{ tag: [t.regexp], color: "#7ee787" },
{ tag: [t.escape], color: "#79c0ff" },
{ tag: [t.meta], color: "#8b949e" },
]
const lightStyles = [
{ tag: [t.comment, t.lineComment, t.blockComment], color: "#6a737d" },
{ tag: [t.keyword], color: "#cf222e" },
{ tag: [t.operator], color: "#0550ae" },
{ tag: [t.string, t.special(t.string)], color: "#0a3069" },
{ tag: [t.number, t.bool, t.null], color: "#0550ae" },
{ tag: [t.variableName], color: "#24292f" },
{ tag: [t.definition(t.variableName)], color: "#8250df" },
{ tag: [t.function(t.variableName)], color: "#8250df" },
{ tag: [t.typeName, t.className], color: "#953800" },
{ tag: [t.propertyName], color: "#0550ae" },
{ tag: [t.definition(t.propertyName)], color: "#24292f" },
{ tag: [t.bracket], color: "#57606a" },
{ tag: [t.punctuation], color: "#57606a" },
{ tag: [t.tagName], color: "#116329" },
{ tag: [t.attributeName], color: "#0550ae" },
{ tag: [t.attributeValue], color: "#0a3069" },
{ tag: [t.regexp], color: "#116329" },
{ tag: [t.escape], color: "#0550ae" },
{ tag: [t.meta], color: "#57606a" },
]
/* ── Static theme objects (module-level, never recreated on render) ── */
const darkTheme = createTheme({
theme: "dark",
settings: {
background: "oklch(0.09 0 0)",
foreground: "oklch(0.9 0 0)",
caret: "oklch(0.9 0 0)",
selection: "oklch(0.2 0 0)",
lineHighlight: "oklch(0.12 0 0)",
gutterBackground: "oklch(0.09 0 0)",
gutterForeground: "oklch(0.42 0 0)",
gutterBorder: "transparent",
},
styles: darkStyles,
})
const lightTheme = createTheme({
theme: "light",
settings: {
background: "oklch(0.98 0 0)",
foreground: "oklch(0.15 0 0)",
caret: "oklch(0.15 0 0)",
selection: "oklch(0.9 0 0)",
lineHighlight: "oklch(0.96 0 0)",
gutterBackground: "oklch(0.98 0 0)",
gutterForeground: "oklch(0.55 0 0)",
gutterBorder: "transparent",
},
styles: lightStyles,
})
/* ── Language mapping (shiki lang names → CodeMirror loadLanguage names) ── */
const CM_LANG_MAP: Record<string, LanguageName | null> = {
// TypeScript / JavaScript family
typescript: "ts",
tsx: "tsx",
javascript: "js",
jsx: "jsx",
// Shell variants
bash: "bash",
sh: "sh",
zsh: "sh",
// Data formats
json: "json",
jsonc: "json",
yaml: "yaml",
toml: "toml",
// Markup
markdown: "markdown",
mdx: "markdown", // CM has no mdx — use markdown
html: "html",
xml: "xml",
// Styles
css: "css",
scss: "scss",
less: "less",
// Systems
python: "py",
ruby: "rb",
rust: "rs",
go: "go",
java: "java",
kotlin: "kt",
swift: "swift",
c: "c",
cpp: "cpp",
csharp: "cs",
// Other
php: "php",
sql: "sql",
graphql: null, // CM has no graphql support
dockerfile: null, // CM has no dockerfile support
makefile: null, // CM has no makefile support
lua: "lua",
r: "r",
latex: "tex",
diff: "diff",
// No CM equivalent → plain text
viml: null,
dotenv: null,
fish: null,
ini: "ini",
}
/* ── Component ── */
interface CodeEditorProps {
value: string
onChange: (value: string) => void
language: string | null
fontSize: number
className?: string
}
export function CodeEditor({
value,
onChange,
language,
fontSize,
className,
}: CodeEditorProps) {
const { resolvedTheme } = useTheme()
const theme = resolvedTheme !== "light" ? darkTheme : lightTheme
// Resolve and cache language extension
const langExtension = useMemo(() => {
if (!language) return null
const cmName = CM_LANG_MAP[language]
if (cmName === undefined || cmName === null) return null
return loadLanguage(cmName)
}, [language])
// Font size extension
const fontSizeExt = useMemo(
() =>
EditorView.theme({
"&": { fontSize: `${fontSize}px` },
".cm-gutters": { fontSize: `${fontSize}px` },
}),
[fontSize],
)
// Combined extensions (memoized to avoid re-initialization)
const extensions = useMemo(() => {
const exts = [fontSizeExt]
if (langExtension) exts.push(langExtension)
return exts
}, [fontSizeExt, langExtension])
return (
<ReactCodeMirror
value={value}
onChange={onChange}
theme={theme}
extensions={extensions}
height="100%"
basicSetup={{
lineNumbers: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
foldGutter: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: false,
tabSize: 2,
}}
className={cn("overflow-hidden rounded-md border", className)}
/>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,442 +0,0 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import {
Activity,
Clock,
DollarSign,
Zap,
CheckCircle2,
Circle,
Play,
GitBranch,
TrendingDown,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
useGSDWorkspaceState,
useGSDWorkspaceActions,
buildPromptCommand,
buildProjectUrl,
formatDuration,
formatCost,
formatTokens,
getCurrentScopeLabel,
getCurrentBranch,
getCurrentSlice,
getLiveAutoDashboard,
getLiveWorkspaceIndex,
type WorkspaceTerminalLine,
type TerminalLineType,
} from "@/lib/gsd-workspace-store"
import { getTaskStatus, type ItemStatus } from "@/lib/workspace-status"
import { deriveWorkflowAction } from "@/lib/workflow-actions"
import { executeWorkflowActionInPowerMode } from "@/lib/workflow-action-execution"
import { Skeleton } from "@/components/ui/skeleton"
import {
CurrentSliceCardSkeleton,
ActivityCardSkeleton,
} from "@/components/gsd/loading-skeletons"
import { ScopeBadge } from "@/components/gsd/scope-badge"
import { ProjectWelcome } from "@/components/gsd/project-welcome"
import { authFetch } from "@/lib/auth"
import { type ProjectTotals } from "@/lib/visualizer-types"
/** Interpolate progress bar color from red (0%) through yellow (50%) to green (100%) using oklch. */
function getProgressColor(percent: number): string {
const p = Math.max(0, Math.min(100, percent))
// Hue: 25 (red) → 85 (yellow) at 50% → 145 (green) at 100%
const hue = 25 + (p / 100) * 120
return `oklch(0.65 0.16 ${hue.toFixed(1)})`
}
interface MetricCardProps {
label: string
value: string | null
subtext?: string | null
icon: React.ReactNode
}
function MetricCard({ label, value, subtext, icon }: MetricCardProps) {
return (
<div className="rounded-md border border-border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
{value === null ? (
<>
<Skeleton className="mt-2 h-7 w-20" />
<Skeleton className="mt-1.5 h-3 w-16" />
</>
) : (
<>
<p className="mt-1 truncate text-2xl font-semibold tracking-tight">{value}</p>
{subtext && <p className="mt-0.5 truncate text-xs text-muted-foreground">{subtext}</p>}
</>
)}
</div>
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">{icon}</div>
</div>
</div>
)
}
function taskStatusIcon(status: ItemStatus) {
switch (status) {
case "done":
return <CheckCircle2 className="h-4 w-4 text-muted-foreground" />
case "in-progress":
return <Play className="h-4 w-4 text-foreground" />
case "pending":
return <Circle className="h-4 w-4 text-muted-foreground" />
}
}
function activityDotColor(type: TerminalLineType): string {
switch (type) {
case "success":
return "bg-success"
case "error":
return "bg-destructive"
default:
return "bg-foreground/50"
}
}
interface DashboardProps {
onSwitchView?: (view: string) => void
onExpandTerminal?: () => void
}
export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {}) {
const state = useGSDWorkspaceState()
const { sendCommand } = useGSDWorkspaceActions()
const boot = state.boot
const workspace = getLiveWorkspaceIndex(state)
const auto = getLiveAutoDashboard(state)
const bridge = boot?.bridge ?? null
const freshness = state.live.freshness
const projectCwd = boot?.project.cwd
// ── Project-level totals from visualizer API ──
// Provides fallback metrics when auto-mode is not active (#2709).
// Same polling pattern as status-bar.tsx.
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 — dashboard metrics are 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])
const elapsed = projectTotals?.duration ?? auto?.elapsed ?? 0
const totalCost = projectTotals?.cost ?? auto?.totalCost ?? 0
const totalTokens = projectTotals?.tokens.total ?? auto?.totalTokens ?? 0
const rtkSavings = auto?.rtkSavings ?? null
const rtkEnabled = auto?.rtkEnabled === true
const currentSlice = getCurrentSlice(workspace)
const doneTasks = currentSlice?.tasks.filter((t) => t.done).length ?? 0
const totalTasks = currentSlice?.tasks.length ?? 0
const progressPercent = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0
const scopeLabel = getCurrentScopeLabel(workspace)
const branch = getCurrentBranch(workspace)
const isAutoActive = auto?.active ?? false
const currentUnitLabel = auto?.currentUnit?.id ?? scopeLabel
const currentUnitFreshness = freshness.auto.stale ? "stale" : freshness.auto.status
const workflowAction = deriveWorkflowAction({
phase: workspace?.active.phase ?? "pre-planning",
autoActive: auto?.active ?? false,
autoPaused: auto?.paused ?? false,
onboardingLocked: boot?.onboarding.locked ?? false,
commandInFlight: state.commandInFlight,
bootStatus: state.bootStatus,
hasMilestones: (workspace?.milestones.length ?? 0) > 0,
projectDetectionKind: boot?.projectDetection?.kind ?? null,
})
const handleWorkflowAction = (command: string) => {
executeWorkflowActionInPowerMode({
dispatch: () => sendCommand(buildPromptCommand(command, bridge)),
})
}
const handlePrimaryAction = () => {
if (!workflowAction.primary) return
handleWorkflowAction(workflowAction.primary.command)
}
const recentLines: WorkspaceTerminalLine[] = (state.terminalLines ?? []).slice(-6)
const isConnecting = state.bootStatus === "idle" || state.bootStatus === "loading"
const rtkValue = isConnecting ? null : formatTokens(rtkSavings?.savedTokens ?? 0)
const rtkSubtext = isConnecting
? null
: rtkSavings && rtkSavings.commands > 0
? `${Math.round(rtkSavings.savingsPct)}% saved • ${rtkSavings.commands} cmd${rtkSavings.commands === 1 ? "" : "s"}`
: "Waiting for shell usage"
// ─── Project Welcome Gate ───────────────────────────────────────────
// Show welcome screen for projects that aren't initialized with GSD yet
const detection = boot?.projectDetection
const showWelcome =
!isConnecting &&
detection &&
detection.kind !== "active-gsd" &&
detection.kind !== "empty-gsd"
if (showWelcome) {
return (
<div className="flex h-full flex-col overflow-hidden">
<ProjectWelcome
detection={detection}
onCommand={(cmd) => handleWorkflowAction(cmd)}
onSwitchView={(view) => onSwitchView?.(view)}
disabled={!!state.commandInFlight || boot?.onboarding.locked}
/>
</div>
)
}
return (
<div className="flex h-full flex-col overflow-hidden">
<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="hidden sm:inline text-lg font-thin text-muted-foreground 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-2 md:gap-3" data-testid="dashboard-action-bar">
{isConnecting ? (
<>
<Skeleton className="h-8 w-40 rounded-md" />
</>
) : null}
{!isConnecting && (
<div className="flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1.5 text-sm">
<span
className={cn(
"h-2 w-2 rounded-full",
isAutoActive ? "animate-pulse bg-success" : "bg-muted-foreground/50",
)}
/>
<span className="font-medium">
{isAutoActive ? "Auto Mode Active" : "Auto Mode Inactive"}
</span>
</div>
)}
{!isConnecting && branch && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<GitBranch className="h-4 w-4" />
<span className="font-mono">{branch}</span>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 md:p-6">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<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">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Current Unit</p>
{isConnecting ? (
<>
<Skeleton className="mt-2 h-7 w-20" />
<Skeleton className="mt-1.5 h-3 w-16" />
</>
) : (
<>
<div className="mt-2">
<ScopeBadge label={currentUnitLabel} />
</div>
<p className="mt-1.5 text-xs text-muted-foreground" data-testid="dashboard-current-unit-freshness">
Auto freshness: {currentUnitFreshness}
</p>
</>
)}
</div>
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">
<Activity className="h-5 w-5" />
</div>
</div>
</div>
<MetricCard
label="Elapsed Time"
value={isConnecting ? null : formatDuration(elapsed)}
icon={<Clock className="h-5 w-5" />}
/>
<MetricCard
label="Total Cost"
value={isConnecting ? null : formatCost(totalCost)}
icon={<DollarSign className="h-5 w-5" />}
/>
<MetricCard
label="Tokens Used"
value={isConnecting ? null : formatTokens(totalTokens)}
icon={<Zap className="h-5 w-5" />}
/>
{rtkEnabled && (
<MetricCard
label="RTK Saved"
value={rtkValue}
subtext={rtkSubtext}
icon={<TrendingDown className="h-5 w-5" />}
/>
)}
</div>
<div className="mt-6">
{/* Current Slice */}
{isConnecting ? (
<CurrentSliceCardSkeleton />
) : (
<div className="flex flex-col rounded-md border border-border bg-card">
{/* Header */}
<div className="border-b border-border px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Current Slice</h2>
{currentSlice ? (
<p className="mt-0.5 truncate text-sm font-medium text-foreground">
{currentSlice.id} {currentSlice.title}
</p>
) : (
<p className="mt-0.5 text-sm text-muted-foreground">No active slice</p>
)}
</div>
{currentSlice && totalTasks > 0 && (
<div className="shrink-0 text-right">
<span className="text-2xl font-bold tabular-nums leading-none">{progressPercent}</span>
<span className="text-xs text-muted-foreground">%</span>
</div>
)}
</div>
{currentSlice && totalTasks > 0 && (
<div className="mt-3">
<div className="h-1 w-full overflow-hidden rounded-full bg-accent">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%`, backgroundColor: getProgressColor(progressPercent) }}
/>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">{doneTasks} of {totalTasks} tasks complete</p>
</div>
)}
</div>
{/* Task list */}
<div className="flex-1 p-3">
{currentSlice && currentSlice.tasks.length > 0 ? (
<div className="space-y-0.5">
{currentSlice.tasks.map((task) => {
const status = getTaskStatus(
workspace!.active.milestoneId!,
currentSlice.id,
task,
workspace!.active,
)
return (
<div
key={task.id}
className={cn(
"flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors",
status === "in-progress" && "bg-accent",
)}
>
{taskStatusIcon(status)}
<span
className={cn(
"min-w-0 flex-1 truncate text-xs",
status === "done" && "text-muted-foreground line-through decoration-muted-foreground/40",
status === "pending" && "text-muted-foreground",
status === "in-progress" && "font-medium text-foreground",
)}
>
<span className="font-mono text-muted-foreground">{task.id}</span>
<span className="mx-1.5 text-border">·</span>
{task.title}
</span>
{status === "in-progress" && (
<span className="shrink-0 rounded-sm bg-foreground/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
active
</span>
)}
</div>
)
})}
</div>
) : (
<p className="px-2 py-2 text-xs text-muted-foreground">
No active slice or no tasks defined yet.
</p>
)}
</div>
</div>
)}
</div>
{isConnecting ? (
<div className="mt-6">
<ActivityCardSkeleton />
</div>
) : (
<div className="mt-6 rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Recent Activity</h2>
</div>
{recentLines.length > 0 ? (
<div className="divide-y divide-border">
{recentLines.map((line) => (
<div key={line.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="w-16 flex-shrink-0 font-mono text-xs text-muted-foreground">
{line.timestamp}
</span>
<span
className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
activityDotColor(line.type),
)}
/>
<span className="truncate text-sm">{line.content}</span>
</div>
))}
</div>
) : (
<div className="px-4 py-4 text-sm text-muted-foreground">
No activity yet.
</div>
)}
</div>
)}
</div>
</div>
)
}

View file

@ -1,523 +0,0 @@
"use client"
import { AlertTriangle, CheckCircle2, Info, LoaderCircle, RefreshCw, ShieldAlert, Wrench, XCircle } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import type {
DoctorIssue,
ForensicAnomaly,
ForensicReport,
DoctorReport,
SkillHealthReport,
SkillHealSuggestion,
} from "@/lib/diagnostics-types"
import { cn } from "@/lib/utils"
import {
formatCost,
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/gsd-workspace-store"
// ═══════════════════════════════════════════════════════════════════════
// SHARED
// ═══════════════════════════════════════════════════════════════════════
function SeverityIcon({ severity, className }: { severity: "info" | "warning" | "error" | "critical"; className?: string }) {
const base = cn("h-3.5 w-3.5 shrink-0", className)
switch (severity) {
case "error":
case "critical":
return <XCircle className={cn(base, "text-destructive")} />
case "warning":
return <AlertTriangle className={cn(base, "text-warning")} />
default:
return <Info className={cn(base, "text-info")} />
}
}
function severityBadgeVariant(s: string): "destructive" | "secondary" | "outline" {
if (s === "error" || s === "critical") return "destructive"
if (s === "warning") return "secondary"
return "outline"
}
function DiagHeader({
title,
subtitle,
status,
onRefresh,
refreshing,
}: {
title: string
subtitle?: string | null
status?: React.ReactNode
onRefresh: () => void
refreshing: boolean
}) {
return (
<div className="flex items-center justify-between gap-3 pb-4">
<div className="flex items-center gap-2.5">
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{title}</h3>
{status}
{subtitle && <span className="text-[11px] text-muted-foreground">{subtitle}</span>}
</div>
<Button type="button" variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing} className="h-7 gap-1.5 text-xs">
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
Refresh
</Button>
</div>
)
}
function DiagError({ message }: { message: string }) {
return (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-xs text-destructive">
{message}
</div>
)
}
function DiagLoading({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
{label}
</div>
)
}
function DiagEmpty({ message }: { message: string }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-4 py-5 text-center text-xs text-muted-foreground">
{message}
</div>
)
}
function StatPill({ label, value, variant }: { label: string; value: number | string; variant?: "default" | "error" | "warning" | "info" }) {
return (
<div className={cn(
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs",
variant === "error" && "border-destructive/20 bg-destructive/5 text-destructive",
variant === "warning" && "border-warning/20 bg-warning/5 text-warning",
variant === "info" && "border-info/20 bg-info/5 text-info",
(!variant || variant === "default") && "border-border/50 bg-card/50 text-foreground/80",
)}>
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// FORENSICS PANEL
// ═══════════════════════════════════════════════════════════════════════
function AnomalyRow({ anomaly }: { anomaly: ForensicAnomaly }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2">
<SeverityIcon severity={anomaly.severity} />
<Badge variant={severityBadgeVariant(anomaly.severity)} className="text-[10px] px-1.5 py-0">{anomaly.severity}</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">{anomaly.type}</Badge>
{anomaly.unitId && (
<span className="text-[10px] text-muted-foreground font-mono truncate">{anomaly.unitType}/{anomaly.unitId}</span>
)}
</div>
<p className="text-xs text-foreground">{anomaly.summary}</p>
{anomaly.details && anomaly.details !== anomaly.summary && (
<p className="text-[11px] text-muted-foreground leading-relaxed">{anomaly.details}</p>
)}
</div>
)
}
export function ForensicsPanel() {
const workspace = useGSDWorkspaceState()
const { loadForensicsDiagnostics } = useGSDWorkspaceActions()
const state = workspace.commandSurface.diagnostics.forensics
const data = state.data as ForensicReport | null
const busy = state.phase === "loading"
return (
<div className="space-y-4" data-testid="diagnostics-forensics">
<DiagHeader
title="Forensic Analysis"
subtitle={data ? new Date(data.timestamp).toLocaleString() : null}
status={data ? (
<span className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
data.anomalies.length > 0 ? "bg-warning" : "bg-success",
)} />
) : null}
onRefresh={() => void loadForensicsDiagnostics()}
refreshing={busy}
/>
{state.error && <DiagError message={state.error} />}
{busy && !data && <DiagLoading label="Running forensic analysis…" />}
{data && (
<>
{/* Metrics summary */}
{data.metrics && (
<div className="flex flex-wrap gap-2">
<StatPill label="Units" value={data.metrics.totalUnits} />
<StatPill label="Cost" value={formatCost(data.metrics.totalCost)} />
<StatPill label="Duration" value={`${Math.round(data.metrics.totalDuration / 1000)}s`} />
<StatPill label="Traces" value={data.unitTraceCount} />
</div>
)}
{/* Crash lock */}
{data.crashLock ? (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2">
<ShieldAlert className="h-3.5 w-3.5 text-destructive" />
<span className="text-xs font-medium text-destructive">Crash Lock Active</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-[11px]">
<span className="text-muted-foreground">PID</span>
<span className="font-mono text-foreground/80">{data.crashLock.pid}</span>
<span className="text-muted-foreground">Started</span>
<span className="text-foreground/80">{new Date(data.crashLock.startedAt).toLocaleString()}</span>
<span className="text-muted-foreground">Unit</span>
<span className="font-mono text-foreground/80">{data.crashLock.unitType}/{data.crashLock.unitId}</span>
</div>
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-2 text-xs text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
No crash lock
</div>
)}
{/* Anomalies */}
{data.anomalies.length > 0 ? (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Anomalies ({data.anomalies.length})</h4>
{data.anomalies.map((a, i) => <AnomalyRow key={i} anomaly={a} />)}
</div>
) : (
<DiagEmpty message="No anomalies detected" />
)}
{/* Recent units */}
{data.recentUnits.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Recent Units ({data.recentUnits.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Type</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">ID</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Model</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{data.recentUnits.map((u, i) => (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80">{u.type}</td>
<td className="px-2.5 py-1.5 font-mono text-foreground/80 truncate max-w-[120px]">{u.id}</td>
<td className="px-2.5 py-1.5 text-muted-foreground">{u.model}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(u.cost)}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{Math.round(u.duration / 1000)}s</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// DOCTOR PANEL
// ═══════════════════════════════════════════════════════════════════════
function humanizeCode(code: string): string {
return code.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
function IssueRow({ issue }: { issue: DoctorIssue }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<SeverityIcon severity={issue.severity} />
<Badge variant={severityBadgeVariant(issue.severity)} className="text-[10px] px-1.5 py-0">{issue.severity}</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">{humanizeCode(issue.code)}</Badge>
{issue.scope && <span className="text-[10px] text-muted-foreground font-mono">{issue.scope}</span>}
{issue.fixable && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-success/30 text-success">
<Wrench className="h-2.5 w-2.5 mr-0.5" />fixable
</Badge>
)}
</div>
<p className="text-xs text-foreground">{issue.message}</p>
{issue.file && <p className="text-[10px] font-mono text-muted-foreground truncate">{issue.file}</p>}
</div>
)
}
export function DoctorPanel() {
const workspace = useGSDWorkspaceState()
const { loadDoctorDiagnostics, applyDoctorFixes } = useGSDWorkspaceActions()
const state = workspace.commandSurface.diagnostics.doctor
const data = state.data as DoctorReport | null
const busy = state.phase === "loading"
const fixableCount = data?.summary.fixable ?? 0
return (
<div className="space-y-4" data-testid="diagnostics-doctor">
<DiagHeader
title="Doctor Health Check"
status={data ? (
<span className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
data.ok ? "bg-success" : "bg-destructive",
)} />
) : null}
onRefresh={() => void loadDoctorDiagnostics()}
refreshing={busy}
/>
{state.error && <DiagError message={state.error} />}
{busy && !data && <DiagLoading label="Running health check…" />}
{data && (
<>
{/* Summary bar */}
<div className="flex flex-wrap gap-2">
<StatPill label="Total" value={data.summary.total} />
{data.summary.errors > 0 && <StatPill label="Errors" value={data.summary.errors} variant="error" />}
{data.summary.warnings > 0 && <StatPill label="Warnings" value={data.summary.warnings} variant="warning" />}
{data.summary.infos > 0 && <StatPill label="Info" value={data.summary.infos} variant="info" />}
{fixableCount > 0 && (
<StatPill label="Fixable" value={fixableCount} variant="info" />
)}
</div>
{/* Apply fixes button */}
{fixableCount > 0 && (
<div className="flex items-center gap-3">
<Button
type="button"
variant="default"
size="sm"
onClick={() => void applyDoctorFixes()}
disabled={state.fixPending}
className="h-7 gap-1.5 text-xs"
data-testid="doctor-apply-fixes"
>
{state.fixPending ? (
<LoaderCircle className="h-3 w-3 animate-spin" />
) : (
<Wrench className="h-3 w-3" />
)}
Apply Fixes ({fixableCount})
</Button>
{state.lastFixError && (
<span className="text-[11px] text-destructive">{state.lastFixError}</span>
)}
</div>
)}
{/* Fix results */}
{state.lastFixResult && state.lastFixResult.fixesApplied.length > 0 && (
<div className="rounded-lg border border-success/20 bg-success/5 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
<span className="text-xs font-medium text-success">Fixes Applied</span>
</div>
<ul className="space-y-0.5 pl-5">
{state.lastFixResult.fixesApplied.map((fix, i) => (
<li key={i} className="text-[11px] text-foreground/80 list-disc">{fix}</li>
))}
</ul>
</div>
)}
{/* Issue list */}
{data.issues.length > 0 ? (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Issues ({data.issues.length})</h4>
{data.issues.map((issue, i) => <IssueRow key={i} issue={issue} />)}
</div>
) : (
<DiagEmpty message="No issues found — workspace is healthy" />
)}
</>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// SKILL HEALTH PANEL
// ═══════════════════════════════════════════════════════════════════════
function trendArrow(trend: "stable" | "rising" | "declining"): string {
if (trend === "rising") return "↑"
if (trend === "declining") return "↓"
return "→"
}
function trendColor(trend: "stable" | "rising" | "declining"): string {
if (trend === "rising") return "text-warning"
if (trend === "declining") return "text-destructive"
return "text-muted-foreground"
}
function SuggestionRow({ suggestion }: { suggestion: SkillHealSuggestion }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<SeverityIcon severity={suggestion.severity} />
<Badge variant={severityBadgeVariant(suggestion.severity)} className="text-[10px] px-1.5 py-0">{suggestion.severity}</Badge>
<span className="text-[11px] font-medium text-foreground/80">{suggestion.skillName}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">{suggestion.trigger.replace(/_/g, " ")}</Badge>
</div>
<p className="text-xs text-foreground">{suggestion.message}</p>
</div>
)
}
export function SkillHealthPanel() {
const workspace = useGSDWorkspaceState()
const { loadSkillHealthDiagnostics } = useGSDWorkspaceActions()
const state = workspace.commandSurface.diagnostics.skillHealth
const data = state.data as SkillHealthReport | null
const busy = state.phase === "loading"
return (
<div className="space-y-4" data-testid="diagnostics-skill-health">
<DiagHeader
title="Skill Health"
subtitle={data ? new Date(data.generatedAt).toLocaleString() : null}
status={data ? (
<span className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
data.decliningSkills.length > 0 ? "bg-warning" : "bg-success",
)} />
) : null}
onRefresh={() => void loadSkillHealthDiagnostics()}
refreshing={busy}
/>
{state.error && <DiagError message={state.error} />}
{busy && !data && <DiagLoading label="Analyzing skill health…" />}
{data && (
<>
{/* Stats bar */}
<div className="flex flex-wrap gap-2">
<StatPill label="Skills" value={data.skills.length} />
{data.staleSkills.length > 0 && <StatPill label="Stale" value={data.staleSkills.length} variant="warning" />}
{data.decliningSkills.length > 0 && <StatPill label="Declining" value={data.decliningSkills.length} variant="error" />}
<StatPill label="Total units" value={data.totalUnitsWithSkills} />
</div>
{/* Skill table */}
{data.skills.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Skills ({data.skills.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Skill</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Uses</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Success</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Tokens</th>
<th className="px-2.5 py-1.5 text-center font-medium text-muted-foreground">Trend</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Stale</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
</tr>
</thead>
<tbody>
{data.skills.map((skill) => (
<tr key={skill.name} className={cn(
"border-b border-border/50 last:border-0",
skill.flagged && "bg-destructive/3",
)}>
<td className="px-2.5 py-1.5 font-mono text-foreground/80">
<span className="flex items-center gap-1.5">
{skill.name}
{skill.flagged && <AlertTriangle className="h-3 w-3 text-warning shrink-0" />}
</span>
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{skill.totalUses}</td>
<td className={cn(
"px-2.5 py-1.5 text-right tabular-nums",
skill.successRate >= 0.9 ? "text-success" : skill.successRate >= 0.7 ? "text-warning" : "text-destructive",
)}>
{(skill.successRate * 100).toFixed(0)}%
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{Math.round(skill.avgTokens)}</td>
<td className={cn("px-2.5 py-1.5 text-center", trendColor(skill.tokenTrend))}>
{trendArrow(skill.tokenTrend)}
</td>
<td className={cn(
"px-2.5 py-1.5 text-right tabular-nums",
skill.staleDays > 30 ? "text-warning" : "text-foreground/80",
)}>
{skill.staleDays > 0 ? `${skill.staleDays}d` : "—"}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(skill.avgCost)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Stale skills */}
{data.staleSkills.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-xs font-medium text-muted-foreground">Stale Skills</h4>
<div className="flex flex-wrap gap-1.5">
{data.staleSkills.map((name) => (
<Badge key={name} variant="secondary" className="text-[10px] font-mono">{name}</Badge>
))}
</div>
</div>
)}
{/* Declining skills */}
{data.decliningSkills.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-xs font-medium text-muted-foreground">Declining Skills</h4>
<div className="flex flex-wrap gap-1.5">
{data.decliningSkills.map((name) => (
<Badge key={name} variant="destructive" className="text-[10px] font-mono">{name}</Badge>
))}
</div>
</div>
)}
{/* Suggestions */}
{data.suggestions.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Suggestions ({data.suggestions.length})</h4>
{data.suggestions.map((s, i) => <SuggestionRow key={i} suggestion={s} />)}
</div>
)}
{data.skills.length === 0 && data.suggestions.length === 0 && (
<DiagEmpty message="No skill usage data available" />
)}
</>
)}
</div>
)
}

View file

@ -1,119 +0,0 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { GripVertical, Loader2 } from "lucide-react"
import { MainSessionTerminal } from "@/components/gsd/main-session-terminal"
import { ShellTerminal } from "@/components/gsd/shell-terminal"
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
import { useGSDWorkspaceState } from "@/lib/gsd-workspace-store"
import { derivePendingWorkflowCommandLabel } from "@/lib/workflow-action-execution"
export function DualTerminal() {
const [splitPosition, setSplitPosition] = useState(50)
const containerRef = useRef<HTMLDivElement>(null)
const rootRef = useRef<HTMLDivElement>(null)
const isDragging = useRef(false)
const [terminalFontSize] = useTerminalFontSize()
const workspace = useGSDWorkspaceState()
const projectCwd = workspace.boot?.project.cwd
const pendingCommandLabel = derivePendingWorkflowCommandLabel({
commandInFlight: workspace.commandInFlight,
terminalLines: workspace.terminalLines,
})
const handleMouseDown = () => {
isDragging.current = true
}
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current || !containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const percent = (x / rect.width) * 100
setSplitPosition(Math.max(20, Math.min(80, percent)))
}
const handleMouseUp = () => {
isDragging.current = false
}
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [])
// Prevent browser default file-open on drag/drop anywhere in the dual terminal.
// Uses native DOM listeners so xterm's internal DOM can't swallow the events first.
useEffect(() => {
const el = rootRef.current
if (!el) return
const preventDragDefault = (e: DragEvent) => {
e.preventDefault()
}
// Capture phase ensures we fire before any child element can consume the event
el.addEventListener("dragover", preventDragDefault, true)
el.addEventListener("drop", preventDragDefault, true)
return () => {
el.removeEventListener("dragover", preventDragDefault, true)
el.removeEventListener("drop", preventDragDefault, true)
}
}, [])
return (
<div ref={rootRef} className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
<span className="font-medium">Power User Mode</span>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{pendingCommandLabel && (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-primary"
data-testid="power-mode-pending-command"
title={pendingCommandLabel}
>
<Loader2 className="h-3 w-3 animate-spin" />
Sending {pendingCommandLabel}
</span>
)}
<span>Left: Main Session TUI</span>
<span className="text-border">|</span>
<span>Right: Interactive GSD</span>
</div>
</div>
{/* Split terminals */}
<div ref={containerRef} className="flex flex-1 overflow-hidden">
{/* Left terminal - Main bridge native TUI */}
<div style={{ width: `${splitPosition}%` }} className="flex h-full min-w-0 flex-col overflow-hidden bg-terminal">
<MainSessionTerminal className="min-h-0 flex-1" fontSize={terminalFontSize} projectCwd={projectCwd} />
</div>
{/* Divider */}
<div
className="flex w-1 cursor-col-resize items-center justify-center bg-border hover:bg-muted-foreground/30 transition-colors"
onMouseDown={handleMouseDown}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
{/* Right terminal - Interactive GSD instance */}
<div style={{ width: `${100 - splitPosition}%` }} className="h-full min-w-0 overflow-hidden bg-terminal">
<ShellTerminal
className="h-full"
command="gsd"
sessionPrefix="gsd-interactive"
fontSize={terminalFontSize}
hideInitialGsdHeader
projectCwd={projectCwd}
/>
</div>
</div>
</div>
)
}

View file

@ -1,740 +0,0 @@
"use client"
import { useEffect, useMemo, useRef, useState, useCallback } from "react"
import { Loader2, Save, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { CodeEditor } from "@/components/gsd/code-editor"
import { useEditorFontSize } from "@/lib/use-editor-font-size"
import { useTheme } from "next-themes"
/* ── Language detection ── */
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
json: "json",
jsonc: "jsonc",
md: "markdown",
mdx: "mdx",
css: "css",
scss: "scss",
less: "less",
html: "html",
htm: "html",
xml: "xml",
svg: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "fish",
py: "python",
rb: "ruby",
rs: "rust",
go: "go",
java: "java",
kt: "kotlin",
swift: "swift",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
php: "php",
sql: "sql",
graphql: "graphql",
gql: "graphql",
dockerfile: "dockerfile",
makefile: "makefile",
lua: "lua",
vim: "viml",
r: "r",
tex: "latex",
diff: "diff",
ini: "ini",
conf: "ini",
env: "dotenv",
}
const SPECIAL_FILENAMES: Record<string, string> = {
Dockerfile: "dockerfile",
Makefile: "makefile",
Containerfile: "dockerfile",
Justfile: "makefile",
Rakefile: "ruby",
Gemfile: "ruby",
".env": "dotenv",
".env.local": "dotenv",
".env.example": "dotenv",
".eslintrc": "json",
".prettierrc": "json",
"tsconfig.json": "jsonc",
"jsconfig.json": "jsonc",
}
function detectLanguage(filepath: string): string | null {
const filename = filepath.split("/").pop() ?? ""
// Check special filenames first
if (SPECIAL_FILENAMES[filename]) return SPECIAL_FILENAMES[filename]
const ext = filename.includes(".") ? filename.split(".").pop()?.toLowerCase() : null
if (ext && EXT_TO_LANG[ext]) return EXT_TO_LANG[ext]
return null
}
function isMarkdown(filepath: string): boolean {
const ext = filepath.split(".").pop()?.toLowerCase()
return ext === "md" || ext === "mdx"
}
/* ── Shiki singleton ── */
type ShikiHighlighter = {
codeToHtml: (code: string, options: { lang: string; theme: string }) => string
}
let highlighterPromise: Promise<ShikiHighlighter> | null = null
async function getHighlighter(): Promise<ShikiHighlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then((mod) =>
mod.createHighlighter({
themes: ["github-dark-default", "github-light-default"],
langs: [
"typescript", "tsx", "javascript", "jsx",
"json", "jsonc", "markdown", "mdx",
"css", "scss", "less", "html", "xml",
"yaml", "toml", "bash", "python", "ruby",
"rust", "go", "java", "kotlin", "swift",
"c", "cpp", "csharp", "php", "sql",
"graphql", "dockerfile", "makefile", "lua",
"diff", "ini", "dotenv",
],
}),
).catch((err) => {
// Reset so the next call retries instead of returning a rejected promise forever
highlighterPromise = null
throw err
})
}
return highlighterPromise
}
/* ── Code viewer (syntax highlighted) ── */
function CodeViewer({ content, filepath, shikiTheme = "github-dark-default" }: { content: string; filepath: string; shikiTheme?: string }) {
const [html, setHtml] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const lang = detectLanguage(filepath)
useEffect(() => {
let cancelled = false
if (!lang) {
const readyTimer = window.setTimeout(() => {
setReady(true)
}, 0)
return () => window.clearTimeout(readyTimer)
}
getHighlighter().then((highlighter) => {
if (cancelled) return
try {
const highlighted = highlighter.codeToHtml(content, {
lang,
theme: shikiTheme,
})
setHtml(highlighted)
} catch {
// Language not loaded or unsupported — fall back to plain
setHtml(null)
}
setReady(true)
}).catch(() => {
if (!cancelled) setReady(true)
})
return () => { cancelled = true }
}, [content, lang, shikiTheme])
if (!ready) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Highlighting
</div>
)
}
if (html) {
return (
<div
ref={containerRef}
className="file-viewer-code overflow-x-auto text-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
// Fallback: plain text with line numbers
return <PlainViewer content={content} />
}
/* ── Plain text viewer with line numbers ── */
function PlainViewer({ content }: { content: string }) {
const lines = useMemo(() => content.split("\n"), [content])
const gutterWidth = String(lines.length).length
return (
<div className="overflow-x-auto text-sm leading-relaxed font-mono">
<table className="border-collapse">
<tbody>
{lines.map((line, i) => (
<tr key={i} className="hover:bg-accent/20">
<td
className="select-none pr-4 text-right text-muted-foreground align-top"
style={{ minWidth: `${gutterWidth + 1}ch` }}
>
{i + 1}
</td>
<td className="whitespace-pre text-muted-foreground">{line || " "}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/* ── Markdown viewer ── */
function MarkdownViewer({ content, filepath, shikiTheme = "github-dark-default" }: { content: string; filepath: string; shikiTheme?: string }) {
const [rendered, setRendered] = useState<React.ReactNode | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
// Dynamic import to keep the main bundle lean
Promise.all([
import("react-markdown"),
import("remark-gfm"),
getHighlighter(),
]).then(([ReactMarkdownMod, remarkGfmMod, highlighter]) => {
if (cancelled) return
const ReactMarkdown = ReactMarkdownMod.default
const remarkGfm = remarkGfmMod.default
setRendered(
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "")
const codeStr = String(children).replace(/\n$/, "")
if (match) {
try {
const highlighted = highlighter.codeToHtml(codeStr, {
lang: match[1],
theme: shikiTheme,
})
return (
<div
className="file-viewer-code my-3 rounded-md overflow-x-auto text-sm"
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)
} catch {
// Fall through to default rendering
}
}
// Inline code or unknown language
const isInline = !className && !String(children).includes("\n")
if (isInline) {
return (
<code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono" {...props}>
{children}
</code>
)
}
return (
<pre className="my-3 overflow-x-auto rounded-md bg-[#0d1117] p-4 text-sm">
<code>{children}</code>
</pre>
)
},
pre({ children }) {
// Unwrap <pre> since code blocks handle their own wrapper
return <>{children}</>
},
table({ children }) {
return (
<div className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-border text-sm">
{children}
</table>
</div>
)
},
th({ children }) {
return (
<th className="border border-border bg-muted/50 px-3 py-2 text-left font-medium">
{children}
</th>
)
},
td({ children }) {
return (
<td className="border border-border px-3 py-2">{children}</td>
)
},
a({ href, children }) {
return (
<a href={href} className="text-info hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
)
},
img({ src, alt }) {
return (
<span className="my-2 block rounded border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground italic">
🖼 {alt || (typeof src === "string" ? src : "") || "image"}
</span>
)
},
}}
>
{content}
</ReactMarkdown>,
)
setReady(true)
}).catch(() => {
if (!cancelled) setReady(true)
})
return () => { cancelled = true }
}, [content, filepath, shikiTheme])
if (!ready) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Rendering
</div>
)
}
if (!rendered) {
return <PlainViewer content={content} />
}
return <div className="markdown-body">{rendered}</div>
}
/* ── Inline diff viewer — shows before/after with red/green line highlights ── */
function computeDiffLines(before: string, after: string): Array<{ type: "add" | "remove" | "context"; lineNum: number | null; text: string }> {
const oldLines = before.split("\n")
const newLines = after.split("\n")
const result: Array<{ type: "add" | "remove" | "context"; lineNum: number | null; text: string }> = []
// Simple LCS-based diff for inline display
const n = oldLines.length
const m = newLines.length
// For files that are too large, fall back to showing just additions/removals
if (n + m > 5000) {
oldLines.forEach((l, i) => result.push({ type: "remove", lineNum: i + 1, text: l }))
newLines.forEach((l, i) => result.push({ type: "add", lineNum: i + 1, text: l }))
return result
}
// Build edit script using O(ND) algorithm (simplified Myers)
const max = n + m
const v = new Int32Array(2 * max + 1)
const trace: Int32Array[] = []
outer:
for (let d = 0; d <= max; d++) {
const vCopy = new Int32Array(v)
trace.push(vCopy)
for (let k = -d; k <= d; k += 2) {
let x: number
if (k === -d || (k !== d && v[k - 1 + max] < v[k + 1 + max])) {
x = v[k + 1 + max]
} else {
x = v[k - 1 + max] + 1
}
let y = x - k
while (x < n && y < m && oldLines[x] === newLines[y]) {
x++
y++
}
v[k + max] = x
if (x >= n && y >= m) break outer
}
}
// Backtrack to produce diff
type Edit = { type: "add" | "remove" | "context"; oldIdx: number; newIdx: number }
const edits: Edit[] = []
let x = n, y = m
for (let d = trace.length - 1; d >= 0; d--) {
const vPrev = trace[d]
const k = x - y
let prevK: number
if (k === -d || (k !== d && vPrev[k - 1 + max] < vPrev[k + 1 + max])) {
prevK = k + 1
} else {
prevK = k - 1
}
const prevX = vPrev[prevK + max]
const prevY = prevX - prevK
// Diag moves = context lines
while (x > prevX && y > prevY) {
x--; y--
edits.push({ type: "context", oldIdx: x, newIdx: y })
}
if (d > 0) {
if (x === prevX) {
// Insert
y--
edits.push({ type: "add", oldIdx: x, newIdx: y })
} else {
// Delete
x--
edits.push({ type: "remove", oldIdx: x, newIdx: y })
}
}
}
edits.reverse()
// Convert to output lines, showing only changed regions with ±3 lines of context
const CONTEXT = 3
const important = new Set<number>()
edits.forEach((e, i) => {
if (e.type !== "context") {
for (let j = Math.max(0, i - CONTEXT); j <= Math.min(edits.length - 1, i + CONTEXT); j++) {
important.add(j)
}
}
})
let lastIncluded = -1
for (let i = 0; i < edits.length; i++) {
if (!important.has(i)) continue
if (lastIncluded >= 0 && i - lastIncluded > 1) {
result.push({ type: "context", lineNum: null, text: "···" })
}
const e = edits[i]
if (e.type === "context") {
result.push({ type: "context", lineNum: e.newIdx + 1, text: newLines[e.newIdx] })
} else if (e.type === "remove") {
result.push({ type: "remove", lineNum: e.oldIdx + 1, text: oldLines[e.oldIdx] })
} else {
result.push({ type: "add", lineNum: e.newIdx + 1, text: newLines[e.newIdx] })
}
lastIncluded = i
}
return result
}
function InlineDiffViewer({ before, after, onDismiss }: { before: string; after: string; onDismiss?: () => void }) {
const lines = useMemo(() => computeDiffLines(before, after), [before, after])
return (
<div className="flex-1 overflow-y-auto font-mono text-sm leading-relaxed">
<table className="w-full border-collapse">
<tbody>
{lines.map((line, i) => (
<tr
key={i}
className={cn(
line.type === "add" && "bg-emerald-500/10",
line.type === "remove" && "bg-red-500/10",
)}
>
<td className="select-none w-[1ch] pl-2 pr-1 text-center align-top">
{line.type === "add" ? (
<span className="text-emerald-400/80">+</span>
) : line.type === "remove" ? (
<span className="text-red-400/80"></span>
) : null}
</td>
<td
className={cn(
"select-none pr-3 text-right align-top min-w-[3ch]",
line.type === "add" ? "text-emerald-400/40" :
line.type === "remove" ? "text-red-400/40" :
"text-muted-foreground/50",
)}
>
{line.lineNum ?? ""}
</td>
<td
className={cn(
"whitespace-pre pr-4",
line.type === "add" && "text-emerald-300",
line.type === "remove" && "text-red-300 line-through decoration-red-400/30",
line.type === "context" && line.text === "···" && "text-muted-foreground/50 text-center italic",
line.type === "context" && line.text !== "···" && "text-muted-foreground",
)}
>
{line.text || " "}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/* ── Read-only content renderer (shared between standalone and tab modes) ── */
function ReadOnlyContent({ content, filepath, fontSize, shikiTheme }: { content: string; filepath: string; fontSize?: number; shikiTheme?: string }) {
return (
<div style={fontSize ? { fontSize } : undefined}>
{isMarkdown(filepath) ? (
<MarkdownViewer content={content} filepath={filepath} shikiTheme={shikiTheme} />
) : (
<CodeViewer content={content} filepath={filepath} shikiTheme={shikiTheme} />
)}
</div>
)
}
/* ── Exported component ── */
interface FileContentViewerProps {
content: string
filepath: string
className?: string
/** Required for editing — the root context for the file */
root?: "gsd" | "project"
/** Required for editing — the relative path within the root */
path?: string
/** Required for editing — called with new content when the user saves */
onSave?: (newContent: string) => Promise<void>
/** When set, shows an inline diff overlay (before/after content) */
diff?: { before: string; after: string }
/** Called to dismiss the diff overlay */
onDismissDiff?: () => void
/** When true, MD files default to Edit tab so the raw changes are visible */
agentOpened?: boolean
}
export function FileContentViewer({
content,
filepath,
className,
root,
path,
onSave,
diff,
onDismissDiff,
agentOpened,
}: FileContentViewerProps) {
const canEdit = root !== undefined && path !== undefined && onSave !== undefined
// ── Dirty state tracking ──
const [editContent, setEditContent] = useState(content)
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// Reset edit content when the source content changes (e.g. after save + re-fetch)
useEffect(() => {
setEditContent(content)
}, [content])
const isDirty = editContent !== content
const [fontSize] = useEditorFontSize()
const { resolvedTheme } = useTheme()
const shikiTheme = resolvedTheme === "light" ? "github-light-default" : "github-dark-default"
const language = detectLanguage(filepath)
const handleSave = useCallback(async () => {
if (!onSave || !isDirty || isSaving) return
setIsSaving(true)
setSaveError(null)
try {
await onSave(editContent)
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Failed to save")
} finally {
setIsSaving(false)
}
}, [onSave, isDirty, isSaving, editContent])
// ── Ctrl+S / Cmd+S keyboard shortcut ──
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault()
handleSave()
}
}
document.addEventListener("keydown", handler)
return () => document.removeEventListener("keydown", handler)
}, [handleSave])
// ── Read-only mode (backward compatible) ──
if (!canEdit) {
return (
<div className={cn("flex-1 overflow-y-auto p-4", className)} style={{ fontSize }}>
<ReadOnlyContent content={content} filepath={filepath} fontSize={fontSize} shikiTheme={shikiTheme} />
</div>
)
}
// ── Diff overlay mode: agent just edited this file ──
if (diff) {
return (
<div className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}>
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
<span className="text-sm font-medium font-mono truncate">{filepath}</span>
<span className="ml-2 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400 uppercase tracking-wide">
Changed
</span>
<div className="ml-auto flex items-center gap-2">
<button
onClick={onDismissDiff}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="h-3 w-3" />
Dismiss
</button>
</div>
</div>
<InlineDiffViewer before={diff.before} after={diff.after} onDismiss={onDismissDiff} />
</div>
)
}
// ── Editable mode: markdown keeps View/Edit tabs ──
if (isMarkdown(filepath)) {
return (
<Tabs key={agentOpened ? "agent-edit" : "normal"} defaultValue={agentOpened ? "edit" : "view"} className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}>
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
<span className="text-sm font-medium font-mono truncate mr-2">{filepath}</span>
<TabsList className="h-7 bg-transparent p-0 ml-auto">
<TabsTrigger
value="view"
className="h-6 rounded-md px-2 text-xs data-[state=active]:bg-muted"
>
View
</TabsTrigger>
<TabsTrigger
value="edit"
className="h-6 rounded-md px-2 text-xs data-[state=active]:bg-muted"
>
Edit
</TabsTrigger>
</TabsList>
{/* Save button */}
<div className="flex items-center gap-2">
{saveError && (
<span className="text-xs text-destructive max-w-[200px] truncate" title={saveError}>
{saveError}
</span>
)}
<button
onClick={handleSave}
disabled={!isDirty || isSaving}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
isDirty && !isSaving
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground cursor-not-allowed opacity-50",
)}
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
Save
</button>
</div>
</div>
<TabsContent value="view" className="flex-1 overflow-y-auto p-4 mt-0" style={{ fontSize }}>
<ReadOnlyContent content={content} filepath={filepath} fontSize={fontSize} shikiTheme={shikiTheme} />
</TabsContent>
<TabsContent value="edit" className="flex-1 overflow-hidden mt-0 min-h-0">
<CodeEditor
value={editContent}
onChange={setEditContent}
language={language}
fontSize={fontSize}
className="h-full border-0 rounded-none"
/>
</TabsContent>
</Tabs>
)
}
// ── Editable mode: non-markdown gets single CodeEditor view ──
return (
<div className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}>
{/* Header bar with filepath and save button */}
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
<span className="text-sm font-medium font-mono truncate">{filepath}</span>
<div className="ml-auto flex items-center gap-2">
{saveError && (
<span className="text-xs text-destructive max-w-[200px] truncate" title={saveError}>
{saveError}
</span>
)}
<button
onClick={handleSave}
disabled={!isDirty || isSaving}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
isDirty && !isSaving
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground cursor-not-allowed opacity-50",
)}
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
Save
</button>
</div>
</div>
{/* CodeEditor fills remaining space */}
<CodeEditor
value={editContent}
onChange={setEditContent}
language={language}
fontSize={fontSize}
className="flex-1 min-h-0 border-0 rounded-none"
/>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,332 +0,0 @@
"use client"
import { useState } from "react"
import { CheckSquare, MessageSquare, Send, TextCursorInput, Type } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
import {
type PendingUiRequest,
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/gsd-workspace-store"
import { cn } from "@/lib/utils"
function methodIcon(method: PendingUiRequest["method"]) {
switch (method) {
case "select":
return <CheckSquare className="h-4 w-4" />
case "confirm":
return <MessageSquare className="h-4 w-4" />
case "input":
return <TextCursorInput className="h-4 w-4" />
case "editor":
return <Type className="h-4 w-4" />
}
}
function methodLabel(method: PendingUiRequest["method"]): string {
switch (method) {
case "select":
return "Selection"
case "confirm":
return "Confirmation"
case "input":
return "Input"
case "editor":
return "Editor"
}
}
// --- Renderers for each blocking UI request type ---
function SelectRenderer({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "select" }>
onSubmit: (value: Record<string, unknown>) => void
disabled: boolean
}) {
const isMulti = Boolean(request.allowMultiple)
const [singleValue, setSingleValue] = useState("")
const [multiValues, setMultiValues] = useState<Set<string>>(new Set())
const handleSubmit = () => {
if (isMulti) {
onSubmit({ value: Array.from(multiValues) })
} else {
onSubmit({ value: singleValue })
}
}
const canSubmit = isMulti ? multiValues.size > 0 : singleValue !== ""
if (isMulti) {
return (
<div className="space-y-4">
<div className="space-y-2">
{request.options.map((option) => (
<label
key={option}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 transition-colors hover:bg-accent/40"
>
<Checkbox
checked={multiValues.has(option)}
onCheckedChange={(checked) => {
const next = new Set(multiValues)
if (checked) {
next.add(option)
} else {
next.delete(option)
}
setMultiValues(next)
}}
disabled={disabled}
/>
<span className="text-sm">{option}</span>
</label>
))}
</div>
<Button onClick={handleSubmit} disabled={disabled || !canSubmit} className="w-full">
<Send className="h-4 w-4" />
Submit selection ({multiValues.size})
</Button>
</div>
)
}
return (
<div className="space-y-4">
<RadioGroup value={singleValue} onValueChange={setSingleValue} disabled={disabled}>
{request.options.map((option) => (
<label
key={option}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 transition-colors hover:bg-accent/40"
>
<RadioGroupItem value={option} id={`select-${option}`} />
<Label htmlFor={`select-${option}`} className="cursor-pointer text-sm font-normal">
{option}
</Label>
</label>
))}
</RadioGroup>
<Button onClick={handleSubmit} disabled={disabled || !canSubmit} className="w-full">
<Send className="h-4 w-4" />
Submit
</Button>
</div>
)
}
function ConfirmRenderer({
request,
onSubmit,
onCancel,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "confirm" }>
onSubmit: (value: Record<string, unknown>) => void
onCancel: () => void
disabled: boolean
}) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm leading-relaxed">
{request.message}
</div>
<div className="flex gap-3">
<Button onClick={() => onSubmit({ value: true })} disabled={disabled} className="flex-1">
Confirm
</Button>
<Button onClick={onCancel} disabled={disabled} variant="outline" className="flex-1">
Cancel
</Button>
</div>
</div>
)
}
function InputRenderer({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "input" }>
onSubmit: (value: Record<string, unknown>) => void
disabled: boolean
}) {
const [value, setValue] = useState("")
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
if (value.trim()) onSubmit({ value })
}}
>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={request.placeholder || "Enter a value"}
disabled={disabled}
autoFocus
/>
<Button type="submit" disabled={disabled || !value.trim()} className="w-full">
<Send className="h-4 w-4" />
Submit
</Button>
</form>
)
}
function EditorRenderer({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "editor" }>
onSubmit: (value: Record<string, unknown>) => void
disabled: boolean
}) {
const [value, setValue] = useState(request.prefill || "")
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
onSubmit({ value })
}}
>
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={disabled}
className="min-h-[200px] font-mono text-sm"
autoFocus
/>
<Button type="submit" disabled={disabled} className="w-full">
<Send className="h-4 w-4" />
Submit
</Button>
</form>
)
}
function RequestBody({
request,
onSubmit,
onCancel,
disabled,
}: {
request: PendingUiRequest
onSubmit: (value: Record<string, unknown>) => void
onCancel: () => void
disabled: boolean
}) {
switch (request.method) {
case "select":
return <SelectRenderer request={request} onSubmit={onSubmit} disabled={disabled} />
case "confirm":
return <ConfirmRenderer request={request} onSubmit={onSubmit} onCancel={onCancel} disabled={disabled} />
case "input":
return <InputRenderer request={request} onSubmit={onSubmit} disabled={disabled} />
case "editor":
return <EditorRenderer request={request} onSubmit={onSubmit} disabled={disabled} />
}
}
export function FocusedPanel() {
const workspace = useGSDWorkspaceState()
const { respondToUiRequest, dismissUiRequest } = useGSDWorkspaceActions()
const pending = workspace.pendingUiRequests
const isOpen = pending.length > 0
const current = pending[0] ?? null
const isSubmitting = workspace.commandInFlight === "extension_ui_response"
const handleSubmit = (response: Record<string, unknown>) => {
if (!current) return
void respondToUiRequest(current.id, response)
}
const handleDismiss = () => {
if (!current) return
void dismissUiRequest(current.id)
}
// Prevent the Sheet from closing via overlay click / escape while submitting
const handleOpenChange = (open: boolean) => {
if (!open && !isSubmitting && current) {
handleDismiss()
}
}
return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetContent side="right" className="flex flex-col sm:max-w-md" data-testid="focused-panel">
{current && (
<>
<SheetHeader>
<div className="flex items-center gap-2">
{methodIcon(current.method)}
<SheetTitle>{current.title || methodLabel(current.method)}</SheetTitle>
</div>
<SheetDescription>
<span className="flex items-center gap-2">
<span>{methodLabel(current.method)} requested by the agent</span>
{pending.length > 1 && (
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-semibold text-background",
)}
data-testid="focused-panel-queue-badge"
>
+{pending.length - 1}
</span>
)}
</span>
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-2">
<RequestBody
request={current}
onSubmit={handleSubmit}
onCancel={handleDismiss}
disabled={isSubmitting}
/>
</div>
<SheetFooter>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
disabled={isSubmitting}
className="text-muted-foreground"
>
Dismiss
</Button>
</SheetFooter>
</>
)}
</SheetContent>
</Sheet>
)
}

View file

@ -1,74 +0,0 @@
"use client"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { ChatPane } from "@/components/gsd/chat-mode"
// ─── Types ──────────────────────────────────────────────────────────────────
export interface GuidedDialogProps {
/** Whether the dialog is open */
open: boolean
/** Callback when open state changes (e.g. close button clicked) */
onOpenChange: (open: boolean) => void
/** Detection kind for contextual title */
detectionKind?: string
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function getDialogTitle(detectionKind?: string): string {
switch (detectionKind) {
case "v1-legacy":
return "Migrating to GSD v2"
case "brownfield":
return "Mapping Your Project"
case "blank":
return "Setting Up Your Project"
default:
return "Getting Started"
}
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Full-screen dialog that embeds ChatPane to render the bridge session
* response to an onboarding CTA command.
*
* The initial command dispatch is NOT handled here it is managed by
* the parent (Dashboard) via a useEffect keyed on open + command.
*/
export function GuidedDialog({
open,
onOpenChange,
detectionKind,
}: GuidedDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-4xl h-[85vh] flex flex-col p-0 gap-0"
data-testid="guided-dialog"
>
<DialogHeader className="px-6 py-4 border-b border-border shrink-0">
<DialogTitle className="text-base font-semibold">
{getDialogTitle(detectionKind)}
</DialogTitle>
<DialogDescription className="sr-only">
Interactive guided setup responses stream below as they are generated.
</DialogDescription>
</DialogHeader>
{/* ChatPane without onOpenAction hides the Discuss/Next/Auto action buttons */}
<div className="flex-1 min-h-0 overflow-hidden">
<ChatPane className="h-full" />
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -1,457 +0,0 @@
"use client"
import { useState } from "react"
import {
BookOpen,
InboxIcon,
LoaderCircle,
RefreshCw,
Zap,
Clock,
Tag,
FileText,
Lightbulb,
Repeat2,
StickyNote,
ArrowRightLeft,
CalendarClock,
ListTodo,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import type {
KnowledgeData,
KnowledgeEntry,
CapturesData,
CaptureEntry,
Classification,
} from "@/lib/knowledge-captures-types"
import { cn } from "@/lib/utils"
import {
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/gsd-workspace-store"
// ═══════════════════════════════════════════════════════════════════════
// SHARED HELPERS
// ═══════════════════════════════════════════════════════════════════════
function PanelHeader({
title,
subtitle,
status,
onRefresh,
refreshing,
}: {
title: string
subtitle?: string | null
status?: React.ReactNode
onRefresh: () => void
refreshing: boolean
}) {
return (
<div className="flex items-center justify-between gap-3 pb-4">
<div className="flex items-center gap-2.5">
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{title}</h3>
{status}
{subtitle && <span className="text-[11px] text-muted-foreground">{subtitle}</span>}
</div>
<Button type="button" variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing} className="h-7 gap-1.5 text-xs">
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
Refresh
</Button>
</div>
)
}
function PanelError({ message }: { message: string }) {
return (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-xs text-destructive">
{message}
</div>
)
}
function PanelLoading({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
{label}
</div>
)
}
function PanelEmpty({ message }: { message: string }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-4 py-5 text-center text-xs text-muted-foreground">
{message}
</div>
)
}
function StatPill({ label, value, variant }: { label: string; value: number | string; variant?: "default" | "error" | "warning" | "info" }) {
return (
<div className={cn(
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs",
variant === "error" && "border-destructive/20 bg-destructive/5 text-destructive",
variant === "warning" && "border-warning/20 bg-warning/5 text-warning",
variant === "info" && "border-info/20 bg-info/5 text-info",
(!variant || variant === "default") && "border-border/50 bg-card/50 text-foreground/80",
)}>
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// KNOWLEDGE TYPE STYLING
// ═══════════════════════════════════════════════════════════════════════
function knowledgeTypeBadge(type: KnowledgeEntry["type"]) {
switch (type) {
case "rule":
return { label: "Rule", className: "border-violet-500/30 bg-violet-500/10 text-violet-400" }
case "pattern":
return { label: "Pattern", className: "border-info/30 bg-info/10 text-info" }
case "lesson":
return { label: "Lesson", className: "border-warning/30 bg-warning/10 text-warning" }
case "freeform":
return { label: "Freeform", className: "border-success/30 bg-success/10 text-success" }
}
}
function KnowledgeTypeIcon({ type, className }: { type: KnowledgeEntry["type"]; className?: string }) {
const base = cn("h-3.5 w-3.5 shrink-0", className)
switch (type) {
case "rule":
return <Tag className={cn(base, "text-violet-400")} />
case "pattern":
return <Repeat2 className={cn(base, "text-info")} />
case "lesson":
return <Lightbulb className={cn(base, "text-warning")} />
case "freeform":
return <FileText className={cn(base, "text-success")} />
}
}
// ═══════════════════════════════════════════════════════════════════════
// CAPTURE STATUS STYLING
// ═══════════════════════════════════════════════════════════════════════
function captureStatusStyle(status: CaptureEntry["status"]) {
switch (status) {
case "pending":
return { label: "Pending", className: "border-warning/30 bg-warning/10 text-warning" }
case "triaged":
return { label: "Triaged", className: "border-info/30 bg-info/10 text-info" }
case "resolved":
return { label: "Resolved", className: "border-success/30 bg-success/10 text-success" }
}
}
function classificationLabel(c: Classification): string {
switch (c) {
case "quick-task": return "Quick Task"
case "inject": return "Inject"
case "defer": return "Defer"
case "replan": return "Replan"
case "note": return "Note"
}
}
function ClassificationIcon({ classification, className }: { classification: Classification; className?: string }) {
const base = cn("h-3 w-3 shrink-0", className)
switch (classification) {
case "quick-task": return <Zap className={base} />
case "inject": return <ArrowRightLeft className={base} />
case "defer": return <CalendarClock className={base} />
case "replan": return <ListTodo className={base} />
case "note": return <StickyNote className={base} />
}
}
const CLASSIFICATION_OPTIONS: Classification[] = ["quick-task", "inject", "defer", "replan", "note"]
// ═══════════════════════════════════════════════════════════════════════
// KNOWLEDGE TAB CONTENT
// ═══════════════════════════════════════════════════════════════════════
function KnowledgeEntryRow({ entry }: { entry: KnowledgeEntry }) {
const badge = knowledgeTypeBadge(entry.type)
return (
<div className="group rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 transition-colors hover:bg-card/50">
<div className="flex items-start gap-2.5">
<KnowledgeTypeIcon type={entry.type} className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground truncate">{entry.title}</span>
<Badge variant="outline" className={cn("text-[10px] px-1.5 py-0 h-4 shrink-0", badge.className)}>
{badge.label}
</Badge>
</div>
{entry.content && (
<p className="mt-1 text-[11px] text-muted-foreground line-clamp-2 leading-relaxed">
{entry.content}
</p>
)}
</div>
</div>
</div>
)
}
function KnowledgeTabContent({
data,
phase,
error,
onRefresh,
}: {
data: KnowledgeData | null
phase: string
error: string | null
onRefresh: () => void
}) {
if (phase === "loading") return <PanelLoading label="Loading knowledge base…" />
if (phase === "error" && error) return <PanelError message={error} />
if (!data || data.entries.length === 0) return <PanelEmpty message="No knowledge entries found" />
return (
<div className="space-y-3">
<PanelHeader
title="Knowledge Base"
subtitle={`${data.entries.length} entries`}
onRefresh={onRefresh}
refreshing={phase === "loading"}
/>
<div className="space-y-1.5">
{data.entries.map((entry) => (
<KnowledgeEntryRow key={entry.id} entry={entry} />
))}
</div>
{data.lastModified && (
<p className="pt-2 text-[10px] text-muted-foreground">
Last modified: {new Date(data.lastModified).toLocaleString()}
</p>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// CAPTURES TAB CONTENT
// ═══════════════════════════════════════════════════════════════════════
function CaptureEntryRow({
entry,
onResolve,
resolvePending,
}: {
entry: CaptureEntry
onResolve: (captureId: string, classification: Classification) => void
resolvePending: boolean
}) {
const status = captureStatusStyle(entry.status)
return (
<div className="group rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 transition-colors hover:bg-card/50">
<div className="flex items-start gap-2.5">
<div className={cn(
"mt-1 h-2 w-2 shrink-0 rounded-full",
entry.status === "pending" && "bg-warning",
entry.status === "triaged" && "bg-info",
entry.status === "resolved" && "bg-success",
)} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-foreground">{entry.text}</span>
<Badge variant="outline" className={cn("text-[10px] px-1.5 py-0 h-4 shrink-0", status.className)}>
{status.label}
</Badge>
{entry.classification && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0 border-border/50 text-muted-foreground">
{classificationLabel(entry.classification)}
</Badge>
)}
</div>
{entry.timestamp && (
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<Clock className="h-2.5 w-2.5" />
{entry.timestamp}
</div>
)}
{entry.resolution && (
<p className="mt-1 text-[10px] text-muted-foreground italic">{entry.resolution}</p>
)}
{entry.status === "pending" && (
<div className="mt-2 flex flex-wrap gap-1">
{CLASSIFICATION_OPTIONS.map((c) => (
<Button
key={c}
type="button"
variant="outline"
size="sm"
disabled={resolvePending}
onClick={() => onResolve(entry.id, c)}
className="h-6 gap-1 px-2 text-[10px] font-normal border-border/50 hover:bg-foreground/5"
>
<ClassificationIcon classification={c} />
{classificationLabel(c)}
</Button>
))}
</div>
)}
</div>
</div>
</div>
)
}
function CapturesTabContent({
data,
phase,
error,
resolvePending,
resolveError,
onRefresh,
onResolve,
}: {
data: CapturesData | null
phase: string
error: string | null
resolvePending: boolean
resolveError: string | null
onRefresh: () => void
onResolve: (captureId: string, classification: Classification) => void
}) {
if (phase === "loading") return <PanelLoading label="Loading captures…" />
if (phase === "error" && error) return <PanelError message={error} />
if (!data || data.entries.length === 0) return <PanelEmpty message="No captures found" />
return (
<div className="space-y-3">
<PanelHeader
title="Captures"
subtitle={`${data.entries.length} total`}
status={
<div className="flex gap-1.5">
<StatPill label="Pending" value={data.pendingCount} variant={data.pendingCount > 0 ? "warning" : "default"} />
<StatPill label="Actionable" value={data.actionableCount} variant={data.actionableCount > 0 ? "info" : "default"} />
</div>
}
onRefresh={onRefresh}
refreshing={phase === "loading"}
/>
{resolveError && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
Resolve error: {resolveError}
</div>
)}
<div className="space-y-1.5">
{data.entries.map((entry) => (
<CaptureEntryRow
key={entry.id}
entry={entry}
onResolve={onResolve}
resolvePending={resolvePending}
/>
))}
</div>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// MAIN PANEL COMPONENT
// ═══════════════════════════════════════════════════════════════════════
interface KnowledgeCapturesPanelProps {
initialTab: "knowledge" | "captures"
}
export function KnowledgeCapturesPanel({ initialTab }: KnowledgeCapturesPanelProps) {
const [activeTab, setActiveTab] = useState<"knowledge" | "captures">(initialTab)
const workspace = useGSDWorkspaceState()
const { loadKnowledgeData, loadCapturesData, resolveCaptureAction } = useGSDWorkspaceActions()
const knowledgeCaptures = workspace.commandSurface.knowledgeCaptures
const knowledgeState = knowledgeCaptures.knowledge
const capturesState = knowledgeCaptures.captures
const resolveState = knowledgeCaptures.resolveRequest
const capturesData = capturesState.data as CapturesData | null
const pendingCount = capturesData?.pendingCount ?? 0
const handleResolve = (captureId: string, classification: Classification) => {
void resolveCaptureAction({
captureId,
classification,
resolution: "Manual browser triage",
rationale: "Triaged via web UI",
})
}
return (
<div className="space-y-0">
{/* Tab bar */}
<div className="flex items-center gap-0.5 border-b border-border/50 px-1">
<button
type="button"
onClick={() => setActiveTab("knowledge")}
className={cn(
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all border-b-2 -mb-px",
activeTab === "knowledge"
? "border-foreground/60 text-foreground"
: "border-transparent text-muted-foreground hover:text-muted-foreground",
)}
>
<BookOpen className="h-3.5 w-3.5" />
Knowledge
</button>
<button
type="button"
onClick={() => setActiveTab("captures")}
className={cn(
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all border-b-2 -mb-px",
activeTab === "captures"
? "border-foreground/60 text-foreground"
: "border-transparent text-muted-foreground hover:text-muted-foreground",
)}
>
<InboxIcon className="h-3.5 w-3.5" />
Captures
{pendingCount > 0 && (
<Badge variant="outline" className="ml-1 h-4 px-1.5 py-0 text-[10px] border-warning/30 bg-warning/10 text-warning">
{pendingCount} pending
</Badge>
)}
</button>
</div>
{/* Tab content */}
<div className="p-4">
{activeTab === "knowledge" ? (
<KnowledgeTabContent
data={knowledgeState.data as KnowledgeData | null}
phase={knowledgeState.phase}
error={knowledgeState.error}
onRefresh={() => void loadKnowledgeData()}
/>
) : (
<CapturesTabContent
data={capturesData}
phase={capturesState.phase}
error={capturesState.error}
resolvePending={resolveState.pending}
resolveError={resolveState.lastError}
onRefresh={() => void loadCapturesData()}
onResolve={handleResolve}
/>
)}
</div>
</div>
)
}

View file

@ -1,198 +0,0 @@
"use client"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
// ─── Dashboard skeletons ──────────────────────────────────────────────────────
function MetricCardSkeleton({ label, icon }: { label: string; icon: React.ReactNode }) {
return (
<div className="rounded-md border border-border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
<Skeleton className="mt-2 h-7 w-24" />
<Skeleton className="mt-1.5 h-3 w-20" />
</div>
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">{icon}</div>
</div>
</div>
)
}
function CurrentUnitCardSkeleton({ icon }: { icon: React.ReactNode }) {
return (
<div className="rounded-md border border-border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Current Unit</p>
<Skeleton className="mt-2 h-7 w-20" />
<Skeleton className="mt-1.5 h-3 w-16" />
</div>
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">{icon}</div>
</div>
</div>
)
}
export function CurrentSliceCardSkeleton() {
return (
<div className="rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Current Slice</h2>
</div>
<div className="space-y-3 p-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
<Skeleton className={cn("h-4", i === 1 ? "w-48" : i === 2 ? "w-40" : "w-36")} />
</div>
))}
</div>
</div>
)
}
export function SessionCardSkeleton() {
return (
<div className="rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Session</h2>
</div>
<div className="p-4">
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded" />
<span className="text-muted-foreground">{i === 1 ? "Model" : i === 2 ? "Cost" : "Tokens"}</span>
</div>
<Skeleton className={cn("h-4", i === 1 ? "w-28" : "w-12")} />
</div>
))}
</div>
</div>
</div>
)
}
export function RecoveryCardSkeleton() {
return (
<div className="rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Recovery Summary</h2>
</div>
<div className="space-y-4 p-4">
<div className="space-y-1.5">
<Skeleton className="h-4 w-44" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
<div className="space-y-1.5">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className={cn("h-3", i % 2 === 0 ? "w-28" : "w-36")} />
))}
</div>
<Skeleton className="h-9 w-36 rounded-md" />
</div>
</div>
)
}
export function ActivityCardSkeleton() {
return (
<div className="rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Recent Activity</h2>
</div>
<div className="divide-y divide-border">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-3 w-16 shrink-0" />
<Skeleton className="h-1.5 w-1.5 shrink-0 rounded-full" />
<Skeleton className={cn("h-4 flex-1", i % 3 === 0 ? "max-w-xs" : i % 3 === 1 ? "max-w-sm" : "max-w-md")} />
</div>
))}
</div>
</div>
)
}
interface DashboardSkeletonProps {
icons: {
Activity: React.ReactNode
Clock: React.ReactNode
DollarSign: React.ReactNode
Zap: React.ReactNode
}
}
export function DashboardMetricsSkeleton({ icons }: DashboardSkeletonProps) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<CurrentUnitCardSkeleton icon={icons.Activity} />
<MetricCardSkeleton label="Elapsed Time" icon={icons.Clock} />
<MetricCardSkeleton label="Total Cost" icon={icons.DollarSign} />
<MetricCardSkeleton label="Tokens Used" icon={icons.Zap} />
<MetricCardSkeleton label="Progress" icon={icons.Activity} />
</div>
)
}
// ─── Sidebar skeletons ────────────────────────────────────────────────────────
/** Only the data-dependent portion of the sidebar content panel */
export function SidebarDataSkeleton() {
return (
<>
{/* Project path */}
<Skeleton className="mt-2 h-3 w-36" />
{/* Scope section */}
<div className="border-b border-border px-3 py-3">
<div className="space-y-1.5">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Active scope</p>
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-2.5 w-28" />
</div>
</div>
{/* Milestones list */}
<div className="flex-1 overflow-y-auto py-1">
<div className="px-2 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Milestones
</span>
</div>
<div className="space-y-0.5 px-1">
{[1, 2].map((m) => (
<div key={m}>
<div className="flex items-center gap-1.5 px-2 py-1.5">
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
<Skeleton className={cn("h-4", m === 1 ? "w-40" : "w-32")} />
</div>
{m === 1 && (
<div className="ml-4 space-y-0.5">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center gap-1.5 px-2 py-1.5">
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
<Skeleton className={cn("h-3.5", s === 1 ? "w-32" : s === 2 ? "w-28" : "w-24")} />
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
</>
)
}
// ─── Status bar value skeletons ───────────────────────────────────────────────
export function StatusBarValueSkeleton({ width = "w-16" }: { width?: string }) {
return <Skeleton className={cn("h-3 inline-block", width)} />
}

View file

@ -1,394 +0,0 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTheme } from "next-themes"
import { Loader2, ImagePlus } from "lucide-react"
import { cn } from "@/lib/utils"
import { validateImageFile } from "@/lib/image-utils"
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
import { authFetch, appendAuthParam } from "@/lib/auth"
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
import "@xterm/xterm/css/xterm.css"
type XTerminal = import("@xterm/xterm").Terminal
type XFitAddon = import("@xterm/addon-fit").FitAddon
interface MainSessionTerminalProps {
className?: string
fontSize?: number
projectCwd?: string
}
const MIN_INITIAL_ATTACH_WIDTH = 180
const MIN_INITIAL_ATTACH_HEIGHT = 120
const MIN_INITIAL_ATTACH_COLS = 20
const MIN_INITIAL_ATTACH_ROWS = 8
function getAttachableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null {
if (!container || !terminal) return null
const rect = container.getBoundingClientRect()
if (rect.width < MIN_INITIAL_ATTACH_WIDTH || rect.height < MIN_INITIAL_ATTACH_HEIGHT) {
return null
}
if (terminal.cols < MIN_INITIAL_ATTACH_COLS || terminal.rows < MIN_INITIAL_ATTACH_ROWS) {
return null
}
return { cols: terminal.cols, rows: terminal.rows }
}
async function settleTerminalLayout(
container: HTMLDivElement | null,
terminal: XTerminal | null,
fitAddon: XFitAddon | null,
isDisposed: () => boolean,
): Promise<{ cols: number; rows: number } | null> {
if (typeof document !== "undefined" && "fonts" in document) {
try {
await Promise.race([
document.fonts.ready,
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
])
} catch {
// Ignore font loading failures and fall through to repeated fit attempts.
}
}
for (let attempt = 0; attempt < 12; attempt++) {
if (isDisposed()) return null
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
if (isDisposed()) return null
try {
fitAddon?.fit()
} catch {
// Hidden or detached.
}
const size = getAttachableTerminalSize(container, terminal)
if (size) {
return size
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
return getAttachableTerminalSize(container, terminal)
}
export function MainSessionTerminal({ className, fontSize, projectCwd }: MainSessionTerminalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme !== "light"
const wrapperRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerminal | null>(null)
const fitAddonRef = useRef<XFitAddon | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const inputQueueRef = useRef<string[]>([])
const flushingRef = useRef(false)
const [connectionState, setConnectionState] = useState<"connecting" | "connected" | "error">("connecting")
const [hasOutput, setHasOutput] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const flushInputQueue = useCallback(async () => {
if (flushingRef.current) return
flushingRef.current = true
while (inputQueueRef.current.length > 0) {
const data = inputQueueRef.current.shift()!
try {
await authFetch(buildProjectPath("/api/bridge-terminal/input", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
})
} catch {
inputQueueRef.current.unshift(data)
break
}
}
flushingRef.current = false
}, [projectCwd])
const sendInput = useCallback((data: string) => {
inputQueueRef.current.push(data)
void flushInputQueue()
}, [flushInputQueue])
const sendResize = useCallback((cols: number, rows: number) => {
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
resizeTimeoutRef.current = setTimeout(() => {
void authFetch(buildProjectPath("/api/bridge-terminal/resize", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cols, rows }),
})
}, 75)
}, [projectCwd])
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = getXtermTheme(isDark)
}
}, [isDark])
useEffect(() => {
if (!termRef.current) return
termRef.current.options.fontSize = fontSize ?? 13
try {
fitAddonRef.current?.fit()
sendResize(termRef.current.cols, termRef.current.rows)
} catch {
// Hidden or not mounted yet.
}
}, [fontSize, sendResize])
useEffect(() => {
if (!containerRef.current) return
let disposed = false
let resizeObserver: ResizeObserver | null = null
let terminal: XTerminal | null = null
let fitAddon: XFitAddon | null = null
const init = async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
])
if (disposed) return
terminal = new Terminal(getXtermOptions(isDark, fontSize))
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(containerRef.current!)
termRef.current = terminal
fitAddonRef.current = fitAddon
const initialSize = await settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed)
if (disposed) return
terminal.onData((data) => {
sendInput(data)
})
terminal.onBinary((data) => {
sendInput(data)
})
const connectStream = (preferredSize: { cols: number; rows: number } | null) => {
const streamUrl = buildProjectAbsoluteUrl(
"/api/bridge-terminal/stream",
window.location.origin,
projectCwd,
)
if (preferredSize) {
streamUrl.searchParams.set("cols", String(preferredSize.cols))
streamUrl.searchParams.set("rows", String(preferredSize.rows))
}
const es = new EventSource(appendAuthParam(streamUrl.toString()))
eventSourceRef.current = es
setConnectionState((current) => (current === "connected" ? current : "connecting"))
es.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as { type: string; data?: string }
if (message.type === "connected") {
setConnectionState("connected")
void settleTerminalLayout(containerRef.current, termRef.current, fitAddonRef.current, () => disposed).then((size) => {
if (!size) return
sendResize(size.cols, size.rows)
})
return
}
if (message.type === "output" && typeof message.data === "string") {
termRef.current?.write(message.data)
setHasOutput(true)
}
} catch {
setConnectionState("error")
}
}
es.onerror = () => {
setConnectionState("error")
}
}
connectStream(initialSize)
resizeObserver = new ResizeObserver(() => {
if (disposed) return
try {
fitAddon?.fit()
if (terminal) {
sendResize(terminal.cols, terminal.rows)
}
} catch {
// Hidden or detached.
}
})
resizeObserver.observe(containerRef.current!)
}
void init()
return () => {
disposed = true
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
eventSourceRef.current?.close()
eventSourceRef.current = null
resizeObserver?.disconnect()
terminal?.dispose()
termRef.current = null
fitAddonRef.current = null
}
}, [fontSize, isDark, projectCwd, sendInput, sendResize])
const handleClick = useCallback(() => {
termRef.current?.focus()
}, [])
// ── Shift+Enter → newline (native DOM, capture phase) ────────────────────
// xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor
// recognizes \n (LF) as "insert newline". Capture-phase keydown intercepts
// before xterm's internal textarea processes the event.
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault()
e.stopPropagation()
sendInput("\n")
}
}
el.addEventListener("keydown", onKeyDown, true)
return () => el.removeEventListener("keydown", onKeyDown, true)
}, [sendInput])
// ── Drag-and-drop image upload (native DOM, capture phase) ──────────────
// React synthetic events don't reliably fire through xterm's internal DOM.
// Native capture-phase listeners intercept before xterm can swallow them —
// same pattern used for paste in ShellTerminal.
useEffect(() => {
const el = wrapperRef.current
if (!el) return
let counter = 0
const onDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter += 1
if (counter === 1) setIsDragOver(true)
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter -= 1
if (counter <= 0) {
counter = 0
setIsDragOver(false)
}
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter = 0
setIsDragOver(false)
const files = Array.from(e.dataTransfer?.files ?? [])
const imageFile = files.find((f) => f.type.startsWith("image/"))
if (!imageFile) return
const validation = validateImageFile(imageFile)
if (!validation.valid) {
console.warn("[main-terminal-upload] validation failed:", validation.error)
return
}
const formData = new FormData()
formData.append("file", imageFile)
void (async () => {
try {
const res = await authFetch(buildProjectPath("/api/terminal/upload", projectCwd), {
method: "POST",
body: formData,
})
const data = (await res.json()) as { ok?: boolean; path?: string; error?: string }
if (!res.ok || !data.path) {
console.error("[main-terminal-upload] upload failed:", data.error ?? `HTTP ${res.status}`)
return
}
console.log("[main-terminal-upload] injecting path:", data.path)
sendInput(`@${data.path} `)
} catch (err) {
console.error("[main-terminal-upload] upload request failed:", err)
}
})()
}
el.addEventListener("dragenter", onDragEnter, true)
el.addEventListener("dragover", onDragOver, true)
el.addEventListener("dragleave", onDragLeave, true)
el.addEventListener("drop", onDrop, true)
return () => {
el.removeEventListener("dragenter", onDragEnter, true)
el.removeEventListener("dragover", onDragOver, true)
el.removeEventListener("dragleave", onDragLeave, true)
el.removeEventListener("drop", onDrop, true)
}
}, [projectCwd, sendInput])
useEffect(() => {
const timer = setTimeout(() => termRef.current?.focus(), 80)
return () => clearTimeout(timer)
}, [])
return (
<div
ref={wrapperRef}
className={cn("relative h-full w-full bg-terminal", className)}
onClick={handleClick}
data-testid="main-session-native-terminal"
>
{!hasOutput && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{connectionState === "error" ? "Reconnecting main session terminal…" : "Connecting to main session…"}
</span>
</div>
)}
{/* Drop overlay */}
{isDragOver && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background backdrop-blur-sm border-2 border-dashed border-primary rounded-md pointer-events-none">
<ImagePlus className="h-8 w-8 text-primary" />
<span className="text-sm font-medium text-primary">Drop image here</span>
</div>
)}
<div ref={containerRef} className="h-full w-full" style={{ padding: "8px 4px 4px 8px" }} />
</div>
)
}

View file

@ -1,303 +0,0 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import Image from "next/image"
import {
type WorkspaceOnboardingProviderState,
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/gsd-workspace-store"
import { useDevOverrides } from "@/lib/dev-overrides"
import { useUserMode, type UserMode } from "@/lib/use-user-mode"
import { navigateToGSDView } from "@/lib/workflow-action-execution"
import { cn } from "@/lib/utils"
import { StepWelcome } from "./onboarding/step-welcome"
import { StepMode } from "./onboarding/step-mode"
import { StepProvider } from "./onboarding/step-provider"
import { StepAuthenticate } from "./onboarding/step-authenticate"
import { StepDevRoot } from "./onboarding/step-dev-root"
import { StepOptional } from "./onboarding/step-optional"
import { StepRemote } from "./onboarding/step-remote"
import { StepReady } from "./onboarding/step-ready"
import { StepProject } from "./onboarding/step-project"
// ─── Constants ──────────────────────────────────────────────────────
const WIZARD_STEPS = [
{ id: "welcome", label: "Welcome" },
{ id: "mode", label: "Mode" },
{ id: "provider", label: "Provider" },
{ id: "authenticate", label: "Auth" },
{ id: "devRoot", label: "Root" },
{ id: "optional", label: "Extras" },
{ id: "remote", label: "Remote" },
{ id: "ready", label: "Ready" },
{ id: "project", label: "Project" },
] as const
const TOTAL_STEPS = WIZARD_STEPS.length
const EMPTY_PROVIDERS: WorkspaceOnboardingProviderState[] = []
// ─── Helpers ────────────────────────────────────────────────────────
function chooseDefaultProvider(providers: WorkspaceOnboardingProviderState[]): string | null {
const unresolvedRecommended = providers.find((p) => !p.configured && p.recommended)
if (unresolvedRecommended) return unresolvedRecommended.id
const unresolved = providers.find((p) => !p.configured)
if (unresolved) return unresolved.id
return providers[0]?.id ?? null
}
// Slide animation
const slideVariants = {
enter: (dir: number) => ({ x: dir > 0 ? 50 : -50, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir < 0 ? 50 : -50, opacity: 0 }),
}
// ─── Step indicator (centered row of dots with labels) ──────────────
function StepIndicator({ current, total }: { current: number; total: number }) {
return (
<div className="flex items-center gap-1">
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className={cn(
"rounded-full transition-all duration-300",
i === current
? "h-1.5 w-5 bg-foreground"
: i < current
? "h-1.5 w-1.5 bg-foreground/40"
: "h-1.5 w-1.5 bg-foreground/10",
)}
/>
))}
</div>
)
}
// ─── Main Component ─────────────────────────────────────────────────
export function OnboardingGate() {
const workspace = useGSDWorkspaceState()
const {
refreshOnboarding,
saveApiKey,
startProviderFlow,
submitProviderFlowInput,
cancelProviderFlow,
refreshBoot,
} = useGSDWorkspaceActions()
const devOverrides = useDevOverrides()
const onboarding = workspace.boot?.onboarding
const forceVisible = devOverrides.isActive("forceOnboarding")
const isBusy = workspace.onboardingRequestState !== "idle"
// ─── Wizard state ───
const [stepIndex, setStepIndex] = useState(0)
const [direction, setDirection] = useState(0)
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
const [dismissedAfterSuccess, setDismissedAfterSuccess] = useState(false)
const [userMode, setUserMode] = useUserMode()
const [selectedMode, setSelectedMode] = useState<UserMode | null>(userMode)
const providers = onboarding?.required.providers ?? EMPTY_PROVIDERS
const effectiveSelectedProviderId = useMemo(() => {
if (onboarding?.activeFlow?.providerId) return onboarding.activeFlow.providerId
if (selectedProviderId && providers.some((p) => p.id === selectedProviderId)) return selectedProviderId
return chooseDefaultProvider(providers)
}, [onboarding?.activeFlow?.providerId, providers, selectedProviderId])
const shouldHideAfterSuccess = dismissedAfterSuccess && !onboarding?.locked && !isBusy
// Track whether auth was locked when the user arrived at step 3.
// Auto-advance only fires when auth transitions from locked → unlocked
// while the user is on the auth step — not when navigating back or
// when the provider was already configured.
const [authWasLockedOnArrival, setAuthWasLockedOnArrival] = useState(false)
const goTo = useCallback(
(target: number) => {
// When arriving at auth step, snapshot the locked state
if (target === 3 && onboarding?.locked) {
setAuthWasLockedOnArrival(true)
} else if (target === 3 && !onboarding?.locked) {
// Already unlocked — don't set the flag (prevents auto-advance)
setAuthWasLockedOnArrival(false)
}
setDirection(target > stepIndex ? 1 : -1)
setStepIndex(target)
},
[stepIndex, onboarding?.locked],
)
// Auto-advance past auth only when it just succeeded during this visit
useEffect(() => {
if (!onboarding) return
if (stepIndex !== 3) return
if (!authWasLockedOnArrival) return
const isUnlocked = !onboarding.locked
const bridgeDone = onboarding.bridgeAuthRefresh.phase === "succeeded" || onboarding.bridgeAuthRefresh.phase === "idle"
if (!isUnlocked || !bridgeDone) return
const t = window.setTimeout(() => goTo(4), 0)
return () => window.clearTimeout(t)
}, [onboarding, goTo, stepIndex, authWasLockedOnArrival])
const selectedProvider = useMemo(() => {
return providers.find((p) => p.id === effectiveSelectedProviderId) ?? null
}, [effectiveSelectedProviderId, providers])
// ─── Gate check ───
if (!onboarding) return null
const onboardingSettled =
!onboarding.locked ||
(onboarding.lastValidation?.status === "succeeded" &&
(onboarding.bridgeAuthRefresh.phase === "succeeded" || onboarding.bridgeAuthRefresh.phase === "idle"))
if (!forceVisible && (onboardingSettled || shouldHideAfterSuccess)) return null
const stepLabel = WIZARD_STEPS[stepIndex]?.label ?? ""
return (
<div className="pointer-events-auto absolute inset-0 z-30 flex flex-col bg-background" data-testid="onboarding-gate">
{/* Header */}
<header className="relative z-10 flex h-12 shrink-0 items-center justify-between px-5 md:px-8">
{/* Left — logo */}
<div className="flex w-24 items-center gap-2">
<Image src="/logo-white.svg" alt="GSD" width={57} height={16} className="hidden h-4 w-auto dark:block" />
<Image src="/logo-black.svg" alt="GSD" width={57} height={16} className="h-4 w-auto dark:hidden" />
</div>
{/* Center — step indicator */}
<div className="absolute inset-x-0 flex justify-center pointer-events-none">
<div className="pointer-events-auto">
<StepIndicator current={stepIndex} total={TOTAL_STEPS} />
</div>
</div>
{/* Right — step label */}
<div className="flex w-24 justify-end">
<span className="text-xs text-muted-foreground">{stepLabel}</span>
</div>
</header>
{/* Thin progress — hidden when not needed */}
{/* Content — full remaining height, scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-2xl flex-col justify-center px-5 py-10 md:px-8 md:py-16">
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={stepIndex}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 400, damping: 35, opacity: { duration: 0.15 } }}
>
{stepIndex === 0 && <StepWelcome onNext={() => goTo(1)} />}
{stepIndex === 1 && (
<StepMode
selected={selectedMode}
onSelect={(mode) => { setSelectedMode(mode); setUserMode(mode) }}
onNext={() => goTo(2)}
onBack={() => goTo(0)}
/>
)}
{stepIndex === 2 && (
<StepProvider
providers={onboarding.required.providers}
selectedId={effectiveSelectedProviderId}
onSelect={(id) => {
setSelectedProviderId(id)
goTo(3)
}}
onNext={() => goTo(4)}
onBack={() => goTo(1)}
/>
)}
{stepIndex === 3 && selectedProvider && (
<StepAuthenticate
provider={selectedProvider}
activeFlow={onboarding.activeFlow}
lastValidation={onboarding.lastValidation}
requestState={workspace.onboardingRequestState}
requestProviderId={workspace.onboardingRequestProviderId}
onSaveApiKey={async (pid, key) => {
const next = await saveApiKey(pid, key)
const settled = Boolean(
next && !next.locked &&
(next.bridgeAuthRefresh.phase === "succeeded" || next.bridgeAuthRefresh.phase === "idle"),
)
if (settled) { setDismissedAfterSuccess(true); void refreshBoot() }
return next
}}
onStartFlow={(pid) => void startProviderFlow(pid)}
onSubmitFlowInput={(fid, input) => void submitProviderFlowInput(fid, input)}
onCancelFlow={(fid) => void cancelProviderFlow(fid)}
onBack={() => goTo(2)}
onNext={() => goTo(2)}
bridgeRefreshPhase={onboarding.bridgeAuthRefresh.phase}
bridgeRefreshError={onboarding.bridgeAuthRefresh.error}
/>
)}
{stepIndex === 4 && <StepDevRoot onBack={() => goTo(2)} onNext={() => goTo(5)} />}
{stepIndex === 5 && (
<StepOptional
sections={onboarding.optional.sections}
onBack={() => goTo(4)}
onNext={() => goTo(6)}
/>
)}
{stepIndex === 6 && (
<StepRemote
onBack={() => goTo(5)}
onNext={() => goTo(7)}
/>
)}
{stepIndex === 7 && (
<StepReady
providerLabel={
onboarding.lastValidation?.providerId
? onboarding.required.providers.find((p) => p.id === onboarding.lastValidation?.providerId)?.label ?? "Provider"
: "Provider"
}
onFinish={() => goTo(8)}
/>
)}
{stepIndex === 8 && (
<StepProject
onBack={() => goTo(7)}
onBeforeSwitch={() => {
// Disarm the gate BEFORE switchProject triggers a store remount
if (devOverrides.isActive("forceOnboarding")) {
devOverrides.toggle("forceOnboarding")
}
setDismissedAfterSuccess(true)
}}
onFinish={() => {
const mode = selectedMode ?? userMode
navigateToGSDView("dashboard")
void refreshBoot()
}}
/>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
)
}

View file

@ -1,496 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import {
ArrowRight,
ArrowUpRight,
CheckCircle2,
ClipboardCopy,
ExternalLink,
KeyRound,
LoaderCircle,
RotateCcw,
ShieldAlert,
ShieldCheck,
XCircle,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Progress } from "@/components/ui/progress"
import type {
WorkspaceOnboardingFlowState,
WorkspaceOnboardingProviderState,
WorkspaceOnboardingRequestState,
WorkspaceOnboardingState,
WorkspaceOnboardingValidationResult,
} from "@/lib/gsd-workspace-store"
import { cn } from "@/lib/utils"
// ─── Error parsing ──────────────────────────────────────────────────
function parseValidationError(raw: string | null | undefined): { title: string; detail: string | null } {
if (!raw) return { title: "Validation failed", detail: null }
const jsonInStatusMatch = raw.match(/^\d{3}\s+[^:]+:\s*(.+)$/s)
const jsonCandidate = jsonInStatusMatch?.[1] ?? raw
try {
const parsed = JSON.parse(jsonCandidate)
if (typeof parsed === "object" && parsed !== null) {
const message = parsed.error_details?.message ?? parsed.error?.message ?? parsed.message ?? parsed.error ?? null
if (typeof message === "string" && message.length > 0) {
if (/subscription.*(ended|expired|cancelled)/i.test(message))
return { title: "Subscription expired", detail: message.replace(/\.$/, "") + ". Check your plan status with this provider." }
if (/rate.limit/i.test(message))
return { title: "Rate limited", detail: "Too many requests. Wait a moment and try again." }
if (/invalid.*key|invalid.*token|incorrect.*key/i.test(message))
return { title: "Invalid credentials", detail: "The API key was rejected. Double-check and try again." }
if (/quota|billing|payment/i.test(message))
return { title: "Billing issue", detail: message }
return { title: "Provider error", detail: message }
}
}
} catch { /* not JSON */ }
if (/^401\b/i.test(raw)) return { title: "Unauthorized", detail: "The credentials were rejected. Double-check your API key." }
if (/^403\b/i.test(raw)) return { title: "Access denied", detail: "Your account doesn't have access. Check your subscription or permissions." }
if (/^429\b/i.test(raw)) return { title: "Rate limited", detail: "Too many requests. Wait a moment and try again." }
if (/^5\d{2}\b/i.test(raw)) return { title: "Server error", detail: "The provider returned an error. Try again in a minute." }
return { title: "Validation failed", detail: raw.length > 200 ? raw.slice(0, 200) + "…" : raw }
}
/** Extract a device code from instructions/prompt text */
function extractDeviceCode(flow: WorkspaceOnboardingFlowState): string | null {
const sources = [flow.prompt?.message, flow.auth?.instructions].filter(Boolean)
for (const src of sources) {
const match = src?.match(/(?:code|Code)[:\s]+([A-Z0-9]{4}[-][A-Z0-9]{4})/i)
if (match) return match[1]
}
return null
}
// ─── Component ──────────────────────────────────────────────────────
interface StepAuthenticateProps {
provider: WorkspaceOnboardingProviderState
activeFlow: WorkspaceOnboardingFlowState | null
lastValidation: WorkspaceOnboardingValidationResult | null
requestState: WorkspaceOnboardingRequestState
requestProviderId: string | null
onSaveApiKey: (providerId: string, apiKey: string) => Promise<WorkspaceOnboardingState | null>
onStartFlow: (providerId: string) => void
onSubmitFlowInput: (flowId: string, input: string) => void
onCancelFlow: (flowId: string) => void
onBack: () => void
onNext: () => void
bridgeRefreshPhase: "idle" | "pending" | "succeeded" | "failed"
bridgeRefreshError: string | null
}
export function StepAuthenticate({
provider,
activeFlow,
lastValidation,
requestState,
requestProviderId,
onSaveApiKey,
onStartFlow,
onSubmitFlowInput,
onCancelFlow,
onBack,
onNext,
bridgeRefreshPhase,
bridgeRefreshError,
}: StepAuthenticateProps) {
const [apiKey, setApiKey] = useState("")
const [flowInput, setFlowInput] = useState("")
const [copied, setCopied] = useState(false)
const isBusy = requestState !== "idle"
const isThisProviderBusy = requestProviderId === provider.id && isBusy
const isValidated = lastValidation?.status === "succeeded" && lastValidation.providerId === provider.id
const isBridgeDone = bridgeRefreshPhase === "succeeded" || bridgeRefreshPhase === "idle"
const canProceed = isValidated && isBridgeDone
const validationFailed = lastValidation?.status === "failed" && lastValidation.providerId === provider.id
const parsedError = validationFailed ? parseValidationError(lastValidation.message) : null
const isOAuthOnly = !provider.supports.apiKey && provider.supports.oauth
const hasOAuth = provider.supports.oauth && provider.supports.oauthAvailable
const hasApiKey = provider.supports.apiKey
// Active flow state
const flowActive = activeFlow && activeFlow.providerId === provider.id && !canProceed
const flowFailed = flowActive && activeFlow.status === "failed"
const flowRunning = flowActive && (activeFlow.status === "running" || activeFlow.status === "awaiting_browser_auth")
const flowWaiting = flowActive && activeFlow.status === "awaiting_input"
const deviceCode = flowActive ? extractDeviceCode(activeFlow) : null
useEffect(() => {
if (lastValidation?.status !== "succeeded") return
const t = window.setTimeout(() => setApiKey(""), 0)
return () => window.clearTimeout(t)
}, [lastValidation?.checkedAt, lastValidation?.status])
useEffect(() => {
const t = window.setTimeout(() => setFlowInput(""), 0)
return () => window.clearTimeout(t)
}, [activeFlow?.flowId])
useEffect(() => {
if (!copied) return
const t = window.setTimeout(() => setCopied(false), 2000)
return () => window.clearTimeout(t)
}, [copied])
const copyCode = (code: string) => {
navigator.clipboard.writeText(code).then(() => setCopied(true)).catch(() => {})
}
return (
<div className="flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Connect {provider.label}
</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
{canProceed
? "Authenticated and ready to go."
: hasApiKey && hasOAuth
? "Paste an API key or sign in through your browser."
: hasApiKey
? "Paste your API key to authenticate."
: "Sign in through your browser to authenticate."}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08, duration: 0.45 }}
className="mt-8 w-full max-w-md space-y-4"
>
{/* ─── Success state ─── */}
<AnimatePresence>
{canProceed && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
className="flex flex-col items-center gap-3 rounded-xl border border-success/15 bg-success/[0.04] px-6 py-6 text-center"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-success/15">
<ShieldCheck className="h-5 w-5 text-success" />
</div>
<div className="text-sm font-medium text-foreground">{provider.label} authenticated</div>
</motion.div>
)}
</AnimatePresence>
{/* ─── Validation error ─── */}
{validationFailed && parsedError && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<div className="font-medium text-destructive">{parsedError.title}</div>
{parsedError.detail && <div className="mt-0.5 text-muted-foreground">{parsedError.detail}</div>}
</div>
</div>
)}
{/* ─── Bridge refresh ─── */}
{bridgeRefreshPhase === "pending" && (
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-xl border border-foreground/10 bg-foreground/[0.03] px-4 py-3 text-sm text-foreground/80">
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin" />
Connecting to provider
</div>
<Progress value={66} className="h-1" />
</div>
)}
{bridgeRefreshPhase === "failed" && bridgeRefreshError && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<div className="font-medium text-destructive">Connection failed</div>
<div className="mt-0.5 text-muted-foreground">{bridgeRefreshError}</div>
</div>
</div>
)}
{/* ─── API key form ─── */}
{hasApiKey && !canProceed && (
<div className="space-y-3 rounded-xl border border-border/50 bg-card/50 p-4">
<div className="text-sm font-medium text-foreground">API key</div>
<form
className="space-y-3"
onSubmit={async (e) => {
e.preventDefault()
if (!apiKey.trim()) return
const next = await onSaveApiKey(provider.id, apiKey)
if (next && !next.locked && (next.bridgeAuthRefresh.phase === "succeeded" || next.bridgeAuthRefresh.phase === "idle")) {
onNext()
}
}}
>
<Input
data-testid="onboarding-api-key-input"
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={`Paste your ${provider.label} API key`}
disabled={isBusy}
className="font-mono text-sm"
/>
<div className="flex items-center gap-2">
<Button
type="submit"
disabled={!apiKey.trim() || isBusy}
className="gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-save-api-key"
>
{isThisProviderBusy && requestState === "saving_api_key" ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<KeyRound className="h-4 w-4" />
)}
Validate & save
</Button>
</div>
</form>
</div>
)}
{/* ─── OAuth section ─── */}
{hasOAuth && !canProceed && (
<div className="space-y-3">
{/* Divider between API key and OAuth */}
{hasApiKey && (
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-border/50" />
<span className="text-xs text-muted-foreground">or</span>
<div className="h-px flex-1 bg-border/50" />
</div>
)}
{/* ─── No active flow: show start button ─── */}
{!flowActive && (
<div className="rounded-xl border border-border/50 bg-card/50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">Browser sign-in</div>
<p className="mt-0.5 text-xs text-muted-foreground">
Opens a new tab to authenticate with {provider.label}
</p>
</div>
<Button
variant="outline"
disabled={isBusy}
onClick={() => onStartFlow(provider.id)}
className="shrink-0 gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-start-provider-flow"
>
{isThisProviderBusy && requestState === "starting_provider_flow" ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<ArrowUpRight className="h-4 w-4" />
)}
Sign in
</Button>
</div>
</div>
)}
{/* ─── Active flow: device code UX ─── */}
{flowActive && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="rounded-xl border border-border/50 bg-card/50 p-4 space-y-4"
data-testid="onboarding-active-flow"
>
{/* Device code — big and prominent */}
{deviceCode && (
<div className="flex flex-col items-center gap-3 py-2">
<div className="text-xs text-muted-foreground">Enter this code on the sign-in page</div>
<button
type="button"
onClick={() => copyCode(deviceCode)}
className="group flex items-center gap-3 rounded-lg border border-border bg-background/50 px-5 py-3 transition-colors hover:border-foreground/20 active:scale-[0.98]"
>
<span className="font-mono text-2xl font-bold tracking-[0.15em] text-foreground">
{deviceCode}
</span>
<span className="text-muted-foreground transition-colors group-hover:text-muted-foreground">
{copied ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
</span>
</button>
<div className="text-[11px] text-muted-foreground">
{copied ? "Copied!" : "Click to copy"}
</div>
</div>
)}
{/* Instructions text (when no device code extracted) */}
{!deviceCode && activeFlow.auth?.instructions && (
<p className="text-sm text-muted-foreground">{activeFlow.auth.instructions}</p>
)}
{/* Open sign-in page button */}
{activeFlow.auth?.url && (
<Button asChild className="w-full gap-2 transition-transform active:scale-[0.96]">
<a href={activeFlow.auth.url} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Open sign-in page
</a>
</Button>
)}
{/* Status indicator */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2 text-muted-foreground">
{flowRunning && (
<>
<LoaderCircle className="h-3 w-3 animate-spin" />
<span>Waiting for authentication</span>
</>
)}
{flowFailed && (
<>
<XCircle className="h-3 w-3 text-destructive" />
<span className="text-destructive">Sign-in failed or timed out</span>
</>
)}
{flowWaiting && !deviceCode && (
<>
<LoaderCircle className="h-3 w-3 animate-spin" />
<span>Waiting for input</span>
</>
)}
</div>
<div className="flex items-center gap-1">
{flowFailed && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onStartFlow(provider.id)}
disabled={isBusy}
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<RotateCcw className="h-3 w-3" />
Retry
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onCancelFlow(activeFlow.flowId)}
disabled={isBusy}
className="h-7 text-xs text-muted-foreground"
>
Cancel
</Button>
</div>
</div>
{/* Generic prompt input (non-device-code) */}
{activeFlow.prompt && !deviceCode && (
<form
className="space-y-2 border-t border-border/50 pt-3"
onSubmit={(e) => {
e.preventDefault()
if (!activeFlow.prompt?.allowEmpty && !flowInput.trim()) return
onSubmitFlowInput(activeFlow.flowId, flowInput)
}}
>
<div className="text-xs text-muted-foreground">{activeFlow.prompt.message}</div>
<div className="flex gap-2">
<Input
data-testid="onboarding-flow-input"
value={flowInput}
onChange={(e) => setFlowInput(e.target.value)}
placeholder={activeFlow.prompt.placeholder || "Enter value"}
disabled={isBusy}
className="text-sm"
/>
<Button
type="submit"
disabled={isBusy || (!activeFlow.prompt.allowEmpty && !flowInput.trim())}
className="shrink-0 transition-transform active:scale-[0.96]"
>
{requestState === "submitting_provider_flow_input" ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
"Submit"
)}
</Button>
</div>
</form>
)}
{/* Progress messages */}
{activeFlow.progress.length > 0 && (
<div className="space-y-1 border-t border-border/50 pt-3">
{activeFlow.progress.map((message, i) => (
<div key={`${activeFlow.flowId}-${i}`} className="text-xs text-muted-foreground">
{message}
</div>
))}
</div>
)}
</motion.div>
)}
</div>
)}
{/* OAuth unavailable */}
{provider.supports.oauth && !provider.supports.oauthAvailable && !hasApiKey && (
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-3.5 text-sm text-muted-foreground">
Browser sign-in is not available in this runtime. Go back and choose a provider with API-key support.
</div>
)}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="mt-8 flex w-full max-w-md items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={onNext}
disabled={!canProceed}
className="group gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-auth-continue"
>
Configure another provider
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</motion.div>
</div>
)
}

View file

@ -1,369 +0,0 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import {
ArrowRight,
ChevronRight,
CornerLeftUp,
Folder,
FolderOpen,
FolderRoot,
Loader2,
SkipForward,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
import { authFetch } from "@/lib/auth"
interface StepDevRootProps {
onNext: () => void
onBack: () => void
}
const SUGGESTED_PATHS = ["~/Projects", "~/Developer", "~/Code", "~/dev"]
// ─── Inline folder browser ──────────────────────────────────────────
interface BrowseEntry {
name: string
path: string
}
function InlineFolderBrowser({
onSelect,
onCancel,
}: {
onSelect: (path: string) => void
onCancel: () => void
}) {
const [currentPath, setCurrentPath] = useState("")
const [parentPath, setParentPath] = useState<string | null>(null)
const [entries, setEntries] = useState<BrowseEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const browse = useCallback(async (targetPath?: string) => {
setLoading(true)
setError(null)
try {
const param = targetPath ? `?path=${encodeURIComponent(targetPath)}` : ""
const res = await authFetch(`/api/browse-directories${param}`)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? `${res.status}`)
}
const data = (await res.json()) as { current: string; parent: string | null; entries: BrowseEntry[] }
setCurrentPath(data.current)
setParentPath(data.parent)
setEntries(data.entries)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to browse")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void browse()
}, [browse])
return (
<div className="rounded-xl border border-border/50 bg-card/50 overflow-hidden">
{/* Current path */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-4 py-2.5">
<p className="min-w-0 truncate font-mono text-xs text-muted-foreground" title={currentPath}>
{currentPath}
</p>
<Button
type="button"
size="sm"
onClick={() => onSelect(currentPath)}
className="shrink-0 h-7 gap-1.5 text-xs transition-transform active:scale-[0.96]"
>
Select this folder
</Button>
</div>
{/* Directory listing */}
<ScrollArea className="h-[240px]">
<div className="px-1.5 py-1">
{loading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="px-3 py-4 text-center text-xs text-destructive">{error}</div>
)}
{!loading && !error && (
<>
{parentPath && (
<button
type="button"
onClick={() => void browse(parentPath)}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<CornerLeftUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">..</span>
</button>
)}
{entries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => void browse(entry.path)}
className="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground">{entry.name}</span>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
))}
{entries.length === 0 && !parentPath && (
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
No subdirectories
</div>
)}
</>
)}
</div>
</ScrollArea>
{/* Cancel */}
<div className="border-t border-border/50 px-4 py-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancel}
className="h-7 text-xs text-muted-foreground"
>
Cancel
</Button>
</div>
</div>
)
}
// ─── Main step ──────────────────────────────────────────────────────
export function StepDevRoot({ onNext, onBack }: StepDevRootProps) {
const [path, setPath] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [browsing, setBrowsing] = useState(false)
const handleSuggestionClick = useCallback((suggestion: string) => {
setPath(suggestion)
setError(null)
}, [])
const handleContinue = useCallback(async () => {
const trimmed = path.trim()
if (!trimmed) {
setError("Enter a path or skip this step")
return
}
setSaving(true)
setError(null)
try {
const res = await authFetch("/api/preferences", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ devRoot: trimmed }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(
(body as { error?: string }).error ?? `Request failed (${res.status})`,
)
}
onNext()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save preference")
} finally {
setSaving(false)
}
}, [path, onNext])
return (
<div className="flex flex-col items-center text-center">
{/* Icon */}
<motion.div
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
className="mb-8"
>
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-border/50 bg-card/50">
<FolderRoot className="h-7 w-7 text-foreground/80" strokeWidth={1.5} />
</div>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06, duration: 0.4 }}
className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"
>
Dev root
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12, duration: 0.4 }}
className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground"
>
The folder that contains your projects. GSD discovers and manages workspaces inside it.
</motion.p>
{/* Input + browse */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.18, duration: 0.45 }}
className="mt-8 w-full max-w-md space-y-4"
>
<AnimatePresence mode="wait">
{browsing ? (
<motion.div
key="browser"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<InlineFolderBrowser
onSelect={(selected) => {
setPath(selected)
setBrowsing(false)
setError(null)
}}
onCancel={() => setBrowsing(false)}
/>
</motion.div>
) : (
<motion.div key="input" className="space-y-4">
<div className="flex gap-2">
<Input
value={path}
onChange={(e) => {
setPath(e.target.value)
if (error) setError(null)
}}
placeholder="/Users/you/Projects"
className={cn(
"h-11 flex-1 font-mono text-sm",
error && "border-destructive/50 focus-visible:ring-destructive/30",
)}
data-testid="onboarding-devroot-input"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && path.trim()) {
void handleContinue()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => setBrowsing(true)}
className="h-11 gap-2 shrink-0 transition-transform active:scale-[0.96]"
>
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
{/* Suggestions */}
<div className="flex flex-wrap items-center justify-center gap-2">
{SUGGESTED_PATHS.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => handleSuggestionClick(suggestion)}
className={cn(
"rounded-full border px-3 py-1 font-mono text-xs transition-all duration-150",
"active:scale-[0.96]",
path === suggestion
? "border-foreground/25 bg-foreground/10 text-foreground"
: "border-border/50 text-muted-foreground hover:border-foreground/15 hover:text-foreground",
)}
>
{suggestion}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25, duration: 0.3 }}
className="mt-8 flex w-full max-w-md items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={onNext}
className="gap-1.5 text-muted-foreground transition-transform active:scale-[0.96]"
data-testid="onboarding-devroot-skip"
>
Skip
<SkipForward className="h-3.5 w-3.5" />
</Button>
<Button
onClick={() => void handleContinue()}
className="group gap-2 transition-transform active:scale-[0.96]"
disabled={saving || browsing}
data-testid="onboarding-devroot-continue"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving
</>
) : (
<>
Continue
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</>
)}
</Button>
</div>
</motion.div>
</div>
)
}

View file

@ -1,186 +0,0 @@
"use client"
import { motion } from "motion/react"
import { ArrowRight, Code2, MessageCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { UserMode } from "@/lib/use-user-mode"
interface StepModeProps {
selected: UserMode | null
onSelect: (mode: UserMode) => void
onNext: () => void
onBack: () => void
}
const MODE_OPTIONS: {
id: UserMode
label: string
icon: typeof Code2
tagline: string
description: string
}[] = [
{
id: "expert",
label: "Expert",
icon: Code2,
tagline: "Full control",
description:
"Dashboard metrics, dual-pane power mode, and direct /gsd command access. Built for people who want visibility into every milestone and task.",
},
{
id: "vibe-coder",
label: "Vibe Coder",
icon: MessageCircle,
tagline: "Just chat",
description:
"Conversational interface with the AI agent. Describe what you want and let GSD handle the structure. Same engine, friendlier surface.",
},
]
export function StepMode({ selected, onSelect, onNext, onBack }: StepModeProps) {
return (
<div className="flex flex-col items-center text-center">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"
style={{ textWrap: "balance" } as React.CSSProperties}
>
How do you want to work?
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06, duration: 0.4 }}
className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground"
>
You can switch anytime from settings.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12, duration: 0.45 }}
className="mt-8 grid w-full max-w-lg gap-3 sm:grid-cols-2"
>
{MODE_OPTIONS.map((opt) => {
const isSelected = selected === opt.id
const Icon = opt.icon
return (
<button
key={opt.id}
type="button"
onClick={() => onSelect(opt.id)}
className={cn(
"group relative flex flex-col rounded-xl border px-5 py-5 text-left transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"active:scale-[0.98]",
isSelected
? "border-foreground/30 bg-foreground/[0.06] shadow-[0_0_0_1px_rgba(255,255,255,0.06)]"
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
)}
data-testid={`onboarding-mode-${opt.id}`}
>
{/* Selection indicator */}
<div className="absolute right-3.5 top-3.5">
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-full border-[1.5px] transition-all duration-200",
isSelected
? "border-foreground bg-foreground"
: "border-foreground/20",
)}
>
{isSelected && (
<motion.svg
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
viewBox="0 0 12 12"
className="h-2.5 w-2.5 text-background"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="2.5 6 5 8.5 9.5 3.5" />
</motion.svg>
)}
</div>
</div>
{/* Icon */}
<div
className={cn(
"mb-4 flex h-10 w-10 items-center justify-center rounded-lg transition-colors duration-200",
isSelected
? "bg-foreground/10"
: "bg-foreground/[0.04]",
)}
>
<Icon
className={cn(
"h-5 w-5 transition-colors duration-200",
isSelected ? "text-foreground" : "text-muted-foreground",
)}
strokeWidth={1.5}
/>
</div>
{/* Label + tagline */}
<div className="pr-7">
<span className="text-[15px] font-semibold text-foreground">
{opt.label}
</span>
<span
className={cn(
"ml-2 text-xs font-medium transition-colors duration-200",
isSelected ? "text-muted-foreground" : "text-muted-foreground",
)}
>
{opt.tagline}
</span>
</div>
{/* Description */}
<p className="mt-2 text-[13px] leading-relaxed text-muted-foreground">
{opt.description}
</p>
</button>
)
})}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.3 }}
className="mt-8 flex w-full max-w-lg items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={onNext}
disabled={!selected}
className="group gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-mode-continue"
>
Continue
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</motion.div>
</div>
)
}

View file

@ -1,161 +0,0 @@
"use client"
import { motion } from "motion/react"
import { ArrowRight, Check, CircleDashed } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import type { WorkspaceOnboardingOptionalSectionState } from "@/lib/gsd-workspace-store"
import { cn } from "@/lib/utils"
interface StepOptionalProps {
sections: WorkspaceOnboardingOptionalSectionState[]
onBack: () => void
onNext: () => void
}
export function StepOptional({ sections, onBack, onNext }: StepOptionalProps) {
// Remote questions has its own dedicated step — don't show it here
const filtered = sections.filter((s) => s.id !== "remote_questions")
const configuredCount = filtered.filter((s) => s.configured).length
return (
<div className="flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Integrations
</h2>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Optional tools. Nothing here blocks the workspace configure later from settings.
</p>
</motion.div>
{configuredCount > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.08, duration: 0.3 }}
className="mt-4"
>
<span className="text-xs text-muted-foreground">
<span className="font-medium text-success">{configuredCount}</span>
{" of "}
{filtered.length} configured
</span>
</motion.div>
)}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.45 }}
className="mt-8 w-full space-y-2"
>
{filtered.map((section) => (
<div
key={section.id}
className={cn(
"flex items-start gap-3.5 rounded-xl border px-4 py-3.5 transition-colors",
section.configured
? "border-success/15 bg-success/[0.03]"
: "border-border/50 bg-card/50",
)}
data-testid={`onboarding-optional-${section.id}`}
>
{/* Status dot */}
<div
className={cn(
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full",
section.configured
? "bg-success/15 text-success"
: "bg-foreground/[0.05] text-muted-foreground",
)}
>
{section.configured ? (
<Check className="h-3 w-3" strokeWidth={3} />
) : (
<CircleDashed className="h-3 w-3" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-foreground">{section.label}</span>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={cn(
"text-[10px]",
section.configured
? "border-success/15 text-success/70"
: "border-border/50 text-muted-foreground",
)}
>
{section.configured ? "Ready" : "Skipped"}
</Badge>
</TooltipTrigger>
<TooltipContent>
{section.configured
? "This integration is configured and active"
: "You can set this up later from workspace settings"}
</TooltipContent>
</Tooltip>
</div>
{section.configuredItems.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{section.configuredItems.map((item) => (
<Badge
key={item}
variant="outline"
className="border-border/50 text-[10px] text-muted-foreground"
>
{item}
</Badge>
))}
</div>
)}
{section.configuredItems.length === 0 && (
<p className="mt-0.5 text-xs text-muted-foreground">
Not configured add later from settings.
</p>
)}
</div>
</div>
))}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.18, duration: 0.3 }}
className="mt-8 flex w-full items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={onNext}
className="group gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-optional-continue"
>
Continue
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</motion.div>
</div>
)
}

View file

@ -1,470 +0,0 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { motion } from "motion/react"
import {
ArrowRight,
FolderOpen,
GitBranch,
Layers,
Loader2,
Plus,
Sparkles,
Zap,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useProjectStoreManager } from "@/lib/project-store-manager"
import { cn } from "@/lib/utils"
import { authFetch } from "@/lib/auth"
// ─── Types ──────────────────────────────────────────────────────────
type ProjectDetectionKind = "active-gsd" | "empty-gsd" | "v1-legacy" | "brownfield" | "blank"
interface ProjectDetectionSignals {
hasGsdFolder: boolean
hasPlanningFolder: boolean
hasGitRepo: boolean
hasPackageJson: boolean
fileCount: number
hasMilestones?: boolean
hasCargo?: boolean
hasGoMod?: boolean
hasPyproject?: boolean
isMonorepo?: boolean
}
interface ProjectProgressInfo {
activeMilestone: string | null
activeSlice: string | null
phase: string | null
milestonesCompleted: number
milestonesTotal: number
}
interface ProjectMetadata {
name: string
path: string
kind: ProjectDetectionKind
signals: ProjectDetectionSignals
lastModified: number
progress?: ProjectProgressInfo | null
}
// ─── Helpers ────────────────────────────────────────────────────────
const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; icon: typeof Layers }> = {
"active-gsd": { label: "Active", color: "text-success", icon: Layers },
"empty-gsd": { label: "Initialized", color: "text-info", icon: FolderOpen },
brownfield: { label: "Existing", color: "text-warning", icon: GitBranch },
"v1-legacy": { label: "Legacy", color: "text-warning", icon: GitBranch },
blank: { label: "New", color: "text-muted-foreground", icon: Sparkles },
}
function techStack(signals: ProjectDetectionSignals): string[] {
const tags: string[] = []
if (signals.isMonorepo) tags.push("Monorepo")
if (signals.hasGitRepo) tags.push("Git")
if (signals.hasPackageJson) tags.push("Node.js")
if (signals.hasCargo) tags.push("Rust")
if (signals.hasGoMod) tags.push("Go")
if (signals.hasPyproject) tags.push("Python")
return tags
}
function progressLabel(p: ProjectProgressInfo): string | null {
if (p.milestonesTotal === 0) return null
const parts: string[] = []
if (p.activeMilestone) parts.push(p.activeMilestone)
if (p.activeSlice) parts.push(p.activeSlice)
if (p.phase) parts.push(p.phase)
return parts.join(" · ") || null
}
function shortenPath(p: string): string {
const home = typeof window !== "undefined" ? "" : ""
// Show last 2-3 segments
const parts = p.split("/").filter(Boolean)
if (parts.length <= 3) return p
return "…/" + parts.slice(-2).join("/")
}
// ─── Component ──────────────────────────────────────────────────────
interface StepProjectProps {
onFinish: (projectPath: string) => void
onBack: () => void
/** Called immediately before a project switch starts — use to disarm gates. */
onBeforeSwitch?: () => void
}
export function StepProject({ onFinish, onBack, onBeforeSwitch }: StepProjectProps) {
const manager = useProjectStoreManager()
const [devRoot, setDevRoot] = useState<string | null>(null)
const [projects, setProjects] = useState<ProjectMetadata[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState("")
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const createInputRef = useRef<HTMLInputElement>(null)
const [switchingTo, setSwitchingTo] = useState<string | null>(null)
const switchPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const prefsRes = await authFetch("/api/preferences")
if (!prefsRes.ok) throw new Error("Failed to load preferences")
const prefs = await prefsRes.json()
if (!prefs.devRoot) { setDevRoot(null); setProjects([]); setLoading(false); return }
setDevRoot(prefs.devRoot)
const projRes = await authFetch(`/api/projects?root=${encodeURIComponent(prefs.devRoot)}&detail=true`)
if (!projRes.ok) throw new Error("Failed to discover projects")
const discovered = (await projRes.json()) as ProjectMetadata[]
if (!cancelled) setProjects(discovered)
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Unknown error")
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [])
useEffect(() => {
return () => { if (switchPollRef.current) clearInterval(switchPollRef.current) }
}, [])
useEffect(() => {
if (showCreate) {
const t = setTimeout(() => createInputRef.current?.focus(), 50)
return () => clearTimeout(t)
}
}, [showCreate])
const existingNames = projects.map((p) => p.name)
const nameValid = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(newName)
const nameConflict = existingNames.includes(newName)
const canCreate = newName.length > 0 && nameValid && !nameConflict && !creating
const handleSelectProject = useCallback((project: ProjectMetadata) => {
onBeforeSwitch?.()
setSwitchingTo(project.path)
const store = manager.switchProject(project.path)
if (switchPollRef.current) clearInterval(switchPollRef.current)
const startTime = Date.now()
switchPollRef.current = setInterval(() => {
const state = store.getSnapshot()
const elapsed = Date.now() - startTime
if (state.bootStatus === "ready" || state.bootStatus === "error" || elapsed > 30000) {
if (switchPollRef.current) clearInterval(switchPollRef.current)
switchPollRef.current = null
setSwitchingTo(null)
onFinish(project.path)
}
}, 150)
}, [manager, onFinish, onBeforeSwitch])
const handleCreate = useCallback(async () => {
if (!canCreate || !devRoot) return
setCreating(true)
setCreateError(null)
try {
const res = await authFetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ devRoot, name: newName }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? `Failed (${res.status})`)
}
const project = (await res.json()) as ProjectMetadata
setProjects((prev) => [...prev, project].sort((a, b) => a.name.localeCompare(b.name)))
setNewName("")
setShowCreate(false)
handleSelectProject(project)
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed to create project")
setCreating(false)
}
}, [canCreate, devRoot, newName, handleSelectProject])
const noDevRoot = !loading && !devRoot
// Sort: active-gsd first, then by name
const sortedProjects = [...projects].sort((a, b) => {
const kindOrder: Record<ProjectDetectionKind, number> = { "active-gsd": 0, "empty-gsd": 1, brownfield: 2, "v1-legacy": 3, blank: 4 }
const ka = kindOrder[a.kind] ?? 5
const kb = kindOrder[b.kind] ?? 5
if (ka !== kb) return ka - kb
return a.name.localeCompare(b.name)
})
return (
<div className="flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Open a project
</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
{noDevRoot
? "Set a dev root first to discover your projects."
: "Pick a project to start working in, or create a new one."}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08, duration: 0.45 }}
className="mt-8 w-full max-w-lg space-y-2"
>
{loading && (
<div className="flex items-center justify-center gap-2 py-10 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Discovering projects
</div>
)}
{error && (
<div className="rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{noDevRoot && (
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-6 text-center text-sm text-muted-foreground">
No dev root configured. Go back and set one, or finish setup to configure later.
</div>
)}
{/* Project cards */}
{!loading && sortedProjects.length > 0 && (
<div className="space-y-2">
{sortedProjects.map((project) => {
const isSwitching = switchingTo === project.path
const style = KIND_STYLE[project.kind]
const KindIcon = style.icon
const stack = techStack(project.signals)
const progress = project.progress ? progressLabel(project.progress) : null
const milestoneCount = project.progress
? `${project.progress.milestonesCompleted}/${project.progress.milestonesTotal}`
: null
return (
<button
key={project.path}
type="button"
onClick={() => handleSelectProject(project)}
disabled={!!switchingTo}
className={cn(
"group flex w-full items-start gap-3.5 rounded-xl border px-4 py-3.5 text-left transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"active:scale-[0.98]",
isSwitching
? "border-foreground/30 bg-foreground/[0.06]"
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
switchingTo && !isSwitching && "opacity-40 pointer-events-none",
)}
>
{/* Icon */}
<div className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg mt-0.5",
project.kind === "active-gsd" ? "bg-success/10" : "bg-foreground/[0.04]",
)}>
{isSwitching ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<KindIcon className={cn("h-4 w-4", style.color)} />
)}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
{/* Row 1: name + kind badge */}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground truncate">{project.name}</span>
<span className={cn("text-[10px] font-medium shrink-0", style.color)}>
{style.label}
</span>
</div>
{/* Row 2: tech stack tags */}
{stack.length > 0 && (
<div className="mt-1 flex items-center gap-1.5">
{stack.map((tag) => (
<span
key={tag}
className="rounded bg-foreground/[0.04] px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
{/* Row 3: progress info (for active-gsd projects) */}
{progress && (
<div className="mt-1.5 text-[11px] text-muted-foreground">
{progress}
</div>
)}
{/* Row 4: milestone bar (for active-gsd with milestones) */}
{project.progress && project.progress.milestonesTotal > 0 && (
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-foreground/[0.06]">
<div
className="h-full rounded-full bg-success/60 transition-all"
style={{
width: `${Math.round((project.progress.milestonesCompleted / project.progress.milestonesTotal) * 100)}%`,
}}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground">
{milestoneCount}
</span>
</div>
)}
</div>
{/* Arrow */}
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-muted-foreground/50 transition-all group-hover:text-muted-foreground group-hover:translate-x-0.5" />
</button>
)
})}
</div>
)}
{!loading && devRoot && projects.length === 0 && !error && (
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-6 text-center text-sm text-muted-foreground">
No projects found in {devRoot}
</div>
)}
{/* Create new project */}
{!loading && devRoot && (
<>
{!showCreate ? (
<button
type="button"
onClick={() => setShowCreate(true)}
disabled={!!switchingTo}
className={cn(
"flex w-full items-center gap-3.5 rounded-xl border border-dashed px-4 py-3.5 text-left transition-all duration-200",
"border-border/50 text-muted-foreground hover:border-foreground/15 hover:text-foreground",
"active:scale-[0.98]",
switchingTo && "opacity-40 pointer-events-none",
)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-foreground/[0.04]">
<Plus className="h-4 w-4" />
</div>
<div>
<span className="text-sm font-medium">Create new project</span>
<p className="mt-0.5 text-[11px] text-muted-foreground">Initialize a new directory with Git</p>
</div>
</button>
) : (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
transition={{ duration: 0.2 }}
className="rounded-xl border border-border/50 bg-card/50 p-4 space-y-3"
>
<div className="text-sm font-medium text-foreground">New project</div>
<form
onSubmit={(e) => { e.preventDefault(); void handleCreate() }}
className="space-y-2"
>
<Input
ref={createInputRef}
value={newName}
onChange={(e) => { setNewName(e.target.value); setCreateError(null) }}
placeholder="my-project"
autoComplete="off"
className="text-sm"
disabled={creating}
/>
{newName && !nameValid && (
<p className="text-xs text-destructive">Letters, numbers, hyphens, underscores, dots. Must start with a letter or number.</p>
)}
{nameConflict && (
<p className="text-xs text-destructive">A project with this name already exists</p>
)}
{createError && (
<p className="text-xs text-destructive">{createError}</p>
)}
{newName && nameValid && !nameConflict && (
<p className="font-mono text-xs text-muted-foreground">{devRoot}/{newName}</p>
)}
<div className="flex items-center gap-2 pt-1">
<Button
type="submit"
size="sm"
disabled={!canCreate}
className="gap-1.5 transition-transform active:scale-[0.96]"
>
{creating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
Create & open
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => { setShowCreate(false); setNewName(""); setCreateError(null) }}
disabled={creating}
className="text-muted-foreground"
>
Cancel
</Button>
</div>
</form>
</motion.div>
)}
</>
)}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="mt-8 flex w-full max-w-lg items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={() => { onBeforeSwitch?.(); onFinish("") }}
className="group gap-2 transition-transform active:scale-[0.96]"
>
Finish setup
<Zap className="h-4 w-4 transition-transform group-hover:scale-110" />
</Button>
</motion.div>
</div>
)
}

View file

@ -1,189 +0,0 @@
"use client"
import { useMemo } from "react"
import { motion } from "motion/react"
import { ArrowRight, Check, ShieldCheck } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import type { WorkspaceOnboardingProviderState } from "@/lib/gsd-workspace-store"
import { cn } from "@/lib/utils"
interface StepProviderProps {
providers: WorkspaceOnboardingProviderState[]
selectedId: string | null
onSelect: (id: string) => void
onNext: () => void
onBack: () => void
}
function capabilityBadges(provider: WorkspaceOnboardingProviderState): string[] {
const badges: string[] = []
if (provider.supports.apiKey) badges.push("API key")
if (provider.supports.oauth)
badges.push(provider.supports.oauthAvailable ? "Browser sign-in" : "OAuth unavailable")
return badges
}
function configuredViaLabel(source: WorkspaceOnboardingProviderState["configuredVia"]): string {
switch (source) {
case "auth_file": return "Saved auth"
case "environment": return "Environment variable"
case "runtime": return "Runtime"
default: return "Not configured"
}
}
/** Group providers: configured first, then recommended, then rest. */
function groupProviders(providers: WorkspaceOnboardingProviderState[]): {
label: string
items: WorkspaceOnboardingProviderState[]
}[] {
const configured = providers.filter((p) => p.configured)
const recommended = providers.filter((p) => !p.configured && p.recommended)
const rest = providers.filter((p) => !p.configured && !p.recommended)
const groups: { label: string; items: WorkspaceOnboardingProviderState[] }[] = []
if (configured.length > 0) groups.push({ label: "Configured", items: configured })
if (recommended.length > 0) groups.push({ label: "Recommended", items: recommended })
if (rest.length > 0) groups.push({ label: "Other Providers", items: rest })
return groups
}
export function StepProvider({ providers, selectedId, onSelect, onNext, onBack }: StepProviderProps) {
const groups = useMemo(() => groupProviders(providers), [providers])
const hasConfigured = providers.some((p) => p.configured)
return (
<div className="flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Choose a provider
</h2>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Click a provider to configure it. Set up as many as you want, then continue.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08, duration: 0.45 }}
className="mt-8 w-full space-y-5"
>
{groups.map((group) => (
<div key={group.label}>
<div className="mb-2 px-0.5 text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
{group.label}
</div>
<div className="grid gap-2 sm:grid-cols-2">
{group.items.map((provider) => {
const selected = provider.id === selectedId
return (
<button
key={provider.id}
type="button"
onClick={() => onSelect(provider.id)}
className={cn(
"group relative rounded-xl border px-4 py-3.5 text-left transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"active:scale-[0.98]",
selected
? "border-foreground/30 bg-foreground/[0.06]"
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
)}
data-testid={`onboarding-provider-${provider.id}`}
>
{/* Radio dot */}
<div className="absolute right-3 top-3">
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-full border-[1.5px] transition-all duration-200",
selected ? "border-foreground bg-foreground" : "border-foreground/15",
)}
>
{selected && <Check className="h-2.5 w-2.5 text-background" strokeWidth={3} />}
</div>
</div>
<div className="pr-8">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">{provider.label}</span>
{provider.recommended && (
<Badge variant="outline" className="border-foreground/10 bg-foreground/[0.03] text-[9px] text-muted-foreground">
Recommended
</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
{provider.configured ? (
<>
<ShieldCheck className="h-3 w-3 text-success/80" />
<span>{configuredViaLabel(provider.configuredVia)}</span>
</>
) : (
<span className="text-muted-foreground">Not configured</span>
)}
</div>
</div>
<div className="mt-2.5 flex flex-wrap gap-1">
{capabilityBadges(provider).map((cap) => (
<Tooltip key={cap}>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-border/50 text-[10px] text-muted-foreground">
{cap}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom">
{cap === "API key"
? "Enter an API key to authenticate"
: cap === "Browser sign-in"
? "Authenticate through your browser"
: "This auth method is not available"}
</TooltipContent>
</Tooltip>
))}
</div>
</button>
)
})}
</div>
</div>
))}
</motion.div>
{/* Navigation — pinned inside the step */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="mt-8 flex w-full items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={onNext}
disabled={!hasConfigured}
className="group gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-provider-continue"
>
Continue
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</motion.div>
</div>
)
}

View file

@ -1,98 +0,0 @@
"use client"
import Image from "next/image"
import { motion } from "motion/react"
import { CheckCircle2, Zap } from "lucide-react"
import { Button } from "@/components/ui/button"
interface StepReadyProps {
providerLabel: string
onFinish: () => void
}
export function StepReady({ providerLabel, onFinish }: StepReadyProps) {
return (
<div className="flex flex-col items-center text-center">
{/* Success icon with staggered entrance */}
<motion.div
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", duration: 0.6, bounce: 0.15 }}
className="relative mb-8"
>
<div className="absolute inset-0 rounded-full bg-success/10 blur-2xl" />
<div className="relative flex h-16 w-16 items-center justify-center rounded-2xl border border-success/20 bg-success/10">
<CheckCircle2 className="h-8 w-8 text-success" strokeWidth={1.5} />
</div>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.4 }}
className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl"
>
You&apos;re all set
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.18, duration: 0.4 }}
className="mt-3 max-w-sm text-[15px] leading-relaxed text-muted-foreground"
>
<span className="font-medium text-foreground">{providerLabel}</span> is
validated. The workspace is live.
</motion.p>
{/* Compact summary strip */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.26, duration: 0.4 }}
className="mt-8 flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 px-5 py-3"
>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Image
src="/logo-icon-white.svg"
alt=""
width={14}
height={14}
className="hidden opacity-40 dark:block"
/>
<Image
src="/logo-icon-black.svg"
alt=""
width={14}
height={14}
className="opacity-40 dark:hidden"
/>
<span>Shell unlocked</span>
</div>
<div className="h-3 w-px bg-border" />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
<span>{providerLabel}</span>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35, duration: 0.4 }}
className="mt-8"
>
<Button
size="lg"
className="group gap-2.5 px-8 text-[15px] transition-transform active:scale-[0.96]"
onClick={onFinish}
data-testid="onboarding-finish"
>
Launch workspace
<Zap className="h-4 w-4 transition-transform group-hover:scale-110" />
</Button>
</motion.div>
</div>
)
}

View file

@ -1,385 +0,0 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { motion } from "motion/react"
import {
ArrowRight,
CheckCircle2,
Eye,
EyeOff,
KeyRound,
LoaderCircle,
MessageSquare,
SkipForward,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { authFetch } from "@/lib/auth"
// ─── Types ──────────────────────────────────────────────────────────
type RemoteChannel = "slack" | "discord" | "telegram"
interface RemoteQuestionsApiResponse {
config: {
channel: RemoteChannel
channelId: string
timeoutMinutes: number
pollIntervalSeconds: number
} | null
envVarSet: boolean
tokenSet: boolean
envVarName: string | null
status: string
error?: string
}
const CHANNEL_OPTIONS: { value: RemoteChannel; label: string; description: string }[] = [
{ value: "slack", label: "Slack", description: "Get notified in a Slack channel" },
{ value: "discord", label: "Discord", description: "Get notified in a Discord channel" },
{ value: "telegram", label: "Telegram", description: "Get notified via Telegram bot" },
]
const CHANNEL_ID_HINTS: Record<RemoteChannel, string> = {
slack: "Channel ID (e.g. C01ABCD2EFG)",
discord: "Channel ID (1720 digit number)",
telegram: "Chat ID (numeric, may start with -)",
}
const CHANNEL_ID_PATTERNS: Record<RemoteChannel, RegExp> = {
slack: /^[A-Z0-9]{9,12}$/,
discord: /^\d{17,20}$/,
telegram: /^-?\d{5,20}$/,
}
const ENV_KEYS: Record<RemoteChannel, string> = {
slack: "SLACK_BOT_TOKEN",
discord: "DISCORD_BOT_TOKEN",
telegram: "TELEGRAM_BOT_TOKEN",
}
// ─── Component ──────────────────────────────────────────────────────
interface StepRemoteProps {
onBack: () => void
onNext: () => void
}
export function StepRemote({ onBack, onNext }: StepRemoteProps) {
const [channel, setChannel] = useState<RemoteChannel | null>(null)
const [channelId, setChannelId] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [alreadyConfigured, setAlreadyConfigured] = useState(false)
const [loading, setLoading] = useState(true)
const [botToken, setBotToken] = useState("")
const [showToken, setShowToken] = useState(false)
const [savingToken, setSavingToken] = useState(false)
const [tokenSet, setTokenSet] = useState(false)
const [tokenSuccess, setTokenSuccess] = useState<string | null>(null)
// Check if already configured
useEffect(() => {
authFetch("/api/remote-questions", { cache: "no-store" })
.then((res) => res.json())
.then((data: RemoteQuestionsApiResponse) => {
if (data.tokenSet) setTokenSet(true)
if (data.status === "configured" && data.config) {
setAlreadyConfigured(true)
setChannel(data.config.channel)
setChannelId(data.config.channelId)
setSuccess(true)
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const channelIdValid =
channel !== null &&
channelId.trim().length > 0 &&
CHANNEL_ID_PATTERNS[channel].test(channelId.trim())
const handleSave = useCallback(async () => {
if (!channel || !channelIdValid) return
setSaving(true)
setError(null)
try {
const res = await authFetch("/api/remote-questions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
channel,
channelId: channelId.trim(),
timeoutMinutes: 5,
pollIntervalSeconds: 5,
}),
})
const json = await res.json()
if (!res.ok) {
setError(json.error ?? `Save failed (${res.status})`)
return
}
setSuccess(true)
setAlreadyConfigured(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save")
} finally {
setSaving(false)
}
}, [channel, channelId, channelIdValid])
const handleSaveToken = useCallback(async () => {
if (!channel || !botToken.trim()) return
setSavingToken(true)
setError(null)
setTokenSuccess(null)
try {
const res = await authFetch("/api/remote-questions", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channel, token: botToken.trim() }),
})
const json = await res.json()
if (!res.ok) { setError(json.error ?? `Token save failed (${res.status})`); return }
setTokenSuccess(`Token saved (${json.masked})`)
setTokenSet(true)
setBotToken("")
setShowToken(false)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save token")
} finally {
setSavingToken(false)
}
}, [channel, botToken])
return (
<div className="flex flex-col items-center">
{/* Icon */}
<motion.div
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
className="mb-8"
>
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-border/50 bg-card/50">
<MessageSquare className="h-7 w-7 text-foreground/80" strokeWidth={1.5} />
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06, duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Remote notifications
</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
Get notified when GSD needs your input. Connect a chat channel and
the agent pings you instead of waiting silently.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12, duration: 0.45 }}
className="mt-8 w-full max-w-md space-y-5"
>
{/* Already configured banner */}
{success && (
<div className="flex items-center gap-3 rounded-xl border border-success/15 bg-success/[0.04] px-4 py-3 text-sm">
<CheckCircle2 className="h-4 w-4 shrink-0 text-success" />
<span className="text-muted-foreground">
{alreadyConfigured && !saving
? `Connected to ${channel ?? "channel"}`
: "Configuration saved"}
</span>
</div>
)}
{/* Channel picker */}
{!loading && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Channel</div>
<div className="grid grid-cols-3 gap-2">
{CHANNEL_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setChannel(opt.value)
setError(null)
if (success && !alreadyConfigured) setSuccess(false)
}}
disabled={saving}
className={cn(
"rounded-xl border px-3 py-3 text-left transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"active:scale-[0.97]",
channel === opt.value
? "border-foreground/30 bg-foreground/[0.06]"
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
)}
>
<div className="text-sm font-medium text-foreground">{opt.label}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{opt.description}</div>
</button>
))}
</div>
</div>
)}
{/* Channel ID input */}
{channel && !loading && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Channel ID</div>
<Input
value={channelId}
onChange={(e) => {
setChannelId(e.target.value)
if (error) setError(null)
}}
placeholder={CHANNEL_ID_HINTS[channel]}
disabled={saving}
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && channelIdValid) {
void handleSave()
}
}}
/>
{channelId.trim().length > 0 && !CHANNEL_ID_PATTERNS[channel].test(channelId.trim()) && (
<p className="text-xs text-destructive/70">
Doesn't match the expected format for {channel}
</p>
)}
</div>
)}
{/* Bot token input */}
{channel && !loading && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Bot token
{tokenSet && (
<span className="ml-2 text-success"> configured</span>
)}
</div>
{tokenSuccess && (
<div className="flex items-center gap-2 rounded-xl border border-success/15 bg-success/[0.04] px-3 py-2 text-xs text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
{tokenSuccess}
</div>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showToken ? "text" : "password"}
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
placeholder={`Paste your ${ENV_KEYS[channel]}`}
disabled={savingToken}
className="pr-9 font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && botToken.trim()) void handleSaveToken()
}}
/>
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-muted-foreground transition-colors"
>
{showToken ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<Button
type="button"
variant="outline"
onClick={() => void handleSaveToken()}
disabled={!botToken.trim() || savingToken}
className="gap-1.5 transition-transform active:scale-[0.96]"
>
{savingToken ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />}
Save
</Button>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{/* Save button */}
{channel && channelId.trim().length > 0 && !success && (
<Button
onClick={() => void handleSave()}
disabled={!channelIdValid || saving}
className="gap-2 transition-transform active:scale-[0.96]"
>
{saving ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
Save & connect
</Button>
)}
{loading && (
<div className="flex items-center gap-2 py-4 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
Checking existing configuration
</div>
)}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.3 }}
className="mt-8 flex w-full max-w-md items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<div className="flex items-center gap-2">
{!success && (
<Button
variant="ghost"
onClick={onNext}
className="gap-1.5 text-muted-foreground transition-transform active:scale-[0.96]"
>
Skip
<SkipForward className="h-3.5 w-3.5" />
</Button>
)}
<Button
onClick={onNext}
className="group gap-2 transition-transform active:scale-[0.96]"
>
{success ? "Continue" : "Continue"}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</div>
</motion.div>
</div>
)
}

View file

@ -1,87 +0,0 @@
"use client"
import Image from "next/image"
import { motion } from "motion/react"
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
interface StepWelcomeProps {
onNext: () => void
}
export function StepWelcome({ onNext }: StepWelcomeProps) {
return (
<div className="flex flex-col items-center text-center">
{/* Logo mark with glow */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", duration: 0.6, bounce: 0 }}
className="relative"
>
<div className="absolute inset-0 rounded-2xl bg-foreground/5 blur-2xl" />
<div className="relative mb-4 flex h-18 items-center justify-center">
<Image
src="/logo-white.svg"
alt="GSD"
height={70}
width={200}
className="hidden dark:block"
/>
<Image
src="/logo-black.svg"
alt="GSD"
height={70}
width={200}
className="dark:hidden"
/>
</div>
</motion.div>
{/* Headline */}
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.16, duration: 0.4 }}
className="max-w-sm text-[15px] leading-relaxed text-muted-foreground"
>
Let's get your workspace ready. This takes about a minute.
</motion.p>
{/* Steps preview */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.24, duration: 0.4 }}
className="mt-10 flex items-center gap-3 text-xs text-muted-foreground"
>
{["Mode", "Provider", "Auth", "Workspace"].map((label, i) => (
<span key={label} className="flex items-center gap-3">
{i > 0 && (
<span className="h-px w-5 bg-border" />
)}
<span className="font-medium">{label}</span>
</span>
))}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35, duration: 0.4 }}
className="mt-10"
>
<Button
size="lg"
className="group gap-2.5 px-8 text-[15px] transition-transform active:scale-[0.96]"
onClick={onNext}
data-testid="onboarding-start"
>
Get started
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</motion.div>
</div>
)
}

View file

@ -1,88 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
import { Check } from "lucide-react"
export interface WizardStep {
id: string
label: string
shortLabel?: string
}
interface WizardStepperProps {
steps: WizardStep[]
currentIndex: number
onStepClick?: (index: number) => void
className?: string
}
export function WizardStepper({ steps, currentIndex, onStepClick, className }: WizardStepperProps) {
return (
<nav aria-label="Onboarding progress" className={cn("flex items-center gap-0", className)}>
{steps.map((step, index) => {
const isComplete = index < currentIndex
const isCurrent = index === currentIndex
const isClickable = onStepClick && index <= currentIndex
return (
<div key={step.id} className="flex items-center">
{/* Step node */}
<button
type="button"
onClick={() => isClickable && onStepClick(index)}
disabled={!isClickable}
aria-current={isCurrent ? "step" : undefined}
className={cn(
"group relative flex items-center gap-2.5 rounded-full px-1 py-1 transition-all duration-300",
isClickable && "cursor-pointer",
!isClickable && "cursor-default",
)}
>
{/* Circle indicator */}
<div
className={cn(
"relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-300",
isComplete && "border-foreground/80 bg-foreground/90 text-background",
isCurrent && "border-foreground bg-foreground text-background shadow-[0_0_12px_rgba(255,255,255,0.15)]",
!isComplete && !isCurrent && "border-border bg-background text-muted-foreground",
)}
>
{isComplete ? (
<Check className="h-3.5 w-3.5" strokeWidth={3} />
) : (
<span className={cn("text-xs font-semibold tabular-nums", isCurrent && "text-background")}>
{index + 1}
</span>
)}
</div>
{/* Label */}
<span
className={cn(
"hidden text-sm font-medium transition-colors duration-200 sm:inline",
isCurrent && "text-foreground",
isComplete && "text-muted-foreground",
!isComplete && !isCurrent && "text-muted-foreground",
)}
>
{step.shortLabel ?? step.label}
</span>
</button>
{/* Connector line */}
{index < steps.length - 1 && (
<div className="mx-1 hidden h-px w-8 sm:block lg:w-12">
<div
className={cn(
"h-full transition-all duration-500",
index < currentIndex ? "bg-foreground/50" : "bg-border",
)}
/>
</div>
)}
</div>
)
})}
</nav>
)
}

View file

@ -1,253 +0,0 @@
"use client"
import {
ArrowRight,
FolderOpen,
GitBranch,
Package,
FileCode,
Sparkles,
ArrowUpCircle,
Folder,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { ProjectDetection } from "@/lib/gsd-workspace-store"
// ─── Variant Config ─────────────────────────────────────────────────────────
interface WelcomeVariant {
icon: React.ReactNode
headline: string
body: string
detail?: string
primaryLabel: string
primaryCommand: string
secondary?: {
label: string
action: "files-view" | "command"
command?: string
}
}
function getVariant(detection: ProjectDetection): WelcomeVariant {
switch (detection.kind) {
case "brownfield":
return {
icon: <FolderOpen className="h-8 w-8 text-foreground" strokeWidth={1.5} />,
headline: "Existing project detected",
body: "GSD will map your codebase and ask a few questions about what you want to build. From there it generates structured milestones and deliverable slices.",
primaryLabel: "Map & Initialize",
primaryCommand: "/gsd",
secondary: {
label: "Browse files first",
action: "files-view",
},
}
case "v1-legacy":
return {
icon: <ArrowUpCircle className="h-8 w-8 text-foreground" strokeWidth={1.5} />,
headline: "GSD v1 project found",
body: "This project has a .planning/ folder from an earlier GSD version. Migration converts your existing planning data into the new .gsd/ format.",
detail: "Your original files will be preserved — migration creates the new structure alongside them.",
primaryLabel: "Migrate to v2",
primaryCommand: "/gsd migrate",
secondary: {
label: "Start fresh instead",
action: "command",
command: "/gsd",
},
}
case "blank":
return {
icon: <Sparkles className="h-8 w-8 text-foreground" strokeWidth={1.5} />,
headline: "Start a new project",
body: "This folder is empty. GSD will ask what you want to build, then generate a structured plan — milestones broken into deliverable slices with risk-ordered execution.",
primaryLabel: "Start Project Setup",
primaryCommand: "/gsd",
}
// active-gsd and empty-gsd shouldn't reach here, but handle gracefully
default:
return {
icon: <Folder className="h-8 w-8 text-foreground" strokeWidth={1.5} />,
headline: "Set up your project",
body: "Run the GSD wizard to get started.",
primaryLabel: "Get Started",
primaryCommand: "/gsd",
}
}
}
// ─── Signal Chips ───────────────────────────────────────────────────────────
function SignalChip({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-muted-foreground">
{icon}
{label}
</span>
)
}
function SignalChips({ signals }: { signals: ProjectDetection["signals"] }) {
const chips: { icon: React.ReactNode; label: string }[] = []
if (signals.hasGitRepo) {
chips.push({ icon: <GitBranch className="h-3 w-3" />, label: "Git repository" })
}
if (signals.hasPackageJson) {
chips.push({ icon: <Package className="h-3 w-3" />, label: "Node.js project" })
}
if (signals.fileCount > 0) {
chips.push({
icon: <FileCode className="h-3 w-3" />,
label: `${signals.fileCount} file${signals.fileCount === 1 ? "" : "s"}`,
})
}
if (chips.length === 0) return null
return (
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<SignalChip key={chip.label} icon={chip.icon} label={chip.label} />
))}
</div>
)
}
// ─── Main Component ─────────────────────────────────────────────────────────
interface ProjectWelcomeProps {
detection: ProjectDetection
onCommand: (command: string) => void
onSwitchView: (view: string) => void
disabled?: boolean
}
export function ProjectWelcome({
detection,
onCommand,
onSwitchView,
disabled = false,
}: ProjectWelcomeProps) {
const variant = getVariant(detection)
const showSignals = detection.kind === "brownfield" || detection.kind === "v1-legacy"
return (
<div className="flex h-full items-center justify-center p-8">
<div className="w-full max-w-lg">
{/* Icon */}
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-xl border border-border bg-card">
{variant.icon}
</div>
{/* Headline */}
<h2 className="text-2xl font-bold tracking-tight text-foreground">
{variant.headline}
</h2>
{/* Body */}
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{variant.body}
</p>
{/* Detail note */}
{variant.detail && (
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{variant.detail}
</p>
)}
{/* Detected signals */}
{showSignals && (
<div className="mt-5">
<SignalChips signals={detection.signals} />
</div>
)}
{/* Actions */}
<div className="mt-8 flex items-center gap-3">
<button
onClick={() => onCommand(variant.primaryCommand)}
disabled={disabled}
className={cn(
"inline-flex items-center gap-2 rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background transition-colors hover:bg-foreground/90",
disabled && "cursor-not-allowed opacity-50",
)}
>
{variant.primaryLabel}
<ArrowRight className="h-3.5 w-3.5" />
</button>
{variant.secondary && (
<button
onClick={() => {
if (variant.secondary!.action === "files-view") {
onSwitchView("files")
} else if (variant.secondary!.command) {
onCommand(variant.secondary!.command)
}
}}
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-4 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent",
disabled && "cursor-not-allowed opacity-50",
)}
>
{variant.secondary.label}
</button>
)}
</div>
{/* What happens next — for blank projects */}
{detection.kind === "blank" && (
<div className="mt-8 rounded-lg border border-border/50 bg-card/50 p-4">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
What happens next
</p>
<ul className="mt-2.5 space-y-2">
{[
"A few questions about what you're building",
"Codebase analysis and context gathering",
"Structured milestone and slice generation",
].map((step, i) => (
<li key={i} className="flex items-start gap-2.5 text-xs text-muted-foreground">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] font-medium text-muted-foreground">
{i + 1}
</span>
{step}
</li>
))}
</ul>
</div>
)}
{/* What happens next — for brownfield */}
{detection.kind === "brownfield" && (
<div className="mt-8 rounded-lg border border-border/50 bg-card/50 p-4">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
What happens next
</p>
<ul className="mt-2.5 space-y-2">
{[
"GSD scans your codebase and asks about your goals",
"You discuss scope, constraints, and priorities",
"A milestone with risk-ordered slices is generated",
].map((step, i) => (
<li key={i} className="flex items-start gap-2.5 text-xs text-muted-foreground">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] font-medium text-muted-foreground">
{i + 1}
</span>
{step}
</li>
))}
</ul>
</div>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,159 +0,0 @@
"use client"
import { CheckCircle2, Circle, Play, AlertTriangle, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { getLiveWorkspaceIndex, useGSDWorkspaceState, type RiskLevel } from "@/lib/gsd-workspace-store"
import { getMilestoneStatus, getSliceStatus, type ItemStatus } from "@/lib/workspace-status"
const StatusIcon = ({
status,
size = "default",
}: {
status: ItemStatus
size?: "default" | "large"
}) => {
const sizeClass = size === "large" ? "h-5 w-5" : "h-4 w-4"
if (status === "done") {
return <CheckCircle2 className={cn(sizeClass, "text-success")} />
}
if (status === "in-progress") {
return <Play className={cn(sizeClass, "text-warning")} />
}
return <Circle className={cn(sizeClass, "text-muted-foreground")} />
}
const RiskBadge = ({ risk }: { risk: RiskLevel }) => {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase",
risk === "high" && "bg-destructive/20 text-destructive",
risk === "medium" && "bg-warning/20 text-warning",
risk === "low" && "bg-muted text-muted-foreground",
)}
>
{risk === "high" && <AlertTriangle className="h-2.5 w-2.5" />}
{risk}
</span>
)
}
export function Roadmap() {
const workspace = useGSDWorkspaceState()
const liveWorkspace = getLiveWorkspaceIndex(workspace)
const milestones = liveWorkspace?.milestones ?? []
const activeScope = liveWorkspace?.active ?? {}
const workspaceFreshness = workspace.live.freshness.workspace.stale ? "stale" : workspace.live.freshness.workspace.status
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold">Roadmap</h1>
<p className="text-sm text-muted-foreground">
Project milestone structure with slices and dependencies
</p>
<p className="mt-1 text-xs text-muted-foreground" data-testid="roadmap-workspace-freshness">
Workspace freshness: {workspaceFreshness}
</p>
</div>
<div className="flex-1 overflow-y-auto p-6">
{workspace.bootStatus === "loading" && (
<div className="py-8 text-center text-sm text-muted-foreground">Loading workspace</div>
)}
{workspace.bootStatus === "ready" && milestones.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
No milestones found. Create a milestone with <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">/gsd</code> to get started.
</div>
)}
<div className="space-y-6">
{milestones.map((milestone) => {
const milestoneStatus = getMilestoneStatus(milestone, activeScope)
const doneSlices = milestone.slices.filter((s) => s.done).length
const totalTasks = milestone.slices.reduce((acc, s) => acc + s.tasks.length, 0)
const doneTasks = milestone.slices.reduce((acc, s) => acc + s.tasks.filter((t) => t.done).length, 0)
return (
<div key={milestone.id} className="rounded-md border border-border bg-card">
<div
className={cn(
"flex items-center gap-3 border-b border-border px-4 py-3",
milestoneStatus === "in-progress" && "bg-accent/30",
)}
>
<StatusIcon status={milestoneStatus} size="large" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{milestone.id}</span>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="font-semibold">{milestone.title}</span>
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium">
{doneSlices}/{milestone.slices.length} slices
</div>
<div className="text-xs text-muted-foreground">
{doneTasks}/{totalTasks} tasks
</div>
</div>
</div>
<div className="divide-y divide-border">
{milestone.slices.map((slice) => {
const sliceStatus = getSliceStatus(milestone.id, slice, activeScope)
const sliceDoneTasks = slice.tasks.filter((t) => t.done).length
const sliceTotalTasks = slice.tasks.length
return (
<div
key={`${milestone.id}-${slice.id}`}
className={cn(
"flex items-center gap-3 px-4 py-2.5",
sliceStatus === "in-progress" && "bg-accent/20",
sliceStatus === "pending" && "opacity-70",
)}
>
<div className="w-4" />
<StatusIcon status={sliceStatus} />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{slice.id}</span>
<span className="text-sm">{slice.title}</span>
{slice.risk && <RiskBadge risk={slice.risk} />}
{slice.depends && slice.depends.length > 0 && (
<span className="text-[10px] text-muted-foreground">
depends on {slice.depends.join(", ")}
</span>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-24">
<div className="h-1 w-full rounded-full bg-accent">
<div
className="h-full rounded-full bg-foreground/70 transition-all"
style={{
width: sliceTotalTasks > 0 ? `${(sliceDoneTasks / sliceTotalTasks) * 100}%` : "0%",
}}
/>
</div>
</div>
<span className="w-12 text-right text-xs text-muted-foreground">
{sliceDoneTasks}/{sliceTotalTasks}
</span>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}

View file

@ -1,154 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
/* ─── Helpers ──────────────────────────────────────────────────────────────── */
type PhaseTone = "success" | "active" | "warning" | "muted" | "info"
function phasePresentation(phase: string): { label: string; tone: PhaseTone } {
switch (phase) {
case "complete":
case "completed":
return { label: "Complete", tone: "success" }
case "executing":
return { label: "Executing", tone: "active" }
case "in-progress":
return { label: "In Progress", tone: "active" }
case "planning":
return { label: "Planning", tone: "info" }
case "pre-planning":
return { label: "Pre-planning", tone: "muted" }
case "summarizing":
return { label: "Summarizing", tone: "info" }
case "blocked":
return { label: "Blocked", tone: "warning" }
case "needs-discussion":
return { label: "Discussion", tone: "warning" }
case "replanning-slice":
return { label: "Replanning", tone: "info" }
case "completing-milestone":
return { label: "Completing", tone: "info" }
case "evaluating-gates":
return { label: "Evaluating Gates", tone: "info" }
default:
return { label: phase, tone: "muted" }
}
}
const tonePill: Record<PhaseTone, string> = {
success: "bg-success/15 text-success",
active: "bg-primary/15 text-primary",
warning: "bg-warning/15 text-warning",
info: "bg-info/15 text-info",
muted: "bg-muted text-muted-foreground",
}
const toneDot: Record<PhaseTone, string> = {
success: "bg-success",
active: "bg-primary",
warning: "bg-warning",
info: "bg-info",
muted: "bg-muted-foreground/50",
}
/**
* Strip leading zeros from GSD IDs: M002 M2, S01 S1, T03 T3.
* Handles compound paths like "M001/S02/T03" "M1/S2/T3".
*/
function normalizeScopeId(raw: string): string {
return raw.replace(/([MST])0*(\d+)/g, "$1$2")
}
/**
* Parse a scope label like "M002 — completed" into { scopeId, phase }.
* Also handles bare IDs like "M002" (from auto mode).
*/
function parseScopeLabel(label: string): { scopeId: string; phase: string | null } {
const m = label.match(/^(.+?)\s*—\s*(.+)$/)
if (m) return { scopeId: normalizeScopeId(m[1].trim()), phase: m[2].trim() }
return { scopeId: normalizeScopeId(label.trim()), phase: null }
}
/* ─── Components ───────────────────────────────────────────────────────────── */
interface ScopeBadgeProps {
/** Raw scope label, e.g. "M002 — completed", "M001/S02/T03 — executing", or just "M002" */
label: string
/** Size variant */
size?: "sm" | "md"
className?: string
}
/**
* Renders a scope label as: M002 [Complete]
* The scope ID stays as-is (compact), phase gets a small colored pill.
*/
export function ScopeBadge({ label, size = "md", className }: ScopeBadgeProps) {
const { scopeId, phase } = parseScopeLabel(label)
if (scopeId === "Project scope pending") {
return <span className={cn("text-muted-foreground", sizeText(size), className)}>Scope pending</span>
}
const phaseInfo = phase ? phasePresentation(phase) : null
return (
<span className={cn("inline-flex items-center gap-2", className)}>
<span className={cn("font-semibold tracking-tight", sizeValue(size))}>
{scopeId}
</span>
{phaseInfo && (
<span
className={cn(
"inline-flex shrink-0 items-center rounded-full px-2 font-medium leading-snug",
tonePill[phaseInfo.tone],
sizeText(size),
sizePy(size),
)}
>
{phaseInfo.label}
</span>
)}
</span>
)
}
function sizeText(size: "sm" | "md") {
return size === "sm" ? "text-[10px]" : "text-[11px]"
}
function sizeValue(size: "sm" | "md") {
return size === "sm" ? "text-sm" : "text-lg"
}
function sizePy(size: "sm" | "md") {
return size === "sm" ? "py-px" : "py-0.5"
}
/**
* Inline variant for the status bar renders: M002 · Complete
*/
export function ScopeBadgeInline({ label, className }: { label: string; className?: string }) {
const { scopeId, phase } = parseScopeLabel(label)
if (scopeId === "Project scope pending") {
return <span className={cn("text-muted-foreground", className)}>Scope pending</span>
}
const phaseInfo = phase ? phasePresentation(phase) : null
const dotColor = phaseInfo ? toneDot[phaseInfo.tone] : "bg-muted-foreground/50"
return (
<span className={cn("inline-flex items-center gap-1.5", className)}>
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", dotColor)} />
<span>{scopeId}</span>
{phaseInfo && (
<>
<span className="text-border">·</span>
<span>{phaseInfo.label}</span>
</>
)}
</span>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,743 +0,0 @@
"use client"
import { useEffect, useRef, useCallback, useState } from "react"
import { useTheme } from "next-themes"
import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react"
import { cn } from "@/lib/utils"
import { validateImageFile } from "@/lib/image-utils"
import { filterInitialGsdHeader } from "@/lib/initial-gsd-header-filter"
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
import { authFetch, appendAuthParam } from "@/lib/auth"
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
import "@xterm/xterm/css/xterm.css"
type XTerminal = import("@xterm/xterm").Terminal
type XFitAddon = import("@xterm/addon-fit").FitAddon
const MIN_TERMINAL_ATTACH_WIDTH = 180
const MIN_TERMINAL_ATTACH_HEIGHT = 120
const MIN_TERMINAL_ATTACH_COLS = 20
const MIN_TERMINAL_ATTACH_ROWS = 8
// ─── Types ────────────────────────────────────────────────────────────────────
interface TerminalTab {
id: string
label: string
connected: boolean
}
interface ShellTerminalProps {
className?: string
command?: string
commandArgs?: string[]
sessionPrefix?: string
hideSidebar?: boolean
fontSize?: number
hideInitialGsdHeader?: boolean
projectCwd?: string
}
function getRenderableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null {
if (!container || !terminal) return null
const rect = container.getBoundingClientRect()
if (rect.width < MIN_TERMINAL_ATTACH_WIDTH || rect.height < MIN_TERMINAL_ATTACH_HEIGHT) {
return null
}
if (terminal.cols < MIN_TERMINAL_ATTACH_COLS || terminal.rows < MIN_TERMINAL_ATTACH_ROWS) {
return null
}
return { cols: terminal.cols, rows: terminal.rows }
}
async function settleTerminalLayout(
container: HTMLDivElement | null,
terminal: XTerminal | null,
fitAddon: XFitAddon | null,
isDisposed: () => boolean,
): Promise<{ cols: number; rows: number } | null> {
if (typeof document !== "undefined" && "fonts" in document) {
try {
await Promise.race([
document.fonts.ready,
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
])
} catch {
// Ignore font loading failures and fall through to repeated fit attempts.
}
}
for (let attempt = 0; attempt < 12; attempt++) {
if (isDisposed()) return null
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
if (isDisposed()) return null
try {
fitAddon?.fit()
} catch {
/* hidden or detached */
}
const size = getRenderableTerminalSize(container, terminal)
if (size) {
return size
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
return getRenderableTerminalSize(container, terminal)
}
function deriveCommandLabel(command?: string): string {
if (!command?.trim()) return "zsh"
const token = command.trim().split(/\s+/)[0] || command
const normalized = token.replace(/\\/g, "/")
const parts = normalized.split("/")
return parts[parts.length - 1] || token
}
// ─── Single terminal instance (internal) ──────────────────────────────────────
interface TerminalInstanceProps {
sessionId: string
visible: boolean
command?: string
commandArgs?: string[]
isDark: boolean
fontSize?: number
hideInitialGsdHeader?: boolean
projectCwd?: string
onConnectionChange: (connected: boolean) => void
}
function TerminalInstance({
sessionId,
visible,
command,
commandArgs,
isDark,
fontSize,
hideInitialGsdHeader = false,
projectCwd,
onConnectionChange,
}: TerminalInstanceProps) {
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerminal | null>(null)
const fitAddonRef = useRef<XFitAddon | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const inputQueueRef = useRef<string[]>([])
const flushingRef = useRef(false)
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const onConnectionChangeRef = useRef(onConnectionChange)
const initialHeaderSettledRef = useRef(!hideInitialGsdHeader)
const initialHeaderBufferRef = useRef("")
const commandArgsKey = (commandArgs ?? []).join("\u0000")
const [hasOutput, setHasOutput] = useState(false)
const sendResize = useCallback(
(cols: number, rows: number) => {
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
resizeTimeoutRef.current = setTimeout(() => {
void authFetch(buildProjectPath("/api/terminal/resize", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: sessionId, cols, rows }),
})
}, 100)
},
[projectCwd, sessionId],
)
const flushInputQueue = useCallback(async () => {
if (flushingRef.current) return
flushingRef.current = true
while (inputQueueRef.current.length > 0) {
const data = inputQueueRef.current.shift()!
try {
await authFetch(buildProjectPath("/api/terminal/input", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: sessionId, data }),
})
} catch {
inputQueueRef.current.unshift(data)
break
}
}
flushingRef.current = false
}, [projectCwd, sessionId])
const sendInput = useCallback(
(data: string) => {
inputQueueRef.current.push(data)
void flushInputQueue()
},
[flushInputQueue],
)
useEffect(() => {
onConnectionChangeRef.current = onConnectionChange
}, [onConnectionChange])
useEffect(() => {
initialHeaderSettledRef.current = !hideInitialGsdHeader
initialHeaderBufferRef.current = ""
}, [hideInitialGsdHeader, sessionId])
// Update xterm theme when isDark changes
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = getXtermTheme(isDark)
}
}, [isDark])
// Update xterm font size when fontSize changes
useEffect(() => {
if (termRef.current) {
termRef.current.options.fontSize = fontSize ?? 13
try {
fitAddonRef.current?.fit()
if (termRef.current) {
sendResize(termRef.current.cols, termRef.current.rows)
}
} catch {
/* not visible yet */
}
}
}, [fontSize, sendResize])
// Re-fit when visibility changes
useEffect(() => {
if (visible && fitAddonRef.current && termRef.current) {
// Small delay to let the DOM settle
const t = setTimeout(() => {
try {
fitAddonRef.current?.fit()
if (termRef.current) {
sendResize(termRef.current.cols, termRef.current.rows)
}
} catch {
/* not visible yet */
}
}, 50)
return () => clearTimeout(t)
}
}, [visible, sendResize])
useEffect(() => {
if (!containerRef.current) return
let disposed = false
let terminal: XTerminal | null = null
let fitAddon: XFitAddon | null = null
let resizeObserver: ResizeObserver | null = null
const init = async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
])
if (disposed) return
terminal = new Terminal(getXtermOptions(isDark, fontSize))
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(containerRef.current!)
termRef.current = terminal
fitAddonRef.current = fitAddon
await settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed)
if (disposed) return
terminal.onData((data) => sendInput(data))
terminal.onBinary((data) => sendInput(data))
// SSE stream
const streamUrl = buildProjectAbsoluteUrl(
"/api/terminal/stream",
window.location.origin,
projectCwd,
)
streamUrl.searchParams.set("id", sessionId)
if (command) streamUrl.searchParams.set("command", command)
for (const arg of commandArgs ?? []) {
streamUrl.searchParams.append("arg", arg)
}
const es = new EventSource(appendAuthParam(streamUrl.toString()))
eventSourceRef.current = es
es.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as {
type: string
data?: string
}
if (msg.type === "connected") {
onConnectionChangeRef.current(true)
void settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed).then((size) => {
if (!size) return
sendResize(size.cols, size.rows)
})
} else if (msg.type === "output" && msg.data) {
let output = msg.data
if (hideInitialGsdHeader && !initialHeaderSettledRef.current) {
initialHeaderBufferRef.current += output
const filtered = filterInitialGsdHeader(initialHeaderBufferRef.current)
if (filtered.status === "needs-more") {
return
}
initialHeaderSettledRef.current = true
initialHeaderBufferRef.current = ""
output = filtered.text
}
if (output) {
terminal?.write(output)
setHasOutput(true)
}
}
} catch {
/* malformed */
}
}
es.onerror = () => onConnectionChangeRef.current(false)
// Resize observer
resizeObserver = new ResizeObserver(() => {
if (disposed) return
try {
fitAddon?.fit()
if (terminal) sendResize(terminal.cols, terminal.rows)
} catch {
/* not visible */
}
})
resizeObserver.observe(containerRef.current!)
}
void init()
return () => {
disposed = true
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
eventSourceRef.current?.close()
eventSourceRef.current = null
resizeObserver?.disconnect()
terminal?.dispose()
termRef.current = null
fitAddonRef.current = null
}
}, [sessionId, command, commandArgs, commandArgsKey, fontSize, hideInitialGsdHeader, isDark, projectCwd, sendInput, sendResize])
// Focus on click
const wrapperRef = useRef<HTMLDivElement>(null)
const handleClick = useCallback(() => {
termRef.current?.focus()
}, [])
// Shift+Enter → newline (native DOM, capture phase)
// xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor
// recognizes \n (LF) as "insert newline".
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault()
e.stopPropagation()
sendInput("\n")
}
}
el.addEventListener("keydown", onKeyDown, true)
return () => el.removeEventListener("keydown", onKeyDown, true)
}, [sendInput])
// Auto-focus when this tab becomes visible
useEffect(() => {
if (visible) {
// Small delay to let layout settle
const t = setTimeout(() => termRef.current?.focus(), 80)
return () => clearTimeout(t)
}
}, [visible])
return (
<div
ref={wrapperRef}
className={cn("relative h-full w-full bg-terminal", !visible && "hidden")}
onClick={handleClick}
>
{/* Loading overlay — visible until first output arrives */}
{!hasOutput && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{command ? "Starting GSD…" : "Connecting…"}
</span>
</div>
)}
<div
ref={containerRef}
className="h-full w-full"
style={{ padding: "8px 4px 4px 8px" }}
/>
</div>
)
}
// ─── Image upload helpers ─────────────────────────────────────────────────────
const ALLOWED_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
])
/**
* Upload an image file to the server's temp directory and inject the `@filepath`
* text into the PTY session's stdin.
*
* Observability:
* - console.warn on client-side validation failure
* - console.error on upload or inject failure
*/
async function uploadAndInjectImage(file: File, sessionId: string, projectCwd?: string): Promise<void> {
// Client-side validation
const validation = validateImageFile(file)
if (!validation.valid) {
console.warn("[terminal-upload] validation failed:", validation.error)
return
}
// Upload to temp dir
const formData = new FormData()
formData.append("file", file)
let uploadPath: string
try {
const res = await authFetch(buildProjectPath("/api/terminal/upload", projectCwd), {
method: "POST",
body: formData,
})
const data = await res.json() as { ok?: boolean; path?: string; error?: string }
if (!res.ok || !data.path) {
console.error("[terminal-upload] upload failed:", data.error ?? `HTTP ${res.status}`)
return
}
uploadPath = data.path
} catch (err) {
console.error("[terminal-upload] upload request failed:", err)
return
}
// Inject @filepath into PTY stdin
try {
const res = await authFetch(buildProjectPath("/api/terminal/input", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: sessionId, data: `@${uploadPath} ` }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { error?: string }
console.error("[terminal-upload] inject failed:", data.error ?? `HTTP ${res.status}`)
}
} catch (err) {
console.error("[terminal-upload] inject request failed:", err)
}
}
// ─── Multi-instance terminal panel ────────────────────────────────────────────
/**
* Derive a session ID that is scoped to the project path. This ensures
* that switching projects creates a separate PTY session per project, and
* switching back reconnects to the *same* server-side PTY instead of
* spawning a new one (the server's getOrCreateSession returns the existing
* live session when the ID matches).
*/
function deriveProjectScopedSessionId(
projectCwd: string | undefined,
sessionPrefix?: string,
command?: string,
): string {
const base = sessionPrefix ?? (command ? "gsd-default" : "default")
if (!projectCwd) return base
return `${base}:${projectCwd}`
}
export function ShellTerminal({
className,
command,
commandArgs,
sessionPrefix,
hideSidebar = false,
fontSize,
hideInitialGsdHeader = false,
projectCwd,
}: ShellTerminalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme !== "light"
const defaultId = deriveProjectScopedSessionId(projectCwd, sessionPrefix, command)
const commandLabel = deriveCommandLabel(command)
const [tabs, setTabs] = useState<TerminalTab[]>([
{ id: defaultId, label: commandLabel, connected: false },
])
const [activeTabId, setActiveTabId] = useState(defaultId)
const [isDragOver, setIsDragOver] = useState(false)
const terminalAreaRef = useRef<HTMLDivElement>(null)
// When the project changes, the defaultId changes. Reset tabs so the
// terminal reconnects to the project-scoped PTY session on the server.
// The server's getOrCreateSession will return the existing live session
// when the session ID matches, preserving terminal state.
const prevDefaultIdRef = useRef(defaultId)
useEffect(() => {
if (prevDefaultIdRef.current !== defaultId) {
prevDefaultIdRef.current = defaultId
setTabs([{ id: defaultId, label: commandLabel, connected: false }])
setActiveTabId(defaultId)
}
}, [defaultId, commandLabel])
// ── Drag-and-drop handlers (native DOM, capture phase) ──────────────────
// React synthetic events don't reliably fire through xterm's internal DOM.
// Native capture-phase listeners intercept before xterm can swallow them —
// same pattern used for paste below.
useEffect(() => {
const el = terminalAreaRef.current
if (!el) return
let counter = 0
const onDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter += 1
if (counter === 1) setIsDragOver(true)
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter -= 1
if (counter <= 0) {
counter = 0
setIsDragOver(false)
}
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter = 0
setIsDragOver(false)
if (!activeTabId) return
const files = Array.from(e.dataTransfer?.files ?? [])
const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type))
if (imageFile) {
void uploadAndInjectImage(imageFile, activeTabId, projectCwd)
}
}
el.addEventListener("dragenter", onDragEnter, true)
el.addEventListener("dragover", onDragOver, true)
el.addEventListener("dragleave", onDragLeave, true)
el.addEventListener("drop", onDrop, true)
return () => {
el.removeEventListener("dragenter", onDragEnter, true)
el.removeEventListener("dragover", onDragOver, true)
el.removeEventListener("dragleave", onDragLeave, true)
el.removeEventListener("drop", onDrop, true)
}
}, [activeTabId, projectCwd])
// ── Paste handler for images ──────────────────────────────────────────────
useEffect(() => {
const el = terminalAreaRef.current
if (!el) return
const handlePaste = (e: ClipboardEvent) => {
if (!e.clipboardData) return
const files = Array.from(e.clipboardData.files)
const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type))
if (imageFile) {
e.preventDefault()
e.stopPropagation()
if (activeTabId) {
void uploadAndInjectImage(imageFile, activeTabId, projectCwd)
}
}
// If no image files, don't prevent default — let xterm.js handle text paste
}
el.addEventListener("paste", handlePaste, true) // capture phase to fire before xterm
return () => el.removeEventListener("paste", handlePaste, true)
}, [activeTabId, projectCwd])
const createTab = useCallback(async () => {
try {
const res = await authFetch(buildProjectPath("/api/terminal/sessions", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command ? { command } : {}),
})
const data = (await res.json()) as { id: string }
const newTab: TerminalTab = {
id: data.id,
label: commandLabel,
connected: false,
}
setTabs((prev) => [...prev, newTab])
setActiveTabId(data.id)
} catch {
/* network error */
}
}, [command, commandLabel, projectCwd])
const closeTab = useCallback(
(id: string) => {
// Don't close the last tab
if (tabs.length <= 1) return
const deleteUrl = buildProjectAbsoluteUrl("/api/terminal/sessions", window.location.origin, projectCwd)
deleteUrl.searchParams.set("id", id)
void authFetch(deleteUrl.toString(), {
method: "DELETE",
})
const remaining = tabs.filter((t) => t.id !== id)
setTabs(remaining)
if (activeTabId === id) {
setActiveTabId(remaining[remaining.length - 1]?.id ?? defaultId)
}
},
[tabs, activeTabId, defaultId, projectCwd],
)
const updateConnection = useCallback(
(id: string, connected: boolean) => {
setTabs((prev) =>
prev.map((t) => (t.id === id ? { ...t, connected } : t)),
)
},
[],
)
return (
<div className={cn("flex bg-terminal", className)}>
{/* Terminal area — receives drag/drop and paste for images */}
<div
ref={terminalAreaRef}
className="relative flex-1 min-w-0"
>
{tabs.map((tab) => (
<TerminalInstance
key={tab.id}
sessionId={tab.id}
visible={tab.id === activeTabId}
command={command}
commandArgs={tab.id === defaultId ? commandArgs : undefined}
isDark={isDark}
fontSize={fontSize}
hideInitialGsdHeader={hideInitialGsdHeader}
projectCwd={projectCwd}
onConnectionChange={(c) => updateConnection(tab.id, c)}
/>
))}
{/* Drop overlay */}
{isDragOver && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background backdrop-blur-sm border-2 border-dashed border-primary rounded-md pointer-events-none">
<ImagePlus className="h-8 w-8 text-primary" />
<span className="text-sm font-medium text-primary">Drop image here</span>
</div>
)}
</div>
{!hideSidebar && (
<div className="flex w-[34px] flex-shrink-0 flex-col border-l border-border/50 bg-terminal">
{/* New terminal button */}
<button
onClick={createTab}
className="flex h-[30px] w-full items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
title="New terminal"
>
<Plus className="h-3 w-3" />
</button>
<div className="h-px bg-border/50" />
{/* Tab list */}
<div className="flex-1 overflow-y-auto">
{tabs.map((tab, index) => (
<button
key={tab.id}
onClick={() => setActiveTabId(tab.id)}
className={cn(
"group relative flex h-[30px] w-full items-center justify-center transition-colors",
tab.id === activeTabId
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
)}
title={`${tab.label} ${index + 1}`}
>
{/* Active indicator bar */}
{tab.id === activeTabId && (
<div className="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full bg-muted-foreground" />
)}
<div className="relative flex items-center">
<TerminalSquare className="h-3 w-3" />
{/* Connection dot */}
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 h-1.5 w-1.5 rounded-full border border-terminal",
tab.connected ? "bg-success" : "bg-muted-foreground/40",
)}
/>
</div>
{/* Close button — shows on hover as small badge in corner */}
{tabs.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}
className="absolute -right-0.5 -top-0.5 z-10 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-accent text-muted-foreground hover:bg-destructive/20 hover:text-destructive group-hover:flex"
title="Kill terminal"
>
<X className="h-2 w-2" />
</button>
)}
</button>
))}
</div>
</div>
)}
</div>
)
}

View file

@ -1,798 +0,0 @@
"use client"
import { useMemo, useState, useSyncExternalStore } from "react"
import {
ChevronRight,
ChevronDown,
CheckCircle2,
Circle,
Play,
Folder,
FileText,
GitBranch,
Settings,
LayoutDashboard,
Map as MapIcon,
Activity,
BarChart3,
Columns2,
MessagesSquare,
LifeBuoy,
LogOut,
FolderKanban,
Loader2,
Milestone,
SkipForward,
Monitor,
Sun,
Moon,
PanelRightClose,
PanelRightOpen,
} from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useTheme } from "next-themes"
import {
getCurrentScopeLabel,
getLiveWorkspaceIndex,
getLiveAutoDashboard,
useGSDWorkspaceState,
useGSDWorkspaceActions,
buildPromptCommand,
} from "@/lib/gsd-workspace-store"
import { getMilestoneStatus, getSliceStatus, getTaskStatus, type ItemStatus } from "@/lib/workspace-status"
import { deriveWorkflowAction } from "@/lib/workflow-actions"
import { executeWorkflowActionInPowerMode } from "@/lib/workflow-action-execution"
import { useProjectStoreManager } from "@/lib/project-store-manager"
import { Skeleton } from "@/components/ui/skeleton"
import { authFetch } from "@/lib/auth"
const StatusIcon = ({ status }: { status: ItemStatus }) => {
if (status === "done") {
return <CheckCircle2 className="h-4 w-4 shrink-0 text-success" />
}
if (status === "in-progress") {
return <Play className="h-4 w-4 shrink-0 text-warning" />
}
return <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
}
/* ─── Nav Rail (left icon bar) ─── */
interface NavRailProps {
activeView: string
onViewChange: (view: string) => void
isConnecting?: boolean
}
export function NavRail({ activeView, onViewChange, isConnecting = false }: NavRailProps) {
const { openCommandSurface } = useGSDWorkspaceActions()
const manager = useProjectStoreManager()
const activeProjectCwd = useSyncExternalStore(manager.subscribe, manager.getSnapshot, manager.getSnapshot)
const [exitDialogOpen, setExitDialogOpen] = useState(false)
const { theme, setTheme } = useTheme()
const cycleTheme = () => {
if (theme === "system") setTheme("light")
else if (theme === "light") setTheme("dark")
else setTheme("system")
}
const themeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor
const themeLabel = theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System"
const ThemeIcon = themeIcon
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 w-12 flex-col items-center gap-1 border-r border-border bg-sidebar py-3">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => onViewChange(item.id)}
disabled={isConnecting}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
isConnecting
? "cursor-not-allowed text-muted-foreground/50"
: activeView === item.id
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
title={isConnecting ? "Connecting…" : item.label}
>
<item.icon className="h-5 w-5" />
</button>
))}
<div className="mt-auto flex flex-col gap-1">
<button
onClick={() => window.dispatchEvent(new CustomEvent("gsd:open-projects"))}
disabled={isConnecting}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
isConnecting
? "cursor-not-allowed text-muted-foreground/50"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
title={isConnecting ? "Connecting…" : "Projects"}
>
<FolderKanban className="h-5 w-5" />
</button>
<button
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors",
isConnecting
? "cursor-not-allowed opacity-30"
: "hover:bg-accent/50 hover:text-foreground",
)}
title="Git"
disabled={isConnecting}
onClick={() => !isConnecting && openCommandSurface("git", { source: "sidebar" })}
data-testid="sidebar-git-button"
>
<GitBranch className="h-5 w-5" />
</button>
<button
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors",
isConnecting
? "cursor-not-allowed opacity-30"
: "hover:bg-accent/50 hover:text-foreground",
)}
title="Settings"
disabled={isConnecting}
onClick={() => !isConnecting && openCommandSurface("settings", { source: "sidebar" })}
data-testid="sidebar-settings-button"
>
<Settings className="h-5 w-5" />
</button>
<button
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors",
isConnecting
? "cursor-not-allowed opacity-30"
: "hover:bg-accent/50 hover:text-foreground",
)}
title={`Theme: ${themeLabel}`}
disabled={isConnecting}
onClick={() => !isConnecting && cycleTheme()}
data-testid="sidebar-theme-toggle"
>
<ThemeIcon className="h-5 w-5" />
</button>
<button
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors",
isConnecting
? "cursor-not-allowed opacity-30"
: "hover:bg-destructive/15 hover:text-destructive",
)}
title="Exit"
disabled={isConnecting}
onClick={() => !isConnecting && setExitDialogOpen(true)}
data-testid="sidebar-signoff-button"
>
<LogOut className="h-5 w-5" />
</button>
<ExitDialog
open={exitDialogOpen}
onOpenChange={setExitDialogOpen}
projectCount={manager.getProjectCount()}
activeProjectCwd={activeProjectCwd}
onCloseProject={(cwd) => {
manager.closeProject(cwd)
onViewChange("dashboard")
setExitDialogOpen(false)
}}
onStopServer={async () => {
await authFetch("/api/shutdown", { method: "POST" }).catch(() => {})
setTimeout(() => {
try { window.close() } catch { /* ignore */ }
setTimeout(() => { window.location.href = "about:blank" }, 300)
}, 400)
}}
/>
</div>
</div>
)
}
/* ─── Exit Dialog (multi-project aware) ─── */
function ExitDialog({
open,
onOpenChange,
projectCount,
activeProjectCwd,
onCloseProject,
onStopServer,
}: {
open: boolean
onOpenChange: (open: boolean) => void
projectCount: number
activeProjectCwd: string | null
onCloseProject: (cwd: string) => void
onStopServer: () => void
}) {
const hasMultipleProjects = projectCount > 1
const projectName = activeProjectCwd ? activeProjectCwd.split("/").pop() ?? activeProjectCwd : null
if (!hasMultipleProjects) {
// Single project — simple stop server dialog
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Stop the GSD web server?</DialogTitle>
<DialogDescription>
This will shut down the server process and close this tab. Run{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">gsd --web</code> again to restart.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onStopServer}
>
Stop server
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// Multiple projects — offer close project vs stop server
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Close project or stop server?</DialogTitle>
<DialogDescription>
You have {projectCount} projects open. You can close just the current project or stop the entire server.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 py-2">
{activeProjectCwd && (
<Button
variant="outline"
className="h-auto justify-start gap-3 px-4 py-3 text-left"
onClick={() => onCloseProject(activeProjectCwd)}
>
<FolderKanban className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-sm font-medium">Close {projectName}</div>
<div className="text-xs text-muted-foreground">
Disconnect this project and switch to another
</div>
</div>
</Button>
)}
<Button
variant="outline"
className="h-auto justify-start gap-3 px-4 py-3 text-left border-destructive/30 hover:bg-destructive/10 hover:text-destructive"
onClick={onStopServer}
>
<LogOut className="h-4 w-4 shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium">Stop server</div>
<div className="text-xs text-muted-foreground">
Shut down all {projectCount} projects and close the tab
</div>
</div>
</Button>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* ─── Milestone Explorer (right sidebar) ─── */
export function MilestoneExplorer({ isConnecting = false, width, onCollapse }: { isConnecting?: boolean; width?: number; onCollapse?: () => void }) {
const workspace = useGSDWorkspaceState()
const { openCommandSurface, setCommandSurfaceSection, sendCommand } = useGSDWorkspaceActions()
const [expandedMilestones, setExpandedMilestones] = useState<string[]>([])
const [expandedSlices, setExpandedSlices] = useState<string[]>([])
const liveWorkspace = getLiveWorkspaceIndex(workspace)
const milestones = useMemo(() => liveWorkspace?.milestones ?? [], [liveWorkspace?.milestones])
const activeScope = liveWorkspace?.active
const auto = getLiveAutoDashboard(workspace)
const recoverySummary = workspace.live.recoverySummary
const validationCount = liveWorkspace?.validationIssues.length ?? 0
const currentScopeLabel = getCurrentScopeLabel(liveWorkspace)
const projectCwd = workspace.boot?.project.cwd ?? null
const bridge = workspace.boot?.bridge ?? null
const openTaskFile = (absolutePath: string | undefined) => {
if (!absolutePath || !projectCwd) return
const gsdPrefix = `${projectCwd}/.gsd/`
if (!absolutePath.startsWith(gsdPrefix)) return
const relativePath = absolutePath.slice(gsdPrefix.length)
window.dispatchEvent(new CustomEvent("gsd:open-file", { detail: { root: "gsd", path: relativePath } }))
}
const workflowAction = deriveWorkflowAction({
phase: liveWorkspace?.active.phase ?? "pre-planning",
autoActive: auto?.active ?? false,
autoPaused: auto?.paused ?? false,
onboardingLocked: workspace.boot?.onboarding.locked ?? false,
commandInFlight: workspace.commandInFlight,
bootStatus: workspace.bootStatus,
hasMilestones: milestones.length > 0,
projectDetectionKind: workspace.boot?.projectDetection?.kind ?? null,
})
const handleCommand = (command: string) => {
executeWorkflowActionInPowerMode({
dispatch: () => sendCommand(buildPromptCommand(command, bridge)),
})
}
const handlePrimaryAction = () => {
if (!workflowAction.primary) return
handleCommand(workflowAction.primary.command)
}
const handleOpenRecovery = () => {
openCommandSurface("settings", { source: "sidebar" })
setCommandSurfaceSection("recovery")
}
const effectiveExpandedMilestones =
activeScope?.milestoneId && !expandedMilestones.includes(activeScope.milestoneId)
? [...expandedMilestones, activeScope.milestoneId]
: expandedMilestones
const effectiveExpandedSlices =
activeScope?.milestoneId && activeScope.sliceId
? (() => {
const sliceKey = `${activeScope.milestoneId}-${activeScope.sliceId}`
return expandedSlices.includes(sliceKey) ? expandedSlices : [...expandedSlices, sliceKey]
})()
: expandedSlices
const milestoneStatus = new Map(
milestones.map((milestone) => [milestone.id, getMilestoneStatus(milestone, activeScope ?? {})]),
)
const toggleMilestone = (id: string) => {
setExpandedMilestones((prev) =>
prev.includes(id) ? prev.filter((entry) => entry !== id) : [...prev, id],
)
}
const toggleSlice = (id: string) => {
setExpandedSlices((prev) =>
prev.includes(id) ? prev.filter((entry) => entry !== id) : [...prev, id],
)
}
return (
<div className="flex flex-col bg-sidebar" style={{ width: width ?? 256, flexShrink: 0 }}>
{isConnecting && (
<div className="flex-1 overflow-y-auto px-1.5 py-1">
<div className="px-2 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Milestones
</span>
</div>
<div className="space-y-0.5 px-1">
{[1, 2].map((m) => (
<div key={m}>
<div className="flex items-center gap-1.5 px-2 py-1.5">
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
<Skeleton className={cn("h-4", m === 1 ? "w-40" : "w-32")} />
</div>
{m === 1 && (
<div className="ml-4 space-y-0.5">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center gap-1.5 px-2 py-1.5">
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
<Skeleton className={cn("h-3.5", s === 1 ? "w-32" : s === 2 ? "w-28" : "w-24")} />
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{!isConnecting && (
<div className="flex-1 overflow-y-auto px-1.5 py-1">
<div className="flex items-start justify-between px-2 py-1.5">
<div className="min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Milestones
</span>
<div className="mt-1 text-xs text-foreground" data-testid="sidebar-current-scope">
{currentScopeLabel}
</div>
</div>
{onCollapse && (
<button
onClick={onCollapse}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Collapse sidebar"
>
<PanelRightClose className="h-3.5 w-3.5" />
</button>
)}
</div>
{workspace.bootStatus === "error" && milestones.length === 0 && (
<div className="px-3 py-2 text-xs text-destructive">Workspace boot failed before the explorer could load.</div>
)}
{workspace.bootStatus === "ready" && milestones.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">No milestones found for this project.</div>
)}
{milestones.map((milestone) => {
const milestoneOpen = effectiveExpandedMilestones.includes(milestone.id)
const milestoneActive = activeScope?.milestoneId === milestone.id
const status = milestoneStatus.get(milestone.id) ?? "pending"
return (
<div key={milestone.id}>
<button
onClick={() => toggleMilestone(milestone.id)}
className={cn(
"flex w-full items-center gap-1.5 px-2 py-1.5 text-sm transition-colors hover:bg-accent/50",
milestoneActive && "bg-accent/30",
)}
>
{milestoneOpen ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<StatusIcon status={status} />
<span className={cn("truncate", status === "pending" && "text-muted-foreground")}>
{milestone.id}: {milestone.title}
</span>
</button>
{milestoneOpen && (
<div className="ml-4">
{milestone.slices.map((slice) => {
const sliceKey = `${milestone.id}-${slice.id}`
const sliceOpen = effectiveExpandedSlices.includes(sliceKey)
const sliceStatus = getSliceStatus(milestone.id, slice, activeScope ?? {})
const sliceActive = activeScope?.milestoneId === milestone.id && activeScope.sliceId === slice.id
return (
<div key={sliceKey}>
<button
onClick={() => toggleSlice(sliceKey)}
className={cn(
"flex w-full items-center gap-1.5 px-2 py-1.5 text-sm transition-colors hover:bg-accent/50",
sliceActive && "bg-accent/20",
)}
>
{sliceOpen ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<StatusIcon status={sliceStatus} />
<span className={cn("truncate text-[13px]", sliceStatus === "pending" && "text-muted-foreground")}>
{slice.id}: {slice.title}
</span>
</button>
{sliceOpen && (
<div className="ml-5">
{slice.branch && (
<div className="px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
{slice.branch}
</div>
)}
{slice.tasks.map((task) => {
const taskStatus = getTaskStatus(milestone.id, slice.id, task, activeScope ?? {})
const hasFile = !!(task.planPath || task.summaryPath)
return (
<button
key={`${sliceKey}-${task.id}`}
type="button"
onClick={() => openTaskFile(task.summaryPath ?? task.planPath)}
disabled={!hasFile}
className={cn(
"flex w-full items-center gap-1.5 px-2 py-1 text-xs transition-colors",
hasFile ? "cursor-pointer hover:bg-accent/50" : "cursor-default opacity-70",
activeScope?.taskId === task.id && sliceActive && "bg-accent/10",
)}
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<StatusIcon status={taskStatus} />
<span className={cn("truncate text-left", taskStatus === "pending" && "text-muted-foreground")}>
{task.id}: {task.title}
</span>
</button>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
{/* Sticky action footer */}
{!isConnecting && (
<div className="border-t border-border px-3 py-2.5">
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-background px-3 py-2 text-xs">
<div className="min-w-0">
<div className="font-medium text-foreground" data-testid="sidebar-validation-count">
{validationCount} validation issue{validationCount === 1 ? "" : "s"}
</div>
<div className="truncate text-muted-foreground">{recoverySummary.label}</div>
</div>
<button
type="button"
onClick={handleOpenRecovery}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-2.5 text-[11px] font-medium text-foreground transition-colors hover:bg-accent"
data-testid="sidebar-recovery-summary-entrypoint"
>
<LifeBuoy className="h-3.5 w-3.5" />
Recovery
</button>
</div>
</div>
)}
{!isConnecting && workflowAction.primary && (
<div className="border-t border-border px-3 py-2.5">
<div className="flex items-center gap-2">
<button
onClick={handlePrimaryAction}
disabled={workflowAction.disabled}
className={cn(
"inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors",
workflowAction.primary.variant === "destructive"
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90",
workflowAction.disabled && "cursor-not-allowed opacity-50",
)}
title={workflowAction.disabledReason}
>
{workspace.commandInFlight ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : workflowAction.isNewMilestone ? (
<Milestone className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
{workflowAction.primary.label}
</button>
{workflowAction.secondaries.map((action) => (
<button
key={action.command}
onClick={() => handleCommand(action.command)}
disabled={workflowAction.disabled}
className={cn(
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border bg-background transition-colors hover:bg-accent",
workflowAction.disabled && "cursor-not-allowed opacity-50",
)}
title={action.label}
>
<SkipForward className="h-3.5 w-3.5" />
</button>
))}
</div>
</div>
)}
</div>
)
}
/* ─── Collapsed Milestone Sidebar (icon-only rail) ─── */
export function CollapsedMilestoneSidebar({ onExpand }: { onExpand: () => void }) {
const workspace = useGSDWorkspaceState()
const { sendCommand } = useGSDWorkspaceActions()
const liveWorkspace = getLiveWorkspaceIndex(workspace)
const milestones = liveWorkspace?.milestones ?? []
const auto = getLiveAutoDashboard(workspace)
const bridge = workspace.boot?.bridge ?? null
const workflowAction = deriveWorkflowAction({
phase: liveWorkspace?.active.phase ?? "pre-planning",
autoActive: auto?.active ?? false,
autoPaused: auto?.paused ?? false,
onboardingLocked: workspace.boot?.onboarding.locked ?? false,
commandInFlight: workspace.commandInFlight,
bootStatus: workspace.bootStatus,
hasMilestones: milestones.length > 0,
projectDetectionKind: workspace.boot?.projectDetection?.kind ?? null,
})
const handleCommand = (command: string) => {
executeWorkflowActionInPowerMode({
dispatch: () => sendCommand(buildPromptCommand(command, bridge)),
})
}
const handlePrimaryAction = () => {
if (!workflowAction.primary) return
handleCommand(workflowAction.primary.command)
}
return (
<div className="flex h-full w-10 flex-col items-center border-l border-border bg-sidebar py-3">
<button
onClick={onExpand}
className="flex h-8 w-8 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Expand milestone sidebar"
>
<PanelRightOpen className="h-4 w-4" />
</button>
{workflowAction.primary && (
<div className="mt-auto pb-0.5">
<button
onClick={handlePrimaryAction}
disabled={workflowAction.disabled}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md transition-colors",
workflowAction.primary.variant === "destructive"
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90",
workflowAction.disabled && "cursor-not-allowed opacity-50",
)}
title={workflowAction.disabledReason ?? workflowAction.primary.label}
>
{workspace.commandInFlight ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : workflowAction.isNewMilestone ? (
<Milestone className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</button>
</div>
)}
</div>
)
}
/* ─── Legacy Sidebar export (back-compat) ─── */
interface SidebarProps {
activeView: string
onViewChange: (view: string) => void
isConnecting?: boolean
mobile?: boolean
}
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/50"
: 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>
)
}

View file

@ -1,163 +0,0 @@
"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>
)
}

View file

@ -1,784 +0,0 @@
"use client"
import { useEffect, useRef, useCallback, useState } from "react"
import { useTheme } from "next-themes"
import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react"
import { cn } from "@/lib/utils"
import { validateImageFile } from "@/lib/image-utils"
import { filterInitialGsdHeader } from "@/lib/initial-gsd-header-filter"
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
import { authFetch, appendAuthParam } from "@/lib/auth"
import "@xterm/xterm/css/xterm.css"
type XTerminal = import("@xterm/xterm").Terminal
type XFitAddon = import("@xterm/addon-fit").FitAddon
const MIN_TERMINAL_ATTACH_WIDTH = 180
const MIN_TERMINAL_ATTACH_HEIGHT = 120
const MIN_TERMINAL_ATTACH_COLS = 20
const MIN_TERMINAL_ATTACH_ROWS = 8
// ─── Types ────────────────────────────────────────────────────────────────────
interface TerminalTab {
id: string
label: string
connected: boolean
}
interface ShellTerminalProps {
className?: string
command?: string
commandArgs?: string[]
sessionPrefix?: string
hideSidebar?: boolean
fontSize?: number
hideInitialGsdHeader?: boolean
projectCwd?: string
}
// ─── xterm themes ─────────────────────────────────────────────────────────────
const XTERM_DARK_THEME = {
background: "#0a0a0a",
foreground: "#e4e4e7",
cursor: "#e4e4e7",
cursorAccent: "#0a0a0a",
selectionBackground: "#27272a",
selectionForeground: "#e4e4e7",
black: "#18181b",
red: "#ef4444",
green: "#22c55e",
yellow: "#eab308",
blue: "#3b82f6",
magenta: "#a855f7",
cyan: "#06b6d4",
white: "#e4e4e7",
brightBlack: "#52525b",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#facc15",
brightBlue: "#60a5fa",
brightMagenta: "#c084fc",
brightCyan: "#22d3ee",
brightWhite: "#fafafa",
} as const
const XTERM_LIGHT_THEME = {
background: "#f5f5f5",
foreground: "#1a1a1a",
cursor: "#1a1a1a",
cursorAccent: "#f5f5f5",
selectionBackground: "#d4d4d8",
selectionForeground: "#1a1a1a",
black: "#1a1a1a",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#e4e4e7",
brightBlack: "#71717a",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#eab308",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#fafafa",
} as const
function getXtermTheme(isDark: boolean) {
return isDark ? XTERM_DARK_THEME : XTERM_LIGHT_THEME
}
function getXtermOptions(isDark: boolean, fontSize?: number) {
return {
cursorBlink: true,
cursorStyle: "bar" as const,
fontSize: fontSize ?? 13,
fontFamily:
"'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, 'Courier New', monospace",
lineHeight: 1.35,
letterSpacing: 0,
theme: getXtermTheme(isDark),
allowProposedApi: true,
scrollback: 10000,
convertEol: false,
}
}
function getRenderableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null {
if (!container || !terminal) return null
const rect = container.getBoundingClientRect()
if (rect.width < MIN_TERMINAL_ATTACH_WIDTH || rect.height < MIN_TERMINAL_ATTACH_HEIGHT) {
return null
}
if (terminal.cols < MIN_TERMINAL_ATTACH_COLS || terminal.rows < MIN_TERMINAL_ATTACH_ROWS) {
return null
}
return { cols: terminal.cols, rows: terminal.rows }
}
async function settleTerminalLayout(
container: HTMLDivElement | null,
terminal: XTerminal | null,
fitAddon: XFitAddon | null,
isDisposed: () => boolean,
): Promise<{ cols: number; rows: number } | null> {
if (typeof document !== "undefined" && "fonts" in document) {
try {
await Promise.race([
document.fonts.ready,
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
])
} catch {
// Ignore font loading failures and fall through to repeated fit attempts.
}
}
for (let attempt = 0; attempt < 12; attempt++) {
if (isDisposed()) return null
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
if (isDisposed()) return null
try {
fitAddon?.fit()
} catch {
/* hidden or detached */
}
const size = getRenderableTerminalSize(container, terminal)
if (size) {
return size
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
return getRenderableTerminalSize(container, terminal)
}
function deriveCommandLabel(command?: string): string {
if (!command?.trim()) return "zsh"
const token = command.trim().split(/\s+/)[0] || command
const normalized = token.replace(/\\/g, "/")
const parts = normalized.split("/")
return parts[parts.length - 1] || token
}
// ─── Single terminal instance (internal) ──────────────────────────────────────
interface TerminalInstanceProps {
sessionId: string
visible: boolean
command?: string
commandArgs?: string[]
isDark: boolean
fontSize?: number
hideInitialGsdHeader?: boolean
projectCwd?: string
onConnectionChange: (connected: boolean) => void
}
function TerminalInstance({
sessionId,
visible,
command,
commandArgs,
isDark,
fontSize,
hideInitialGsdHeader = false,
projectCwd,
onConnectionChange,
}: TerminalInstanceProps) {
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerminal | null>(null)
const fitAddonRef = useRef<XFitAddon | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const inputQueueRef = useRef<string[]>([])
const flushingRef = useRef(false)
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const onConnectionChangeRef = useRef(onConnectionChange)
const initialHeaderSettledRef = useRef(!hideInitialGsdHeader)
const initialHeaderBufferRef = useRef("")
const commandArgsKey = (commandArgs ?? []).join("\u0000")
const [hasOutput, setHasOutput] = useState(false)
const sendResize = useCallback(
(cols: number, rows: number) => {
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
resizeTimeoutRef.current = setTimeout(() => {
void authFetch(buildProjectPath("/api/terminal/resize", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: sessionId, cols, rows }),
})
}, 100)
},
[projectCwd, sessionId],
)
const flushInputQueue = useCallback(async () => {
if (flushingRef.current) return
flushingRef.current = true
while (inputQueueRef.current.length > 0) {
const data = inputQueueRef.current.shift()!
try {
await authFetch(buildProjectPath("/api/terminal/input", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: sessionId, data }),
})
} catch {
inputQueueRef.current.unshift(data)
break
}
}
flushingRef.current = false
}, [projectCwd, sessionId])
const sendInput = useCallback(
(data: string) => {
inputQueueRef.current.push(data)
void flushInputQueue()
},
[flushInputQueue],
)
useEffect(() => {
onConnectionChangeRef.current = onConnectionChange
}, [onConnectionChange])
useEffect(() => {
initialHeaderSettledRef.current = !hideInitialGsdHeader
initialHeaderBufferRef.current = ""
}, [hideInitialGsdHeader, sessionId])
// Update xterm theme when isDark changes
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = getXtermTheme(isDark)
}
}, [isDark])
// Update xterm font size when fontSize changes
useEffect(() => {
if (termRef.current) {
termRef.current.options.fontSize = fontSize ?? 13
try {
fitAddonRef.current?.fit()
if (termRef.current) {
sendResize(termRef.current.cols, termRef.current.rows)
}
} catch {
/* not visible yet */
}
}
}, [fontSize, sendResize])
// Re-fit when visibility changes
useEffect(() => {
if (visible && fitAddonRef.current && termRef.current) {
// Small delay to let the DOM settle
const t = setTimeout(() => {
try {
fitAddonRef.current?.fit()
if (termRef.current) {
sendResize(termRef.current.cols, termRef.current.rows)
}
} catch {
/* not visible yet */
}
}, 50)
return () => clearTimeout(t)
}
}, [visible, sendResize])
useEffect(() => {
if (!containerRef.current) return
let disposed = false
let terminal: XTerminal | null = null
let fitAddon: XFitAddon | null = null
let resizeObserver: ResizeObserver | null = null
const init = async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
])
if (disposed) return
terminal = new Terminal(getXtermOptions(isDark, fontSize))
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(containerRef.current!)
termRef.current = terminal
fitAddonRef.current = fitAddon
await settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed)
if (disposed) return
terminal.onData((data) => sendInput(data))
terminal.onBinary((data) => sendInput(data))
// SSE stream
const streamUrl = buildProjectAbsoluteUrl(
"/api/terminal/stream",
window.location.origin,
projectCwd,
)
streamUrl.searchParams.set("id", sessionId)
if (command) streamUrl.searchParams.set("command", command)
for (const arg of commandArgs ?? []) {
streamUrl.searchParams.append("arg", arg)
}
const es = new EventSource(appendAuthParam(streamUrl.toString()))
eventSourceRef.current = es
es.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as {
type: string
data?: string
}
if (msg.type === "connected") {
onConnectionChangeRef.current(true)
void settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed).then((size) => {
if (!size) return
sendResize(size.cols, size.rows)
})
} else if (msg.type === "output" && msg.data) {
let output = msg.data
if (hideInitialGsdHeader && !initialHeaderSettledRef.current) {
initialHeaderBufferRef.current += output
const filtered = filterInitialGsdHeader(initialHeaderBufferRef.current)
if (filtered.status === "needs-more") {
return
}
initialHeaderSettledRef.current = true
initialHeaderBufferRef.current = ""
output = filtered.text
}
if (output) {
terminal?.write(output)
setHasOutput(true)
}
}
} catch {
/* malformed */
}
}
es.onerror = () => onConnectionChangeRef.current(false)
// Resize observer
resizeObserver = new ResizeObserver(() => {
if (disposed) return
try {
fitAddon?.fit()
if (terminal) sendResize(terminal.cols, terminal.rows)
} catch {
/* not visible */
}
})
resizeObserver.observe(containerRef.current!)
}
void init()
return () => {
disposed = true
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
eventSourceRef.current?.close()
eventSourceRef.current = null
resizeObserver?.disconnect()
terminal?.dispose()
termRef.current = null
fitAddonRef.current = null
}
}, [sessionId, command, commandArgs, commandArgsKey, fontSize, hideInitialGsdHeader, isDark, projectCwd, sendInput, sendResize])
// Focus on click
const wrapperRef = useRef<HTMLDivElement>(null)
const handleClick = useCallback(() => {
termRef.current?.focus()
}, [])
// Shift+Enter → newline (native DOM, capture phase)
// xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor
// recognizes \n (LF) as "insert newline".
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault()
e.stopPropagation()
sendInput("\n")
}
}
el.addEventListener("keydown", onKeyDown, true)
return () => el.removeEventListener("keydown", onKeyDown, true)
}, [sendInput])
// Auto-focus when this tab becomes visible
useEffect(() => {
if (visible) {
// Small delay to let layout settle
const t = setTimeout(() => termRef.current?.focus(), 80)
return () => clearTimeout(t)
}
}, [visible])
return (
<div
ref={wrapperRef}
className={cn("relative h-full w-full bg-terminal", !visible && "hidden")}
onClick={handleClick}
>
{/* Loading overlay — visible until first output arrives */}
{!hasOutput && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{command ? "Starting GSD…" : "Connecting…"}
</span>
</div>
)}
<div
ref={containerRef}
className="h-full w-full"
style={{ padding: "8px 4px 4px 8px" }}
/>
</div>
)
}
// ─── Image upload helpers ─────────────────────────────────────────────────────
const ALLOWED_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
])
/**
* Upload an image file to the server's temp directory and inject the `@filepath`
* text into the PTY session's stdin.
*
* Observability:
* - console.warn on client-side validation failure
* - console.error on upload or inject failure
*/
async function uploadAndInjectImage(file: File, sessionId: string, projectCwd?: string): Promise<void> {
// Client-side validation
const validation = validateImageFile(file)
if (!validation.valid) {
console.warn("[terminal-upload] validation failed:", validation.error)
return
}
// Upload to temp dir
const formData = new FormData()
formData.append("file", file)
let uploadPath: string
try {
const res = await authFetch(buildProjectPath("/api/terminal/upload", projectCwd), {
method: "POST",
body: formData,
})
const data = await res.json() as { ok?: boolean; path?: string; error?: string }
if (!res.ok || !data.path) {
console.error("[terminal-upload] upload failed:", data.error ?? `HTTP ${res.status}`)
return
}
uploadPath = data.path
} catch (err) {
console.error("[terminal-upload] upload request failed:", err)
return
}
// Inject @filepath into PTY stdin
try {
const res = await authFetch(buildProjectPath("/api/terminal/input", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: sessionId, data: `@${uploadPath} ` }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { error?: string }
console.error("[terminal-upload] inject failed:", data.error ?? `HTTP ${res.status}`)
}
} catch (err) {
console.error("[terminal-upload] inject request failed:", err)
}
}
// ─── Multi-instance terminal panel ────────────────────────────────────────────
export function ShellTerminal({
className,
command,
commandArgs,
sessionPrefix,
hideSidebar = false,
fontSize,
hideInitialGsdHeader = false,
projectCwd,
}: ShellTerminalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme !== "light"
const defaultId = sessionPrefix ?? (command ? "gsd-default" : "default")
const commandLabel = deriveCommandLabel(command)
const [tabs, setTabs] = useState<TerminalTab[]>([
{ id: defaultId, label: commandLabel, connected: false },
])
const [activeTabId, setActiveTabId] = useState(defaultId)
const [isDragOver, setIsDragOver] = useState(false)
const terminalAreaRef = useRef<HTMLDivElement>(null)
// ── Drag-and-drop handlers (native DOM, capture phase) ──────────────────
// React synthetic events don't reliably fire through xterm's internal DOM.
// Native capture-phase listeners intercept before xterm can swallow them —
// same pattern used for paste below.
useEffect(() => {
const el = terminalAreaRef.current
if (!el) return
let counter = 0
const onDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter += 1
if (counter === 1) setIsDragOver(true)
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter -= 1
if (counter <= 0) {
counter = 0
setIsDragOver(false)
}
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter = 0
setIsDragOver(false)
if (!activeTabId) return
const files = Array.from(e.dataTransfer?.files ?? [])
const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type))
if (imageFile) {
void uploadAndInjectImage(imageFile, activeTabId, projectCwd)
}
}
el.addEventListener("dragenter", onDragEnter, true)
el.addEventListener("dragover", onDragOver, true)
el.addEventListener("dragleave", onDragLeave, true)
el.addEventListener("drop", onDrop, true)
return () => {
el.removeEventListener("dragenter", onDragEnter, true)
el.removeEventListener("dragover", onDragOver, true)
el.removeEventListener("dragleave", onDragLeave, true)
el.removeEventListener("drop", onDrop, true)
}
}, [activeTabId, projectCwd])
// ── Paste handler for images ──────────────────────────────────────────────
useEffect(() => {
const el = terminalAreaRef.current
if (!el) return
const handlePaste = (e: ClipboardEvent) => {
if (!e.clipboardData) return
const files = Array.from(e.clipboardData.files)
const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type))
if (imageFile) {
e.preventDefault()
e.stopPropagation()
if (activeTabId) {
void uploadAndInjectImage(imageFile, activeTabId, projectCwd)
}
}
// If no image files, don't prevent default — let xterm.js handle text paste
}
el.addEventListener("paste", handlePaste, true) // capture phase to fire before xterm
return () => el.removeEventListener("paste", handlePaste, true)
}, [activeTabId, projectCwd])
const createTab = useCallback(async () => {
try {
const res = await authFetch(buildProjectPath("/api/terminal/sessions", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command ? { command } : {}),
})
const data = (await res.json()) as { id: string }
const newTab: TerminalTab = {
id: data.id,
label: commandLabel,
connected: false,
}
setTabs((prev) => [...prev, newTab])
setActiveTabId(data.id)
} catch {
/* network error */
}
}, [command, commandLabel, projectCwd])
const closeTab = useCallback(
(id: string) => {
// Don't close the last tab
if (tabs.length <= 1) return
const deleteUrl = buildProjectAbsoluteUrl("/api/terminal/sessions", window.location.origin, projectCwd)
deleteUrl.searchParams.set("id", id)
void authFetch(deleteUrl.toString(), {
method: "DELETE",
})
const remaining = tabs.filter((t) => t.id !== id)
setTabs(remaining)
if (activeTabId === id) {
setActiveTabId(remaining[remaining.length - 1]?.id ?? defaultId)
}
},
[tabs, activeTabId, defaultId, projectCwd],
)
const updateConnection = useCallback(
(id: string, connected: boolean) => {
setTabs((prev) =>
prev.map((t) => (t.id === id ? { ...t, connected } : t)),
)
},
[],
)
return (
<div className={cn("flex bg-terminal", className)}>
{/* Terminal area — receives drag/drop and paste for images */}
<div
ref={terminalAreaRef}
className="relative flex-1 min-w-0"
>
{tabs.map((tab) => (
<TerminalInstance
key={tab.id}
sessionId={tab.id}
visible={tab.id === activeTabId}
command={command}
commandArgs={tab.id === defaultId ? commandArgs : undefined}
isDark={isDark}
fontSize={fontSize}
hideInitialGsdHeader={hideInitialGsdHeader}
projectCwd={projectCwd}
onConnectionChange={(c) => updateConnection(tab.id, c)}
/>
))}
{/* Drop overlay */}
{isDragOver && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background/80 backdrop-blur-sm border-2 border-dashed border-primary rounded-md pointer-events-none">
<ImagePlus className="h-8 w-8 text-primary" />
<span className="text-sm font-medium text-primary">Drop image here</span>
</div>
)}
</div>
{!hideSidebar && (
<div className="flex w-[34px] flex-shrink-0 flex-col border-l border-border/40 bg-terminal">
{/* New terminal button */}
<button
onClick={createTab}
className="flex h-[30px] w-full items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
title="New terminal"
>
<Plus className="h-3 w-3" />
</button>
<div className="h-px bg-border/40" />
{/* Tab list */}
<div className="flex-1 overflow-y-auto">
{tabs.map((tab, index) => (
<button
key={tab.id}
onClick={() => setActiveTabId(tab.id)}
className={cn(
"group relative flex h-[30px] w-full items-center justify-center transition-colors",
tab.id === activeTabId
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
)}
title={`${tab.label} ${index + 1}`}
>
{/* Active indicator bar */}
{tab.id === activeTabId && (
<div className="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full bg-muted-foreground" />
)}
<div className="relative flex items-center">
<TerminalSquare className="h-3 w-3" />
{/* Connection dot */}
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 h-1.5 w-1.5 rounded-full border border-terminal",
tab.connected ? "bg-success" : "bg-muted-foreground/40",
)}
/>
</div>
{/* Close button — shows on hover as small badge in corner */}
{tabs.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}
className="absolute -right-0.5 -top-0.5 z-10 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-accent text-muted-foreground hover:bg-destructive/20 hover:text-destructive group-hover:flex"
title="Kill terminal"
>
<X className="h-2 w-2" />
</button>
)}
</button>
))}
</div>
</div>
)}
</div>
)
}

View file

@ -1,345 +0,0 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Compass, Loader2, OctagonX, Wrench } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
getOnboardingPresentation,
getSessionLabelFromBridge,
getStatusPresentation,
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/gsd-workspace-store"
interface TerminalProps {
className?: string
}
type InputMode = "prompt" | "follow_up" | "steer"
type WidgetPlacement = "aboveEditor" | "belowEditor"
const MAX_VISIBLE_WIDGET_LINES = 6
function getInputMode(state: ReturnType<typeof useGSDWorkspaceState>): InputMode {
const session = state.boot?.bridge.sessionState
if (!session) return "prompt"
if (session.isStreaming) return "follow_up"
return "prompt"
}
function inputModePlaceholder(mode: InputMode, state: ReturnType<typeof useGSDWorkspaceState>): string {
if (state.bootStatus === "loading") return "Loading workspace…"
if (state.bootStatus === "error") return "Workspace boot failed — check the visible error state"
if (state.commandInFlight) return `Sending ${state.commandInFlight}`
if (state.boot?.onboarding.locked) {
return getOnboardingPresentation(state).detail
}
switch (mode) {
case "steer":
return "Type a steering message to redirect the agent…"
case "follow_up":
return "Agent is active — type a follow-up or /state"
case "prompt":
return "Type a prompt, /state, /new, or /clear"
}
}
function inputModeLabel(mode: InputMode): string {
switch (mode) {
case "steer":
return "steer"
case "follow_up":
return "follow-up"
case "prompt":
return "$"
}
}
function getWidgetsForPlacement(
widgetContents: Record<string, { lines: string[] | undefined; placement?: WidgetPlacement }>,
placement: WidgetPlacement,
): Array<{ key: string; placement: WidgetPlacement; visibleLines: string[]; hiddenLineCount: number; fullText: string }> {
return Object.entries(widgetContents)
.filter(([, widget]) => {
const widgetPlacement = widget.placement ?? "aboveEditor"
return widgetPlacement === placement && Array.isArray(widget.lines) && widget.lines.length > 0
})
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, widget]) => {
const lines = widget.lines ?? []
return {
key,
placement,
visibleLines: lines.slice(0, MAX_VISIBLE_WIDGET_LINES),
hiddenLineCount: Math.max(0, lines.length - MAX_VISIBLE_WIDGET_LINES),
fullText: lines.join("\n"),
}
})
}
function TerminalWidgetBand({
placement,
widgets,
}: {
placement: WidgetPlacement
widgets: Array<{ key: string; placement: WidgetPlacement; visibleLines: string[]; hiddenLineCount: number; fullText: string }>
}) {
if (widgets.length === 0) return null
return (
<div
className="border-t border-border/50 bg-card/50 px-4 py-2"
data-testid={placement === "aboveEditor" ? "terminal-widgets-above-editor" : "terminal-widgets-below-editor"}
>
<div className="space-y-2">
{widgets.map((widget) => (
<div
key={`${widget.placement}:${widget.key}`}
className="rounded-md border border-border bg-background/50 px-3 py-2"
data-testid="terminal-widget"
data-widget-key={widget.key}
data-widget-placement={widget.placement}
title={widget.fullText}
>
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
<span className="truncate">{widget.key}</span>
<span>{widget.placement === "aboveEditor" ? "Above editor" : "Below editor"}</span>
</div>
<div className="space-y-1 text-xs text-foreground">
{widget.visibleLines.map((line, index) => (
<div key={`${widget.key}:${index}`} className="whitespace-pre-wrap break-words">
{line}
</div>
))}
{widget.hiddenLineCount > 0 && (
<div className="text-[11px] text-muted-foreground" data-testid="terminal-widget-overflow">
+{widget.hiddenLineCount} more line{widget.hiddenLineCount === 1 ? "" : "s"}
</div>
)}
</div>
</div>
))}
</div>
</div>
)
}
export function Terminal({ className }: TerminalProps) {
const workspace = useGSDWorkspaceState()
const { submitInput, sendAbort, sendSteer, consumeEditorTextBuffer } = useGSDWorkspaceActions()
const [input, setInput] = useState("")
const [steerMode, setSteerMode] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const autoMode = getInputMode(workspace)
const isStreaming = Boolean(workspace.boot?.bridge.sessionState?.isStreaming)
const inputMode: InputMode = steerMode && isStreaming ? "steer" : autoMode
const widgetsAboveEditor = getWidgetsForPlacement(workspace.widgetContents, "aboveEditor")
const widgetsBelowEditor = getWidgetsForPlacement(workspace.widgetContents, "belowEditor")
useEffect(() => {
if (workspace.editorTextBuffer === null) return
const buffer = workspace.editorTextBuffer
const updateTimer = window.setTimeout(() => {
setInput(buffer)
consumeEditorTextBuffer()
inputRef.current?.focus()
}, 0)
return () => window.clearTimeout(updateTimer)
}, [consumeEditorTextBuffer, workspace.editorTextBuffer])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
}, [workspace.terminalLines, workspace.streamingAssistantText, workspace.liveTranscript])
const status = getStatusPresentation(workspace)
const sessionLabel = getSessionLabelFromBridge(workspace.boot?.bridge)
const isInputDisabled =
workspace.bootStatus !== "ready" ||
workspace.commandInFlight === "refresh" ||
Boolean(workspace.boot?.onboarding.locked)
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
const trimmed = input.trim()
if (!trimmed) return
if (inputMode === "steer") {
await sendSteer(trimmed)
setInput("")
setSteerMode(false)
return
}
await submitInput(trimmed)
setInput("")
}
return (
<div
className={cn("flex flex-col bg-terminal font-mono text-sm", className)}
onClick={() => inputRef.current?.focus()}
>
{/* Terminal header */}
<div className="flex items-center justify-between border-b border-border/50 px-4 py-2 text-[11px] text-muted-foreground">
<div className="min-w-0 flex items-center gap-2 truncate">
<span data-testid="terminal-session-banner">
{sessionLabel || "Waiting for live session…"}
</span>
{/* Active tool execution badge */}
{workspace.activeToolExecution && (
<span
className="inline-flex items-center gap-1 rounded-full border border-foreground/15 bg-accent/60 px-2 py-0.5 text-[10px] font-medium text-foreground/80"
data-testid="terminal-tool-badge"
>
<Wrench className="h-3 w-3" />
{workspace.activeToolExecution.name}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Abort button */}
{isStreaming && (
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-[11px] text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
void sendAbort()
}}
disabled={workspace.commandInFlight === "abort"}
data-testid="terminal-abort-button"
>
<OctagonX className="h-3 w-3" />
Abort
</Button>
)}
<span
className={cn(
"h-2 w-2 rounded-full",
status.tone === "success"
? "bg-success"
: status.tone === "warning"
? "bg-warning"
: status.tone === "danger"
? "bg-destructive"
: "bg-muted-foreground/60",
status.tone === "success" && "animate-pulse",
)}
/>
<span>{status.label}</span>
</div>
</div>
{/* Terminal lines + streaming content */}
<div className="flex-1 overflow-y-auto p-4">
{workspace.terminalLines.map((line) => (
<div key={line.id} className="flex" data-testid="terminal-line">
<span className="mr-2 select-none text-muted-foreground">{line.timestamp}</span>
<span
className={cn(
"whitespace-pre-wrap",
line.type === "input" && "text-foreground before:content-['$_'] before:text-muted-foreground",
line.type === "output" && "text-terminal-foreground",
line.type === "system" && "text-muted-foreground",
line.type === "success" && "text-success",
line.type === "error" && "text-destructive",
)}
>
{line.content}
</span>
</div>
))}
{/* Completed transcript blocks from previous turns */}
{workspace.liveTranscript.length > 0 && (
<div className="mt-2 space-y-2" data-testid="terminal-transcript">
{workspace.liveTranscript.map((block, i) => (
<div
key={`transcript-${i}`}
className="whitespace-pre-wrap rounded border border-border/50 bg-accent/20 px-3 py-2 text-foreground"
>
{block}
</div>
))}
</div>
)}
{/* Live streaming assistant text */}
{workspace.streamingAssistantText && (
<div className="mt-2" data-testid="terminal-streaming-text">
<div className="whitespace-pre-wrap rounded border border-foreground/10 bg-foreground/[0.03] px-3 py-2 text-foreground">
{workspace.streamingAssistantText}
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-foreground/60" />
</div>
</div>
)}
{/* Streaming indicator when active but no text yet */}
{isStreaming && !workspace.streamingAssistantText && !workspace.activeToolExecution && (
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Agent is thinking
</div>
)}
<div ref={bottomRef} />
</div>
<TerminalWidgetBand placement="aboveEditor" widgets={widgetsAboveEditor} />
{/* Input area with steer toggle */}
<form onSubmit={handleSubmit} className="flex items-center gap-2 border-t border-border/50 px-4 py-2">
{/* Steer toggle — only visible when agent is streaming */}
{isStreaming && (
<Button
type="button"
variant={steerMode ? "default" : "ghost"}
size="sm"
className={cn(
"h-6 gap-1 px-2 text-[11px]",
steerMode
? "bg-foreground text-background"
: "text-muted-foreground hover:text-foreground",
)}
onClick={(e) => {
e.stopPropagation()
setSteerMode(!steerMode)
}}
data-testid="terminal-steer-toggle"
>
<Compass className="h-3 w-3" />
Steer
</Button>
)}
<span
className={cn(
"text-muted-foreground",
inputMode === "steer" && "font-semibold text-foreground",
)}
>
{inputModeLabel(inputMode)}
</span>
<input
ref={inputRef}
type="text"
value={input}
onChange={(event) => setInput(event.target.value)}
className="flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:text-muted-foreground"
placeholder={inputModePlaceholder(inputMode, workspace)}
disabled={isInputDisabled}
data-testid="terminal-command-input"
autoFocus
/>
{workspace.commandInFlight && (
<span className="text-xs text-muted-foreground">{workspace.commandInFlight}</span>
)}
</form>
<TerminalWidgetBand placement="belowEditor" widgets={widgetsBelowEditor} />
</div>
)
}

View file

@ -1,179 +0,0 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { cn } from "@/lib/utils"
import { authFetch } from "@/lib/auth"
interface UpdateInfo {
currentVersion: string
latestVersion: string
updateAvailable: boolean
updateStatus: string
targetVersion?: string
error?: string
}
const POLL_INTERVAL = 3000
export function UpdateBanner() {
const [info, setInfo] = useState<UpdateInfo | null>(null)
const [triggering, setTriggering] = useState(false)
const [dismissed, setDismissed] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fetchStatus = useCallback(async () => {
try {
const res = await authFetch("/api/update")
if (!res.ok) return
const data: UpdateInfo = await res.json()
setInfo(data)
} catch {
// Network error — silently ignore, banner stays in last known state
}
}, [])
// Initial fetch on mount
useEffect(() => {
void fetchStatus()
}, [fetchStatus])
// Polling while update is running
useEffect(() => {
if (info?.updateStatus === "running") {
intervalRef.current = setInterval(() => void fetchStatus(), POLL_INTERVAL)
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [info?.updateStatus, fetchStatus])
const handleUpdate = async () => {
setTriggering(true)
try {
const res = await authFetch("/api/update", { method: "POST" })
if (res.ok || res.status === 202) {
// Immediately poll to pick up the "running" status
await fetchStatus()
} else if (res.status === 409) {
// Already running — just refresh status
await fetchStatus()
}
} catch {
// Network error during trigger
} finally {
setTriggering(false)
}
}
// Don't render until we have data, or if no update is available and status is idle
if (!info) return null
if (!info.updateAvailable && info.updateStatus === "idle") return null
if (dismissed) return null
const isRunning = info.updateStatus === "running"
const isSuccess = info.updateStatus === "success"
const isError = info.updateStatus === "error"
const targetLabel = info.targetVersion ?? info.latestVersion
return (
<div
data-testid="update-banner"
className={cn(
"flex items-center gap-3 border-b px-4 py-2 text-xs",
isSuccess && "border-success/20 bg-success/10 text-success",
isError && "border-destructive/20 bg-destructive/10 text-destructive",
!isSuccess && !isError && "border-warning/20 bg-warning/10 text-warning",
)}
>
{isSuccess ? (
<span className="flex-1" data-testid="update-banner-message">
Update complete restart GSD to use v{targetLabel}
</span>
) : isError ? (
<>
<span className="flex-1" data-testid="update-banner-message">
Update failed{info.error ? `: ${info.error}` : ""}
</span>
<button
onClick={() => void handleUpdate()}
disabled={triggering}
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",
triggering && "cursor-not-allowed opacity-50",
)}
data-testid="update-banner-retry"
>
Retry
</button>
</>
) : (
<>
<span className="flex-1" data-testid="update-banner-message">
{isRunning ? (
<span className="flex items-center gap-2">
<Spinner />
Updating to v{targetLabel}
</span>
) : (
<>
Update available: v{info.currentVersion} v{info.latestVersion}
</>
)}
</span>
{!isRunning && (
<button
onClick={() => void handleUpdate()}
disabled={triggering}
className={cn(
"flex-shrink-0 rounded border border-warning/30 bg-background px-2 py-0.5 text-xs font-medium text-warning transition-colors hover:bg-warning/10",
triggering && "cursor-not-allowed opacity-50",
)}
data-testid="update-banner-action"
>
Update
</button>
)}
</>
)}
<button
onClick={() => setDismissed(true)}
aria-label="Dismiss update banner"
className="flex-shrink-0 rounded p-0.5 opacity-50 transition-opacity hover:opacity-100"
data-testid="update-banner-dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)
}
function Spinner() {
return (
<svg
className="h-3 w-3 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)
}

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
import { CheckCircle2, Play, Clock, Terminal, AlertCircle } from "lucide-react"
import { cn } from "@/lib/utils"
import { useGSDWorkspaceState, type TerminalLineType } from "@/lib/sf-workspace-store"
import { useSFWorkspaceState, type TerminalLineType } from "@/lib/sf-workspace-store"
function EventIcon({ type }: { type: TerminalLineType }) {
const baseClass = "h-4 w-4"
@ -23,7 +23,7 @@ function EventIcon({ type }: { type: TerminalLineType }) {
}
export function ActivityView() {
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const terminalLines = workspace.terminalLines ?? []
// Show most recent events first

View file

@ -21,13 +21,13 @@ import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import {
GSDWorkspaceProvider,
SFWorkspaceProvider,
getCurrentScopeLabel,
getProjectDisplayName,
getStatusPresentation,
getVisibleWorkspaceError,
useGSDWorkspaceState,
useGSDWorkspaceActions,
useSFWorkspaceState,
useSFWorkspaceActions,
} from "@/lib/sf-workspace-store"
import { ChatMode } from "@/components/sf/chat-mode"
import { ScopeBadge } from "@/components/sf/scope-badge"
@ -60,8 +60,8 @@ function WorkspaceChrome() {
const [projectsPanelOpen, setProjectsPanelOpen] = useState(false)
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false)
const workspace = useGSDWorkspaceState()
const { refreshBoot } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { refreshBoot } = useSFWorkspaceActions()
const status = getStatusPresentation(workspace)
const projectPath = workspace.boot?.project.cwd
@ -146,8 +146,8 @@ function WorkspaceChrome() {
const handler = () => {
setActiveView("files")
}
window.addEventListener("gsd:open-file", handler)
return () => window.removeEventListener("gsd:open-file", handler)
window.addEventListener("sf:open-file", handler)
return () => window.removeEventListener("sf:open-file", handler)
}, [])
// Listen for cross-component view navigation events (e.g. /gsd visualize dispatch)
@ -157,8 +157,8 @@ function WorkspaceChrome() {
handleViewChange(e.detail.view)
}
}
window.addEventListener("gsd:navigate-view", handler as EventListener)
return () => window.removeEventListener("gsd:navigate-view", handler as EventListener)
window.addEventListener("sf:navigate-view", handler as EventListener)
return () => window.removeEventListener("sf:navigate-view", handler as EventListener)
}, [handleViewChange])
// Listen for projects panel toggle (sidebar icon, or programmatic)
@ -242,8 +242,8 @@ function WorkspaceChrome() {
!isConnecting &&
activeView === "dashboard" &&
detection != null &&
detection.kind !== "active-gsd" &&
detection.kind !== "empty-gsd"
detection.kind !== "active-sf" &&
detection.kind !== "empty-sf"
// --- Unauthenticated gate ---
// Render a clear recovery screen before any workspace chrome is mounted so
@ -547,7 +547,7 @@ function WorkspaceChrome() {
)
}
export function GSDAppShell() {
export function SFAppShell() {
// Extract the auth token from the URL fragment on first render.
// Must happen before any API calls fire.
getAuthToken()
@ -596,10 +596,10 @@ function ProjectAwareWorkspace() {
}
return (
<GSDWorkspaceProvider store={activeStore}>
<SFWorkspaceProvider store={activeStore}>
<DevOverridesProvider>
<WorkspaceChrome />
</DevOverridesProvider>
</GSDWorkspaceProvider>
</SFWorkspaceProvider>
)
}

View file

@ -10,8 +10,8 @@ import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover
import { ChatMessage, TuiPrompt } from "@/lib/pty-chat-parser"
import { PendingImage, processImageFile, generateImageId, MAX_PENDING_IMAGES } from "@/lib/image-utils"
import {
useGSDWorkspaceState,
useGSDWorkspaceActions,
useSFWorkspaceState,
useSFWorkspaceActions,
buildPromptCommand,
type CompletedToolExecution,
type ActiveToolExecution,
@ -32,7 +32,7 @@ import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
* Top 3 are shown as standalone buttons; the rest live in the overflow menu.
* All commands dispatch through the main bridge session.
*/
interface GSDActionDef {
interface SFActionDef {
label: string
command: string
icon: LucideIcon
@ -42,7 +42,7 @@ interface GSDActionDef {
disabledDuringAuto?: boolean
}
const GSD_ACTIONS: GSDActionDef[] = [
const SF_ACTIONS: SFActionDef[] = [
// ── Top 3 (standalone buttons) ──
{ label: "Discuss", command: "/gsd discuss", icon: MessageCircle, description: "Start guided milestone/slice discussion", category: "workflow", disabledDuringAuto: true },
{ label: "Next", command: "/gsd next", icon: Play, description: "Execute next task, then pause", category: "workflow" },
@ -74,11 +74,11 @@ const GSD_ACTIONS: GSDActionDef[] = [
]
/** Top 3 shown as standalone buttons next to chat input */
const TOP_ACTIONS = GSD_ACTIONS.slice(0, 3)
const TOP_ACTIONS = SF_ACTIONS.slice(0, 3)
/** Remaining actions in the overflow menu */
const OVERFLOW_ACTIONS = GSD_ACTIONS.slice(3)
const OVERFLOW_ACTIONS = SF_ACTIONS.slice(3)
const CATEGORY_LABELS: Record<GSDActionDef["category"], string> = {
const CATEGORY_LABELS: Record<SFActionDef["category"], string> = {
workflow: "Workflow",
visibility: "Visibility",
correction: "Course Correction",
@ -87,8 +87,8 @@ const CATEGORY_LABELS: Record<GSDActionDef["category"], string> = {
maintenance: "Maintenance",
}
function groupByCategory(actions: GSDActionDef[]): Array<{ category: GSDActionDef["category"]; label: string; items: GSDActionDef[] }> {
const seen = new Map<GSDActionDef["category"], GSDActionDef[]>()
function groupByCategory(actions: SFActionDef[]): Array<{ category: SFActionDef["category"]; label: string; items: SFActionDef[] }> {
const seen = new Map<SFActionDef["category"], SFActionDef[]>()
for (const a of actions) {
let group = seen.get(a.category)
if (!group) {
@ -115,8 +115,8 @@ function groupByCategory(actions: GSDActionDef[]): Array<{ category: GSDActionDe
* - Secondary buttons: data-testid="chat-secondary-action-{command}".
*/
export function ChatMode({ className }: { className?: string }) {
const state = useGSDWorkspaceState()
const { sendCommand } = useGSDWorkspaceActions()
const state = useSFWorkspaceState()
const { sendCommand } = useSFWorkspaceActions()
const bridge = state.boot?.bridge ?? null
@ -165,7 +165,7 @@ interface ChatModeHeaderProps {
* - data-testid="chat-secondary-action-{command}" on each secondary button
*/
function ChatModeHeader({ onPrimaryAction, onSecondaryAction }: ChatModeHeaderProps) {
const state = useGSDWorkspaceState()
const state = useSFWorkspaceState()
const boot = state.boot
const workspace = boot?.workspace ?? null
@ -1166,9 +1166,9 @@ function ChatInputBar({
}: {
onSendInput: (data: string, images?: PendingImage[]) => void
connected: boolean
onOpenAction?: (action: GSDActionDef) => void
onOpenAction?: (action: SFActionDef) => void
}) {
const autoActive = useGSDWorkspaceState().boot?.auto?.active ?? false
const autoActive = useSFWorkspaceState().boot?.auto?.active ?? false
const [value, setValue] = useState("")
const [overflowOpen, setOverflowOpen] = useState(false)
const [pendingImages, setPendingImages] = useState<PendingImage[]>([])
@ -1592,8 +1592,8 @@ function PlaceholderState({
* first resolves the request the store deduplicates.
*/
function InlineUiRequest({ request }: { request: PendingUiRequest }) {
const { respondToUiRequest, dismissUiRequest } = useGSDWorkspaceActions()
const isSubmitting = useGSDWorkspaceState().commandInFlight === "extension_ui_response"
const { respondToUiRequest, dismissUiRequest } = useSFWorkspaceActions()
const isSubmitting = useSFWorkspaceState().commandInFlight === "extension_ui_response"
const handleSubmit = useCallback((value: Record<string, unknown>) => {
void respondToUiRequest(request.id, value)
@ -1875,7 +1875,7 @@ interface ChatPaneProps {
className?: string
initialCommand?: string
onCompletionSignal?: () => void
onOpenAction?: (action: GSDActionDef) => void
onOpenAction?: (action: SFActionDef) => void
activityLabel?: string
suppressTerminalChrome?: boolean
suppressInitialEcho?: boolean
@ -2020,8 +2020,8 @@ function ToolExecutionBlock({ tool }: { tool: CompletedToolExecution }) {
* - ChatInputBar shows "Disconnected" badge when bridge is not connected
*/
export function ChatPane({ className, onOpenAction }: ChatPaneProps) {
const state = useGSDWorkspaceState()
const { submitInput, sendCommand, pushChatUserMessage } = useGSDWorkspaceActions()
const state = useSFWorkspaceState()
const { submitInput, sendCommand, pushChatUserMessage } = useSFWorkspaceActions()
const [terminalFontSize] = useTerminalFontSize()
const connected = state.connectionState === "connected"

View file

@ -77,8 +77,8 @@ import {
getModelLabel,
getSessionLabelFromBridge,
shortenPath,
useGSDWorkspaceActions,
useGSDWorkspaceState,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store"
// ─── Section metadata ────────────────────────────────────────────────
@ -318,7 +318,7 @@ function SegmentedControl<T extends string>({
// ═════════════════════════════════════════════════════════════════════
export function CommandSurface() {
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const {
closeCommandSurface,
openCommandSurface,
@ -361,7 +361,7 @@ export function CommandSurface() {
loadUndoInfo,
loadCleanupData,
loadSteerData,
} = useGSDWorkspaceActions()
} = useSFWorkspaceActions()
const { commandSurface } = workspace
const onboarding = workspace.boot?.onboarding ?? null

View file

@ -14,8 +14,8 @@ import {
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
useGSDWorkspaceState,
useGSDWorkspaceActions,
useSFWorkspaceState,
useSFWorkspaceActions,
buildPromptCommand,
buildProjectUrl,
formatDuration,
@ -111,8 +111,8 @@ interface DashboardProps {
}
export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {}) {
const state = useGSDWorkspaceState()
const { sendCommand } = useGSDWorkspaceActions()
const state = useSFWorkspaceState()
const { sendCommand } = useSFWorkspaceActions()
const boot = state.boot
const workspace = getLiveWorkspaceIndex(state)
const auto = getLiveAutoDashboard(state)
@ -204,8 +204,8 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
const showWelcome =
!isConnecting &&
detection &&
detection.kind !== "active-gsd" &&
detection.kind !== "empty-gsd"
detection.kind !== "active-sf" &&
detection.kind !== "empty-sf"
if (showWelcome) {
return (

View file

@ -15,8 +15,8 @@ import type {
import { cn } from "@/lib/utils"
import {
formatCost,
useGSDWorkspaceActions,
useGSDWorkspaceState,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store"
// ═══════════════════════════════════════════════════════════════════════
@ -134,8 +134,8 @@ function AnomalyRow({ anomaly }: { anomaly: ForensicAnomaly }) {
}
export function ForensicsPanel() {
const workspace = useGSDWorkspaceState()
const { loadForensicsDiagnostics } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadForensicsDiagnostics } = useSFWorkspaceActions()
const state = workspace.commandSurface.diagnostics.forensics
const data = state.data as ForensicReport | null
const busy = state.phase === "loading"
@ -268,8 +268,8 @@ function IssueRow({ issue }: { issue: DoctorIssue }) {
}
export function DoctorPanel() {
const workspace = useGSDWorkspaceState()
const { loadDoctorDiagnostics, applyDoctorFixes } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadDoctorDiagnostics, applyDoctorFixes } = useSFWorkspaceActions()
const state = workspace.commandSurface.diagnostics.doctor
const data = state.data as DoctorReport | null
const busy = state.phase === "loading"
@ -392,8 +392,8 @@ function SuggestionRow({ suggestion }: { suggestion: SkillHealSuggestion }) {
}
export function SkillHealthPanel() {
const workspace = useGSDWorkspaceState()
const { loadSkillHealthDiagnostics } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadSkillHealthDiagnostics } = useSFWorkspaceActions()
const state = workspace.commandSurface.diagnostics.skillHealth
const data = state.data as SkillHealthReport | null
const busy = state.phase === "loading"

View file

@ -5,7 +5,7 @@ import { GripVertical, Loader2 } from "lucide-react"
import { MainSessionTerminal } from "@/components/sf/main-session-terminal"
import { ShellTerminal } from "@/components/sf/shell-terminal"
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
import { useGSDWorkspaceState } from "@/lib/sf-workspace-store"
import { useSFWorkspaceState } from "@/lib/sf-workspace-store"
import { derivePendingWorkflowCommandLabel } from "@/lib/workflow-action-execution"
export function DualTerminal() {
@ -14,7 +14,7 @@ export function DualTerminal() {
const rootRef = useRef<HTMLDivElement>(null)
const isDragging = useRef(false)
const [terminalFontSize] = useTerminalFontSize()
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const projectCwd = workspace.boot?.project.cwd
const pendingCommandLabel = derivePendingWorkflowCommandLabel({
commandInFlight: workspace.commandInFlight,
@ -107,9 +107,9 @@ export function DualTerminal() {
<ShellTerminal
className="h-full"
command="gsd"
sessionPrefix="gsd-interactive"
sessionPrefix="sf-interactive"
fontSize={terminalFontSize}
hideInitialGsdHeader
hideInitialSfHeader
projectCwd={projectCwd}
/>
</div>

View file

@ -21,7 +21,7 @@ import {
Bot,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useGSDWorkspaceState, buildProjectUrl } from "@/lib/sf-workspace-store"
import { useSFWorkspaceState, buildProjectUrl } from "@/lib/sf-workspace-store"
import { authFetch } from "@/lib/auth"
import { FileContentViewer } from "@/components/sf/file-content-viewer"
import { ChatPane } from "@/components/sf/chat-mode"
@ -34,7 +34,7 @@ let pendingFileRequest: { root: RootMode; path: string } | null = null
// Set up the global event listener once (module-level, not component-level)
if (typeof window !== "undefined") {
window.addEventListener("gsd:open-file", (e: Event) => {
window.addEventListener("sf:open-file", (e: Event) => {
const detail = (e as CustomEvent<{ root: RootMode; path: string }>).detail
if (detail?.root && detail?.path) {
pendingFileRequest = { root: detail.root, path: detail.path }
@ -472,7 +472,7 @@ function tabLabel(tab: OpenTab): string {
type LeftPanel = "tree" | "agent"
export function FilesView() {
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const projectCwd = workspace.boot?.project.cwd
const [activeRoot, setActiveRoot] = useState<RootMode>("gsd")
@ -705,8 +705,8 @@ export function FilesView() {
pendingFileRequest = null // clear since we're handling it directly
void processFileOpen(detail.root, detail.path)
}
window.addEventListener("gsd:open-file", handler)
return () => window.removeEventListener("gsd:open-file", handler)
window.addEventListener("sf:open-file", handler)
return () => window.removeEventListener("sf:open-file", handler)
}, [processFileOpen])
const handleToggleDir = useCallback((path: string) => {

View file

@ -19,8 +19,8 @@ import {
import { Textarea } from "@/components/ui/textarea"
import {
type PendingUiRequest,
useGSDWorkspaceActions,
useGSDWorkspaceState,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store"
import { cn } from "@/lib/utils"
@ -252,8 +252,8 @@ function RequestBody({
}
export function FocusedPanel() {
const workspace = useGSDWorkspaceState()
const { respondToUiRequest, dismissUiRequest } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { respondToUiRequest, dismissUiRequest } = useSFWorkspaceActions()
const pending = workspace.pendingUiRequests
const isOpen = pending.length > 0

View file

@ -29,8 +29,8 @@ import type {
} from "@/lib/knowledge-captures-types"
import { cn } from "@/lib/utils"
import {
useGSDWorkspaceActions,
useGSDWorkspaceState,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store"
// ═══════════════════════════════════════════════════════════════════════
@ -374,8 +374,8 @@ interface KnowledgeCapturesPanelProps {
export function KnowledgeCapturesPanel({ initialTab }: KnowledgeCapturesPanelProps) {
const [activeTab, setActiveTab] = useState<"knowledge" | "captures">(initialTab)
const workspace = useGSDWorkspaceState()
const { loadKnowledgeData, loadCapturesData, resolveCaptureAction } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadKnowledgeData, loadCapturesData, resolveCaptureAction } = useSFWorkspaceActions()
const knowledgeCaptures = workspace.commandSurface.knowledgeCaptures
const knowledgeState = knowledgeCaptures.knowledge

View file

@ -5,12 +5,12 @@ import { AnimatePresence, motion } from "motion/react"
import Image from "next/image"
import {
type WorkspaceOnboardingProviderState,
useGSDWorkspaceActions,
useGSDWorkspaceState,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store"
import { useDevOverrides } from "@/lib/dev-overrides"
import { useUserMode, type UserMode } from "@/lib/use-user-mode"
import { navigateToGSDView } from "@/lib/workflow-action-execution"
import { navigateToSFView } from "@/lib/workflow-action-execution"
import { cn } from "@/lib/utils"
import { StepWelcome } from "./onboarding/step-welcome"
@ -82,7 +82,7 @@ function StepIndicator({ current, total }: { current: number; total: number }) {
// ─── Main Component ─────────────────────────────────────────────────
export function OnboardingGate() {
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const {
refreshOnboarding,
saveApiKey,
@ -90,7 +90,7 @@ export function OnboardingGate() {
submitProviderFlowInput,
cancelProviderFlow,
refreshBoot,
} = useGSDWorkspaceActions()
} = useSFWorkspaceActions()
const devOverrides = useDevOverrides()
const onboarding = workspace.boot?.onboarding
@ -289,7 +289,7 @@ export function OnboardingGate() {
}}
onFinish={() => {
const mode = selectedMode ?? userMode
navigateToGSDView("dashboard")
navigateToSFView("dashboard")
void refreshBoot()
}}
/>

View file

@ -21,7 +21,7 @@ import { authFetch } from "@/lib/auth"
// ─── Types ──────────────────────────────────────────────────────────
type ProjectDetectionKind = "active-gsd" | "empty-gsd" | "v1-legacy" | "brownfield" | "blank"
type ProjectDetectionKind = "active-sf" | "empty-sf" | "v1-legacy" | "brownfield" | "blank"
interface ProjectDetectionSignals {
hasGsdFolder: boolean
@ -56,8 +56,8 @@ interface ProjectMetadata {
// ─── Helpers ────────────────────────────────────────────────────────
const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; icon: typeof Layers }> = {
"active-gsd": { label: "Active", color: "text-success", icon: Layers },
"empty-gsd": { label: "Initialized", color: "text-info", icon: FolderOpen },
"active-sf": { label: "Active", color: "text-success", icon: Layers },
"empty-sf": { label: "Initialized", color: "text-info", icon: FolderOpen },
brownfield: { label: "Existing", color: "text-warning", icon: GitBranch },
"v1-legacy": { label: "Legacy", color: "text-warning", icon: GitBranch },
blank: { label: "New", color: "text-muted-foreground", icon: Sparkles },
@ -203,9 +203,9 @@ export function StepProject({ onFinish, onBack, onBeforeSwitch }: StepProjectPro
const noDevRoot = !loading && !devRoot
// Sort: active-gsd first, then by name
// Sort: active-sf first, then by name
const sortedProjects = [...projects].sort((a, b) => {
const kindOrder: Record<ProjectDetectionKind, number> = { "active-gsd": 0, "empty-gsd": 1, brownfield: 2, "v1-legacy": 3, blank: 4 }
const kindOrder: Record<ProjectDetectionKind, number> = { "active-sf": 0, "empty-sf": 1, brownfield: 2, "v1-legacy": 3, blank: 4 }
const ka = kindOrder[a.kind] ?? 5
const kb = kindOrder[b.kind] ?? 5
if (ka !== kb) return ka - kb
@ -287,7 +287,7 @@ export function StepProject({ onFinish, onBack, onBeforeSwitch }: StepProjectPro
{/* Icon */}
<div className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg mt-0.5",
project.kind === "active-gsd" ? "bg-success/10" : "bg-foreground/[0.04]",
project.kind === "active-sf" ? "bg-success/10" : "bg-foreground/[0.04]",
)}>
{isSwitching ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
@ -320,14 +320,14 @@ export function StepProject({ onFinish, onBack, onBeforeSwitch }: StepProjectPro
</div>
)}
{/* Row 3: progress info (for active-gsd projects) */}
{/* Row 3: progress info (for active-sf projects) */}
{progress && (
<div className="mt-1.5 text-[11px] text-muted-foreground">
{progress}
</div>
)}
{/* Row 4: milestone bar (for active-gsd with milestones) */}
{/* Row 4: milestone bar (for active-sf with milestones) */}
{project.progress && project.progress.milestonesTotal > 0 && (
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-foreground/[0.06]">

View file

@ -68,7 +68,7 @@ function getVariant(detection: ProjectDetection): WelcomeVariant {
primaryCommand: "/gsd",
}
// active-gsd and empty-gsd shouldn't reach here, but handle gracefully
// active-sf and empty-sf shouldn't reach here, but handle gracefully
default:
return {
icon: <Folder className="h-8 w-8 text-foreground" strokeWidth={1.5} />,

View file

@ -24,7 +24,7 @@ import {
import { cn } from "@/lib/utils"
import { useProjectStoreManager } from "@/lib/project-store-manager"
import {
useGSDWorkspaceState,
useSFWorkspaceState,
getLiveWorkspaceIndex,
getLiveAutoDashboard,
formatCost,
@ -53,7 +53,7 @@ import {
// ─── Types (mirroring server-side ProjectMetadata) ─────────────────────────
type ProjectDetectionKind = "active-gsd" | "empty-gsd" | "v1-legacy" | "brownfield" | "blank"
type ProjectDetectionKind = "active-sf" | "empty-sf" | "v1-legacy" | "brownfield" | "blank"
interface ProjectDetectionSignals {
hasGsdFolder: boolean
@ -88,13 +88,13 @@ interface ProjectMetadata {
// ─── Kind style config ─────────────────────────────────────────────────
const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; bgClass: string; icon: typeof Layers }> = {
"active-gsd": {
"active-sf": {
label: "Active",
color: "text-success",
bgClass: "bg-success/10",
icon: Layers,
},
"empty-gsd": {
"empty-sf": {
label: "Initialized",
color: "text-info",
bgClass: "bg-info/10",
@ -348,7 +348,7 @@ export function ProjectsPanel({
const [newProjectOpen, setNewProjectOpen] = useState(false)
const [changeRootOpen, setChangeRootOpen] = useState(false)
const workspaceState = useGSDWorkspaceState()
const workspaceState = useSFWorkspaceState()
const handleProjectCreated = useCallback(
(newProject: ProjectMetadata) => {
@ -373,11 +373,11 @@ export function ProjectsPanel({
manager.switchProject(project.path)
}
// Sort: active-gsd first, then by name
// Sort: active-sf first, then by name
const sortedProjects = [...projects].sort((a, b) => {
const kindOrder: Record<ProjectDetectionKind, number> = {
"active-gsd": 0,
"empty-gsd": 1,
"active-sf": 0,
"empty-sf": 1,
brownfield: 2,
"v1-legacy": 3,
blank: 4,
@ -521,7 +521,7 @@ export function ProjectsPanel({
// ─── Active project inline summary (compact for panel card) ────────────
function ActiveProjectSummary({ workspaceState }: { workspaceState: ReturnType<typeof useGSDWorkspaceState> }) {
function ActiveProjectSummary({ workspaceState }: { workspaceState: ReturnType<typeof useSFWorkspaceState> }) {
const workspace = getLiveWorkspaceIndex(workspaceState)
const dashboard = getLiveAutoDashboard(workspaceState)
const currentSlice = getCurrentSlice(workspace)
@ -1059,11 +1059,11 @@ export function ProjectSelectionGate() {
manager.switchProject(project.path)
}
// Sort: active-gsd first, then by name
// Sort: active-sf first, then by name
const sortedProjects = [...projects].sort((a, b) => {
const kindOrder: Record<ProjectDetectionKind, number> = {
"active-gsd": 0,
"empty-gsd": 1,
"active-sf": 0,
"empty-sf": 1,
brownfield: 2,
"v1-legacy": 3,
blank: 4,

View file

@ -47,8 +47,8 @@ import { cn } from "@/lib/utils"
import {
formatCost,
getLiveWorkspaceIndex,
useGSDWorkspaceActions,
useGSDWorkspaceState,
useSFWorkspaceActions,
useSFWorkspaceState,
type WorkspaceMilestoneTarget,
type WorkspaceSliceTarget,
} from "@/lib/sf-workspace-store"
@ -201,8 +201,8 @@ export function QuickPanel() {
type HistoryTab = "phase" | "slice" | "model" | "units"
export function HistoryPanel() {
const workspace = useGSDWorkspaceState()
const { loadHistoryData } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadHistoryData } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.history
const data = state.data as HistoryData | null
const busy = state.phase === "loading"
@ -373,8 +373,8 @@ export function HistoryPanel() {
// ═══════════════════════════════════════════════════════════════════════
export function UndoPanel() {
const workspace = useGSDWorkspaceState()
const { loadUndoInfo, executeUndoAction } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadUndoInfo, executeUndoAction } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.undo
const data = state.data as UndoInfo | null
const busy = state.phase === "loading"
@ -519,8 +519,8 @@ export function UndoPanel() {
// ═══════════════════════════════════════════════════════════════════════
export function SteerPanel() {
const workspace = useGSDWorkspaceState()
const { loadSteerData, sendSteer } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadSteerData, sendSteer } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.steer
const data = state.data as SteerData | null
const busy = state.phase === "loading"
@ -607,8 +607,8 @@ export function SteerPanel() {
// ═══════════════════════════════════════════════════════════════════════
export function HooksPanel() {
const workspace = useGSDWorkspaceState()
const { loadHooksData } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadHooksData } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.hooks
const data = state.data as HooksData | null
const busy = state.phase === "loading"
@ -699,8 +699,8 @@ export function HooksPanel() {
// ═══════════════════════════════════════════════════════════════════════
export function InspectPanel() {
const workspace = useGSDWorkspaceState()
const { loadInspectData } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadInspectData } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.inspect
const data = state.data as InspectData | null
const busy = state.phase === "loading"
@ -807,8 +807,8 @@ export function InspectPanel() {
// ═══════════════════════════════════════════════════════════════════════
export function ExportPanel() {
const workspace = useGSDWorkspaceState()
const { loadExportData } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadExportData } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.exportData
const data = state.data as ExportResult | null
const busy = state.phase === "loading"
@ -907,8 +907,8 @@ export function ExportPanel() {
// ═══════════════════════════════════════════════════════════════════════
export function CleanupPanel() {
const workspace = useGSDWorkspaceState()
const { loadCleanupData, executeCleanupAction } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadCleanupData, executeCleanupAction } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.cleanup
const data = state.data as CleanupData | null
const busy = state.phase === "loading"
@ -1072,7 +1072,7 @@ function sliceProgress(slices: WorkspaceSliceTarget[]): { done: number; total: n
}
export function QueuePanel() {
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const workspaceIndex = getLiveWorkspaceIndex(workspace)
const milestones = workspaceIndex?.milestones ?? []
const active = workspaceIndex?.active
@ -1175,7 +1175,7 @@ export function QueuePanel() {
// ═══════════════════════════════════════════════════════════════════════
export function StatusPanel() {
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const workspaceIndex = getLiveWorkspaceIndex(workspace)
const active = workspaceIndex?.active
const milestones = workspaceIndex?.milestones ?? []

View file

@ -2,7 +2,7 @@
import { CheckCircle2, Circle, Play, AlertTriangle, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { getLiveWorkspaceIndex, useGSDWorkspaceState, type RiskLevel } from "@/lib/sf-workspace-store"
import { getLiveWorkspaceIndex, useSFWorkspaceState, type RiskLevel } from "@/lib/sf-workspace-store"
import { getMilestoneStatus, getSliceStatus, type ItemStatus } from "@/lib/workspace-status"
const StatusIcon = ({
@ -39,7 +39,7 @@ const RiskBadge = ({ risk }: { risk: RiskLevel }) => {
}
export function Roadmap() {
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const liveWorkspace = getLiveWorkspaceIndex(workspace)
const milestones = liveWorkspace?.milestones ?? []
const activeScope = liveWorkspace?.active ?? {}

View file

@ -30,8 +30,8 @@ import { cn } from "@/lib/utils"
import {
formatCost,
formatTokens,
useGSDWorkspaceActions,
useGSDWorkspaceState,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store"
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
import { useEditorFontSize } from "@/lib/use-editor-font-size"
@ -171,8 +171,8 @@ function KvRow({ label, children }: { label: string; children: React.ReactNode }
// ═══════════════════════════════════════════════════════════════════════
function useSettingsData() {
const workspace = useGSDWorkspaceState()
const { loadSettingsData } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { loadSettingsData } = useSFWorkspaceActions()
const state = workspace.commandSurface.settingsData
return {
state,

View file

@ -5,7 +5,7 @@ import { useTheme } from "next-themes"
import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react"
import { cn } from "@/lib/utils"
import { validateImageFile } from "@/lib/image-utils"
import { filterInitialGsdHeader } from "@/lib/initial-gsd-header-filter"
import { filterInitialSfHeader } from "@/lib/initial-sf-header-filter"
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
import { authFetch, appendAuthParam } from "@/lib/auth"
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
@ -34,7 +34,7 @@ interface ShellTerminalProps {
sessionPrefix?: string
hideSidebar?: boolean
fontSize?: number
hideInitialGsdHeader?: boolean
hideInitialSfHeader?: boolean
projectCwd?: string
}
@ -113,7 +113,7 @@ interface TerminalInstanceProps {
commandArgs?: string[]
isDark: boolean
fontSize?: number
hideInitialGsdHeader?: boolean
hideInitialSfHeader?: boolean
projectCwd?: string
onConnectionChange: (connected: boolean) => void
}
@ -125,7 +125,7 @@ function TerminalInstance({
commandArgs,
isDark,
fontSize,
hideInitialGsdHeader = false,
hideInitialSfHeader = false,
projectCwd,
onConnectionChange,
}: TerminalInstanceProps) {
@ -137,7 +137,7 @@ function TerminalInstance({
const flushingRef = useRef(false)
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const onConnectionChangeRef = useRef(onConnectionChange)
const initialHeaderSettledRef = useRef(!hideInitialGsdHeader)
const initialHeaderSettledRef = useRef(!hideInitialSfHeader)
const initialHeaderBufferRef = useRef("")
const commandArgsKey = (commandArgs ?? []).join("\u0000")
const [hasOutput, setHasOutput] = useState(false)
@ -188,9 +188,9 @@ function TerminalInstance({
}, [onConnectionChange])
useEffect(() => {
initialHeaderSettledRef.current = !hideInitialGsdHeader
initialHeaderSettledRef.current = !hideInitialSfHeader
initialHeaderBufferRef.current = ""
}, [hideInitialGsdHeader, sessionId])
}, [hideInitialSfHeader, sessionId])
// Update xterm theme when isDark changes
useEffect(() => {
@ -291,9 +291,9 @@ function TerminalInstance({
} else if (msg.type === "output" && msg.data) {
let output = msg.data
if (hideInitialGsdHeader && !initialHeaderSettledRef.current) {
if (hideInitialSfHeader && !initialHeaderSettledRef.current) {
initialHeaderBufferRef.current += output
const filtered = filterInitialGsdHeader(initialHeaderBufferRef.current)
const filtered = filterInitialSfHeader(initialHeaderBufferRef.current)
if (filtered.status === "needs-more") {
return
@ -341,7 +341,7 @@ function TerminalInstance({
termRef.current = null
fitAddonRef.current = null
}
}, [sessionId, command, commandArgs, commandArgsKey, fontSize, hideInitialGsdHeader, isDark, projectCwd, sendInput, sendResize])
}, [sessionId, command, commandArgs, commandArgsKey, fontSize, hideInitialSfHeader, isDark, projectCwd, sendInput, sendResize])
// Focus on click
const wrapperRef = useRef<HTMLDivElement>(null)
@ -477,7 +477,7 @@ function deriveProjectScopedSessionId(
sessionPrefix?: string,
command?: string,
): string {
const base = sessionPrefix ?? (command ? "gsd-default" : "default")
const base = sessionPrefix ?? (command ? "sf-default" : "default")
if (!projectCwd) return base
return `${base}:${projectCwd}`
}
@ -489,7 +489,7 @@ export function ShellTerminal({
sessionPrefix,
hideSidebar = false,
fontSize,
hideInitialGsdHeader = false,
hideInitialSfHeader = false,
projectCwd,
}: ShellTerminalProps) {
const { resolvedTheme } = useTheme()
@ -662,7 +662,7 @@ export function ShellTerminal({
commandArgs={tab.id === defaultId ? commandArgs : undefined}
isDark={isDark}
fontSize={fontSize}
hideInitialGsdHeader={hideInitialGsdHeader}
hideInitialSfHeader={hideInitialSfHeader}
projectCwd={projectCwd}
onConnectionChange={(c) => updateConnection(tab.id, c)}
/>

View file

@ -44,8 +44,8 @@ import {
getCurrentScopeLabel,
getLiveWorkspaceIndex,
getLiveAutoDashboard,
useGSDWorkspaceState,
useGSDWorkspaceActions,
useSFWorkspaceState,
useSFWorkspaceActions,
buildPromptCommand,
} from "@/lib/sf-workspace-store"
import { getMilestoneStatus, getSliceStatus, getTaskStatus, type ItemStatus } from "@/lib/workspace-status"
@ -74,7 +74,7 @@ interface NavRailProps {
}
export function NavRail({ activeView, onViewChange, isConnecting = false }: NavRailProps) {
const { openCommandSurface } = useGSDWorkspaceActions()
const { openCommandSurface } = useSFWorkspaceActions()
const manager = useProjectStoreManager()
const activeProjectCwd = useSyncExternalStore(manager.subscribe, manager.getSnapshot, manager.getSnapshot)
const [exitDialogOpen, setExitDialogOpen] = useState(false)
@ -314,8 +314,8 @@ function ExitDialog({
/* ─── Milestone Explorer (right sidebar) ─── */
export function MilestoneExplorer({ isConnecting = false, width, onCollapse }: { isConnecting?: boolean; width?: number; onCollapse?: () => void }) {
const workspace = useGSDWorkspaceState()
const { openCommandSurface, setCommandSurfaceSection, sendCommand } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { openCommandSurface, setCommandSurfaceSection, sendCommand } = useSFWorkspaceActions()
const [expandedMilestones, setExpandedMilestones] = useState<string[]>([])
const [expandedSlices, setExpandedSlices] = useState<string[]>([])
@ -334,7 +334,7 @@ export function MilestoneExplorer({ isConnecting = false, width, onCollapse }: {
const gsdPrefix = `${projectCwd}/.gsd/`
if (!absolutePath.startsWith(gsdPrefix)) return
const relativePath = absolutePath.slice(gsdPrefix.length)
window.dispatchEvent(new CustomEvent("gsd:open-file", { detail: { root: "gsd", path: relativePath } }))
window.dispatchEvent(new CustomEvent("sf:open-file", { detail: { root: "gsd", path: relativePath } }))
}
const workflowAction = deriveWorkflowAction({
@ -624,8 +624,8 @@ export function MilestoneExplorer({ isConnecting = false, width, onCollapse }: {
/* ─── Collapsed Milestone Sidebar (icon-only rail) ─── */
export function CollapsedMilestoneSidebar({ onExpand }: { onExpand: () => void }) {
const workspace = useGSDWorkspaceState()
const { sendCommand } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { sendCommand } = useSFWorkspaceActions()
const liveWorkspace = getLiveWorkspaceIndex(workspace)
const milestones = liveWorkspace?.milestones ?? []
@ -715,7 +715,7 @@ export function Sidebar({ activeView, onViewChange, isConnecting = false, mobile
/* ─── Mobile Nav Panel (full-width labels for touch) ─── */
function MobileNavPanel({ activeView, onViewChange, isConnecting = false }: NavRailProps) {
const { openCommandSurface } = useGSDWorkspaceActions()
const { openCommandSurface } = useSFWorkspaceActions()
const { theme, setTheme } = useTheme()
const cycleTheme = () => {

View file

@ -13,7 +13,7 @@ import {
getModelLabel,
getStatusPresentation,
getVisibleWorkspaceError,
useGSDWorkspaceState,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store"
import {
formatCost as formatProjectCost,
@ -38,7 +38,7 @@ function toneClass(tone: ReturnType<typeof getStatusPresentation>["tone"]): stri
}
export function StatusBar() {
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const status = getStatusPresentation(workspace)
const liveWorkspace = getLiveWorkspaceIndex(workspace)
const auto = getLiveAutoDashboard(workspace)

View file

@ -5,7 +5,7 @@ import { useTheme } from "next-themes"
import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react"
import { cn } from "@/lib/utils"
import { validateImageFile } from "@/lib/image-utils"
import { filterInitialGsdHeader } from "@/lib/initial-gsd-header-filter"
import { filterInitialSfHeader } from "@/lib/initial-sf-header-filter"
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
import { authFetch, appendAuthParam } from "@/lib/auth"
import "@xterm/xterm/css/xterm.css"
@ -33,7 +33,7 @@ interface ShellTerminalProps {
sessionPrefix?: string
hideSidebar?: boolean
fontSize?: number
hideInitialGsdHeader?: boolean
hideInitialSfHeader?: boolean
projectCwd?: string
}
@ -184,7 +184,7 @@ interface TerminalInstanceProps {
commandArgs?: string[]
isDark: boolean
fontSize?: number
hideInitialGsdHeader?: boolean
hideInitialSfHeader?: boolean
projectCwd?: string
onConnectionChange: (connected: boolean) => void
}
@ -196,7 +196,7 @@ function TerminalInstance({
commandArgs,
isDark,
fontSize,
hideInitialGsdHeader = false,
hideInitialSfHeader = false,
projectCwd,
onConnectionChange,
}: TerminalInstanceProps) {
@ -208,7 +208,7 @@ function TerminalInstance({
const flushingRef = useRef(false)
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const onConnectionChangeRef = useRef(onConnectionChange)
const initialHeaderSettledRef = useRef(!hideInitialGsdHeader)
const initialHeaderSettledRef = useRef(!hideInitialSfHeader)
const initialHeaderBufferRef = useRef("")
const commandArgsKey = (commandArgs ?? []).join("\u0000")
const [hasOutput, setHasOutput] = useState(false)
@ -259,9 +259,9 @@ function TerminalInstance({
}, [onConnectionChange])
useEffect(() => {
initialHeaderSettledRef.current = !hideInitialGsdHeader
initialHeaderSettledRef.current = !hideInitialSfHeader
initialHeaderBufferRef.current = ""
}, [hideInitialGsdHeader, sessionId])
}, [hideInitialSfHeader, sessionId])
// Update xterm theme when isDark changes
useEffect(() => {
@ -362,9 +362,9 @@ function TerminalInstance({
} else if (msg.type === "output" && msg.data) {
let output = msg.data
if (hideInitialGsdHeader && !initialHeaderSettledRef.current) {
if (hideInitialSfHeader && !initialHeaderSettledRef.current) {
initialHeaderBufferRef.current += output
const filtered = filterInitialGsdHeader(initialHeaderBufferRef.current)
const filtered = filterInitialSfHeader(initialHeaderBufferRef.current)
if (filtered.status === "needs-more") {
return
@ -412,7 +412,7 @@ function TerminalInstance({
termRef.current = null
fitAddonRef.current = null
}
}, [sessionId, command, commandArgs, commandArgsKey, fontSize, hideInitialGsdHeader, isDark, projectCwd, sendInput, sendResize])
}, [sessionId, command, commandArgs, commandArgsKey, fontSize, hideInitialSfHeader, isDark, projectCwd, sendInput, sendResize])
// Focus on click
const wrapperRef = useRef<HTMLDivElement>(null)
@ -543,12 +543,12 @@ export function ShellTerminal({
sessionPrefix,
hideSidebar = false,
fontSize,
hideInitialGsdHeader = false,
hideInitialSfHeader = false,
projectCwd,
}: ShellTerminalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme !== "light"
const defaultId = sessionPrefix ?? (command ? "gsd-default" : "default")
const defaultId = sessionPrefix ?? (command ? "sf-default" : "default")
const commandLabel = deriveCommandLabel(command)
const [tabs, setTabs] = useState<TerminalTab[]>([
{ id: defaultId, label: commandLabel, connected: false },
@ -703,7 +703,7 @@ export function ShellTerminal({
commandArgs={tab.id === defaultId ? commandArgs : undefined}
isDark={isDark}
fontSize={fontSize}
hideInitialGsdHeader={hideInitialGsdHeader}
hideInitialSfHeader={hideInitialSfHeader}
projectCwd={projectCwd}
onConnectionChange={(c) => updateConnection(tab.id, c)}
/>

View file

@ -8,8 +8,8 @@ import {
getOnboardingPresentation,
getSessionLabelFromBridge,
getStatusPresentation,
useGSDWorkspaceActions,
useGSDWorkspaceState,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store"
interface TerminalProps {
@ -21,14 +21,14 @@ type WidgetPlacement = "aboveEditor" | "belowEditor"
const MAX_VISIBLE_WIDGET_LINES = 6
function getInputMode(state: ReturnType<typeof useGSDWorkspaceState>): InputMode {
function getInputMode(state: ReturnType<typeof useSFWorkspaceState>): InputMode {
const session = state.boot?.bridge.sessionState
if (!session) return "prompt"
if (session.isStreaming) return "follow_up"
return "prompt"
}
function inputModePlaceholder(mode: InputMode, state: ReturnType<typeof useGSDWorkspaceState>): string {
function inputModePlaceholder(mode: InputMode, state: ReturnType<typeof useSFWorkspaceState>): string {
if (state.bootStatus === "loading") return "Loading workspace…"
if (state.bootStatus === "error") return "Workspace boot failed — check the visible error state"
if (state.commandInFlight) return `Sending ${state.commandInFlight}`
@ -126,8 +126,8 @@ function TerminalWidgetBand({
}
export function Terminal({ className }: TerminalProps) {
const workspace = useGSDWorkspaceState()
const { submitInput, sendAbort, sendSteer, consumeEditorTextBuffer } = useGSDWorkspaceActions()
const workspace = useSFWorkspaceState()
const { submitInput, sendAbort, sendSteer, consumeEditorTextBuffer } = useSFWorkspaceActions()
const [input, setInput] = useState("")
const [steerMode, setSteerMode] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)

View file

@ -23,7 +23,7 @@ import {
AlertCircle,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useGSDWorkspaceState, buildProjectUrl } from "@/lib/sf-workspace-store"
import { useSFWorkspaceState, buildProjectUrl } from "@/lib/sf-workspace-store"
import type {
VisualizerData,
VisualizerSlice,
@ -1163,7 +1163,7 @@ function VisualizerTabList() {
// ─── Main Component ───────────────────────────────────────────────────────────
export function VisualizerView() {
const workspace = useGSDWorkspaceState()
const workspace = useSFWorkspaceState()
const projectCwd = workspace.boot?.project.cwd
const [data, setData] = useState<VisualizerData | null>(null)
const [loading, setLoading] = useState(true)

File diff suppressed because it is too large Load diff

View file

@ -106,7 +106,7 @@ function isLogoLine(line: string | undefined): boolean {
* PTY pane often does. This filter removes only the initial branded banner from
* the PTY attach stream so both panes start on real terminal content.
*/
export function filterInitialGsdHeader(raw: string): InitialGsdHeaderFilterResult {
export function filterInitialSfHeader(raw: string): InitialGsdHeaderFilterResult {
const { plainText, rawOffsetsByPlainIndex } = indexVisibleText(raw)
if (!plainText) {
return { status: 'needs-more', text: '' }

View file

@ -1,10 +1,10 @@
"use client"
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
import { GSDWorkspaceStore } from "./gsd-workspace-store"
import { SFWorkspaceStore } from "./sf-workspace-store"
/**
* ProjectStoreManager maintains a Map<string, GSDWorkspaceStore> of per-project
* ProjectStoreManager maintains a Map<string, SFWorkspaceStore> of per-project
* stores with SSE lifecycle management. Only the active project's store keeps its
* SSE connection open background stores are disconnected to save resources.
*
@ -12,7 +12,7 @@ import { GSDWorkspaceStore } from "./gsd-workspace-store"
* reactively read the active project path.
*/
export class ProjectStoreManager {
private stores = new Map<string, GSDWorkspaceStore>()
private stores = new Map<string, SFWorkspaceStore>()
private activeProjectCwd: string | null = null
private listeners = new Set<() => void>()
@ -27,7 +27,7 @@ export class ProjectStoreManager {
// ─── Public API ──────────────────────────────────────────────────────────
getActiveStore(): GSDWorkspaceStore | null {
getActiveStore(): SFWorkspaceStore | null {
if (!this.activeProjectCwd) return null
return this.stores.get(this.activeProjectCwd) ?? null
}
@ -40,7 +40,7 @@ export class ProjectStoreManager {
* Switch to the given project. Disconnects SSE on the previous active store,
* creates a new store if needed (lazily), reconnects SSE on re-activated stores.
*/
switchProject(projectCwd: string): GSDWorkspaceStore {
switchProject(projectCwd: string): SFWorkspaceStore {
// Disconnect SSE on current active store
if (this.activeProjectCwd && this.activeProjectCwd !== projectCwd) {
const prev = this.stores.get(this.activeProjectCwd)
@ -50,7 +50,7 @@ export class ProjectStoreManager {
// Get or create store for new project
let store = this.stores.get(projectCwd)
if (!store) {
store = new GSDWorkspaceStore(projectCwd)
store = new SFWorkspaceStore(projectCwd)
this.stores.set(projectCwd, store)
store.start()
} else {

View file

@ -4118,7 +4118,7 @@ export class SFWorkspaceStore {
),
})
window.dispatchEvent(
new CustomEvent("gsd:navigate-view", { detail: { view: outcome.view } }),
new CustomEvent("sf:navigate-view", { detail: { view: outcome.view } }),
)
return outcome
}
@ -5227,10 +5227,10 @@ export class SFWorkspaceStore {
}
}
const WorkspaceStoreContext = createContext<GSDWorkspaceStore | null>(null)
const WorkspaceStoreContext = createContext<SFWorkspaceStore | null>(null)
export function GSDWorkspaceProvider({ children, store: externalStore }: { children: ReactNode; store?: GSDWorkspaceStore }) {
const [internalStore] = useState(() => new GSDWorkspaceStore())
export function SFWorkspaceProvider({ children, store: externalStore }: { children: ReactNode; store?: SFWorkspaceStore }) {
const [internalStore] = useState(() => new SFWorkspaceStore())
const store = externalStore ?? internalStore
useEffect(() => {
@ -5244,21 +5244,21 @@ export function GSDWorkspaceProvider({ children, store: externalStore }: { child
return <WorkspaceStoreContext.Provider value={store}>{children}</WorkspaceStoreContext.Provider>
}
function useWorkspaceStore(): GSDWorkspaceStore {
function useWorkspaceStore(): SFWorkspaceStore {
const store = useContext(WorkspaceStoreContext)
if (!store) {
throw new Error("useWorkspaceStore must be used within GSDWorkspaceProvider")
throw new Error("useWorkspaceStore must be used within SFWorkspaceProvider")
}
return store
}
export function useGSDWorkspaceState(): WorkspaceStoreState {
export function useSFWorkspaceState(): WorkspaceStoreState {
const store = useWorkspaceStore()
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
}
export function useGSDWorkspaceActions(): Pick<
GSDWorkspaceStore,
export function useSFWorkspaceActions(): Pick<
SFWorkspaceStore,
| "sendCommand"
| "submitInput"
| "clearTerminalLines"

View file

@ -1,11 +1,11 @@
import type { WorkspaceTerminalLine } from "./gsd-workspace-store"
import type { WorkspaceTerminalLine } from "./sf-workspace-store"
import { getUserMode } from "./use-user-mode"
export type GSDViewName = "dashboard" | "power" | "chat" | "roadmap" | "files" | "activity" | "visualize"
export type SFViewName = "dashboard" | "power" | "chat" | "roadmap" | "files" | "activity" | "visualize"
export function navigateToGSDView(view: GSDViewName): void {
export function navigateToSFView(view: SFViewName): void {
if (typeof window === "undefined") return
window.dispatchEvent(new CustomEvent("gsd:navigate-view", { detail: { view } }))
window.dispatchEvent(new CustomEvent("sf:navigate-view", { detail: { view } }))
}
/**
@ -26,7 +26,7 @@ export function executeWorkflowActionInPowerMode({
console.error("[workflow-action] dispatch failed:", error)
})
const mode = getUserMode()
navigateToGSDView(mode === "vibe-coder" ? "chat" : "power")
navigateToSFView(mode === "vibe-coder" ? "chat" : "power")
}
export function derivePendingWorkflowCommandLabel({