2026-05-05 14:31:16 +02:00
|
|
|
"use client";
|
|
|
|
|
|
2026-05-05 14:46:18 +02:00
|
|
|
import { Menu, X } from "lucide-react";
|
2026-05-05 14:31:16 +02:00
|
|
|
import Image from "next/image";
|
|
|
|
|
import {
|
|
|
|
|
useCallback,
|
2026-05-05 14:46:18 +02:00
|
|
|
useEffect,
|
2026-05-05 14:31:16 +02:00
|
|
|
useRef,
|
2026-05-05 14:46:18 +02:00
|
|
|
useState,
|
2026-05-05 14:31:16 +02:00
|
|
|
useSyncExternalStore,
|
|
|
|
|
} from "react";
|
2026-05-05 14:46:18 +02:00
|
|
|
import { toast } from "sonner";
|
2026-05-05 14:31:16 +02:00
|
|
|
import { ActivityView } from "@/components/sf/activity-view";
|
2026-05-05 14:46:18 +02:00
|
|
|
import { ChatMode } from "@/components/sf/chat-mode";
|
|
|
|
|
import { CommandSurface } from "@/components/sf/command-surface";
|
|
|
|
|
import { Dashboard } from "@/components/sf/dashboard";
|
2026-05-05 14:31:16 +02:00
|
|
|
import { DualTerminal } from "@/components/sf/dual-terminal";
|
fix(lint): fix all pre-existing lint failures
- check-sf-extension-inventory.mjs: expand parseDirectRegisteredCommands()
scan to include 7 more files (guards/inturn.js, notifications/notify.js,
permissions/index.js, ui/usage-bar.js, commands/legacy/audit.js,
commands/legacy/create-extension.js, commands/legacy/create-slash-command.js)
and filter results by BASE_RUNTIME_COMMAND_NAMES to exclude doc-string false
positives ("name" in create-slash-command.js template text)
- extension-manifest.json: remove 'clear' (subcommand of logs/notifications,
never a top-level pi.registerCommand)
- packages/pi-agent-core/src/db/sf-db.ts: fix 23 noVoidTypeReturn errors
- openDatabase: void → boolean (caller uses return value at line 5625)
- claimEscalationOverride: void → boolean (caller checks at escalation.js:243)
- resolveSelfFeedbackEntry: void → boolean (caller checks at self-feedback.js:387)
- copyWorktreeDb: void → boolean (caller checks at reconcileWorktreeDb)
- compactUokMessages: void → {before,after} (caller returns value at message-bus.js:238)
- insertSessionTurn: void → bigint|null (caller uses id at session-recorder.js:104)
- expireStaleMemories: void → number (caller uses count at auto-start.js:1047)
- deleteMemorySourceRow: void → boolean (caller returns value at memory-source-store.js:107)
- deleteMemoryEmbedding: void → boolean (caller returns value at memory-embeddings.js:328)
- updateBacklogItemStatus: remove dead return expression (callers discard value)
- removeBacklogItem: remove dead return expression (callers discard value)
- updateGateCircuitBreaker: remove dead return {total,avgMs,...} (wrong-type
code accidentally merged from getGateLatencyStats, never reachable)
- markUokMessageRead: remove dead return true/false (callers discard value)
- Auto-fix formatting and organizeImports in ~30 source files (biome --write)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 04:02:31 +02:00
|
|
|
import { ErrorBoundary } from "@/components/sf/error-boundary";
|
2026-05-05 14:46:18 +02:00
|
|
|
import { FilesView } from "@/components/sf/files-view";
|
2026-05-05 14:31:16 +02:00
|
|
|
import { FocusedPanel } from "@/components/sf/focused-panel";
|
|
|
|
|
import { OnboardingGate } from "@/components/sf/onboarding-gate";
|
2026-05-05 14:46:18 +02:00
|
|
|
import {
|
|
|
|
|
ProjectSelectionGate,
|
|
|
|
|
ProjectsPanel,
|
|
|
|
|
} from "@/components/sf/projects-view";
|
|
|
|
|
import { Roadmap } from "@/components/sf/roadmap";
|
|
|
|
|
import { ScopeBadge } from "@/components/sf/scope-badge";
|
|
|
|
|
import { ShellTerminal } from "@/components/sf/shell-terminal";
|
|
|
|
|
import {
|
|
|
|
|
CollapsedMilestoneSidebar,
|
|
|
|
|
MilestoneExplorer,
|
|
|
|
|
Sidebar,
|
|
|
|
|
} from "@/components/sf/sidebar";
|
|
|
|
|
import { StatusBar } from "@/components/sf/status-bar";
|
|
|
|
|
import { UpdateBanner } from "@/components/sf/update-banner";
|
|
|
|
|
import { VisualizerView } from "@/components/sf/visualizer-view";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
|
import { getAuthToken } from "@/lib/auth";
|
2026-05-05 14:31:16 +02:00
|
|
|
import { DevOverridesProvider } from "@/lib/dev-overrides";
|
|
|
|
|
import {
|
|
|
|
|
ProjectStoreManagerProvider,
|
|
|
|
|
useProjectStoreManager,
|
|
|
|
|
} from "@/lib/project-store-manager";
|
|
|
|
|
import {
|
|
|
|
|
getCurrentScopeLabel,
|
|
|
|
|
getProjectDisplayName,
|
|
|
|
|
getStatusPresentation,
|
|
|
|
|
getVisibleWorkspaceError,
|
2026-05-05 14:46:18 +02:00
|
|
|
SFWorkspaceProvider,
|
2026-05-05 14:31:16 +02:00
|
|
|
useSFWorkspaceActions,
|
2026-05-05 14:46:18 +02:00
|
|
|
useSFWorkspaceState,
|
2026-05-05 14:31:16 +02:00
|
|
|
} from "@/lib/sf-workspace-store";
|
2026-05-05 14:46:18 +02:00
|
|
|
import { cn } from "@/lib/utils";
|
2026-05-05 14:31:16 +02:00
|
|
|
|
|
|
|
|
const KNOWN_VIEWS = new Set([
|
|
|
|
|
"dashboard",
|
|
|
|
|
"power",
|
|
|
|
|
"chat",
|
|
|
|
|
"roadmap",
|
|
|
|
|
"files",
|
|
|
|
|
"activity",
|
|
|
|
|
"visualize",
|
|
|
|
|
]);
|
2026-04-15 14:11:45 +02:00
|
|
|
|
|
|
|
|
function viewStorageKey(projectCwd: string): string {
|
2026-05-05 14:31:16 +02:00
|
|
|
return `sf-active-view:${projectCwd}`;
|
2026-04-15 14:11:45 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function WorkspaceChrome() {
|
2026-05-05 14:31:16 +02:00
|
|
|
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 = useSFWorkspaceState();
|
|
|
|
|
const { refreshBoot } = useSFWorkspaceActions();
|
|
|
|
|
|
|
|
|
|
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("sf-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("sf-sidebar-collapsed", String(sidebarCollapsed));
|
|
|
|
|
} catch {
|
|
|
|
|
// localStorage may be unavailable
|
|
|
|
|
}
|
|
|
|
|
}, [sidebarCollapsed]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (typeof document === "undefined") return;
|
|
|
|
|
const base = projectLabel ? `SF - ${projectLabel}` : "SF";
|
|
|
|
|
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("sf:open-file", handler);
|
|
|
|
|
return () => window.removeEventListener("sf:open-file", handler);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Listen for cross-component view navigation events (e.g. /sf visualize dispatch)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (e: CustomEvent<{ view: string }>) => {
|
|
|
|
|
if (KNOWN_VIEWS.has(e.detail.view)) {
|
|
|
|
|
handleViewChange(e.detail.view);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("sf:navigate-view", handler as EventListener);
|
|
|
|
|
return () =>
|
|
|
|
|
window.removeEventListener("sf:navigate-view", handler as EventListener);
|
|
|
|
|
}, [handleViewChange]);
|
|
|
|
|
|
|
|
|
|
// Listen for projects panel toggle (sidebar icon, or programmatic)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = () => setProjectsPanelOpen(true);
|
|
|
|
|
window.addEventListener("sf:open-projects", handler);
|
|
|
|
|
return () => window.removeEventListener("sf: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-sf" &&
|
|
|
|
|
detection.kind !== "empty-sf";
|
|
|
|
|
|
|
|
|
|
// --- 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="SF"
|
|
|
|
|
width={57}
|
|
|
|
|
height={16}
|
|
|
|
|
className="shrink-0 h-4 w-auto dark:hidden"
|
|
|
|
|
/>
|
|
|
|
|
<Image
|
|
|
|
|
src="/logo-white.svg"
|
|
|
|
|
alt="SF"
|
|
|
|
|
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">
|
|
|
|
|
sf --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
|
2026-05-05 14:46:18 +02:00
|
|
|
type="button"
|
2026-05-05 14:31:16 +02:00
|
|
|
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="SF"
|
|
|
|
|
width={57}
|
|
|
|
|
height={16}
|
|
|
|
|
className="shrink-0 h-4 w-auto dark:hidden"
|
|
|
|
|
/>
|
|
|
|
|
<Image
|
|
|
|
|
src="/logo-white.svg"
|
|
|
|
|
alt="SF"
|
|
|
|
|
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
|
2026-05-05 14:46:18 +02:00
|
|
|
type="button"
|
2026-05-05 14:31:16 +02:00
|
|
|
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 && (
|
2026-05-05 14:46:18 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label="Close navigation"
|
2026-05-05 14:31:16 +02:00
|
|
|
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 && (
|
2026-05-05 14:46:18 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label="Close milestones"
|
2026-05-05 14:31:16 +02:00
|
|
|
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 ? (
|
feat(web): add error boundaries, expand test coverage, add README
- Add class-based ErrorBoundary component wrapping all 7 main views
inside WorkspaceChrome; fallback shows view name, error, reload button
- Add 30 new unit tests (boot null-project path × 9, onboarding
pure-function logic × 21); all 43 web/lib tests pass
- Add web/README.md: architecture, auth flow, 7 views, dev setup,
API route pattern, test instructions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 11:24:40 +02:00
|
|
|
<ErrorBoundary viewName="Dashboard">
|
|
|
|
|
<Dashboard />
|
|
|
|
|
</ErrorBoundary>
|
2026-05-05 14:31:16 +02:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{activeView === "dashboard" && (
|
feat(web): add error boundaries, expand test coverage, add README
- Add class-based ErrorBoundary component wrapping all 7 main views
inside WorkspaceChrome; fallback shows view name, error, reload button
- Add 30 new unit tests (boot null-project path × 9, onboarding
pure-function logic × 21); all 43 web/lib tests pass
- Add web/README.md: architecture, auth flow, 7 views, dev setup,
API route pattern, test instructions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 11:24:40 +02:00
|
|
|
<ErrorBoundary viewName="Dashboard">
|
|
|
|
|
<Dashboard
|
|
|
|
|
onSwitchView={handleViewChange}
|
|
|
|
|
onExpandTerminal={() => setIsTerminalExpanded(true)}
|
|
|
|
|
/>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
)}
|
|
|
|
|
{activeView === "power" && (
|
|
|
|
|
<ErrorBoundary viewName="Terminal">
|
|
|
|
|
<DualTerminal />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
)}
|
|
|
|
|
{activeView === "roadmap" && (
|
|
|
|
|
<ErrorBoundary viewName="Roadmap">
|
|
|
|
|
<Roadmap />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
)}
|
|
|
|
|
{activeView === "files" && (
|
|
|
|
|
<ErrorBoundary viewName="Files">
|
|
|
|
|
<FilesView />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
)}
|
|
|
|
|
{activeView === "activity" && (
|
|
|
|
|
<ErrorBoundary viewName="Activity">
|
|
|
|
|
<ActivityView />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
)}
|
|
|
|
|
{activeView === "visualize" && (
|
|
|
|
|
<ErrorBoundary viewName="Visualizer">
|
|
|
|
|
<VisualizerView />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
)}
|
|
|
|
|
{activeView === "chat" && (
|
|
|
|
|
<ErrorBoundary viewName="Chat">
|
|
|
|
|
<ChatMode />
|
|
|
|
|
</ErrorBoundary>
|
2026-05-05 14:31:16 +02:00
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</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 */}
|
2026-05-05 14:46:18 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-05 14:31:16 +02:00
|
|
|
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>
|
2026-05-05 14:46:18 +02:00
|
|
|
</button>
|
2026-05-05 14:31:16 +02:00
|
|
|
{/* 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
|
2026-05-05 14:46:18 +02:00
|
|
|
role="separator"
|
|
|
|
|
aria-label="Resize milestone sidebar"
|
|
|
|
|
aria-orientation="vertical"
|
|
|
|
|
aria-valuemin={260}
|
|
|
|
|
aria-valuemax={520}
|
|
|
|
|
aria-valuenow={sidebarWidth}
|
|
|
|
|
tabIndex={0}
|
2026-05-05 14:31:16 +02:00
|
|
|
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
|
2026-05-05 14:46:18 +02:00
|
|
|
type="button"
|
2026-05-05 14:31:16 +02:00
|
|
|
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>
|
|
|
|
|
);
|
2026-04-15 14:11:45 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 14:22:21 +02:00
|
|
|
export function SFAppShell() {
|
2026-05-05 14:31:16 +02:00
|
|
|
// Extract the auth token from the URL fragment on first render.
|
|
|
|
|
// Must happen before any API calls fire.
|
|
|
|
|
getAuthToken();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ProjectStoreManagerProvider>
|
|
|
|
|
<ProjectAwareWorkspace />
|
|
|
|
|
</ProjectStoreManagerProvider>
|
|
|
|
|
);
|
2026-04-15 14:11:45 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ProjectAwareWorkspace() {
|
2026-05-05 14:31:16 +02:00
|
|
|
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 (
|
|
|
|
|
<SFWorkspaceProvider store={activeStore}>
|
|
|
|
|
<DevOverridesProvider>
|
|
|
|
|
<WorkspaceChrome />
|
|
|
|
|
</DevOverridesProvider>
|
|
|
|
|
</SFWorkspaceProvider>
|
|
|
|
|
);
|
2026-04-15 14:11:45 +02:00
|
|
|
}
|