diff --git a/packages/native/package.json b/packages/native/package.json
index 117e000be..2ba8257ba 100644
--- a/packages/native/package.json
+++ b/packages/native/package.json
@@ -60,9 +60,9 @@
"types": "./dist/diff/index.d.ts",
"default": "./dist/diff/index.js"
},
- "./gsd-parser": {
- "types": "./dist/gsd-parser/index.d.ts",
- "default": "./dist/gsd-parser/index.js"
+ "./forge-parser": {
+ "types": "./dist/forge-parser/index.d.ts",
+ "default": "./dist/forge-parser/index.js"
},
"./highlight": {
"types": "./dist/highlight/index.d.ts",
diff --git a/packages/native/src/gsd-parser/index.ts b/packages/native/src/forge-parser/index.ts
similarity index 100%
rename from packages/native/src/gsd-parser/index.ts
rename to packages/native/src/forge-parser/index.ts
diff --git a/packages/native/src/gsd-parser/types.ts b/packages/native/src/forge-parser/types.ts
similarity index 100%
rename from packages/native/src/gsd-parser/types.ts
rename to packages/native/src/forge-parser/types.ts
diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts
index 70b8aadd7..5e58fdd2d 100644
--- a/packages/native/src/index.ts
+++ b/packages/native/src/index.ts
@@ -113,7 +113,7 @@ export {
extractAllSections,
batchParseGsdFiles,
parseRoadmapFile,
-} from "./gsd-parser/index.js";
+} from "./forge-parser/index.js";
export type {
BatchParseResult,
FrontmatterResult,
@@ -122,7 +122,7 @@ export type {
NativeRoadmapSlice,
ParsedGsdFile,
SectionResult,
-} from "./gsd-parser/index.js";
+} from "./forge-parser/index.js";
export { truncateTail, truncateHead, truncateOutput } from "./truncate/index.js";
export type { TruncateResult, TruncateOutputResult } from "./truncate/index.js";
diff --git a/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts b/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts
index 169e6dc83..e8b8461c6 100644
--- a/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts
+++ b/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
-test("shared/mod.ts has no import from "@sf-run/pi-tui", () => {
+test('shared/mod.ts has no import from "@sf-run/pi-tui"', () => {
const src = readFileSync(join(__dirname, "../../shared/mod.ts"), "utf-8");
- assert.ok(!src.includes("@sf-run/pi-tui"), "mod.ts must not import "@sf-run/pi-tui");
+ assert.ok(!src.includes("@sf-run/pi-tui"), 'mod.ts must not import "@sf-run/pi-tui"');
});
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 9923adfd9..e15812f96 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -2,8 +2,8 @@
import dynamic from "next/dynamic"
-const GSDAppShell = dynamic(
- () => import("@/components/gsd/app-shell").then((mod) => mod.GSDAppShell),
+const SFAppShell = dynamic(
+ () => import("@/components/sf/app-shell").then((mod) => mod.SFAppShell),
{
ssr: false,
loading: () => (
@@ -15,5 +15,5 @@ const GSDAppShell = dynamic(
)
export default function Page() {
- return
- Execution history and git operations -
-{line.content}
-
- This workspace requires an auth token. Copy the full URL from your terminal
- (including the{" "}
- #token=…{" "}
- part) or restart with{" "}
- gsd --web.
-
- {children}
-
- )
- }
-
- return (
-
- {children}
-
- )
- },
- pre({ children }: { children?: React.ReactNode }) {
- return <>{children}>
- },
- table({ children }: { children?: React.ReactNode }) {
- return (
- {children}- }, - hr() { - return
{children}
- }, - img({ alt, src }: { alt?: string; src?: string }) { - return ( - - 🖼 {alt || src || "image"} - - ) - }, - }) - - setRendered( -- {prompt.label} -
- )} - {prompt.options.map((option, i) => { - const isSelected = i === localIndex - const description = prompt.descriptions?.[i] - return ( - - ) - })} -- {prompt.label} -
- )} -- {prompt.label} -
- )} -- Value is transmitted securely and not stored in chat history. -
-{action.label}
-- {isDisabled ? "Disabled while auto-mode is running" : action.description} -
-- {group.label} -
- {group.items.map((action) => { - const Icon = action.icon - const isDisabled = action.disabledDuringAuto && autoActive - return ( -{action.label}
-- {isDisabled ? "Disabled while auto-mode is running" : action.description} -
-Chat Mode
- {showSpinner ? ( -- Running {runningLabel}… -
- ) : notice ? ( -{notice}
- ) : !connected ? ( -- Connecting to GSD session… -
- ) : primaryAction && onPrimaryAction ? ( -- Connected — waiting for GSD output… -
- )} -{request.title}
- )} - {request.method === "select" && ( -{request.message}
-{description}
} -No models matched.
- )} - - {/* Apply */} -How steering messages queue during streaming
-{settingsRequests.steeringMode.error}
- )} -How follow-up prompts sequence during a live turn
-{settingsRequests.followUpMode.error}
- )} -{settingsRequests.autoCompaction.error}
- )} - {settingsRequests.autoCompaction.result && ( -{settingsRequests.autoCompaction.result}
- )} -- {autoRetryBusy - ? "Updating auto-retry…" - : settingsRequests.autoRetry.error - ? settingsRequests.autoRetry.error - : settingsRequests.autoRetry.result - ? settingsRequests.autoRetry.result - : liveSessionState?.autoRetryEnabled - ? "Auto-retry enabled" - : "Auto-retry disabled"} -
- - {liveSessionState?.retryInProgress && ( -Attempt {Math.max(1, liveSessionState.retryAttempt)} is active
-{settingsRequests.autoRetry.error}
} -- {abortRetryBusy - ? "Aborting retry…" - : settingsRequests.abortRetry.error - ? settingsRequests.abortRetry.error - : settingsRequests.abortRetry.result - ? settingsRequests.abortRetry.result - : liveSessionState?.retryInProgress - ? "Retry can be aborted" - : "No retry in progress"} -
- {settingsRequests.abortRetry.error &&{settingsRequests.abortRetry.error}
} -{diag.summary.detail}
-{diag.summary.detail}
-{diag.bridge.lastFailure.message}
-{issue.message}
- {issue.suggestion &&→ {issue.suggestion}
} -{issue.message}
-Available: yes
-Detected: yes
-{diag.interruptedRun.detail}
-{command.label}
-{result.message}
-- +{result.truncatedFileCount} more files not shown -
- )} -No sessions matched.
- )} - - {sessionBrowser.loaded && ( -- Current-project sessions · {sessionBrowser.returnedSessions} of {sessionBrowser.totalSessions} · {sessionBrowser.sortMode} · {sessionBrowser.nameFilter} -
- )} - - {/* Rename controls */} - {renameMode && ( -{commandSurface.renameRequest.error}
} - {commandSurface.renameRequest.result &&{commandSurface.renameRequest.result}
} -No fork points available yet.
- )} - -Refresh to load session stats.
- )} - - {/* Export */} -{commandSurface.lastCompaction.summary}
-First kept: {commandSurface.lastCompaction.firstKeptEntryId}
-{activeFlow.auth.instructions}
- )} - - {activeFlow.auth?.url && ( - - )} - - {activeFlow.progress.length > 0 && ( -{entry.description}
-- Opens the full onboarding flow as a new user would see it. -
-npm run gsd:web.
- Overrides reset on page refresh.
- /gsd {commandSurface.section.slice(4)}
-Unknown GSD surface.
-- {label} -
- {value === null ? ( - <> -{value}
- {subtext &&{subtext}
} - > - )} -Current Unit
- {isConnecting ? ( - <> -- Auto freshness: {currentUnitFreshness} -
- > - )} -- {currentSlice.id} — {currentSlice.title} -
- ) : ( -No active slice
- )} -{doneTasks} of {totalTasks} tasks complete
-- No active slice or no tasks defined yet. -
- )} -{anomaly.summary}
- {anomaly.details && anomaly.details !== anomaly.summary && ( -{anomaly.details}
- )} -| Type | -ID | -Model | -Cost | -Duration | -
|---|---|---|---|---|
| {u.type} | -{u.id} | -{u.model} | -{formatCost(u.cost)} | -{Math.round(u.duration / 1000)}s | -
{issue.message}
- {issue.file &&{issue.file}
} -{suggestion.message}
-| Skill | -Uses | -Success | -Tokens | -Trend | -Stale | -Cost | -
|---|---|---|---|---|---|---|
|
-
- {skill.name}
- {skill.flagged && |
- {skill.totalUses} | -= 0.9 ? "text-success" : skill.successRate >= 0.7 ? "text-warning" : "text-destructive", - )}> - {(skill.successRate * 100).toFixed(0)}% - | -{Math.round(skill.avgTokens)} | -- {trendArrow(skill.tokenTrend)} - | -30 ? "text-warning" : "text-foreground/80", - )}> - {skill.staleDays > 0 ? `${skill.staleDays}d` : "—"} - | -{formatCost(skill.avgCost)} | -
| - {i + 1} - | -{line || " "} | -
- {children}
-
- )
- }
-
- return (
-
- {children}
-
- )
- },
- pre({ children }) {
- // Unwrap since code blocks handle their own wrapper
- return <>{children}>
- },
- table({ children }) {
- return (
-
-
- {children}
-
-
- )
- },
- th({ children }) {
- return (
-
- {children}
-
- )
- },
- td({ children }) {
- return (
- {children}
- )
- },
- a({ href, children }) {
- return (
-
- {children}
-
- )
- },
- img({ src, alt }) {
- return (
-
- 🖼 {alt || (typeof src === "string" ? src : "") || "image"}
-
- )
- },
- }}
- >
- {content}
- ,
- )
- setReady(true)
- }).catch(() => {
- if (!cancelled) setReady(true)
- })
-
- return () => { cancelled = true }
- }, [content, filepath, shikiTheme])
-
- if (!ready) {
- return (
-
-
- Rendering…
-
- )
- }
-
- if (!rendered) {
- return
- }
-
- return {rendered}
-}
-
-/* ── 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()
- 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 (
-
-
-
- {lines.map((line, i) => (
-
-
- {line.type === "add" ? (
- +
- ) : line.type === "remove" ? (
- −
- ) : null}
-
-
- {line.lineNum ?? ""}
-
-
- {line.text || " "}
-
-
- ))}
-
-
-
- )
-}
-
-/* ── 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 (
-
- {isMarkdown(filepath) ? (
-
- ) : (
-
- )}
-
- )
-}
-
-/* ── 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
- /** 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(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 (
-
-
-
- )
- }
-
- // ── Diff overlay mode: agent just edited this file ──
- if (diff) {
- return (
-
-
- {filepath}
-
- Changed
-
-
-
-
-
-
-
- )
- }
-
- // ── Editable mode: markdown keeps View/Edit tabs ──
- if (isMarkdown(filepath)) {
- return (
-
-
- {filepath}
-
-
- View
-
-
- Edit
-
-
-
- {/* Save button */}
-
- {saveError && (
-
- {saveError}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
-
- // ── Editable mode: non-markdown gets single CodeEditor view ──
- return (
-
- {/* Header bar with filepath and save button */}
-
- {filepath}
-
- {saveError && (
-
- {saveError}
-
- )}
-
-
-
- {/* CodeEditor fills remaining space */}
-
-
- )
-}
diff --git a/web/components/gsd/files-view.tsx b/web/components/gsd/files-view.tsx
deleted file mode 100644
index b1cd2af7a..000000000
--- a/web/components/gsd/files-view.tsx
+++ /dev/null
@@ -1,1400 +0,0 @@
-"use client"
-
-import { useState, useEffect, useCallback, useRef, useMemo } from "react"
-import {
- FileText,
- ChevronRight,
- ChevronDown,
- Folder,
- FolderOpen,
- FileCode,
- File,
- Loader2,
- AlertCircle,
- X,
- FilePlus,
- FolderPlus,
- Pencil,
- Trash2,
- Copy,
- ClipboardCopy,
- Bot,
-} from "lucide-react"
-import { cn } from "@/lib/utils"
-import { useGSDWorkspaceState, buildProjectUrl } from "@/lib/gsd-workspace-store"
-import { authFetch } from "@/lib/auth"
-import { FileContentViewer } from "@/components/gsd/file-content-viewer"
-import { ChatPane } from "@/components/gsd/chat-mode"
-
-type RootMode = "gsd" | "project"
-
-// Global pending file request — survives across component mount/unmount cycles.
-// Set by the custom event, consumed by FilesView on mount or when already mounted.
-let pendingFileRequest: { root: RootMode; path: string } | null = null
-
-// Set up the global event listener once (module-level, not component-level)
-if (typeof window !== "undefined") {
- window.addEventListener("gsd:open-file", (e: Event) => {
- const detail = (e as CustomEvent<{ root: RootMode; path: string }>).detail
- if (detail?.root && detail?.path) {
- pendingFileRequest = { root: detail.root, path: detail.path }
- }
- })
-}
-
-interface FileNode {
- name: string
- type: "file" | "directory"
- children?: FileNode[]
-}
-
-/* ── Persistence helpers ── */
-
-function storageKey(projectCwd: string, root: RootMode): string {
- return `gsd-files-expanded:${root}:${projectCwd}`
-}
-
-function loadExpanded(projectCwd: string | undefined, root: RootMode): Set {
- if (!projectCwd) return new Set()
- try {
- const raw = sessionStorage.getItem(storageKey(projectCwd, root))
- if (raw) return new Set(JSON.parse(raw) as string[])
- } catch { /* ignore */ }
- return new Set()
-}
-
-function saveExpanded(projectCwd: string | undefined, root: RootMode, expanded: Set): void {
- if (!projectCwd) return
- try {
- sessionStorage.setItem(storageKey(projectCwd, root), JSON.stringify([...expanded]))
- } catch { /* ignore */ }
-}
-
-/* ── Icons ── */
-
-function FileIcon({ name, isFolder, isOpen }: { name: string; isFolder: boolean; isOpen?: boolean }) {
- if (isFolder) {
- return isOpen ? (
-
- ) : (
-
- )
- }
- if (name.endsWith(".md")) {
- return
- }
- if (name.endsWith(".json") || name.endsWith(".ts") || name.endsWith(".tsx") || name.endsWith(".js") || name.endsWith(".jsx")) {
- return
- }
- return
-}
-
-/* ── Context menu ── */
-
-interface ContextMenuState {
- x: number
- y: number
- path: string
- type: "file" | "directory"
- /** parent directory path (empty string = root) */
- parentPath: string
-}
-
-interface ContextMenuProps {
- menu: ContextMenuState
- onClose: () => void
- onNewFile: (parentDir: string) => void
- onNewFolder: (parentDir: string) => void
- onRename: (path: string) => void
- onDelete: (path: string, type: "file" | "directory") => void
- onCopyPath: (path: string) => void
- onDuplicate: (path: string) => void
-}
-
-function TreeContextMenu({ menu, onClose, onNewFile, onNewFolder, onRename, onDelete, onCopyPath, onDuplicate }: ContextMenuProps) {
- const menuRef = useRef(null)
-
- // Close on click outside or escape
- useEffect(() => {
- const handleClick = (e: MouseEvent) => {
- if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
- onClose()
- }
- }
- const handleKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose()
- }
- document.addEventListener("mousedown", handleClick)
- document.addEventListener("keydown", handleKey)
- return () => {
- document.removeEventListener("mousedown", handleClick)
- document.removeEventListener("keydown", handleKey)
- }
- }, [onClose])
-
- // Keep menu within viewport
- const [pos, setPos] = useState({ x: menu.x, y: menu.y })
- useEffect(() => {
- if (!menuRef.current) return
- const rect = menuRef.current.getBoundingClientRect()
- let { x, y } = menu
- if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 8
- if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 8
- if (x < 0) x = 8
- if (y < 0) y = 8
- setPos({ x, y })
- }, [menu])
-
- const parentDir = menu.type === "directory" ? menu.path : menu.parentPath
-
- const items: { label: string; icon: React.ReactNode; action: () => void; destructive?: boolean; separator?: boolean }[] = [
- {
- label: "New File",
- icon: ,
- action: () => { onNewFile(parentDir); onClose() },
- },
- {
- label: "New Folder",
- icon: ,
- action: () => { onNewFolder(parentDir); onClose() },
- },
- {
- label: "Rename",
- icon: ,
- action: () => { onRename(menu.path); onClose() },
- separator: true,
- },
- {
- label: "Duplicate",
- icon: ,
- action: () => { onDuplicate(menu.path); onClose() },
- },
- {
- label: "Copy Path",
- icon: ,
- action: () => { onCopyPath(menu.path); onClose() },
- separator: true,
- },
- {
- label: "Delete",
- icon: ,
- action: () => { onDelete(menu.path, menu.type); onClose() },
- destructive: true,
- },
- ]
-
- return (
-
- {items.map((item, i) => (
-
- {item.separator && i > 0 && }
-
-
- ))}
-
- )
-}
-
-/* ── Inline input (for rename / new file / new folder) ── */
-
-function InlineInput({
- defaultValue,
- onCommit,
- onCancel,
- depth,
- icon,
-}: {
- defaultValue: string
- onCommit: (value: string) => void
- onCancel: () => void
- depth: number
- icon: React.ReactNode
-}) {
- const inputRef = useRef(null)
-
- useEffect(() => {
- // Focus and select just the filename (not extension) on mount
- const input = inputRef.current
- if (!input) return
- input.focus()
- const dotIndex = defaultValue.lastIndexOf(".")
- if (dotIndex > 0) {
- input.setSelectionRange(0, dotIndex)
- } else {
- input.select()
- }
- }, [defaultValue])
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- e.preventDefault()
- const val = inputRef.current?.value.trim()
- if (val && val.length > 0) onCommit(val)
- else onCancel()
- }
- if (e.key === "Escape") {
- e.preventDefault()
- onCancel()
- }
- }
-
- return (
-
- {icon}
- {
- const val = inputRef.current?.value.trim()
- if (val && val.length > 0) onCommit(val)
- else onCancel()
- }}
- className="flex-1 bg-transparent text-sm outline-none border border-ring rounded px-1 py-0.5 text-foreground"
- spellCheck={false}
- />
-
- )
-}
-
-/* ── Tree item ── */
-
-interface FileTreeItemProps {
- node: FileNode
- depth: number
- parentPath: string
- selectedPath: string | null
- expandedPaths: Set
- renamingPath: string | null
- creatingIn: { parentDir: string; type: "file" | "directory" } | null
- onToggleDir: (path: string) => void
- onSelectFile: (path: string) => void
- onMoveFile: (fromPath: string, toDir: string) => void
- onContextMenu: (e: React.MouseEvent, path: string, type: "file" | "directory", parentPath: string) => void
- onRenameCommit: (oldPath: string, newName: string) => void
- onRenameCancel: () => void
- onCreateCommit: (parentDir: string, name: string, type: "file" | "directory") => void
- onCreateCancel: () => void
-}
-
-function FileTreeItem({
- node, depth, parentPath, selectedPath, expandedPaths,
- renamingPath, creatingIn,
- onToggleDir, onSelectFile, onMoveFile,
- onContextMenu, onRenameCommit, onRenameCancel,
- onCreateCommit, onCreateCancel,
-}: FileTreeItemProps) {
- const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name
- const isOpen = node.type === "directory" && expandedPaths.has(fullPath)
- const [dragOver, setDragOver] = useState(false)
- const isRenaming = renamingPath === fullPath
-
- // Should we show the "create new" input inside this directory?
- const showCreateInput = creatingIn && creatingIn.parentDir === fullPath && node.type === "directory" && isOpen
-
- const handleClick = () => {
- if (node.type === "directory") {
- onToggleDir(fullPath)
- } else {
- onSelectFile(fullPath)
- }
- }
-
- const handleContextMenu = (e: React.MouseEvent) => {
- e.preventDefault()
- e.stopPropagation()
- onContextMenu(e, fullPath, node.type, parentPath)
- }
-
- // ── Drag source ──
- const handleDragStart = (e: React.DragEvent) => {
- e.dataTransfer.setData("text/x-tree-path", fullPath)
- e.dataTransfer.effectAllowed = "move"
- }
-
- // ── Drop target (directories only) ──
- const handleDragOver = (e: React.DragEvent) => {
- if (node.type !== "directory") return
- const srcPath = e.dataTransfer.types.includes("text/x-tree-path") ? "pending" : null
- if (!srcPath) return
- e.preventDefault()
- e.dataTransfer.dropEffect = "move"
- setDragOver(true)
- }
-
- const handleDragLeave = () => {
- setDragOver(false)
- }
-
- const handleDrop = (e: React.DragEvent) => {
- setDragOver(false)
- if (node.type !== "directory") return
- e.preventDefault()
- const srcPath = e.dataTransfer.getData("text/x-tree-path")
- if (!srcPath || srcPath === fullPath) return
- if (fullPath.startsWith(srcPath + "/")) return
- const srcParent = srcPath.includes("/") ? srcPath.substring(0, srcPath.lastIndexOf("/")) : ""
- if (srcParent === fullPath) return
- onMoveFile(srcPath, fullPath)
- }
-
- // Inline rename mode
- if (isRenaming) {
- return (
-
- onRenameCommit(fullPath, newName)}
- onCancel={onRenameCancel}
- depth={depth}
- icon={ }
- />
-
- )
- }
-
- return (
-
-
- {isOpen && node.children && (
-
- {/* Create new item input at the top of the directory */}
- {showCreateInput && (
- onCreateCommit(fullPath, name, creatingIn!.type)}
- onCancel={onCreateCancel}
- depth={depth + 1}
- icon={creatingIn!.type === "directory"
- ?
- :
- }
- />
- )}
- {node.children.map((child, i) => (
-
- ))}
-
- )}
-
- )
-}
-
-/* ── Open tab model ── */
-
-interface OpenTab {
- /** Unique key: "root:path" */
- key: string
- root: RootMode
- path: string
- content: string | null
- loading: boolean
- error: string | null
- /** When set, the viewer shows an inline diff overlay */
- diff?: { before: string; after: string } | null
- /** Set when the agent just opened/edited this file — causes MD files to default to Edit tab */
- agentOpened?: boolean
-}
-
-function tabKey(root: RootMode, path: string): string {
- return `${root}:${path}`
-}
-
-function tabDisplayPath(tab: OpenTab): string {
- return tab.root === "gsd" ? `.gsd/${tab.path}` : tab.path
-}
-
-function tabLabel(tab: OpenTab): string {
- return tab.path.split("/").pop() ?? tab.path
-}
-
-/* ── Main view ── */
-
-type LeftPanel = "tree" | "agent"
-
-export function FilesView() {
- const workspace = useGSDWorkspaceState()
- const projectCwd = workspace.boot?.project.cwd
-
- const [activeRoot, setActiveRoot] = useState("gsd")
- const [leftPanel, setLeftPanel] = useState("tree")
- const [gsdTree, setGsdTree] = useState(null)
- const [projectTree, setProjectTree] = useState(null)
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(null)
-
- // ── Resizable tree panel ──
- const [treeWidth, setTreeWidth] = useState(256)
- const isDraggingTree = useRef(false)
- const dragStartX = useRef(0)
- const dragStartWidth = useRef(0)
-
- useEffect(() => {
- const handleMouseMove = (e: MouseEvent) => {
- if (!isDraggingTree.current) return
- const delta = e.clientX - dragStartX.current
- const newWidth = Math.max(180, Math.min(480, dragStartWidth.current + delta))
- setTreeWidth(newWidth)
- }
- const handleMouseUp = () => {
- if (isDraggingTree.current) {
- isDraggingTree.current = 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 handleTreeDragStart = useCallback(
- (e: React.MouseEvent) => {
- isDraggingTree.current = true
- dragStartX.current = e.clientX
- dragStartWidth.current = treeWidth
- document.body.style.cursor = "col-resize"
- document.body.style.userSelect = "none"
- },
- [treeWidth],
- )
-
- // Expanded paths per root, restored from sessionStorage
- const [gsdExpanded, setGsdExpanded] = useState>(() => loadExpanded(projectCwd, "gsd"))
- const [projectExpanded, setProjectExpanded] = useState>(() => loadExpanded(projectCwd, "project"))
-
- // Re-hydrate from storage once projectCwd is available (boot may arrive after first render)
- const hydratedRef = useRef(false)
- useEffect(() => {
- if (!projectCwd || hydratedRef.current) return
- hydratedRef.current = true
- setGsdExpanded(loadExpanded(projectCwd, "gsd"))
- setProjectExpanded(loadExpanded(projectCwd, "project"))
- }, [projectCwd])
-
- const expandedPaths = activeRoot === "gsd" ? gsdExpanded : projectExpanded
- const setExpandedPaths = activeRoot === "gsd" ? setGsdExpanded : setProjectExpanded
-
- // ── Multi-tab state ──
- const [openTabs, setOpenTabs] = useState([])
- const [activeTabKey, setActiveTabKey] = useState(null)
- const [treeRootDragOver, setTreeRootDragOver] = useState(false)
-
- // ── Context menu state ──
- const [contextMenu, setContextMenu] = useState(null)
- const [renamingPath, setRenamingPath] = useState(null)
- const [creatingIn, setCreatingIn] = useState<{ parentDir: string; type: "file" | "directory" } | null>(null)
- const [deleteConfirm, setDeleteConfirm] = useState<{ path: string; type: "file" | "directory" } | null>(null)
-
- const activeTab = openTabs.find((t) => t.key === activeTabKey) ?? null
-
- // The selected path in the tree corresponds to the active tab
- const selectedPath = activeTab?.path ?? null
-
- const tree = activeRoot === "gsd" ? gsdTree : projectTree
- const treeLoaded = activeRoot === "gsd" ? gsdTree !== null : projectTree !== null
-
- const fetchTree = useCallback(async (root: RootMode) => {
- try {
- setLoading(true)
- setError(null)
- const res = await authFetch(buildProjectUrl(`/api/files?root=${root}`, projectCwd))
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.error || `Failed to fetch files (${res.status})`)
- }
- const data = await res.json()
- const nodes = data.tree ?? []
- if (root === "gsd") {
- setGsdTree(nodes)
- } else {
- setProjectTree(nodes)
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to fetch files")
- } finally {
- setLoading(false)
- }
- }, [projectCwd])
-
- // Fetch tree when tab changes and data isn't cached
- useEffect(() => {
- if (!treeLoaded) {
- fetchTree(activeRoot)
- }
- }, [activeRoot, treeLoaded, fetchTree])
-
- // Initial load
- useEffect(() => {
- fetchTree("gsd")
- }, [fetchTree])
-
- // ── Open or focus a file tab and fetch its content ──
- const openFileTab = useCallback(async (root: RootMode, path: string) => {
- const key = tabKey(root, path)
-
- // If already open, just focus it
- setOpenTabs((prev) => {
- const existing = prev.find((t) => t.key === key)
- if (existing) return prev
- // Add new tab
- return [...prev, { key, root, path, content: null, loading: true, error: null }]
- })
- setActiveTabKey(key)
-
- // Switch tree root to match
- setActiveRoot(root)
-
- // Auto-expand parent dirs
- const parts = path.split("/")
- const setExpanded = root === "gsd" ? setGsdExpanded : setProjectExpanded
- setExpanded((prev) => {
- const next = new Set(prev)
- for (let i = 1; i < parts.length; i++) {
- next.add(parts.slice(0, i).join("/"))
- }
- saveExpanded(projectCwd, root, next)
- return next
- })
-
- // Check if we already have the content cached
- setOpenTabs((prev) => {
- const existing = prev.find((t) => t.key === key)
- if (existing && existing.content !== null) return prev // already loaded
- return prev // will fetch below
- })
-
- // Fetch content
- try {
- const res = await authFetch(buildProjectUrl(`/api/files?root=${root}&path=${encodeURIComponent(path)}`, projectCwd))
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- const errMsg = data.error || `Failed to fetch file (${res.status})`
- setOpenTabs((prev) =>
- prev.map((t) => (t.key === key ? { ...t, loading: false, error: errMsg } : t)),
- )
- return
- }
- const data = await res.json()
- setOpenTabs((prev) =>
- prev.map((t) =>
- t.key === key ? { ...t, content: data.content ?? null, loading: false, error: null } : t,
- ),
- )
- } catch (err) {
- const errMsg = err instanceof Error ? err.message : "Failed to fetch file content"
- setOpenTabs((prev) =>
- prev.map((t) => (t.key === key ? { ...t, loading: false, error: errMsg } : t)),
- )
- }
- }, [projectCwd])
-
- // ── Close a tab ──
- const closeTab = useCallback((key: string, e?: React.MouseEvent) => {
- e?.stopPropagation()
- setOpenTabs((prev) => {
- const idx = prev.findIndex((t) => t.key === key)
- const next = prev.filter((t) => t.key !== key)
-
- // If we're closing the active tab, switch to an adjacent one
- if (key === activeTabKey) {
- if (next.length === 0) {
- setActiveTabKey(null)
- } else {
- // Prefer the tab to the right, then left
- const newIdx = Math.min(idx, next.length - 1)
- setActiveTabKey(next[newIdx].key)
- }
- }
-
- return next
- })
- }, [activeTabKey])
-
- // Process a file open request (used both on mount and on event)
- const processFileOpen = useCallback(async (root: RootMode, path: string) => {
- // Ensure tree is loaded for this root
- if (root === "gsd" && !gsdTree) {
- fetchTree("gsd")
- } else if (root === "project" && !projectTree) {
- fetchTree("project")
- }
-
- await openFileTab(root, path)
- }, [gsdTree, projectTree, fetchTree, openFileTab])
-
- // On mount: consume any pending file request that arrived before this component mounted
- const consumedPendingRef = useRef(false)
- useEffect(() => {
- if (consumedPendingRef.current) return
- if (pendingFileRequest) {
- consumedPendingRef.current = true
- const { root, path } = pendingFileRequest
- pendingFileRequest = null
- void processFileOpen(root, path)
- }
- }, [processFileOpen])
-
- // Listen for file open events while mounted
- useEffect(() => {
- const handler = (e: Event) => {
- const detail = (e as CustomEvent<{ root: RootMode; path: string }>).detail
- if (!detail?.root || !detail?.path) return
- pendingFileRequest = null // clear since we're handling it directly
- void processFileOpen(detail.root, detail.path)
- }
- window.addEventListener("gsd:open-file", handler)
- return () => window.removeEventListener("gsd:open-file", handler)
- }, [processFileOpen])
-
- const handleToggleDir = useCallback((path: string) => {
- setExpandedPaths((prev) => {
- const next = new Set(prev)
- if (next.has(path)) {
- next.delete(path)
- } else {
- next.add(path)
- }
- saveExpanded(projectCwd, activeRoot, next)
- return next
- })
- }, [setExpandedPaths, projectCwd, activeRoot])
-
- const handleTreeRootChange = (root: RootMode) => {
- setActiveRoot(root)
- }
-
- const handleSelectFile = useCallback(async (path: string) => {
- await openFileTab(activeRoot, path)
- }, [activeRoot, openFileTab])
-
- // ── Move file/directory via drag-and-drop ──
- const handleMoveFile = useCallback(async (fromPath: string, toDir: string) => {
- const fileName = fromPath.split("/").pop() ?? fromPath
- const toPath = toDir ? `${toDir}/${fileName}` : fileName
-
- try {
- const res = await authFetch(buildProjectUrl("/api/files", projectCwd), {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ from: fromPath, to: toPath, root: activeRoot }),
- })
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- console.error("Move failed:", data.error || res.statusText)
- return
- }
-
- // Update any open tabs that referenced the moved path
- const oldKey = tabKey(activeRoot, fromPath)
- setOpenTabs((prev) =>
- prev.map((t) => {
- if (t.key === oldKey) {
- const newKey = tabKey(activeRoot, toPath)
- return { ...t, key: newKey, path: toPath }
- }
- // Also update tabs for files inside a moved directory
- if (t.root === activeRoot && t.path.startsWith(fromPath + "/")) {
- const newTabPath = toPath + t.path.slice(fromPath.length)
- return { ...t, key: tabKey(activeRoot, newTabPath), path: newTabPath }
- }
- return t
- }),
- )
- if (activeTabKey?.startsWith(`${activeRoot}:${fromPath}`)) {
- if (activeTabKey === `${activeRoot}:${fromPath}`) {
- setActiveTabKey(tabKey(activeRoot, toPath))
- } else {
- const suffix = activeTabKey.slice(`${activeRoot}:${fromPath}`.length)
- setActiveTabKey(tabKey(activeRoot, toPath + suffix))
- }
- }
-
- // Refresh tree
- await fetchTree(activeRoot)
- } catch (err) {
- console.error("Move failed:", err)
- }
- }, [activeRoot, activeTabKey, fetchTree, projectCwd])
-
- // ── Context menu handlers ──
-
- const handleContextMenu = useCallback((e: React.MouseEvent, path: string, type: "file" | "directory", parentPath: string) => {
- setContextMenu({ x: e.clientX, y: e.clientY, path, type, parentPath })
- }, [])
-
- const handleContextMenuClose = useCallback(() => {
- setContextMenu(null)
- }, [])
-
- const handleNewFile = useCallback((parentDir: string) => {
- // Ensure parent directory is expanded
- if (parentDir) {
- const setExpanded = activeRoot === "gsd" ? setGsdExpanded : setProjectExpanded
- setExpanded((prev) => {
- const next = new Set(prev)
- const parts = parentDir.split("/")
- for (let i = 1; i <= parts.length; i++) {
- next.add(parts.slice(0, i).join("/"))
- }
- saveExpanded(projectCwd, activeRoot, next)
- return next
- })
- }
- setCreatingIn({ parentDir, type: "file" })
- }, [activeRoot, projectCwd])
-
- const handleNewFolder = useCallback((parentDir: string) => {
- if (parentDir) {
- const setExpanded = activeRoot === "gsd" ? setGsdExpanded : setProjectExpanded
- setExpanded((prev) => {
- const next = new Set(prev)
- const parts = parentDir.split("/")
- for (let i = 1; i <= parts.length; i++) {
- next.add(parts.slice(0, i).join("/"))
- }
- saveExpanded(projectCwd, activeRoot, next)
- return next
- })
- }
- setCreatingIn({ parentDir, type: "directory" })
- }, [activeRoot, projectCwd])
-
- const handleCreateCommit = useCallback(async (parentDir: string, name: string, type: "file" | "directory") => {
- const newPath = parentDir ? `${parentDir}/${name}` : name
- try {
- const res = await authFetch(buildProjectUrl("/api/files", projectCwd), {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ path: newPath, type, root: activeRoot }),
- })
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- console.error("Create failed:", data.error || res.statusText)
- return
- }
- await fetchTree(activeRoot)
- // Open the file if it's a file
- if (type === "file") {
- await openFileTab(activeRoot, newPath)
- }
- } catch (err) {
- console.error("Create failed:", err)
- } finally {
- setCreatingIn(null)
- }
- }, [activeRoot, fetchTree, openFileTab, projectCwd])
-
- const handleCreateCancel = useCallback(() => {
- setCreatingIn(null)
- }, [])
-
- const handleRenameStart = useCallback((path: string) => {
- setRenamingPath(path)
- }, [])
-
- const handleRenameCommit = useCallback(async (oldPath: string, newName: string) => {
- const parentDir = oldPath.includes("/") ? oldPath.substring(0, oldPath.lastIndexOf("/")) : ""
- const newPath = parentDir ? `${parentDir}/${newName}` : newName
-
- if (newPath === oldPath) {
- setRenamingPath(null)
- return
- }
-
- try {
- const res = await authFetch(buildProjectUrl("/api/files", projectCwd), {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ from: oldPath, to: newPath, root: activeRoot }),
- })
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- console.error("Rename failed:", data.error || res.statusText)
- return
- }
-
- // Update open tabs
- const oldKey = tabKey(activeRoot, oldPath)
- setOpenTabs((prev) =>
- prev.map((t) => {
- if (t.key === oldKey) {
- return { ...t, key: tabKey(activeRoot, newPath), path: newPath }
- }
- if (t.root === activeRoot && t.path.startsWith(oldPath + "/")) {
- const newTabPath = newPath + t.path.slice(oldPath.length)
- return { ...t, key: tabKey(activeRoot, newTabPath), path: newTabPath }
- }
- return t
- }),
- )
- if (activeTabKey === `${activeRoot}:${oldPath}`) {
- setActiveTabKey(tabKey(activeRoot, newPath))
- } else if (activeTabKey?.startsWith(`${activeRoot}:${oldPath}/`)) {
- const suffix = activeTabKey.slice(`${activeRoot}:${oldPath}`.length)
- setActiveTabKey(tabKey(activeRoot, newPath + suffix))
- }
-
- await fetchTree(activeRoot)
- } catch (err) {
- console.error("Rename failed:", err)
- } finally {
- setRenamingPath(null)
- }
- }, [activeRoot, activeTabKey, fetchTree, projectCwd])
-
- const handleRenameCancel = useCallback(() => {
- setRenamingPath(null)
- }, [])
-
- const handleDelete = useCallback((path: string, type: "file" | "directory") => {
- setDeleteConfirm({ path, type })
- }, [])
-
- const handleDeleteConfirm = useCallback(async () => {
- if (!deleteConfirm) return
- const { path, type } = deleteConfirm
- try {
- const res = await fetch(
- buildProjectUrl(`/api/files?root=${activeRoot}&path=${encodeURIComponent(path)}`, projectCwd),
- { method: "DELETE" },
- )
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- console.error("Delete failed:", data.error || res.statusText)
- return
- }
-
- // Close any tabs for the deleted path
- setOpenTabs((prev) => {
- const next = prev.filter((t) => {
- if (t.root !== activeRoot) return true
- if (t.path === path) return false
- if (t.path.startsWith(path + "/")) return false
- return true
- })
- // If active tab was removed, switch to adjacent
- if (activeTabKey) {
- const wasRemoved = !next.some((t) => t.key === activeTabKey)
- if (wasRemoved) {
- setActiveTabKey(next.length > 0 ? next[next.length - 1].key : null)
- }
- }
- return next
- })
-
- await fetchTree(activeRoot)
- } catch (err) {
- console.error("Delete failed:", err)
- } finally {
- setDeleteConfirm(null)
- }
- }, [deleteConfirm, activeRoot, activeTabKey, fetchTree, projectCwd])
-
- const handleDeleteCancel = useCallback(() => {
- setDeleteConfirm(null)
- }, [])
-
- const handleCopyPath = useCallback((path: string) => {
- const displayPath = activeRoot === "gsd" ? `.gsd/${path}` : path
- void navigator.clipboard.writeText(displayPath)
- }, [activeRoot])
-
- const handleDuplicate = useCallback(async (path: string) => {
- // Read original content
- try {
- const res = await authFetch(buildProjectUrl(`/api/files?root=${activeRoot}&path=${encodeURIComponent(path)}`, projectCwd))
- if (!res.ok) return
- const data = await res.json()
- if (typeof data.content !== "string") return
-
- // Compute duplicate name: file.ts -> file-copy.ts, folder -> folder-copy
- const fileName = path.split("/").pop() ?? path
- const parentDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ""
- const dotIndex = fileName.lastIndexOf(".")
- let newName: string
- if (dotIndex > 0) {
- newName = `${fileName.substring(0, dotIndex)}-copy${fileName.substring(dotIndex)}`
- } else {
- newName = `${fileName}-copy`
- }
- const newPath = parentDir ? `${parentDir}/${newName}` : newName
-
- // Create with content
- const createRes = await authFetch(buildProjectUrl("/api/files", projectCwd), {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ path: newPath, content: data.content, root: activeRoot }),
- })
- if (!createRes.ok) {
- const errData = await createRes.json().catch(() => ({}))
- console.error("Duplicate failed:", errData.error || createRes.statusText)
- return
- }
- await fetchTree(activeRoot)
- await openFileTab(activeRoot, newPath)
- } catch (err) {
- console.error("Duplicate failed:", err)
- }
- }, [activeRoot, fetchTree, openFileTab, projectCwd])
-
- // Save handler: POST to /api/files, then re-fetch content
- const handleSave = useCallback(async (newContent: string) => {
- if (!activeTab) return
- const { root, path, key } = activeTab
- const res = await authFetch(buildProjectUrl("/api/files", projectCwd), {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ path, content: newContent, root }),
- })
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.error || `Save failed (${res.status})`)
- }
- // Re-fetch to sync the view tab
- const refetch = await authFetch(buildProjectUrl(`/api/files?root=${root}&path=${encodeURIComponent(path)}`, projectCwd))
- if (refetch.ok) {
- const data = await refetch.json()
- setOpenTabs((prev) =>
- prev.map((t) =>
- t.key === key ? { ...t, content: data.content ?? null } : t,
- ),
- )
- }
- }, [activeTab, projectCwd])
-
- // Auto-select STATE.md on initial load if no tabs are open
- const autoSelectedRef = useRef(false)
- useEffect(() => {
- if (autoSelectedRef.current) return
- if (!gsdTree || openTabs.length > 0 || consumedPendingRef.current) return
- const hasStateMd = gsdTree.some((n) => n.name === "STATE.md" && n.type === "file")
- if (hasStateMd) {
- autoSelectedRef.current = true
- void openFileTab("gsd", "STATE.md")
- }
- }, [gsdTree, openTabs.length, openFileTab])
-
- // ── Agent file-edit auto-open: watch tool executions for edit/write tools ──
- const lastSeenToolCountRef = useRef(0)
- const completedTools = workspace.completedToolExecutions
- const activeToolExec = workspace.activeToolExecution
- const diffTimerRef = useRef | null>(null)
-
- useEffect(() => {
- if (completedTools.length <= lastSeenToolCountRef.current) return
- const newTools = completedTools.slice(lastSeenToolCountRef.current)
- lastSeenToolCountRef.current = completedTools.length
-
- for (const tool of newTools) {
- if (tool.name !== "edit" && tool.name !== "write") continue
- const filePath = typeof tool.args?.path === "string" ? tool.args.path : null
- if (!filePath) continue
-
- // Determine root and relative path
- const gsdPrefix = ".gsd/"
- let root: RootMode = "project"
- let relativePath = filePath
-
- // Strip leading project cwd if present
- if (projectCwd && relativePath.startsWith(projectCwd)) {
- relativePath = relativePath.slice(projectCwd.length)
- if (relativePath.startsWith("/")) relativePath = relativePath.slice(1)
- }
-
- if (relativePath.startsWith(gsdPrefix)) {
- root = "gsd"
- relativePath = relativePath.slice(gsdPrefix.length)
- }
-
- const key = tabKey(root, relativePath)
-
- // Capture old content before re-fetching (for diff)
- const existingTab = openTabs.find((t) => t.key === key)
- const oldContent = existingTab?.content ?? null
-
- // Fetch new content, then store diff
- ;(async () => {
- try {
- const res = await authFetch(buildProjectUrl(`/api/files?root=${root}&path=${encodeURIComponent(relativePath)}`, projectCwd))
- if (!res.ok) return
- const data = await res.json()
- const newContent: string | null = data.content ?? null
-
- if (newContent !== null) {
- const diffData = oldContent !== null && oldContent !== newContent
- ? { before: oldContent, after: newContent }
- : null
-
- setOpenTabs((prev) => {
- const exists = prev.find((t) => t.key === key)
- if (exists) {
- return prev.map((t) =>
- t.key === key ? { ...t, content: newContent, loading: false, error: null, diff: diffData, agentOpened: true } : t,
- )
- }
- // New tab
- return [...prev, { key, root, path: relativePath, content: newContent, loading: false, error: null, diff: diffData, agentOpened: true }]
- })
- setActiveTabKey(key)
-
- // Auto-clear diff after 8 seconds
- if (diffData) {
- if (diffTimerRef.current) clearTimeout(diffTimerRef.current)
- diffTimerRef.current = setTimeout(() => {
- setOpenTabs((prev) =>
- prev.map((t) => t.key === key ? { ...t, diff: null } : t),
- )
- }, 8000)
- }
- }
- } catch { /* ignore */ }
- })()
- }
- }, [completedTools, projectCwd, openTabs])
-
- // While a file-modifying tool is active, show which file is being worked on
- const activeEditFile = useMemo(() => {
- if (!activeToolExec) return null
- if (activeToolExec.name !== "edit" && activeToolExec.name !== "write") return null
- return typeof activeToolExec.args?.path === "string" ? activeToolExec.args.path : null
- }, [activeToolExec])
-
- return (
-
- {/* Left panel (file tree or agent chat) */}
-
- {/* Tab bar */}
-
-
-
-
-
-
- {/* Panel content */}
- {leftPanel === "agent" ? (
-
-
-
- ) : (
- /* Tree content */
- {
- // Only highlight if dragging directly over the root area, not a folder
- if ((e.target as HTMLElement).closest("[data-tree-item]")) return
- if (!e.dataTransfer.types.includes("text/x-tree-path")) return
- e.preventDefault()
- e.dataTransfer.dropEffect = "move"
- setTreeRootDragOver(true)
- }}
- onDragLeave={(e) => {
- // Only clear if leaving the root container entirely
- if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
- setTreeRootDragOver(false)
- }
- }}
- onDrop={(e) => {
- setTreeRootDragOver(false)
- if ((e.target as HTMLElement).closest("[data-tree-item]")) return
- e.preventDefault()
- const srcPath = e.dataTransfer.getData("text/x-tree-path")
- if (!srcPath) return
- // Already at root level?
- if (!srcPath.includes("/")) return
- handleMoveFile(srcPath, "")
- }}
- onContextMenu={(e) => {
- // Right-click on empty space in tree — offer New File/Folder at root
- if ((e.target as HTMLElement).closest("[data-tree-item]")) return
- e.preventDefault()
- setContextMenu({ x: e.clientX, y: e.clientY, path: "", type: "directory", parentPath: "" })
- }}
- >
- {loading && !treeLoaded ? (
-
-
- Loading…
-
- ) : error && !treeLoaded ? (
-
-
- {error}
-
- ) : tree && tree.length === 0 ? (
-
- {activeRoot === "gsd" ? "No .gsd/ files found" : "No files found"}
-
- ) : tree ? (
- <>
- {/* Root-level create input */}
- {creatingIn && creatingIn.parentDir === "" && (
- handleCreateCommit("", name, creatingIn.type)}
- onCancel={handleCreateCancel}
- depth={0}
- icon={creatingIn.type === "directory"
- ?
- :
- }
- />
- )}
- {tree.map((node, i) => (
-
- ))}
- >
- ) : null}
-
- )}
-
-
- {/* Resize drag handle */}
-
-
-
-
- {/* File content panel */}
-
- {/* Open file tabs */}
- {openTabs.length > 0 && (
-
- {openTabs.map((tab) => (
-
- ))}
-
- )}
-
- {/* Active tab content */}
- {activeTab ? (
- <>
- {activeTab.loading ? (
-
-
- Loading…
-
- ) : activeTab.error ? (
-
-
- {activeTab.error}
-
- ) : activeTab.content !== null ? (
- {
- setOpenTabs((prev) =>
- prev.map((t) => t.key === activeTab.key ? { ...t, diff: null, agentOpened: false } : t),
- )
- }}
- />
- ) : (
-
- No preview available
-
- )}
- >
- ) : (
-
- Select a file to view
-
- )}
-
-
- {/* Context menu */}
- {contextMenu && (
-
- )}
-
- {/* Delete confirmation dialog */}
- {deleteConfirm && (
-
-
-
- Delete {deleteConfirm.type === "directory" ? "folder" : "file"}?
-
-
- Are you sure you want to delete{" "}
-
- {deleteConfirm.path.split("/").pop()}
-
- ?{deleteConfirm.type === "directory" && " This will delete all contents."}
- {" "}This cannot be undone.
-
-
-
-
-
-
-
- )}
-
- )
-}
diff --git a/web/components/gsd/focused-panel.tsx b/web/components/gsd/focused-panel.tsx
deleted file mode 100644
index ee5c79156..000000000
--- a/web/components/gsd/focused-panel.tsx
+++ /dev/null
@@ -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
- case "confirm":
- return
- case "input":
- return
- case "editor":
- return
- }
-}
-
-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
- onSubmit: (value: Record) => void
- disabled: boolean
-}) {
- const isMulti = Boolean(request.allowMultiple)
- const [singleValue, setSingleValue] = useState("")
- const [multiValues, setMultiValues] = useState>(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 (
-
-
- {request.options.map((option) => (
-
- ))}
-
-
-
- )
- }
-
- return (
-
-
- {request.options.map((option) => (
-
- ))}
-
-
-
- )
-}
-
-function ConfirmRenderer({
- request,
- onSubmit,
- onCancel,
- disabled,
-}: {
- request: Extract
- onSubmit: (value: Record) => void
- onCancel: () => void
- disabled: boolean
-}) {
- return (
-
-
- {request.message}
-
-
-
-
-
-
- )
-}
-
-function InputRenderer({
- request,
- onSubmit,
- disabled,
-}: {
- request: Extract
- onSubmit: (value: Record) => void
- disabled: boolean
-}) {
- const [value, setValue] = useState("")
-
- return (
-
- )
-}
-
-function EditorRenderer({
- request,
- onSubmit,
- disabled,
-}: {
- request: Extract
- onSubmit: (value: Record) => void
- disabled: boolean
-}) {
- const [value, setValue] = useState(request.prefill || "")
-
- return (
-
- )
-}
-
-function RequestBody({
- request,
- onSubmit,
- onCancel,
- disabled,
-}: {
- request: PendingUiRequest
- onSubmit: (value: Record) => void
- onCancel: () => void
- disabled: boolean
-}) {
- switch (request.method) {
- case "select":
- return
- case "confirm":
- return
- case "input":
- return
- case "editor":
- return
- }
-}
-
-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) => {
- 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 (
-
-
- {current && (
- <>
-
-
- {methodIcon(current.method)}
- {current.title || methodLabel(current.method)}
-
-
-
- {methodLabel(current.method)} requested by the agent
- {pending.length > 1 && (
-
- +{pending.length - 1}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
- )
-}
diff --git a/web/components/gsd/guided-dialog.tsx b/web/components/gsd/guided-dialog.tsx
deleted file mode 100644
index d247f0980..000000000
--- a/web/components/gsd/guided-dialog.tsx
+++ /dev/null
@@ -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 (
-
- )
-}
diff --git a/web/components/gsd/knowledge-captures-panel.tsx b/web/components/gsd/knowledge-captures-panel.tsx
deleted file mode 100644
index 57291a3dd..000000000
--- a/web/components/gsd/knowledge-captures-panel.tsx
+++ /dev/null
@@ -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 (
-
-
- {title}
- {status}
- {subtitle && {subtitle}}
-
-
-
- )
-}
-
-function PanelError({ message }: { message: string }) {
- return (
-
- {message}
-
- )
-}
-
-function PanelLoading({ label }: { label: string }) {
- return (
-
-
- {label}
-
- )
-}
-
-function PanelEmpty({ message }: { message: string }) {
- return (
-
- {message}
-
- )
-}
-
-function StatPill({ label, value, variant }: { label: string; value: number | string; variant?: "default" | "error" | "warning" | "info" }) {
- return (
-
- {label}
- {value}
-
- )
-}
-
-// ═══════════════════════════════════════════════════════════════════════
-// 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
- case "pattern":
- return
- case "lesson":
- return
- case "freeform":
- return
- }
-}
-
-// ═══════════════════════════════════════════════════════════════════════
-// 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
- case "inject": return
- case "defer": return
- case "replan": return
- case "note": return
- }
-}
-
-const CLASSIFICATION_OPTIONS: Classification[] = ["quick-task", "inject", "defer", "replan", "note"]
-
-// ═══════════════════════════════════════════════════════════════════════
-// KNOWLEDGE TAB CONTENT
-// ═══════════════════════════════════════════════════════════════════════
-
-function KnowledgeEntryRow({ entry }: { entry: KnowledgeEntry }) {
- const badge = knowledgeTypeBadge(entry.type)
- return (
-
-
-
-
-
- {entry.title}
-
- {badge.label}
-
-
- {entry.content && (
-
- {entry.content}
-
- )}
-
-
-
- )
-}
-
-function KnowledgeTabContent({
- data,
- phase,
- error,
- onRefresh,
-}: {
- data: KnowledgeData | null
- phase: string
- error: string | null
- onRefresh: () => void
-}) {
- if (phase === "loading") return
- if (phase === "error" && error) return
- if (!data || data.entries.length === 0) return
-
- return (
-
-
-
- {data.entries.map((entry) => (
-
- ))}
-
- {data.lastModified && (
-
- Last modified: {new Date(data.lastModified).toLocaleString()}
-
- )}
-
- )
-}
-
-// ═══════════════════════════════════════════════════════════════════════
-// CAPTURES TAB CONTENT
-// ═══════════════════════════════════════════════════════════════════════
-
-function CaptureEntryRow({
- entry,
- onResolve,
- resolvePending,
-}: {
- entry: CaptureEntry
- onResolve: (captureId: string, classification: Classification) => void
- resolvePending: boolean
-}) {
- const status = captureStatusStyle(entry.status)
-
- return (
-
-
-
-
-
- {entry.text}
-
- {status.label}
-
- {entry.classification && (
-
- {classificationLabel(entry.classification)}
-
- )}
-
- {entry.timestamp && (
-
-
- {entry.timestamp}
-
- )}
- {entry.resolution && (
- {entry.resolution}
- )}
- {entry.status === "pending" && (
-
- {CLASSIFICATION_OPTIONS.map((c) => (
-
- ))}
-
- )}
-
-
-
- )
-}
-
-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
- if (phase === "error" && error) return
- if (!data || data.entries.length === 0) return
-
- return (
-
-
- 0 ? "warning" : "default"} />
- 0 ? "info" : "default"} />
-
- }
- onRefresh={onRefresh}
- refreshing={phase === "loading"}
- />
-
- {resolveError && (
-
- Resolve error: {resolveError}
-
- )}
-
-
- {data.entries.map((entry) => (
-
- ))}
-
- {label}
-Current Unit
-Active scope
-- {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."} -
-- Opens a new tab to authenticate with {provider.label} -
-{activeFlow.auth.instructions}
- )} - - {/* Open sign-in page button */} - {activeFlow.auth?.url && ( - - )} - - {/* Status indicator */} -- {currentPath} -
- -- {error} -
- )} - - {/* Suggestions */} -- Optional tools. Nothing here blocks the workspace — configure later from settings. -
-- Not configured — add later from settings. -
- )} -- {noDevRoot - ? "Set a dev root first to discover your projects." - : "Pick a project to start working in, or create a new one."} -
-- Click a provider to configure it. Set up as many as you want, then continue. -
-- Get notified when GSD needs your input. Connect a chat channel and - the agent pings you instead of waiting silently. -
-- Doesn't match the expected format for {channel} -
- )} -- {variant.body} -
- - {/* Detail note */} - {variant.detail && ( -- {variant.detail} -
- )} - - {/* Detected signals */} - {showSignals && ( -- What happens next -
-- What happens next -
-{error}
-
- No project directories discovered in{" "}
-
- {devRoot}
-
-
{devRoot}
-
- ·
- {projects.length} project{projects.length !== 1 ? "s" : ""}
-
- {currentRoot}
-
-
- {error}
} - {success &&Dev root updated
} - -- Point GSD at the folder that contains your project directories. It scans one level deep. -
- - {error &&{error}
} -- The parent folder containing your project directories. GSD scans one level deep for projects. -
-- Select a project to get started -
-- Set a development root to get started. GSD will discover projects inside it. -
-- No project directories were discovered. Create one to get started. -
-{devRoot}
-
- - {sortedProjects.length} project{sortedProjects.length !== 1 ? "s" : ""} -
- {showFilter && ( -- Create a quick one-off task outside the current plan. Useful for small fixes, experiments, or ad-hoc work that - doesn't fit into the milestone structure. -
- -/gsd quick {example}
- | Phase | -Units | -Cost | -Duration | -
|---|---|---|---|
| {row.phase} | -{row.units} | -{formatCost(row.cost)} | -{formatDuration(row.duration)} | -
| Slice | -Units | -Cost | -Duration | -
|---|---|---|---|
| {row.sliceId} | -{row.units} | -{formatCost(row.cost)} | -{formatDuration(row.duration)} | -
| Model | -Units | -Cost | -
|---|---|---|
| {row.model} | -{row.units} | -{formatCost(row.cost)} | -
| Type | -ID | -Model | -Cost | -Duration | -
|---|---|---|---|---|
| {u.type} | -{u.id} | -{u.model} | -{formatCost(u.cost)} | -{formatDuration(u.finishedAt - u.startedAt)} | -
{result.message}
-| Name | -Type | -Status | -Targets | -Cycles | -
|---|---|---|---|---|
| {entry.name} | -
- |
-
- |
- - {entry.targets.length > 0 ? entry.targets.join(", ") : "all"} - | -- {totalCycles} - | -
| ID | -Decision | -Choice | -
|---|---|---|
| {d.id} | -{d.decision} | -{d.choice} | -
| ID | -Status | -Description | -
|---|---|---|
| {r.id} | -
- |
- {r.description} | -
{result.message}
-| Branch | -Status | -
|---|---|
|
-
- |
-
- |
-
| Ref | -Date | -
|---|---|
| {s.ref} | -{s.date} | -
- Project milestone structure with slices and dependencies -
-- Workspace freshness: {workspaceFreshness} -
-/gsd to get started.
- - Connect a chat channel so the agent pings you when it needs input - instead of waiting silently. -
- - {/* Feedback banners */} - {error &&- Doesn't match the expected format for {selectedChannelOption.label} -
- )} -- {flag.description} -
- {flag.warning && ( -- Changes are written to{" "} - {prefs?.path ?? "~/.gsd/PREFERENCES.md"} - {" "}and take effect on the next session. -
- )} -{message}
-- {label} -
-- {value} -
- {sub && ( -{sub}
- )} -No milestone dependencies configured.
- ) : ( -No active milestone.
- ) : ( - (() => { - const slDeps = activeMs.slices.filter((s) => s.depends.length > 0) - if (slDeps.length === 0) - returnNo slice dependencies in {activeMs.id}.
- return ( -No critical path data.
- ) : ( -- Milestone Chain -
-- Milestone Slack -
-- Slice Critical Path -
-- Slice Slack -
-| Slice | -Units | -Cost | -Duration | -Tokens | -
|---|---|---|---|---|
| {sl.sliceId} | -{sl.units} | -{formatCost(sl.cost)} | -{formatDuration(sl.duration)} | -{formatTokenCount(sl.tokens.total)} | -
- Showing {recent.length} of {data.units.length} units — most recent first -
-{activity.active ? "Active" : "Idle"}
-- {activity.active ? "Agent is running" : "Waiting for next task"} -
-{formatDuration(activity.elapsed)}
-elapsed
-Currently executing
-- {activity.currentUnit.type} — {activity.currentUnit.id} -
-- “{entry.oneLiner}” -
- )} - - {/* Files modified */} - {entry.filesModified.length > 0 && ( -- Files Modified -
-- Download the current visualizer data as a structured report. Markdown includes - milestones, metrics, critical path, and changelog in a readable format. - JSON contains the full raw data payload. -
- -Download Markdown
-Human-readable report with tables and structure
-Download JSON
-Full raw data payload for tooling
-Loading visualizer data…
-Failed to load visualizer
-{error}
-