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:
parent
434bb527c4
commit
83feadb4e1
73 changed files with 197 additions and 27419 deletions
|
|
@ -60,9 +60,9 @@
|
||||||
"types": "./dist/diff/index.d.ts",
|
"types": "./dist/diff/index.d.ts",
|
||||||
"default": "./dist/diff/index.js"
|
"default": "./dist/diff/index.js"
|
||||||
},
|
},
|
||||||
"./gsd-parser": {
|
"./forge-parser": {
|
||||||
"types": "./dist/gsd-parser/index.d.ts",
|
"types": "./dist/forge-parser/index.d.ts",
|
||||||
"default": "./dist/gsd-parser/index.js"
|
"default": "./dist/forge-parser/index.js"
|
||||||
},
|
},
|
||||||
"./highlight": {
|
"./highlight": {
|
||||||
"types": "./dist/highlight/index.d.ts",
|
"types": "./dist/highlight/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ export {
|
||||||
extractAllSections,
|
extractAllSections,
|
||||||
batchParseGsdFiles,
|
batchParseGsdFiles,
|
||||||
parseRoadmapFile,
|
parseRoadmapFile,
|
||||||
} from "./gsd-parser/index.js";
|
} from "./forge-parser/index.js";
|
||||||
export type {
|
export type {
|
||||||
BatchParseResult,
|
BatchParseResult,
|
||||||
FrontmatterResult,
|
FrontmatterResult,
|
||||||
|
|
@ -122,7 +122,7 @@ export type {
|
||||||
NativeRoadmapSlice,
|
NativeRoadmapSlice,
|
||||||
ParsedGsdFile,
|
ParsedGsdFile,
|
||||||
SectionResult,
|
SectionResult,
|
||||||
} from "./gsd-parser/index.js";
|
} from "./forge-parser/index.js";
|
||||||
|
|
||||||
export { truncateTail, truncateHead, truncateOutput } from "./truncate/index.js";
|
export { truncateTail, truncateHead, truncateOutput } from "./truncate/index.js";
|
||||||
export type { TruncateResult, TruncateOutputResult } from "./truncate/index.js";
|
export type { TruncateResult, TruncateOutputResult } from "./truncate/index.js";
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.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");
|
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"');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
|
|
||||||
const GSDAppShell = dynamic(
|
const SFAppShell = dynamic(
|
||||||
() => import("@/components/gsd/app-shell").then((mod) => mod.GSDAppShell),
|
() => import("@/components/sf/app-shell").then((mod) => mod.SFAppShell),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
|
|
@ -15,5 +15,5 @@ const GSDAppShell = dynamic(
|
||||||
)
|
)
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <GSDAppShell />
|
return <SFAppShell />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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)} />
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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 (17–20 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { CheckCircle2, Play, Clock, Terminal, AlertCircle } from "lucide-react"
|
import { CheckCircle2, Play, Clock, Terminal, AlertCircle } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
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 }) {
|
function EventIcon({ type }: { type: TerminalLineType }) {
|
||||||
const baseClass = "h-4 w-4"
|
const baseClass = "h-4 w-4"
|
||||||
|
|
@ -23,7 +23,7 @@ function EventIcon({ type }: { type: TerminalLineType }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityView() {
|
export function ActivityView() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const terminalLines = workspace.terminalLines ?? []
|
const terminalLines = workspace.terminalLines ?? []
|
||||||
|
|
||||||
// Show most recent events first
|
// Show most recent events first
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,13 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
GSDWorkspaceProvider,
|
SFWorkspaceProvider,
|
||||||
getCurrentScopeLabel,
|
getCurrentScopeLabel,
|
||||||
getProjectDisplayName,
|
getProjectDisplayName,
|
||||||
getStatusPresentation,
|
getStatusPresentation,
|
||||||
getVisibleWorkspaceError,
|
getVisibleWorkspaceError,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
import { ChatMode } from "@/components/sf/chat-mode"
|
import { ChatMode } from "@/components/sf/chat-mode"
|
||||||
import { ScopeBadge } from "@/components/sf/scope-badge"
|
import { ScopeBadge } from "@/components/sf/scope-badge"
|
||||||
|
|
@ -60,8 +60,8 @@ function WorkspaceChrome() {
|
||||||
const [projectsPanelOpen, setProjectsPanelOpen] = useState(false)
|
const [projectsPanelOpen, setProjectsPanelOpen] = useState(false)
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||||
const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false)
|
const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false)
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { refreshBoot } = useGSDWorkspaceActions()
|
const { refreshBoot } = useSFWorkspaceActions()
|
||||||
|
|
||||||
const status = getStatusPresentation(workspace)
|
const status = getStatusPresentation(workspace)
|
||||||
const projectPath = workspace.boot?.project.cwd
|
const projectPath = workspace.boot?.project.cwd
|
||||||
|
|
@ -146,8 +146,8 @@ function WorkspaceChrome() {
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
setActiveView("files")
|
setActiveView("files")
|
||||||
}
|
}
|
||||||
window.addEventListener("gsd:open-file", handler)
|
window.addEventListener("sf:open-file", handler)
|
||||||
return () => window.removeEventListener("gsd:open-file", handler)
|
return () => window.removeEventListener("sf:open-file", handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Listen for cross-component view navigation events (e.g. /gsd visualize dispatch)
|
// Listen for cross-component view navigation events (e.g. /gsd visualize dispatch)
|
||||||
|
|
@ -157,8 +157,8 @@ function WorkspaceChrome() {
|
||||||
handleViewChange(e.detail.view)
|
handleViewChange(e.detail.view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("gsd:navigate-view", handler as EventListener)
|
window.addEventListener("sf:navigate-view", handler as EventListener)
|
||||||
return () => window.removeEventListener("gsd:navigate-view", handler as EventListener)
|
return () => window.removeEventListener("sf:navigate-view", handler as EventListener)
|
||||||
}, [handleViewChange])
|
}, [handleViewChange])
|
||||||
|
|
||||||
// Listen for projects panel toggle (sidebar icon, or programmatic)
|
// Listen for projects panel toggle (sidebar icon, or programmatic)
|
||||||
|
|
@ -242,8 +242,8 @@ function WorkspaceChrome() {
|
||||||
!isConnecting &&
|
!isConnecting &&
|
||||||
activeView === "dashboard" &&
|
activeView === "dashboard" &&
|
||||||
detection != null &&
|
detection != null &&
|
||||||
detection.kind !== "active-gsd" &&
|
detection.kind !== "active-sf" &&
|
||||||
detection.kind !== "empty-gsd"
|
detection.kind !== "empty-sf"
|
||||||
|
|
||||||
// --- Unauthenticated gate ---
|
// --- Unauthenticated gate ---
|
||||||
// Render a clear recovery screen before any workspace chrome is mounted so
|
// 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.
|
// Extract the auth token from the URL fragment on first render.
|
||||||
// Must happen before any API calls fire.
|
// Must happen before any API calls fire.
|
||||||
getAuthToken()
|
getAuthToken()
|
||||||
|
|
@ -596,10 +596,10 @@ function ProjectAwareWorkspace() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GSDWorkspaceProvider store={activeStore}>
|
<SFWorkspaceProvider store={activeStore}>
|
||||||
<DevOverridesProvider>
|
<DevOverridesProvider>
|
||||||
<WorkspaceChrome />
|
<WorkspaceChrome />
|
||||||
</DevOverridesProvider>
|
</DevOverridesProvider>
|
||||||
</GSDWorkspaceProvider>
|
</SFWorkspaceProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover
|
||||||
import { ChatMessage, TuiPrompt } from "@/lib/pty-chat-parser"
|
import { ChatMessage, TuiPrompt } from "@/lib/pty-chat-parser"
|
||||||
import { PendingImage, processImageFile, generateImageId, MAX_PENDING_IMAGES } from "@/lib/image-utils"
|
import { PendingImage, processImageFile, generateImageId, MAX_PENDING_IMAGES } from "@/lib/image-utils"
|
||||||
import {
|
import {
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
buildPromptCommand,
|
buildPromptCommand,
|
||||||
type CompletedToolExecution,
|
type CompletedToolExecution,
|
||||||
type ActiveToolExecution,
|
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.
|
* Top 3 are shown as standalone buttons; the rest live in the overflow menu.
|
||||||
* All commands dispatch through the main bridge session.
|
* All commands dispatch through the main bridge session.
|
||||||
*/
|
*/
|
||||||
interface GSDActionDef {
|
interface SFActionDef {
|
||||||
label: string
|
label: string
|
||||||
command: string
|
command: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
|
|
@ -42,7 +42,7 @@ interface GSDActionDef {
|
||||||
disabledDuringAuto?: boolean
|
disabledDuringAuto?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const GSD_ACTIONS: GSDActionDef[] = [
|
const SF_ACTIONS: SFActionDef[] = [
|
||||||
// ── Top 3 (standalone buttons) ──
|
// ── Top 3 (standalone buttons) ──
|
||||||
{ label: "Discuss", command: "/gsd discuss", icon: MessageCircle, description: "Start guided milestone/slice discussion", category: "workflow", disabledDuringAuto: true },
|
{ 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" },
|
{ 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 */
|
/** 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 */
|
/** 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",
|
workflow: "Workflow",
|
||||||
visibility: "Visibility",
|
visibility: "Visibility",
|
||||||
correction: "Course Correction",
|
correction: "Course Correction",
|
||||||
|
|
@ -87,8 +87,8 @@ const CATEGORY_LABELS: Record<GSDActionDef["category"], string> = {
|
||||||
maintenance: "Maintenance",
|
maintenance: "Maintenance",
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupByCategory(actions: GSDActionDef[]): Array<{ category: GSDActionDef["category"]; label: string; items: GSDActionDef[] }> {
|
function groupByCategory(actions: SFActionDef[]): Array<{ category: SFActionDef["category"]; label: string; items: SFActionDef[] }> {
|
||||||
const seen = new Map<GSDActionDef["category"], GSDActionDef[]>()
|
const seen = new Map<SFActionDef["category"], SFActionDef[]>()
|
||||||
for (const a of actions) {
|
for (const a of actions) {
|
||||||
let group = seen.get(a.category)
|
let group = seen.get(a.category)
|
||||||
if (!group) {
|
if (!group) {
|
||||||
|
|
@ -115,8 +115,8 @@ function groupByCategory(actions: GSDActionDef[]): Array<{ category: GSDActionDe
|
||||||
* - Secondary buttons: data-testid="chat-secondary-action-{command}".
|
* - Secondary buttons: data-testid="chat-secondary-action-{command}".
|
||||||
*/
|
*/
|
||||||
export function ChatMode({ className }: { className?: string }) {
|
export function ChatMode({ className }: { className?: string }) {
|
||||||
const state = useGSDWorkspaceState()
|
const state = useSFWorkspaceState()
|
||||||
const { sendCommand } = useGSDWorkspaceActions()
|
const { sendCommand } = useSFWorkspaceActions()
|
||||||
|
|
||||||
const bridge = state.boot?.bridge ?? null
|
const bridge = state.boot?.bridge ?? null
|
||||||
|
|
||||||
|
|
@ -165,7 +165,7 @@ interface ChatModeHeaderProps {
|
||||||
* - data-testid="chat-secondary-action-{command}" on each secondary button
|
* - data-testid="chat-secondary-action-{command}" on each secondary button
|
||||||
*/
|
*/
|
||||||
function ChatModeHeader({ onPrimaryAction, onSecondaryAction }: ChatModeHeaderProps) {
|
function ChatModeHeader({ onPrimaryAction, onSecondaryAction }: ChatModeHeaderProps) {
|
||||||
const state = useGSDWorkspaceState()
|
const state = useSFWorkspaceState()
|
||||||
|
|
||||||
const boot = state.boot
|
const boot = state.boot
|
||||||
const workspace = boot?.workspace ?? null
|
const workspace = boot?.workspace ?? null
|
||||||
|
|
@ -1166,9 +1166,9 @@ function ChatInputBar({
|
||||||
}: {
|
}: {
|
||||||
onSendInput: (data: string, images?: PendingImage[]) => void
|
onSendInput: (data: string, images?: PendingImage[]) => void
|
||||||
connected: boolean
|
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 [value, setValue] = useState("")
|
||||||
const [overflowOpen, setOverflowOpen] = useState(false)
|
const [overflowOpen, setOverflowOpen] = useState(false)
|
||||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([])
|
const [pendingImages, setPendingImages] = useState<PendingImage[]>([])
|
||||||
|
|
@ -1592,8 +1592,8 @@ function PlaceholderState({
|
||||||
* first resolves the request — the store deduplicates.
|
* first resolves the request — the store deduplicates.
|
||||||
*/
|
*/
|
||||||
function InlineUiRequest({ request }: { request: PendingUiRequest }) {
|
function InlineUiRequest({ request }: { request: PendingUiRequest }) {
|
||||||
const { respondToUiRequest, dismissUiRequest } = useGSDWorkspaceActions()
|
const { respondToUiRequest, dismissUiRequest } = useSFWorkspaceActions()
|
||||||
const isSubmitting = useGSDWorkspaceState().commandInFlight === "extension_ui_response"
|
const isSubmitting = useSFWorkspaceState().commandInFlight === "extension_ui_response"
|
||||||
|
|
||||||
const handleSubmit = useCallback((value: Record<string, unknown>) => {
|
const handleSubmit = useCallback((value: Record<string, unknown>) => {
|
||||||
void respondToUiRequest(request.id, value)
|
void respondToUiRequest(request.id, value)
|
||||||
|
|
@ -1875,7 +1875,7 @@ interface ChatPaneProps {
|
||||||
className?: string
|
className?: string
|
||||||
initialCommand?: string
|
initialCommand?: string
|
||||||
onCompletionSignal?: () => void
|
onCompletionSignal?: () => void
|
||||||
onOpenAction?: (action: GSDActionDef) => void
|
onOpenAction?: (action: SFActionDef) => void
|
||||||
activityLabel?: string
|
activityLabel?: string
|
||||||
suppressTerminalChrome?: boolean
|
suppressTerminalChrome?: boolean
|
||||||
suppressInitialEcho?: boolean
|
suppressInitialEcho?: boolean
|
||||||
|
|
@ -2020,8 +2020,8 @@ function ToolExecutionBlock({ tool }: { tool: CompletedToolExecution }) {
|
||||||
* - ChatInputBar shows "Disconnected" badge when bridge is not connected
|
* - ChatInputBar shows "Disconnected" badge when bridge is not connected
|
||||||
*/
|
*/
|
||||||
export function ChatPane({ className, onOpenAction }: ChatPaneProps) {
|
export function ChatPane({ className, onOpenAction }: ChatPaneProps) {
|
||||||
const state = useGSDWorkspaceState()
|
const state = useSFWorkspaceState()
|
||||||
const { submitInput, sendCommand, pushChatUserMessage } = useGSDWorkspaceActions()
|
const { submitInput, sendCommand, pushChatUserMessage } = useSFWorkspaceActions()
|
||||||
const [terminalFontSize] = useTerminalFontSize()
|
const [terminalFontSize] = useTerminalFontSize()
|
||||||
|
|
||||||
const connected = state.connectionState === "connected"
|
const connected = state.connectionState === "connected"
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,8 @@ import {
|
||||||
getModelLabel,
|
getModelLabel,
|
||||||
getSessionLabelFromBridge,
|
getSessionLabelFromBridge,
|
||||||
shortenPath,
|
shortenPath,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
|
|
||||||
// ─── Section metadata ────────────────────────────────────────────────
|
// ─── Section metadata ────────────────────────────────────────────────
|
||||||
|
|
@ -318,7 +318,7 @@ function SegmentedControl<T extends string>({
|
||||||
// ═════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export function CommandSurface() {
|
export function CommandSurface() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const {
|
const {
|
||||||
closeCommandSurface,
|
closeCommandSurface,
|
||||||
openCommandSurface,
|
openCommandSurface,
|
||||||
|
|
@ -361,7 +361,7 @@ export function CommandSurface() {
|
||||||
loadUndoInfo,
|
loadUndoInfo,
|
||||||
loadCleanupData,
|
loadCleanupData,
|
||||||
loadSteerData,
|
loadSteerData,
|
||||||
} = useGSDWorkspaceActions()
|
} = useSFWorkspaceActions()
|
||||||
|
|
||||||
const { commandSurface } = workspace
|
const { commandSurface } = workspace
|
||||||
const onboarding = workspace.boot?.onboarding ?? null
|
const onboarding = workspace.boot?.onboarding ?? null
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ import {
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
buildPromptCommand,
|
buildPromptCommand,
|
||||||
buildProjectUrl,
|
buildProjectUrl,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
|
|
@ -111,8 +111,8 @@ interface DashboardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {}) {
|
export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {}) {
|
||||||
const state = useGSDWorkspaceState()
|
const state = useSFWorkspaceState()
|
||||||
const { sendCommand } = useGSDWorkspaceActions()
|
const { sendCommand } = useSFWorkspaceActions()
|
||||||
const boot = state.boot
|
const boot = state.boot
|
||||||
const workspace = getLiveWorkspaceIndex(state)
|
const workspace = getLiveWorkspaceIndex(state)
|
||||||
const auto = getLiveAutoDashboard(state)
|
const auto = getLiveAutoDashboard(state)
|
||||||
|
|
@ -204,8 +204,8 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
|
||||||
const showWelcome =
|
const showWelcome =
|
||||||
!isConnecting &&
|
!isConnecting &&
|
||||||
detection &&
|
detection &&
|
||||||
detection.kind !== "active-gsd" &&
|
detection.kind !== "active-sf" &&
|
||||||
detection.kind !== "empty-gsd"
|
detection.kind !== "empty-sf"
|
||||||
|
|
||||||
if (showWelcome) {
|
if (showWelcome) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import type {
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
formatCost,
|
formatCost,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -134,8 +134,8 @@ function AnomalyRow({ anomaly }: { anomaly: ForensicAnomaly }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ForensicsPanel() {
|
export function ForensicsPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadForensicsDiagnostics } = useGSDWorkspaceActions()
|
const { loadForensicsDiagnostics } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.diagnostics.forensics
|
const state = workspace.commandSurface.diagnostics.forensics
|
||||||
const data = state.data as ForensicReport | null
|
const data = state.data as ForensicReport | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
@ -268,8 +268,8 @@ function IssueRow({ issue }: { issue: DoctorIssue }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DoctorPanel() {
|
export function DoctorPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadDoctorDiagnostics, applyDoctorFixes } = useGSDWorkspaceActions()
|
const { loadDoctorDiagnostics, applyDoctorFixes } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.diagnostics.doctor
|
const state = workspace.commandSurface.diagnostics.doctor
|
||||||
const data = state.data as DoctorReport | null
|
const data = state.data as DoctorReport | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
@ -392,8 +392,8 @@ function SuggestionRow({ suggestion }: { suggestion: SkillHealSuggestion }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SkillHealthPanel() {
|
export function SkillHealthPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadSkillHealthDiagnostics } = useGSDWorkspaceActions()
|
const { loadSkillHealthDiagnostics } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.diagnostics.skillHealth
|
const state = workspace.commandSurface.diagnostics.skillHealth
|
||||||
const data = state.data as SkillHealthReport | null
|
const data = state.data as SkillHealthReport | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { GripVertical, Loader2 } from "lucide-react"
|
||||||
import { MainSessionTerminal } from "@/components/sf/main-session-terminal"
|
import { MainSessionTerminal } from "@/components/sf/main-session-terminal"
|
||||||
import { ShellTerminal } from "@/components/sf/shell-terminal"
|
import { ShellTerminal } from "@/components/sf/shell-terminal"
|
||||||
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
|
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"
|
import { derivePendingWorkflowCommandLabel } from "@/lib/workflow-action-execution"
|
||||||
|
|
||||||
export function DualTerminal() {
|
export function DualTerminal() {
|
||||||
|
|
@ -14,7 +14,7 @@ export function DualTerminal() {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
const isDragging = useRef(false)
|
const isDragging = useRef(false)
|
||||||
const [terminalFontSize] = useTerminalFontSize()
|
const [terminalFontSize] = useTerminalFontSize()
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const projectCwd = workspace.boot?.project.cwd
|
const projectCwd = workspace.boot?.project.cwd
|
||||||
const pendingCommandLabel = derivePendingWorkflowCommandLabel({
|
const pendingCommandLabel = derivePendingWorkflowCommandLabel({
|
||||||
commandInFlight: workspace.commandInFlight,
|
commandInFlight: workspace.commandInFlight,
|
||||||
|
|
@ -107,9 +107,9 @@ export function DualTerminal() {
|
||||||
<ShellTerminal
|
<ShellTerminal
|
||||||
className="h-full"
|
className="h-full"
|
||||||
command="gsd"
|
command="gsd"
|
||||||
sessionPrefix="gsd-interactive"
|
sessionPrefix="sf-interactive"
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
hideInitialGsdHeader
|
hideInitialSfHeader
|
||||||
projectCwd={projectCwd}
|
projectCwd={projectCwd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
Bot,
|
Bot,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
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 { authFetch } from "@/lib/auth"
|
||||||
import { FileContentViewer } from "@/components/sf/file-content-viewer"
|
import { FileContentViewer } from "@/components/sf/file-content-viewer"
|
||||||
import { ChatPane } from "@/components/sf/chat-mode"
|
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)
|
// Set up the global event listener once (module-level, not component-level)
|
||||||
if (typeof window !== "undefined") {
|
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
|
const detail = (e as CustomEvent<{ root: RootMode; path: string }>).detail
|
||||||
if (detail?.root && detail?.path) {
|
if (detail?.root && detail?.path) {
|
||||||
pendingFileRequest = { root: detail.root, path: detail.path }
|
pendingFileRequest = { root: detail.root, path: detail.path }
|
||||||
|
|
@ -472,7 +472,7 @@ function tabLabel(tab: OpenTab): string {
|
||||||
type LeftPanel = "tree" | "agent"
|
type LeftPanel = "tree" | "agent"
|
||||||
|
|
||||||
export function FilesView() {
|
export function FilesView() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const projectCwd = workspace.boot?.project.cwd
|
const projectCwd = workspace.boot?.project.cwd
|
||||||
|
|
||||||
const [activeRoot, setActiveRoot] = useState<RootMode>("gsd")
|
const [activeRoot, setActiveRoot] = useState<RootMode>("gsd")
|
||||||
|
|
@ -705,8 +705,8 @@ export function FilesView() {
|
||||||
pendingFileRequest = null // clear since we're handling it directly
|
pendingFileRequest = null // clear since we're handling it directly
|
||||||
void processFileOpen(detail.root, detail.path)
|
void processFileOpen(detail.root, detail.path)
|
||||||
}
|
}
|
||||||
window.addEventListener("gsd:open-file", handler)
|
window.addEventListener("sf:open-file", handler)
|
||||||
return () => window.removeEventListener("gsd:open-file", handler)
|
return () => window.removeEventListener("sf:open-file", handler)
|
||||||
}, [processFileOpen])
|
}, [processFileOpen])
|
||||||
|
|
||||||
const handleToggleDir = useCallback((path: string) => {
|
const handleToggleDir = useCallback((path: string) => {
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ import {
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
type PendingUiRequest,
|
type PendingUiRequest,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -252,8 +252,8 @@ function RequestBody({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FocusedPanel() {
|
export function FocusedPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { respondToUiRequest, dismissUiRequest } = useGSDWorkspaceActions()
|
const { respondToUiRequest, dismissUiRequest } = useSFWorkspaceActions()
|
||||||
|
|
||||||
const pending = workspace.pendingUiRequests
|
const pending = workspace.pendingUiRequests
|
||||||
const isOpen = pending.length > 0
|
const isOpen = pending.length > 0
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ import type {
|
||||||
} from "@/lib/knowledge-captures-types"
|
} from "@/lib/knowledge-captures-types"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -374,8 +374,8 @@ interface KnowledgeCapturesPanelProps {
|
||||||
|
|
||||||
export function KnowledgeCapturesPanel({ initialTab }: KnowledgeCapturesPanelProps) {
|
export function KnowledgeCapturesPanel({ initialTab }: KnowledgeCapturesPanelProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"knowledge" | "captures">(initialTab)
|
const [activeTab, setActiveTab] = useState<"knowledge" | "captures">(initialTab)
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadKnowledgeData, loadCapturesData, resolveCaptureAction } = useGSDWorkspaceActions()
|
const { loadKnowledgeData, loadCapturesData, resolveCaptureAction } = useSFWorkspaceActions()
|
||||||
|
|
||||||
const knowledgeCaptures = workspace.commandSurface.knowledgeCaptures
|
const knowledgeCaptures = workspace.commandSurface.knowledgeCaptures
|
||||||
const knowledgeState = knowledgeCaptures.knowledge
|
const knowledgeState = knowledgeCaptures.knowledge
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ import { AnimatePresence, motion } from "motion/react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import {
|
import {
|
||||||
type WorkspaceOnboardingProviderState,
|
type WorkspaceOnboardingProviderState,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
import { useDevOverrides } from "@/lib/dev-overrides"
|
import { useDevOverrides } from "@/lib/dev-overrides"
|
||||||
import { useUserMode, type UserMode } from "@/lib/use-user-mode"
|
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 { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import { StepWelcome } from "./onboarding/step-welcome"
|
import { StepWelcome } from "./onboarding/step-welcome"
|
||||||
|
|
@ -82,7 +82,7 @@ function StepIndicator({ current, total }: { current: number; total: number }) {
|
||||||
// ─── Main Component ─────────────────────────────────────────────────
|
// ─── Main Component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export function OnboardingGate() {
|
export function OnboardingGate() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const {
|
const {
|
||||||
refreshOnboarding,
|
refreshOnboarding,
|
||||||
saveApiKey,
|
saveApiKey,
|
||||||
|
|
@ -90,7 +90,7 @@ export function OnboardingGate() {
|
||||||
submitProviderFlowInput,
|
submitProviderFlowInput,
|
||||||
cancelProviderFlow,
|
cancelProviderFlow,
|
||||||
refreshBoot,
|
refreshBoot,
|
||||||
} = useGSDWorkspaceActions()
|
} = useSFWorkspaceActions()
|
||||||
const devOverrides = useDevOverrides()
|
const devOverrides = useDevOverrides()
|
||||||
|
|
||||||
const onboarding = workspace.boot?.onboarding
|
const onboarding = workspace.boot?.onboarding
|
||||||
|
|
@ -289,7 +289,7 @@ export function OnboardingGate() {
|
||||||
}}
|
}}
|
||||||
onFinish={() => {
|
onFinish={() => {
|
||||||
const mode = selectedMode ?? userMode
|
const mode = selectedMode ?? userMode
|
||||||
navigateToGSDView("dashboard")
|
navigateToSFView("dashboard")
|
||||||
void refreshBoot()
|
void refreshBoot()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { authFetch } from "@/lib/auth"
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ProjectDetectionKind = "active-gsd" | "empty-gsd" | "v1-legacy" | "brownfield" | "blank"
|
type ProjectDetectionKind = "active-sf" | "empty-sf" | "v1-legacy" | "brownfield" | "blank"
|
||||||
|
|
||||||
interface ProjectDetectionSignals {
|
interface ProjectDetectionSignals {
|
||||||
hasGsdFolder: boolean
|
hasGsdFolder: boolean
|
||||||
|
|
@ -56,8 +56,8 @@ interface ProjectMetadata {
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; icon: typeof Layers }> = {
|
const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; icon: typeof Layers }> = {
|
||||||
"active-gsd": { label: "Active", color: "text-success", icon: Layers },
|
"active-sf": { label: "Active", color: "text-success", icon: Layers },
|
||||||
"empty-gsd": { label: "Initialized", color: "text-info", icon: FolderOpen },
|
"empty-sf": { label: "Initialized", color: "text-info", icon: FolderOpen },
|
||||||
brownfield: { label: "Existing", color: "text-warning", icon: GitBranch },
|
brownfield: { label: "Existing", color: "text-warning", icon: GitBranch },
|
||||||
"v1-legacy": { label: "Legacy", color: "text-warning", icon: GitBranch },
|
"v1-legacy": { label: "Legacy", color: "text-warning", icon: GitBranch },
|
||||||
blank: { label: "New", color: "text-muted-foreground", icon: Sparkles },
|
blank: { label: "New", color: "text-muted-foreground", icon: Sparkles },
|
||||||
|
|
@ -203,9 +203,9 @@ export function StepProject({ onFinish, onBack, onBeforeSwitch }: StepProjectPro
|
||||||
|
|
||||||
const noDevRoot = !loading && !devRoot
|
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 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 ka = kindOrder[a.kind] ?? 5
|
||||||
const kb = kindOrder[b.kind] ?? 5
|
const kb = kindOrder[b.kind] ?? 5
|
||||||
if (ka !== kb) return ka - kb
|
if (ka !== kb) return ka - kb
|
||||||
|
|
@ -287,7 +287,7 @@ export function StepProject({ onFinish, onBack, onBeforeSwitch }: StepProjectPro
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg mt-0.5",
|
"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 ? (
|
{isSwitching ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
|
@ -320,14 +320,14 @@ export function StepProject({ onFinish, onBack, onBeforeSwitch }: StepProjectPro
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row 3: progress info (for active-gsd projects) */}
|
{/* Row 3: progress info (for active-sf projects) */}
|
||||||
{progress && (
|
{progress && (
|
||||||
<div className="mt-1.5 text-[11px] text-muted-foreground">
|
<div className="mt-1.5 text-[11px] text-muted-foreground">
|
||||||
{progress}
|
{progress}
|
||||||
</div>
|
</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 && (
|
{project.progress && project.progress.milestonesTotal > 0 && (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<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-1 flex-1 overflow-hidden rounded-full bg-foreground/[0.06]">
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ function getVariant(detection: ProjectDetection): WelcomeVariant {
|
||||||
primaryCommand: "/gsd",
|
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:
|
default:
|
||||||
return {
|
return {
|
||||||
icon: <Folder className="h-8 w-8 text-foreground" strokeWidth={1.5} />,
|
icon: <Folder className="h-8 w-8 text-foreground" strokeWidth={1.5} />,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useProjectStoreManager } from "@/lib/project-store-manager"
|
import { useProjectStoreManager } from "@/lib/project-store-manager"
|
||||||
import {
|
import {
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
getLiveWorkspaceIndex,
|
getLiveWorkspaceIndex,
|
||||||
getLiveAutoDashboard,
|
getLiveAutoDashboard,
|
||||||
formatCost,
|
formatCost,
|
||||||
|
|
@ -53,7 +53,7 @@ import {
|
||||||
|
|
||||||
// ─── Types (mirroring server-side ProjectMetadata) ─────────────────────────
|
// ─── 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 {
|
interface ProjectDetectionSignals {
|
||||||
hasGsdFolder: boolean
|
hasGsdFolder: boolean
|
||||||
|
|
@ -88,13 +88,13 @@ interface ProjectMetadata {
|
||||||
// ─── Kind style config ─────────────────────────────────────────────────
|
// ─── Kind style config ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; bgClass: string; icon: typeof Layers }> = {
|
const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; bgClass: string; icon: typeof Layers }> = {
|
||||||
"active-gsd": {
|
"active-sf": {
|
||||||
label: "Active",
|
label: "Active",
|
||||||
color: "text-success",
|
color: "text-success",
|
||||||
bgClass: "bg-success/10",
|
bgClass: "bg-success/10",
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
},
|
},
|
||||||
"empty-gsd": {
|
"empty-sf": {
|
||||||
label: "Initialized",
|
label: "Initialized",
|
||||||
color: "text-info",
|
color: "text-info",
|
||||||
bgClass: "bg-info/10",
|
bgClass: "bg-info/10",
|
||||||
|
|
@ -348,7 +348,7 @@ export function ProjectsPanel({
|
||||||
|
|
||||||
const [newProjectOpen, setNewProjectOpen] = useState(false)
|
const [newProjectOpen, setNewProjectOpen] = useState(false)
|
||||||
const [changeRootOpen, setChangeRootOpen] = useState(false)
|
const [changeRootOpen, setChangeRootOpen] = useState(false)
|
||||||
const workspaceState = useGSDWorkspaceState()
|
const workspaceState = useSFWorkspaceState()
|
||||||
|
|
||||||
const handleProjectCreated = useCallback(
|
const handleProjectCreated = useCallback(
|
||||||
(newProject: ProjectMetadata) => {
|
(newProject: ProjectMetadata) => {
|
||||||
|
|
@ -373,11 +373,11 @@ export function ProjectsPanel({
|
||||||
manager.switchProject(project.path)
|
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 sortedProjects = [...projects].sort((a, b) => {
|
||||||
const kindOrder: Record<ProjectDetectionKind, number> = {
|
const kindOrder: Record<ProjectDetectionKind, number> = {
|
||||||
"active-gsd": 0,
|
"active-sf": 0,
|
||||||
"empty-gsd": 1,
|
"empty-sf": 1,
|
||||||
brownfield: 2,
|
brownfield: 2,
|
||||||
"v1-legacy": 3,
|
"v1-legacy": 3,
|
||||||
blank: 4,
|
blank: 4,
|
||||||
|
|
@ -521,7 +521,7 @@ export function ProjectsPanel({
|
||||||
|
|
||||||
// ─── Active project inline summary (compact for panel card) ────────────
|
// ─── 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 workspace = getLiveWorkspaceIndex(workspaceState)
|
||||||
const dashboard = getLiveAutoDashboard(workspaceState)
|
const dashboard = getLiveAutoDashboard(workspaceState)
|
||||||
const currentSlice = getCurrentSlice(workspace)
|
const currentSlice = getCurrentSlice(workspace)
|
||||||
|
|
@ -1059,11 +1059,11 @@ export function ProjectSelectionGate() {
|
||||||
manager.switchProject(project.path)
|
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 sortedProjects = [...projects].sort((a, b) => {
|
||||||
const kindOrder: Record<ProjectDetectionKind, number> = {
|
const kindOrder: Record<ProjectDetectionKind, number> = {
|
||||||
"active-gsd": 0,
|
"active-sf": 0,
|
||||||
"empty-gsd": 1,
|
"empty-sf": 1,
|
||||||
brownfield: 2,
|
brownfield: 2,
|
||||||
"v1-legacy": 3,
|
"v1-legacy": 3,
|
||||||
blank: 4,
|
blank: 4,
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
formatCost,
|
formatCost,
|
||||||
getLiveWorkspaceIndex,
|
getLiveWorkspaceIndex,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
type WorkspaceMilestoneTarget,
|
type WorkspaceMilestoneTarget,
|
||||||
type WorkspaceSliceTarget,
|
type WorkspaceSliceTarget,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
|
|
@ -201,8 +201,8 @@ export function QuickPanel() {
|
||||||
type HistoryTab = "phase" | "slice" | "model" | "units"
|
type HistoryTab = "phase" | "slice" | "model" | "units"
|
||||||
|
|
||||||
export function HistoryPanel() {
|
export function HistoryPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadHistoryData } = useGSDWorkspaceActions()
|
const { loadHistoryData } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.remainingCommands.history
|
const state = workspace.commandSurface.remainingCommands.history
|
||||||
const data = state.data as HistoryData | null
|
const data = state.data as HistoryData | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
@ -373,8 +373,8 @@ export function HistoryPanel() {
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export function UndoPanel() {
|
export function UndoPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadUndoInfo, executeUndoAction } = useGSDWorkspaceActions()
|
const { loadUndoInfo, executeUndoAction } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.remainingCommands.undo
|
const state = workspace.commandSurface.remainingCommands.undo
|
||||||
const data = state.data as UndoInfo | null
|
const data = state.data as UndoInfo | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
@ -519,8 +519,8 @@ export function UndoPanel() {
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export function SteerPanel() {
|
export function SteerPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadSteerData, sendSteer } = useGSDWorkspaceActions()
|
const { loadSteerData, sendSteer } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.remainingCommands.steer
|
const state = workspace.commandSurface.remainingCommands.steer
|
||||||
const data = state.data as SteerData | null
|
const data = state.data as SteerData | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
@ -607,8 +607,8 @@ export function SteerPanel() {
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export function HooksPanel() {
|
export function HooksPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadHooksData } = useGSDWorkspaceActions()
|
const { loadHooksData } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.remainingCommands.hooks
|
const state = workspace.commandSurface.remainingCommands.hooks
|
||||||
const data = state.data as HooksData | null
|
const data = state.data as HooksData | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
@ -699,8 +699,8 @@ export function HooksPanel() {
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export function InspectPanel() {
|
export function InspectPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadInspectData } = useGSDWorkspaceActions()
|
const { loadInspectData } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.remainingCommands.inspect
|
const state = workspace.commandSurface.remainingCommands.inspect
|
||||||
const data = state.data as InspectData | null
|
const data = state.data as InspectData | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
@ -807,8 +807,8 @@ export function InspectPanel() {
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export function ExportPanel() {
|
export function ExportPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadExportData } = useGSDWorkspaceActions()
|
const { loadExportData } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.remainingCommands.exportData
|
const state = workspace.commandSurface.remainingCommands.exportData
|
||||||
const data = state.data as ExportResult | null
|
const data = state.data as ExportResult | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
@ -907,8 +907,8 @@ export function ExportPanel() {
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export function CleanupPanel() {
|
export function CleanupPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadCleanupData, executeCleanupAction } = useGSDWorkspaceActions()
|
const { loadCleanupData, executeCleanupAction } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.remainingCommands.cleanup
|
const state = workspace.commandSurface.remainingCommands.cleanup
|
||||||
const data = state.data as CleanupData | null
|
const data = state.data as CleanupData | null
|
||||||
const busy = state.phase === "loading"
|
const busy = state.phase === "loading"
|
||||||
|
|
@ -1072,7 +1072,7 @@ function sliceProgress(slices: WorkspaceSliceTarget[]): { done: number; total: n
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueuePanel() {
|
export function QueuePanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const workspaceIndex = getLiveWorkspaceIndex(workspace)
|
const workspaceIndex = getLiveWorkspaceIndex(workspace)
|
||||||
const milestones = workspaceIndex?.milestones ?? []
|
const milestones = workspaceIndex?.milestones ?? []
|
||||||
const active = workspaceIndex?.active
|
const active = workspaceIndex?.active
|
||||||
|
|
@ -1175,7 +1175,7 @@ export function QueuePanel() {
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export function StatusPanel() {
|
export function StatusPanel() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const workspaceIndex = getLiveWorkspaceIndex(workspace)
|
const workspaceIndex = getLiveWorkspaceIndex(workspace)
|
||||||
const active = workspaceIndex?.active
|
const active = workspaceIndex?.active
|
||||||
const milestones = workspaceIndex?.milestones ?? []
|
const milestones = workspaceIndex?.milestones ?? []
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { CheckCircle2, Circle, Play, AlertTriangle, ChevronRight } from "lucide-react"
|
import { CheckCircle2, Circle, Play, AlertTriangle, ChevronRight } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
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"
|
import { getMilestoneStatus, getSliceStatus, type ItemStatus } from "@/lib/workspace-status"
|
||||||
|
|
||||||
const StatusIcon = ({
|
const StatusIcon = ({
|
||||||
|
|
@ -39,7 +39,7 @@ const RiskBadge = ({ risk }: { risk: RiskLevel }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Roadmap() {
|
export function Roadmap() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const liveWorkspace = getLiveWorkspaceIndex(workspace)
|
const liveWorkspace = getLiveWorkspaceIndex(workspace)
|
||||||
const milestones = liveWorkspace?.milestones ?? []
|
const milestones = liveWorkspace?.milestones ?? []
|
||||||
const activeScope = liveWorkspace?.active ?? {}
|
const activeScope = liveWorkspace?.active ?? {}
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
formatCost,
|
formatCost,
|
||||||
formatTokens,
|
formatTokens,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
|
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
|
||||||
import { useEditorFontSize } from "@/lib/use-editor-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() {
|
function useSettingsData() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { loadSettingsData } = useGSDWorkspaceActions()
|
const { loadSettingsData } = useSFWorkspaceActions()
|
||||||
const state = workspace.commandSurface.settingsData
|
const state = workspace.commandSurface.settingsData
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useTheme } from "next-themes"
|
||||||
import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react"
|
import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { validateImageFile } from "@/lib/image-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 { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
|
||||||
import { authFetch, appendAuthParam } from "@/lib/auth"
|
import { authFetch, appendAuthParam } from "@/lib/auth"
|
||||||
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
|
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
|
||||||
|
|
@ -34,7 +34,7 @@ interface ShellTerminalProps {
|
||||||
sessionPrefix?: string
|
sessionPrefix?: string
|
||||||
hideSidebar?: boolean
|
hideSidebar?: boolean
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
hideInitialGsdHeader?: boolean
|
hideInitialSfHeader?: boolean
|
||||||
projectCwd?: string
|
projectCwd?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,7 +113,7 @@ interface TerminalInstanceProps {
|
||||||
commandArgs?: string[]
|
commandArgs?: string[]
|
||||||
isDark: boolean
|
isDark: boolean
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
hideInitialGsdHeader?: boolean
|
hideInitialSfHeader?: boolean
|
||||||
projectCwd?: string
|
projectCwd?: string
|
||||||
onConnectionChange: (connected: boolean) => void
|
onConnectionChange: (connected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +125,7 @@ function TerminalInstance({
|
||||||
commandArgs,
|
commandArgs,
|
||||||
isDark,
|
isDark,
|
||||||
fontSize,
|
fontSize,
|
||||||
hideInitialGsdHeader = false,
|
hideInitialSfHeader = false,
|
||||||
projectCwd,
|
projectCwd,
|
||||||
onConnectionChange,
|
onConnectionChange,
|
||||||
}: TerminalInstanceProps) {
|
}: TerminalInstanceProps) {
|
||||||
|
|
@ -137,7 +137,7 @@ function TerminalInstance({
|
||||||
const flushingRef = useRef(false)
|
const flushingRef = useRef(false)
|
||||||
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const onConnectionChangeRef = useRef(onConnectionChange)
|
const onConnectionChangeRef = useRef(onConnectionChange)
|
||||||
const initialHeaderSettledRef = useRef(!hideInitialGsdHeader)
|
const initialHeaderSettledRef = useRef(!hideInitialSfHeader)
|
||||||
const initialHeaderBufferRef = useRef("")
|
const initialHeaderBufferRef = useRef("")
|
||||||
const commandArgsKey = (commandArgs ?? []).join("\u0000")
|
const commandArgsKey = (commandArgs ?? []).join("\u0000")
|
||||||
const [hasOutput, setHasOutput] = useState(false)
|
const [hasOutput, setHasOutput] = useState(false)
|
||||||
|
|
@ -188,9 +188,9 @@ function TerminalInstance({
|
||||||
}, [onConnectionChange])
|
}, [onConnectionChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialHeaderSettledRef.current = !hideInitialGsdHeader
|
initialHeaderSettledRef.current = !hideInitialSfHeader
|
||||||
initialHeaderBufferRef.current = ""
|
initialHeaderBufferRef.current = ""
|
||||||
}, [hideInitialGsdHeader, sessionId])
|
}, [hideInitialSfHeader, sessionId])
|
||||||
|
|
||||||
// Update xterm theme when isDark changes
|
// Update xterm theme when isDark changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -291,9 +291,9 @@ function TerminalInstance({
|
||||||
} else if (msg.type === "output" && msg.data) {
|
} else if (msg.type === "output" && msg.data) {
|
||||||
let output = msg.data
|
let output = msg.data
|
||||||
|
|
||||||
if (hideInitialGsdHeader && !initialHeaderSettledRef.current) {
|
if (hideInitialSfHeader && !initialHeaderSettledRef.current) {
|
||||||
initialHeaderBufferRef.current += output
|
initialHeaderBufferRef.current += output
|
||||||
const filtered = filterInitialGsdHeader(initialHeaderBufferRef.current)
|
const filtered = filterInitialSfHeader(initialHeaderBufferRef.current)
|
||||||
|
|
||||||
if (filtered.status === "needs-more") {
|
if (filtered.status === "needs-more") {
|
||||||
return
|
return
|
||||||
|
|
@ -341,7 +341,7 @@ function TerminalInstance({
|
||||||
termRef.current = null
|
termRef.current = null
|
||||||
fitAddonRef.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
|
// Focus on click
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -477,7 +477,7 @@ function deriveProjectScopedSessionId(
|
||||||
sessionPrefix?: string,
|
sessionPrefix?: string,
|
||||||
command?: string,
|
command?: string,
|
||||||
): string {
|
): string {
|
||||||
const base = sessionPrefix ?? (command ? "gsd-default" : "default")
|
const base = sessionPrefix ?? (command ? "sf-default" : "default")
|
||||||
if (!projectCwd) return base
|
if (!projectCwd) return base
|
||||||
return `${base}:${projectCwd}`
|
return `${base}:${projectCwd}`
|
||||||
}
|
}
|
||||||
|
|
@ -489,7 +489,7 @@ export function ShellTerminal({
|
||||||
sessionPrefix,
|
sessionPrefix,
|
||||||
hideSidebar = false,
|
hideSidebar = false,
|
||||||
fontSize,
|
fontSize,
|
||||||
hideInitialGsdHeader = false,
|
hideInitialSfHeader = false,
|
||||||
projectCwd,
|
projectCwd,
|
||||||
}: ShellTerminalProps) {
|
}: ShellTerminalProps) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
@ -662,7 +662,7 @@ export function ShellTerminal({
|
||||||
commandArgs={tab.id === defaultId ? commandArgs : undefined}
|
commandArgs={tab.id === defaultId ? commandArgs : undefined}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
hideInitialGsdHeader={hideInitialGsdHeader}
|
hideInitialSfHeader={hideInitialSfHeader}
|
||||||
projectCwd={projectCwd}
|
projectCwd={projectCwd}
|
||||||
onConnectionChange={(c) => updateConnection(tab.id, c)}
|
onConnectionChange={(c) => updateConnection(tab.id, c)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,8 @@ import {
|
||||||
getCurrentScopeLabel,
|
getCurrentScopeLabel,
|
||||||
getLiveWorkspaceIndex,
|
getLiveWorkspaceIndex,
|
||||||
getLiveAutoDashboard,
|
getLiveAutoDashboard,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
buildPromptCommand,
|
buildPromptCommand,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
import { getMilestoneStatus, getSliceStatus, getTaskStatus, type ItemStatus } from "@/lib/workspace-status"
|
import { getMilestoneStatus, getSliceStatus, getTaskStatus, type ItemStatus } from "@/lib/workspace-status"
|
||||||
|
|
@ -74,7 +74,7 @@ interface NavRailProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavRail({ activeView, onViewChange, isConnecting = false }: NavRailProps) {
|
export function NavRail({ activeView, onViewChange, isConnecting = false }: NavRailProps) {
|
||||||
const { openCommandSurface } = useGSDWorkspaceActions()
|
const { openCommandSurface } = useSFWorkspaceActions()
|
||||||
const manager = useProjectStoreManager()
|
const manager = useProjectStoreManager()
|
||||||
const activeProjectCwd = useSyncExternalStore(manager.subscribe, manager.getSnapshot, manager.getSnapshot)
|
const activeProjectCwd = useSyncExternalStore(manager.subscribe, manager.getSnapshot, manager.getSnapshot)
|
||||||
const [exitDialogOpen, setExitDialogOpen] = useState(false)
|
const [exitDialogOpen, setExitDialogOpen] = useState(false)
|
||||||
|
|
@ -314,8 +314,8 @@ function ExitDialog({
|
||||||
/* ─── Milestone Explorer (right sidebar) ─── */
|
/* ─── Milestone Explorer (right sidebar) ─── */
|
||||||
|
|
||||||
export function MilestoneExplorer({ isConnecting = false, width, onCollapse }: { isConnecting?: boolean; width?: number; onCollapse?: () => void }) {
|
export function MilestoneExplorer({ isConnecting = false, width, onCollapse }: { isConnecting?: boolean; width?: number; onCollapse?: () => void }) {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { openCommandSurface, setCommandSurfaceSection, sendCommand } = useGSDWorkspaceActions()
|
const { openCommandSurface, setCommandSurfaceSection, sendCommand } = useSFWorkspaceActions()
|
||||||
const [expandedMilestones, setExpandedMilestones] = useState<string[]>([])
|
const [expandedMilestones, setExpandedMilestones] = useState<string[]>([])
|
||||||
const [expandedSlices, setExpandedSlices] = useState<string[]>([])
|
const [expandedSlices, setExpandedSlices] = useState<string[]>([])
|
||||||
|
|
||||||
|
|
@ -334,7 +334,7 @@ export function MilestoneExplorer({ isConnecting = false, width, onCollapse }: {
|
||||||
const gsdPrefix = `${projectCwd}/.gsd/`
|
const gsdPrefix = `${projectCwd}/.gsd/`
|
||||||
if (!absolutePath.startsWith(gsdPrefix)) return
|
if (!absolutePath.startsWith(gsdPrefix)) return
|
||||||
const relativePath = absolutePath.slice(gsdPrefix.length)
|
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({
|
const workflowAction = deriveWorkflowAction({
|
||||||
|
|
@ -624,8 +624,8 @@ export function MilestoneExplorer({ isConnecting = false, width, onCollapse }: {
|
||||||
/* ─── Collapsed Milestone Sidebar (icon-only rail) ─── */
|
/* ─── Collapsed Milestone Sidebar (icon-only rail) ─── */
|
||||||
|
|
||||||
export function CollapsedMilestoneSidebar({ onExpand }: { onExpand: () => void }) {
|
export function CollapsedMilestoneSidebar({ onExpand }: { onExpand: () => void }) {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { sendCommand } = useGSDWorkspaceActions()
|
const { sendCommand } = useSFWorkspaceActions()
|
||||||
|
|
||||||
const liveWorkspace = getLiveWorkspaceIndex(workspace)
|
const liveWorkspace = getLiveWorkspaceIndex(workspace)
|
||||||
const milestones = liveWorkspace?.milestones ?? []
|
const milestones = liveWorkspace?.milestones ?? []
|
||||||
|
|
@ -715,7 +715,7 @@ export function Sidebar({ activeView, onViewChange, isConnecting = false, mobile
|
||||||
/* ─── Mobile Nav Panel (full-width labels for touch) ─── */
|
/* ─── Mobile Nav Panel (full-width labels for touch) ─── */
|
||||||
|
|
||||||
function MobileNavPanel({ activeView, onViewChange, isConnecting = false }: NavRailProps) {
|
function MobileNavPanel({ activeView, onViewChange, isConnecting = false }: NavRailProps) {
|
||||||
const { openCommandSurface } = useGSDWorkspaceActions()
|
const { openCommandSurface } = useSFWorkspaceActions()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
const cycleTheme = () => {
|
const cycleTheme = () => {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
getModelLabel,
|
getModelLabel,
|
||||||
getStatusPresentation,
|
getStatusPresentation,
|
||||||
getVisibleWorkspaceError,
|
getVisibleWorkspaceError,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
import {
|
import {
|
||||||
formatCost as formatProjectCost,
|
formatCost as formatProjectCost,
|
||||||
|
|
@ -38,7 +38,7 @@ function toneClass(tone: ReturnType<typeof getStatusPresentation>["tone"]): stri
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBar() {
|
export function StatusBar() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const status = getStatusPresentation(workspace)
|
const status = getStatusPresentation(workspace)
|
||||||
const liveWorkspace = getLiveWorkspaceIndex(workspace)
|
const liveWorkspace = getLiveWorkspaceIndex(workspace)
|
||||||
const auto = getLiveAutoDashboard(workspace)
|
const auto = getLiveAutoDashboard(workspace)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useTheme } from "next-themes"
|
||||||
import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react"
|
import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { validateImageFile } from "@/lib/image-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 { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
|
||||||
import { authFetch, appendAuthParam } from "@/lib/auth"
|
import { authFetch, appendAuthParam } from "@/lib/auth"
|
||||||
import "@xterm/xterm/css/xterm.css"
|
import "@xterm/xterm/css/xterm.css"
|
||||||
|
|
@ -33,7 +33,7 @@ interface ShellTerminalProps {
|
||||||
sessionPrefix?: string
|
sessionPrefix?: string
|
||||||
hideSidebar?: boolean
|
hideSidebar?: boolean
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
hideInitialGsdHeader?: boolean
|
hideInitialSfHeader?: boolean
|
||||||
projectCwd?: string
|
projectCwd?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,7 +184,7 @@ interface TerminalInstanceProps {
|
||||||
commandArgs?: string[]
|
commandArgs?: string[]
|
||||||
isDark: boolean
|
isDark: boolean
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
hideInitialGsdHeader?: boolean
|
hideInitialSfHeader?: boolean
|
||||||
projectCwd?: string
|
projectCwd?: string
|
||||||
onConnectionChange: (connected: boolean) => void
|
onConnectionChange: (connected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +196,7 @@ function TerminalInstance({
|
||||||
commandArgs,
|
commandArgs,
|
||||||
isDark,
|
isDark,
|
||||||
fontSize,
|
fontSize,
|
||||||
hideInitialGsdHeader = false,
|
hideInitialSfHeader = false,
|
||||||
projectCwd,
|
projectCwd,
|
||||||
onConnectionChange,
|
onConnectionChange,
|
||||||
}: TerminalInstanceProps) {
|
}: TerminalInstanceProps) {
|
||||||
|
|
@ -208,7 +208,7 @@ function TerminalInstance({
|
||||||
const flushingRef = useRef(false)
|
const flushingRef = useRef(false)
|
||||||
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const onConnectionChangeRef = useRef(onConnectionChange)
|
const onConnectionChangeRef = useRef(onConnectionChange)
|
||||||
const initialHeaderSettledRef = useRef(!hideInitialGsdHeader)
|
const initialHeaderSettledRef = useRef(!hideInitialSfHeader)
|
||||||
const initialHeaderBufferRef = useRef("")
|
const initialHeaderBufferRef = useRef("")
|
||||||
const commandArgsKey = (commandArgs ?? []).join("\u0000")
|
const commandArgsKey = (commandArgs ?? []).join("\u0000")
|
||||||
const [hasOutput, setHasOutput] = useState(false)
|
const [hasOutput, setHasOutput] = useState(false)
|
||||||
|
|
@ -259,9 +259,9 @@ function TerminalInstance({
|
||||||
}, [onConnectionChange])
|
}, [onConnectionChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialHeaderSettledRef.current = !hideInitialGsdHeader
|
initialHeaderSettledRef.current = !hideInitialSfHeader
|
||||||
initialHeaderBufferRef.current = ""
|
initialHeaderBufferRef.current = ""
|
||||||
}, [hideInitialGsdHeader, sessionId])
|
}, [hideInitialSfHeader, sessionId])
|
||||||
|
|
||||||
// Update xterm theme when isDark changes
|
// Update xterm theme when isDark changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -362,9 +362,9 @@ function TerminalInstance({
|
||||||
} else if (msg.type === "output" && msg.data) {
|
} else if (msg.type === "output" && msg.data) {
|
||||||
let output = msg.data
|
let output = msg.data
|
||||||
|
|
||||||
if (hideInitialGsdHeader && !initialHeaderSettledRef.current) {
|
if (hideInitialSfHeader && !initialHeaderSettledRef.current) {
|
||||||
initialHeaderBufferRef.current += output
|
initialHeaderBufferRef.current += output
|
||||||
const filtered = filterInitialGsdHeader(initialHeaderBufferRef.current)
|
const filtered = filterInitialSfHeader(initialHeaderBufferRef.current)
|
||||||
|
|
||||||
if (filtered.status === "needs-more") {
|
if (filtered.status === "needs-more") {
|
||||||
return
|
return
|
||||||
|
|
@ -412,7 +412,7 @@ function TerminalInstance({
|
||||||
termRef.current = null
|
termRef.current = null
|
||||||
fitAddonRef.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
|
// Focus on click
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -543,12 +543,12 @@ export function ShellTerminal({
|
||||||
sessionPrefix,
|
sessionPrefix,
|
||||||
hideSidebar = false,
|
hideSidebar = false,
|
||||||
fontSize,
|
fontSize,
|
||||||
hideInitialGsdHeader = false,
|
hideInitialSfHeader = false,
|
||||||
projectCwd,
|
projectCwd,
|
||||||
}: ShellTerminalProps) {
|
}: ShellTerminalProps) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const isDark = resolvedTheme !== "light"
|
const isDark = resolvedTheme !== "light"
|
||||||
const defaultId = sessionPrefix ?? (command ? "gsd-default" : "default")
|
const defaultId = sessionPrefix ?? (command ? "sf-default" : "default")
|
||||||
const commandLabel = deriveCommandLabel(command)
|
const commandLabel = deriveCommandLabel(command)
|
||||||
const [tabs, setTabs] = useState<TerminalTab[]>([
|
const [tabs, setTabs] = useState<TerminalTab[]>([
|
||||||
{ id: defaultId, label: commandLabel, connected: false },
|
{ id: defaultId, label: commandLabel, connected: false },
|
||||||
|
|
@ -703,7 +703,7 @@ export function ShellTerminal({
|
||||||
commandArgs={tab.id === defaultId ? commandArgs : undefined}
|
commandArgs={tab.id === defaultId ? commandArgs : undefined}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
hideInitialGsdHeader={hideInitialGsdHeader}
|
hideInitialSfHeader={hideInitialSfHeader}
|
||||||
projectCwd={projectCwd}
|
projectCwd={projectCwd}
|
||||||
onConnectionChange={(c) => updateConnection(tab.id, c)}
|
onConnectionChange={(c) => updateConnection(tab.id, c)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import {
|
||||||
getOnboardingPresentation,
|
getOnboardingPresentation,
|
||||||
getSessionLabelFromBridge,
|
getSessionLabelFromBridge,
|
||||||
getStatusPresentation,
|
getStatusPresentation,
|
||||||
useGSDWorkspaceActions,
|
useSFWorkspaceActions,
|
||||||
useGSDWorkspaceState,
|
useSFWorkspaceState,
|
||||||
} from "@/lib/sf-workspace-store"
|
} from "@/lib/sf-workspace-store"
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
|
|
@ -21,14 +21,14 @@ type WidgetPlacement = "aboveEditor" | "belowEditor"
|
||||||
|
|
||||||
const MAX_VISIBLE_WIDGET_LINES = 6
|
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
|
const session = state.boot?.bridge.sessionState
|
||||||
if (!session) return "prompt"
|
if (!session) return "prompt"
|
||||||
if (session.isStreaming) return "follow_up"
|
if (session.isStreaming) return "follow_up"
|
||||||
return "prompt"
|
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 === "loading") return "Loading workspace…"
|
||||||
if (state.bootStatus === "error") return "Workspace boot failed — check the visible error state"
|
if (state.bootStatus === "error") return "Workspace boot failed — check the visible error state"
|
||||||
if (state.commandInFlight) return `Sending ${state.commandInFlight}…`
|
if (state.commandInFlight) return `Sending ${state.commandInFlight}…`
|
||||||
|
|
@ -126,8 +126,8 @@ function TerminalWidgetBand({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Terminal({ className }: TerminalProps) {
|
export function Terminal({ className }: TerminalProps) {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const { submitInput, sendAbort, sendSteer, consumeEditorTextBuffer } = useGSDWorkspaceActions()
|
const { submitInput, sendAbort, sendSteer, consumeEditorTextBuffer } = useSFWorkspaceActions()
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [steerMode, setSteerMode] = useState(false)
|
const [steerMode, setSteerMode] = useState(false)
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useGSDWorkspaceState, buildProjectUrl } from "@/lib/sf-workspace-store"
|
import { useSFWorkspaceState, buildProjectUrl } from "@/lib/sf-workspace-store"
|
||||||
import type {
|
import type {
|
||||||
VisualizerData,
|
VisualizerData,
|
||||||
VisualizerSlice,
|
VisualizerSlice,
|
||||||
|
|
@ -1163,7 +1163,7 @@ function VisualizerTabList() {
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function VisualizerView() {
|
export function VisualizerView() {
|
||||||
const workspace = useGSDWorkspaceState()
|
const workspace = useSFWorkspaceState()
|
||||||
const projectCwd = workspace.boot?.project.cwd
|
const projectCwd = workspace.boot?.project.cwd
|
||||||
const [data, setData] = useState<VisualizerData | null>(null)
|
const [data, setData] = useState<VisualizerData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -106,7 +106,7 @@ function isLogoLine(line: string | undefined): boolean {
|
||||||
* PTY pane often does. This filter removes only the initial branded banner from
|
* 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.
|
* 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)
|
const { plainText, rawOffsetsByPlainIndex } = indexVisibleText(raw)
|
||||||
if (!plainText) {
|
if (!plainText) {
|
||||||
return { status: 'needs-more', text: '' }
|
return { status: 'needs-more', text: '' }
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
|
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
|
* stores with SSE lifecycle management. Only the active project's store keeps its
|
||||||
* SSE connection open — background stores are disconnected to save resources.
|
* 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.
|
* reactively read the active project path.
|
||||||
*/
|
*/
|
||||||
export class ProjectStoreManager {
|
export class ProjectStoreManager {
|
||||||
private stores = new Map<string, GSDWorkspaceStore>()
|
private stores = new Map<string, SFWorkspaceStore>()
|
||||||
private activeProjectCwd: string | null = null
|
private activeProjectCwd: string | null = null
|
||||||
private listeners = new Set<() => void>()
|
private listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ export class ProjectStoreManager {
|
||||||
|
|
||||||
// ─── Public API ──────────────────────────────────────────────────────────
|
// ─── Public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
getActiveStore(): GSDWorkspaceStore | null {
|
getActiveStore(): SFWorkspaceStore | null {
|
||||||
if (!this.activeProjectCwd) return null
|
if (!this.activeProjectCwd) return null
|
||||||
return this.stores.get(this.activeProjectCwd) ?? 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,
|
* 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.
|
* 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
|
// Disconnect SSE on current active store
|
||||||
if (this.activeProjectCwd && this.activeProjectCwd !== projectCwd) {
|
if (this.activeProjectCwd && this.activeProjectCwd !== projectCwd) {
|
||||||
const prev = this.stores.get(this.activeProjectCwd)
|
const prev = this.stores.get(this.activeProjectCwd)
|
||||||
|
|
@ -50,7 +50,7 @@ export class ProjectStoreManager {
|
||||||
// Get or create store for new project
|
// Get or create store for new project
|
||||||
let store = this.stores.get(projectCwd)
|
let store = this.stores.get(projectCwd)
|
||||||
if (!store) {
|
if (!store) {
|
||||||
store = new GSDWorkspaceStore(projectCwd)
|
store = new SFWorkspaceStore(projectCwd)
|
||||||
this.stores.set(projectCwd, store)
|
this.stores.set(projectCwd, store)
|
||||||
store.start()
|
store.start()
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -4118,7 +4118,7 @@ export class SFWorkspaceStore {
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("gsd:navigate-view", { detail: { view: outcome.view } }),
|
new CustomEvent("sf:navigate-view", { detail: { view: outcome.view } }),
|
||||||
)
|
)
|
||||||
return outcome
|
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 }) {
|
export function SFWorkspaceProvider({ children, store: externalStore }: { children: ReactNode; store?: SFWorkspaceStore }) {
|
||||||
const [internalStore] = useState(() => new GSDWorkspaceStore())
|
const [internalStore] = useState(() => new SFWorkspaceStore())
|
||||||
const store = externalStore ?? internalStore
|
const store = externalStore ?? internalStore
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -5244,21 +5244,21 @@ export function GSDWorkspaceProvider({ children, store: externalStore }: { child
|
||||||
return <WorkspaceStoreContext.Provider value={store}>{children}</WorkspaceStoreContext.Provider>
|
return <WorkspaceStoreContext.Provider value={store}>{children}</WorkspaceStoreContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function useWorkspaceStore(): GSDWorkspaceStore {
|
function useWorkspaceStore(): SFWorkspaceStore {
|
||||||
const store = useContext(WorkspaceStoreContext)
|
const store = useContext(WorkspaceStoreContext)
|
||||||
if (!store) {
|
if (!store) {
|
||||||
throw new Error("useWorkspaceStore must be used within GSDWorkspaceProvider")
|
throw new Error("useWorkspaceStore must be used within SFWorkspaceProvider")
|
||||||
}
|
}
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGSDWorkspaceState(): WorkspaceStoreState {
|
export function useSFWorkspaceState(): WorkspaceStoreState {
|
||||||
const store = useWorkspaceStore()
|
const store = useWorkspaceStore()
|
||||||
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGSDWorkspaceActions(): Pick<
|
export function useSFWorkspaceActions(): Pick<
|
||||||
GSDWorkspaceStore,
|
SFWorkspaceStore,
|
||||||
| "sendCommand"
|
| "sendCommand"
|
||||||
| "submitInput"
|
| "submitInput"
|
||||||
| "clearTerminalLines"
|
| "clearTerminalLines"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type { WorkspaceTerminalLine } from "./gsd-workspace-store"
|
import type { WorkspaceTerminalLine } from "./sf-workspace-store"
|
||||||
import { getUserMode } from "./use-user-mode"
|
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
|
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)
|
console.error("[workflow-action] dispatch failed:", error)
|
||||||
})
|
})
|
||||||
const mode = getUserMode()
|
const mode = getUserMode()
|
||||||
navigateToGSDView(mode === "vibe-coder" ? "chat" : "power")
|
navigateToSFView(mode === "vibe-coder" ? "chat" : "power")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function derivePendingWorkflowCommandLabel({
|
export function derivePendingWorkflowCommandLabel({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue