mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX: 5 bugs + Class B fixed-anchor + selective vellum
Founder walk on Phase 3 (commit5720ca7) approved structurally; surfaced 5 bug fixes + 1 design refinement. Triple-gate verdicts: review-lead APPROVE, design-reviewer DONE, guard-3 PROCESS-BLOCKER (resolved by this commit's permission preflight in ACK §4). Branch: shell-redesign-v1-attempt-3-phase-3-bounce on top of5720ca7(driver judgment to build on Phase 3 destinations). CLASS A — 5 bug fixes: A1 — topology landing tab content auto-load - Verified at agent-browser: graph placeholder DID render on first navigation (Dashboard → /topology); 25 rigs visible in tree. - Founder's perceived "empty" was Class B Explorer-covers-content layout — Class B fix below resolves as side effect (graph canvas now extends edge-to-edge under translucent Explorer; placeholder visible regardless of Explorer state). A2 — Settings tab HTTP 404 root cause + soft empty-state - Root cause: shipped daemon (npm v0.2.0) doesn't expose /api/config yet; route lands at v0.3.0+. Per SC-29 daemon byte-equivalent — NO daemon patch. - Vite proxy verified configured (vite.config.ts L7-L20). Per dispatch guidance, escalate before patching daemon. - UI fix: SettingsTab error rendering softened. When error contains "404": render honest empty-state pointing at CLI as canonical edit path ("rig config get/set/reset"). Otherwise: existing error message in cleaner empty-state styling. No more raw "could not load HTTP 404" red text. A3 — Log/RecentActivity bodies show "—" instead of message text - New shared `lib/format-event-payload.ts` with smart fallback chain: payload.summary → body → detail → message → title → text → description → compact key=value preview (skipping noise keys ts/created_at/audit_pointer) → JSON.stringify(payload) truncated. - Applied in SettingsCenter LogPanel + Dashboard RecentActivity. - 9 tests in test/format-event-payload.test.ts covering each fallback + noise-key skip + truncation. A4 — Status tab cmux block restored (regression fix) - Original SystemPanel.tsx L52-L131 had cmux query + `data-testid="system-cmux-status"`. My Phase 3 SettingsSystemStatusPanel extraction missed it. - Restored: useQuery({queryKey:["daemon","cmux"]}) + fetchCmux from /api/adapters/cmux/status. Renders via StatusPip primitive (active/warning/info per cmuxAvailable tri-state). - New testid: status-cmux. Side-effect: also restored daemon health fetch ordering for daemonConnected → cmuxAvailable resolution. A5 — Project workspace label hardcoded "openrig-work" - New hook `useWorkspaceName`: reads workspace.root from useSettings (with defensive guards for malformed/missing settings shape — fixes /project route test failure when settings endpoint returns []). - Returns basename of root, or null when unset/unreachable. - ProjectTreeView: live label OR honest empty-state ("No workspace connected" + Link to /settings) when name is null. - WorkspaceScopePage: live title OR full empty-state EmptyState card with action to /settings. - Chains with A2: when daemon doesn't expose /api/config, both flow to empty-state pointing at /settings (which itself shows the CLI hint). CLASS B — fixed-anchor view-mode tabs + selective vellum overlay: Per dispatch + universal-shell.md L48 ("sheets of vellum paper layered together") canon outcome. - NEW: components/topology/topology-overlay-context.tsx — TopologyOverlayContext + Provider + useTopologyOverlay hook. Two modes: "overlay" | "opaque". Default opaque so non-topology surfaces always opaque. - AppShell.tsx — refactored: * Outer `AppShell` wraps SpecsWorkspaceProvider + TopologyOverlayProvider around `AppShellInner` (the actual chrome + state). * AppShellInner reads explorerMode via useTopologyOverlay; computes isTopologyOverlay = mode==="overlay" && surface==="topology". * NEW CSS variables on <main>: - --explorer-anchor-left = 21rem (rail 3 + explorer 18) when desktop+open, else 3rem. - --header-anchor-offset = anchor when isTopologyOverlay (so the ScopeShell header keeps the tab bar at fixed anchor even when canvas extends behind), else 0rem. - workspaceLeftOffset: 0 in overlay mode (canvas extends behind Explorer); anchor in opaque mode. * `<main>` gets explicit padding-left: var(--workspace-left-offset) so all destinations clear the Explorer. * `<main>` carries data-explorer-mode attr for runtime + test probing. - Explorer.tsx — overlayMode prop + grammar: * "overlay" branch: vellum-heavy class (rgba(255,255,255,0.7) + backdrop-blur(16px) per globals.css L118-L122) + z-30 + heavier shadow. Sheets-of-vellum-layered aesthetic. * "opaque" branch: existing Phase 2 background. z-20. * data-explorer-mode attr on the <aside>. * 1px outline-variant border preserved (border weight doctrine). - topology/ScopePages.tsx — useOverlayForActiveTab(active) helper: * useEffect sets overlay mode = "overlay" when active==="graph" else "opaque". Cleanup on unmount → "opaque" so non-topology destinations don't inherit overlay state. * Applied in HostScopePage / RigScopePage / PodScopePage. SeatScopePage (detail/transcript/terminal — no graph) doesn't use the helper. * ScopeShell header gets marginLeft: var(--header-anchor-offset) so the tab bar stays at the anchor in overlay mode (canvas extends behind, but the header section is still positioned at the anchor). * Header z-30 + bg-background so it stays on top of the Explorer overlay even though Explorer is also z-30 (same z-index, header paints later in document order). Visual verification (agent-browser at 1440x900): - /topology graph mode: Explorer translucent vellum over canvas; paper-grid extends edge-to-edge UNDER Explorer; tabs at fixed anchor; "HOST GRAPH" placeholder visible centered in canvas. data-explorer-mode === "overlay". - /topology table mode: clicked the tab (no longer blocked by Explorer overlay — fixed-anchor header is z-above); Explorer reverts to opaque; tabs at SAME left position as graph mode (no jump). 122 of 122 agents in tanstack table with RUNNING StatusPip badges. data-explorer-mode === "opaque". - /project: data-explorer-mode === "opaque"; querySelectorAll ('[data-explorer-mode=overlay]').length === 0. Non-topology destinations NEVER get overlay (negative-assertion ritual #8 ✓). OUT-OF-SCOPE preserved per dispatch: Phase 4 (drawer viewers + triggers + AuthorAgentTag cmux + Advisor/Operator wiring + RigDetailPanel retire + subscription toggles wire) and Phase 5 (mobile polish + slice tab piping + filesystem mission discovery + topology terminal pagination + final dogfood). Daemon untouched per SC-29. Verification: - tsc --noEmit: clean - pnpm test: 634/636 PASS (1 skipped seat-scope; 1 pre-existing library-review unrelated; my Phase 3 /project failures from useWorkspaceName lack-of-defensive-guard FIXED) - pnpm build: ✓ in 1.99s - agent-browser walk: 6 destinations + 3 topology view-modes verified; vellum overlay only on topology graph; tabs at fixed anchor. ETA realized: ~115 min (within 90-120 dispatch estimate; Class B took the most time as anticipated). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
5720ca79a6
commit
7aa1aac65c
13 files changed
+539
-29
No files matched your search
@@ -46,6 +46,10 @@ import { SharedDetailDrawer, type DrawerSelection } from "./SharedDetailDrawer.j
|
||||
import { PreviewStack } from "./preview/PreviewStack.js";
|
||||
import type { DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
|
||||
import { SpecsWorkspaceProvider } from "./SpecsWorkspace.js";
|
||||
import {
|
||||
TopologyOverlayProvider,
|
||||
useTopologyOverlay,
|
||||
} from "./topology/topology-overlay-context.js";
|
||||
import { useActivityFeed } from "../hooks/useActivityFeed.js";
|
||||
import { useGlobalEvents } from "../hooks/useGlobalEvents.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
@@ -302,9 +306,20 @@ interface AppShellProps {
|
||||
const WIDE_LAYOUT_BREAKPOINT = 1024;
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<SpecsWorkspaceProvider>
|
||||
<TopologyOverlayProvider>
|
||||
<AppShellInner>{children}</AppShellInner>
|
||||
</TopologyOverlayProvider>
|
||||
</SpecsWorkspaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShellInner({ children }: AppShellProps) {
|
||||
const routerState = useRouterState();
|
||||
const pathname = routerState.location.pathname;
|
||||
const surface = surfaceForPath(pathname);
|
||||
const { mode: explorerMode } = useTopologyOverlay();
|
||||
|
||||
const [explorerOpen, setExplorerOpen] = useState(false); // mobile slide-over state
|
||||
const [desktopExplorerOpen, setDesktopExplorerOpen] = useState(true);
|
||||
@@ -369,11 +384,28 @@ export function AppShell({ children }: AppShellProps) {
|
||||
const explorerVisible = surface !== "none";
|
||||
const drawerOpen = Boolean(selectionState);
|
||||
|
||||
// CSS var consumed by surfaces that pad themselves inside center.
|
||||
// Rail: 48px (3rem). Explorer: 280px (18rem) when desktop-open, 0 when collapsed/none.
|
||||
// V1 attempt-3 Phase 3 bounce-fix — Class B fixed-anchor + selective overlay.
|
||||
// Topology graph mode signals overlay; only meaningful while on /topology
|
||||
// (surface === "topology"). Other surfaces ALWAYS use opaque layout.
|
||||
const isTopologyOverlay = explorerMode === "overlay" && surface === "topology";
|
||||
|
||||
// Anchor stays the same in BOTH modes — tab bar position never moves.
|
||||
// Main padding-left differs:
|
||||
// - opaque: padding = anchor (content starts AFTER explorer)
|
||||
// - overlay: padding = 0 (content extends behind translucent explorer);
|
||||
// tab bar is sticky/positioned at left=anchor independently.
|
||||
// 21rem = rail (3rem) + explorer (18rem).
|
||||
const explorerAnchorLeft = isWideLayout && explorerVisible && desktopExplorerOpen ? "21rem" : "3rem";
|
||||
const workspaceLeftOffset = isWideLayout
|
||||
? `${3 + (explorerVisible && desktopExplorerOpen ? 18 : 0)}rem`
|
||||
? isTopologyOverlay
|
||||
? "0rem"
|
||||
: explorerAnchorLeft
|
||||
: "0rem";
|
||||
// Class B fixed-anchor: header (eyebrow + title + view-mode tabs) ALWAYS
|
||||
// sits at the explorer-anchor offset, even in overlay mode where the
|
||||
// canvas extends behind the Explorer. This keeps the tab bar at a
|
||||
// stable left position across view-mode switches.
|
||||
const headerAnchorOffset = isWideLayout && isTopologyOverlay ? explorerAnchorLeft : "0rem";
|
||||
// Coupled to VellumSheet wide preset (lg:w-[38rem]) — bounce-fix #3
|
||||
// caught the gap that emerged when bounce-fix #2 calibrated the drawer
|
||||
// 45rem → 38rem without updating this offset. Keep these two literals
|
||||
@@ -382,12 +414,13 @@ export function AppShell({ children }: AppShellProps) {
|
||||
const workspaceStyle = {
|
||||
"--workspace-left-offset": workspaceLeftOffset,
|
||||
"--workspace-right-offset": workspaceRightOffset,
|
||||
"--explorer-anchor-left": explorerAnchorLeft,
|
||||
"--header-anchor-offset": headerAnchorOffset,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<SpecsWorkspaceProvider>
|
||||
<DrawerSelectionContext.Provider value={{ selection: selectionState, setSelection }}>
|
||||
<DiscoveryPlacementContext.Provider
|
||||
<DrawerSelectionContext.Provider value={{ selection: selectionState, setSelection }}>
|
||||
<DiscoveryPlacementContext.Provider
|
||||
value={{
|
||||
selectedDiscoveredId,
|
||||
setSelectedDiscoveredId,
|
||||
@@ -474,7 +507,10 @@ export function AppShell({ children }: AppShellProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Explorer — desktop column or mobile slide-over (Explorer.tsx handles both modes). */}
|
||||
{/* Explorer — desktop column or mobile slide-over.
|
||||
In overlay mode (topology graph): vellum-translucent + z-30
|
||||
so it floats over the canvas. In opaque mode: default
|
||||
Phase 2 behavior (z-20, opaque background). */}
|
||||
{explorerVisible && (
|
||||
<Explorer
|
||||
open={explorerOpen}
|
||||
@@ -484,14 +520,19 @@ export function AppShell({ children }: AppShellProps) {
|
||||
desktopMode={desktopExplorerOpen ? "full" : "hidden"}
|
||||
surface={surface}
|
||||
onDesktopToggle={() => setDesktopExplorerOpen((open) => !open)}
|
||||
overlayMode={isTopologyOverlay ? "overlay" : "opaque"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Center workspace */}
|
||||
<main
|
||||
data-testid="content-area"
|
||||
data-explorer-mode={isTopologyOverlay ? "overlay" : "opaque"}
|
||||
className="flex-1 flex flex-col overflow-auto relative"
|
||||
style={workspaceStyle}
|
||||
style={{
|
||||
...workspaceStyle,
|
||||
paddingLeft: `var(--workspace-left-offset, 0px)`,
|
||||
}}
|
||||
>
|
||||
<div key={pathname} className="relative z-10 route-enter flex-1 flex flex-col">
|
||||
{children}
|
||||
@@ -515,8 +556,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<PreviewStack />
|
||||
</div>
|
||||
</div>
|
||||
</DiscoveryPlacementContext.Provider>
|
||||
</DrawerSelectionContext.Provider>
|
||||
</SpecsWorkspaceProvider>
|
||||
</DiscoveryPlacementContext.Provider>
|
||||
</DrawerSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,11 @@ interface ExplorerProps {
|
||||
desktopMode?: ExplorerDesktopMode;
|
||||
surface?: ExplorerSurface;
|
||||
onDesktopToggle?: () => void;
|
||||
/** V1 attempt-3 Phase 3 bounce-fix — Class B selective vellum overlay.
|
||||
* "overlay" = vellum-translucent + position absolute z-30 (topology
|
||||
* graph view-mode signature). "opaque" = default solid background
|
||||
* (every other destination + view-mode). */
|
||||
overlayMode?: "overlay" | "opaque";
|
||||
}
|
||||
|
||||
function statusColor(startupStatus: string | null): string {
|
||||
@@ -556,6 +561,7 @@ export function Explorer({
|
||||
desktopMode = "full",
|
||||
surface = "topology",
|
||||
onDesktopToggle = () => {},
|
||||
overlayMode = "opaque",
|
||||
}: ExplorerProps) {
|
||||
const routerState = useRouterState();
|
||||
const currentPath = routerState.location.pathname;
|
||||
@@ -568,20 +574,37 @@ export function Explorer({
|
||||
// Surface "none" (Dashboard / Settings) — Explorer is not rendered.
|
||||
if (surface === "none") return null;
|
||||
|
||||
// Class B: overlay vs opaque background grammar.
|
||||
// OPAQUE (default; every destination except topology-graph): solid
|
||||
// paper-cream tone (Phase 2 baseline) so the explore tree reads
|
||||
// crisply against the center workspace.
|
||||
// OVERLAY (topology graph only): vellum-heavy translucent surface
|
||||
// (.vellum-heavy class from globals.css L118-122 — rgba(255,255,255,0.7)
|
||||
// + backdrop-blur(16px)) with elevated z-index so the graph canvas
|
||||
// underneath shows through. Sheets-of-vellum-layered aesthetic per
|
||||
// universal-shell.md L48.
|
||||
const isOverlay = overlayMode === "overlay";
|
||||
return (
|
||||
<aside
|
||||
data-testid="explorer"
|
||||
data-surface={surface}
|
||||
data-explorer-mode={overlayMode}
|
||||
className={cn(
|
||||
// V1 border weight doctrine (universal-shell.md L39–L48):
|
||||
// 1px outline-variant ghost line for inter-region edges.
|
||||
"border-r border-outline-variant flex z-20 overflow-hidden",
|
||||
"bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[6px_0_14px_rgba(46,52,46,0.04)]",
|
||||
"border-r border-outline-variant flex overflow-hidden",
|
||||
// Background grammar by mode:
|
||||
isOverlay
|
||||
? "vellum-heavy z-30 shadow-[6px_0_14px_rgba(46,52,46,0.08)]"
|
||||
: "z-20 bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[6px_0_14px_rgba(46,52,46,0.04)]",
|
||||
// Mobile: slide-over from left below the top-bar header (h-14).
|
||||
"fixed top-14 bottom-0 left-0 transition-transform duration-200 ease-tactical w-72 max-w-[80vw]",
|
||||
open ? "translate-x-0" : "-translate-x-full",
|
||||
// Desktop (>=lg): persistent column at 280px (lg:w-72) per universal-shell.md L34.
|
||||
// Position absolutely after the 48px rail; AppShell pads its main content with workspace-left-offset.
|
||||
// Position absolutely after the 48px rail; in opaque mode AppShell
|
||||
// also pads main with workspace-left-offset so center content
|
||||
// starts AFTER the Explorer. In overlay mode the main padding is
|
||||
// 0 so the canvas extends behind this translucent panel.
|
||||
desktopMode === "full" && "lg:absolute lg:top-0 lg:bottom-0 lg:left-12 lg:w-72 lg:max-w-none lg:translate-x-0",
|
||||
desktopMode === "hidden" && "lg:hidden",
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { EmptyState } from "../ui/empty-state.js";
|
||||
import { useActivityFeed } from "../../hooks/useActivityFeed.js";
|
||||
import { formatEventPayload } from "../../lib/format-event-payload.js";
|
||||
|
||||
const MAX_ITEMS = 10;
|
||||
|
||||
@@ -37,8 +38,8 @@ export function RecentActivity() {
|
||||
<span className="text-on-surface-variant text-[10px] uppercase tracking-wide w-32 shrink-0 truncate">
|
||||
{evt.type}
|
||||
</span>
|
||||
<span className="text-stone-900 truncate">
|
||||
{String((evt.payload as Record<string, unknown>)?.summary ?? evt.type)}
|
||||
<span className="text-stone-900 truncate" title={formatEventPayload(evt.payload)}>
|
||||
{formatEventPayload(evt.payload)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useState, useMemo } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useSlices } from "../../hooks/useSlices.js";
|
||||
import { useWorkspaceName } from "../../hooks/useWorkspaceName.js";
|
||||
import { MissionStatusBadge, type MissionStatus } from "../MissionStatusBadge.js";
|
||||
|
||||
type GroupedMission = {
|
||||
@@ -28,6 +29,7 @@ function deriveStatus(slices: Array<{ status: string }>): MissionStatus {
|
||||
|
||||
export function ProjectTreeView() {
|
||||
const { data: slicesResp } = useSlices("all");
|
||||
const workspace = useWorkspaceName();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({
|
||||
workspace: true,
|
||||
});
|
||||
@@ -56,6 +58,39 @@ export function ProjectTreeView() {
|
||||
}));
|
||||
}, [slicesResp]);
|
||||
|
||||
// A5 bounce-fix: replace hardcoded "openrig-work" with live-wired
|
||||
// workspace name from ConfigStore. When unset/unreachable: render an
|
||||
// honest empty-state node ("No workspace connected" + Link to /settings).
|
||||
if (!workspace.isLoading && workspace.name === null) {
|
||||
return (
|
||||
<div
|
||||
data-testid="project-tree-view"
|
||||
className="flex-1 overflow-y-auto py-3 px-3"
|
||||
>
|
||||
<div
|
||||
data-testid="project-no-workspace"
|
||||
className="border border-outline-variant bg-surface-low px-3 py-3 font-mono text-[10px]"
|
||||
>
|
||||
<div className="text-stone-900 uppercase tracking-wide font-bold mb-1">
|
||||
No workspace connected
|
||||
</div>
|
||||
<p className="text-on-surface-variant mb-2">
|
||||
Configure a workspace root to browse missions and slices.
|
||||
</p>
|
||||
<Link
|
||||
to="/settings"
|
||||
data-testid="project-no-workspace-cta"
|
||||
className="inline-flex items-center text-stone-900 hover:underline uppercase"
|
||||
>
|
||||
Open settings →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceLabel = workspace.name ?? "loading…";
|
||||
|
||||
return (
|
||||
<div data-testid="project-tree-view" className="flex-1 overflow-y-auto py-2">
|
||||
<ul>
|
||||
@@ -71,8 +106,11 @@ export function ProjectTreeView() {
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-on-surface-variant" />
|
||||
)}
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide text-stone-900 flex-1">
|
||||
openrig-work
|
||||
<span
|
||||
data-testid="project-workspace-label"
|
||||
className="font-mono text-[11px] uppercase tracking-wide text-stone-900 flex-1"
|
||||
>
|
||||
{workspaceLabel}
|
||||
</span>
|
||||
<Link
|
||||
to="/project"
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
// pages where mountable + EmptyState placeholders for Phase 5 polish.
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { EmptyState } from "../ui/empty-state.js";
|
||||
import { FilesWorkspace } from "../files/FilesWorkspace.js";
|
||||
import { useWorkspaceName } from "../../hooks/useWorkspaceName.js";
|
||||
|
||||
type SharedTab = "overview" | "progress" | "artifacts" | "queue" | "topology";
|
||||
type SliceTab = SharedTab | "story" | "tests";
|
||||
@@ -118,10 +119,27 @@ function PlaceholderTab({ label, description }: { label: string; description?: s
|
||||
|
||||
export function WorkspaceScopePage() {
|
||||
const [active, setActive] = useState<SharedTab>("overview");
|
||||
const workspace = useWorkspaceName();
|
||||
|
||||
// A5 bounce-fix: live-wired workspace name; honest empty-state when unset.
|
||||
if (!workspace.isLoading && workspace.name === null) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[960px] px-6 py-12">
|
||||
<EmptyState
|
||||
label="NO WORKSPACE CONNECTED"
|
||||
description="Configure a workspace root to browse missions and slices in this destination."
|
||||
variant="card"
|
||||
testId="workspace-scope-no-workspace"
|
||||
action={{ label: "Open settings", href: "/settings" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScopeShell
|
||||
eyebrow="Workspace"
|
||||
title="openrig-work"
|
||||
title={workspace.name ?? "loading…"}
|
||||
tabs={SHARED_TABS}
|
||||
active={active}
|
||||
onSelect={(id) => setActive(id as SharedTab)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SettingsTab } from "./SettingsTab.js";
|
||||
import { SettingsSystemStatusPanel } from "./SettingsSystemStatusPanel.js";
|
||||
import { useActivityFeed } from "../../hooks/useActivityFeed.js";
|
||||
import { EmptyState } from "../ui/empty-state.js";
|
||||
import { formatEventPayload } from "../../lib/format-event-payload.js";
|
||||
|
||||
type SettingsCenterTab = "settings" | "log" | "status";
|
||||
|
||||
@@ -45,8 +46,8 @@ function LogPanel() {
|
||||
<span className="text-on-surface-variant text-[10px] uppercase tracking-wide w-32 shrink-0 truncate">
|
||||
{evt.type}
|
||||
</span>
|
||||
<span className="text-stone-900 truncate">
|
||||
{String((evt.payload as Record<string, unknown>)?.summary ?? "—")}
|
||||
<span className="text-stone-900 truncate" title={formatEventPayload(evt.payload)}>
|
||||
{formatEventPayload(evt.payload)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,20 +1,66 @@
|
||||
// V1 attempt-3 Phase 3 — system status panel for Settings>Status tab.
|
||||
// Per code-map AFTER tree (NEW). Composes daemon health + cmux status
|
||||
// from existing data sources.
|
||||
//
|
||||
// Phase 3 bounce-fix A4: restored cmux block (regression — extracted
|
||||
// from original SystemPanel L52–L131 missed the cmux query + section).
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { StatusPip } from "../ui/status-pip.js";
|
||||
import { useRigSummary } from "../../hooks/useRigSummary.js";
|
||||
import { usePsEntries } from "../../hooks/usePsEntries.js";
|
||||
|
||||
async function fetchHealth(): Promise<boolean> {
|
||||
const res = await fetch("/healthz");
|
||||
if (!res.ok) throw new Error("unhealthy");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchCmux(): Promise<{ available: boolean }> {
|
||||
const res = await fetch("/api/adapters/cmux/status");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function SettingsSystemStatusPanel() {
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ["daemon", "health"],
|
||||
queryFn: fetchHealth,
|
||||
refetchInterval: 10_000,
|
||||
retry: false,
|
||||
});
|
||||
const cmuxQuery = useQuery({
|
||||
queryKey: ["daemon", "cmux"],
|
||||
queryFn: fetchCmux,
|
||||
refetchInterval: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: rigs, isLoading: rigsLoading, error: rigsError } = useRigSummary();
|
||||
const { data: psEntries } = usePsEntries();
|
||||
|
||||
const daemonReachable = !rigsError && !rigsLoading;
|
||||
const daemonConnected = healthQuery.isSuccess;
|
||||
const cmuxAvailable = daemonConnected ? (cmuxQuery.data?.available ?? null) : null;
|
||||
const totalRigs = rigs?.length ?? 0;
|
||||
const runningRigs = psEntries?.filter((p) => p.runningCount > 0).length ?? 0;
|
||||
|
||||
const daemonStatus: React.ComponentProps<typeof StatusPip>["status"] =
|
||||
daemonConnected
|
||||
? "active"
|
||||
: healthQuery.isError
|
||||
? "error"
|
||||
: healthQuery.isLoading
|
||||
? "info"
|
||||
: "stopped";
|
||||
|
||||
const cmuxStatus: React.ComponentProps<typeof StatusPip>["status"] =
|
||||
cmuxAvailable === true
|
||||
? "active"
|
||||
: cmuxAvailable === false
|
||||
? "warning"
|
||||
: "info";
|
||||
|
||||
return (
|
||||
<div data-testid="settings-status-panel" className="space-y-4">
|
||||
<section>
|
||||
@@ -22,13 +68,43 @@ export function SettingsSystemStatusPanel() {
|
||||
<div className="mt-2 flex items-center justify-between font-mono text-xs">
|
||||
<span className="text-on-surface-variant">Reachable</span>
|
||||
<StatusPip
|
||||
status={daemonReachable ? "active" : "error"}
|
||||
label={daemonReachable ? "OK" : rigsError ? "ERROR" : "LOADING"}
|
||||
status={daemonStatus}
|
||||
label={
|
||||
daemonConnected
|
||||
? "OK"
|
||||
: healthQuery.isError
|
||||
? "ERROR"
|
||||
: healthQuery.isLoading
|
||||
? "LOADING"
|
||||
: "DISCONNECTED"
|
||||
}
|
||||
variant="pill"
|
||||
testId="status-daemon"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{/* A4 bounce-fix: cmux control row restored. */}
|
||||
<section>
|
||||
<SectionHeader tone="muted">Cmux control</SectionHeader>
|
||||
<div className="mt-2 flex items-center justify-between font-mono text-xs">
|
||||
<span className="text-on-surface-variant">Adapter</span>
|
||||
<StatusPip
|
||||
status={cmuxStatus}
|
||||
label={
|
||||
cmuxAvailable === true
|
||||
? "AVAILABLE"
|
||||
: cmuxAvailable === false
|
||||
? "UNAVAILABLE"
|
||||
: "UNKNOWN"
|
||||
}
|
||||
variant="pill"
|
||||
testId="status-cmux"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[10px] text-on-surface-variant">
|
||||
OpenRig can control cmux surfaces for node open-or-focus.
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<SectionHeader tone="muted">Rigs</SectionHeader>
|
||||
<div className="mt-2 space-y-1.5 font-mono text-xs">
|
||||
|
||||
@@ -149,9 +149,39 @@ export function SettingsTab() {
|
||||
return <div data-testid="settings-loading" className="px-4 py-3 font-mono text-[10px] text-stone-400">Loading settings…</div>;
|
||||
}
|
||||
if (error || !data) {
|
||||
// V1 attempt-3 Phase 3 bounce-fix A2 — soften the failure mode.
|
||||
// The shipped daemon (npm package) at v0.2.0 doesn't expose /api/config
|
||||
// yet; the route lands at v0.3.0. Render an honest empty-state pointing
|
||||
// at the CLI (canonical edit path per useSettings.ts header note),
|
||||
// not a raw "HTTP 404" red error.
|
||||
const errMsg = (error as Error)?.message ?? "";
|
||||
const looksLikeMissingEndpoint = errMsg.includes("404");
|
||||
return (
|
||||
<div data-testid="settings-error" className="px-4 py-3 font-mono text-[10px] text-red-600">
|
||||
Could not load settings: {(error as Error)?.message ?? "unknown"}
|
||||
<div
|
||||
data-testid="settings-error"
|
||||
className="px-4 py-6 font-mono text-xs text-on-surface-variant border border-outline-variant bg-surface-low"
|
||||
>
|
||||
{looksLikeMissingEndpoint ? (
|
||||
<>
|
||||
<div className="text-stone-900 font-bold uppercase tracking-wide text-[10px] mb-2">
|
||||
Settings UI requires daemon ≥ v0.3.0
|
||||
</div>
|
||||
<p className="mb-2">
|
||||
The shipped daemon doesn't expose the settings HTTP route yet.
|
||||
Until that lands, configure via the CLI:
|
||||
</p>
|
||||
<pre className="font-mono text-[10px] bg-stone-50 border border-outline-variant px-2 py-1 inline-block">
|
||||
rig config get / set / reset
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-stone-900 font-bold uppercase tracking-wide text-[10px] mb-2">
|
||||
Settings unavailable
|
||||
</div>
|
||||
<p>{errMsg || "Daemon is unreachable."}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// page renders its tab nav + the active view-mode panel.
|
||||
// (Attempt-2 violated this by using separate routes per view-mode.)
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import {
|
||||
TopologyViewModeTabs,
|
||||
@@ -22,6 +22,22 @@ import { EmptyState } from "../ui/empty-state.js";
|
||||
import { RigGraph } from "../RigGraph.js";
|
||||
import { useRigSummary } from "../../hooks/useRigSummary.js";
|
||||
import { LiveNodeDetails } from "../LiveNodeDetails.js";
|
||||
import { useTopologyOverlay } from "./topology-overlay-context.js";
|
||||
|
||||
/** Set the AppShell's Explorer overlay mode based on the scope page's
|
||||
* active view-mode. Graph view-mode → overlay (vellum-translucent
|
||||
* Explorer over canvas); table/terminal → opaque. Resets to opaque
|
||||
* when the component unmounts so non-topology destinations don't
|
||||
* inherit overlay state. */
|
||||
function useOverlayForActiveTab(active: string) {
|
||||
const { setMode } = useTopologyOverlay();
|
||||
useEffect(() => {
|
||||
setMode(active === "graph" ? "overlay" : "opaque");
|
||||
return () => {
|
||||
setMode("opaque");
|
||||
};
|
||||
}, [active, setMode]);
|
||||
}
|
||||
|
||||
function ScopeShell({
|
||||
eyebrow,
|
||||
@@ -34,9 +50,20 @@ function ScopeShell({
|
||||
tabsNav: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Class B fixed-anchor: header (with eyebrow + title + view-mode tabs)
|
||||
// ALWAYS sits at left = var(--explorer-anchor-left) so the tab bar is
|
||||
// positioned consistently across graph / table / terminal switches.
|
||||
// The CONTENT below the header inherits the parent <main>'s
|
||||
// padding-left rule (which is 0 in overlay mode so canvas extends
|
||||
// behind the Explorer; or anchor in opaque mode). Tab bar gets z-30
|
||||
// and a solid background so it stays on top of the Explorer overlay
|
||||
// in graph mode.
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 pt-6 pb-2 border-b border-outline-variant">
|
||||
<div
|
||||
className="px-6 pt-6 pb-2 border-b border-outline-variant relative z-30 bg-background"
|
||||
style={{ marginLeft: "var(--header-anchor-offset, 0px)" }}
|
||||
>
|
||||
<SectionHeader tone="muted">{eyebrow}</SectionHeader>
|
||||
<h1 className="font-headline text-headline-md font-bold tracking-tight uppercase text-stone-900 mt-1 mb-3">
|
||||
{title}
|
||||
@@ -66,6 +93,7 @@ function TerminalGridPlaceholder({ scope }: { scope: string }) {
|
||||
export function HostScopePage() {
|
||||
const [active, setActive] = useState<TopologyHostScopeTab>("graph");
|
||||
const { data: rigs } = useRigSummary();
|
||||
useOverlayForActiveTab(active);
|
||||
|
||||
return (
|
||||
<ScopeShell
|
||||
@@ -103,6 +131,7 @@ export function RigScopePage() {
|
||||
const { data: rigs } = useRigSummary();
|
||||
const rig = rigs?.find((r) => r.id === rigId);
|
||||
const [active, setActive] = useState<TopologyRigPodScopeTab>("graph");
|
||||
useOverlayForActiveTab(active);
|
||||
|
||||
return (
|
||||
<ScopeShell
|
||||
@@ -138,6 +167,7 @@ export function RigScopePage() {
|
||||
export function PodScopePage() {
|
||||
const { rigId, podName } = useParams({ from: "/topology/pod/$rigId/$podName" });
|
||||
const [active, setActive] = useState<TopologyRigPodScopeTab>("table");
|
||||
useOverlayForActiveTab(active);
|
||||
|
||||
return (
|
||||
<ScopeShell
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// V1 attempt-3 Phase 3 bounce-fix — Class B TopologyOverlayContext.
|
||||
//
|
||||
// Topology destination signature: when the active view-mode is GRAPH,
|
||||
// the Explorer renders as a vellum-translucent overlay floating over
|
||||
// the canvas (sheets-of-vellum-layered aesthetic per universal-shell.md
|
||||
// L48). Center workspace canvas extends to the viewport-left edge
|
||||
// underneath the Explorer overlay.
|
||||
//
|
||||
// When the active view-mode is TABLE / TERMINAL (or any non-topology
|
||||
// destination), the Explorer is opaque (default behavior); center
|
||||
// workspace starts at the Explorer's right edge.
|
||||
//
|
||||
// View-mode tab bar anchors at fixed left = rail (48px) + explorer
|
||||
// (280px) = 328px = var(--explorer-anchor-left). Independent of mode,
|
||||
// so tabs never jump position between graph / table / terminal switches.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
export type ExplorerMode = "overlay" | "opaque";
|
||||
|
||||
interface TopologyOverlayContextValue {
|
||||
mode: ExplorerMode;
|
||||
setMode: (mode: ExplorerMode) => void;
|
||||
}
|
||||
|
||||
const TopologyOverlayContext = createContext<TopologyOverlayContextValue>({
|
||||
mode: "opaque",
|
||||
setMode: () => {},
|
||||
});
|
||||
|
||||
export function TopologyOverlayProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setModeState] = useState<ExplorerMode>("opaque");
|
||||
const setMode = useCallback((next: ExplorerMode) => {
|
||||
setModeState(next);
|
||||
}, []);
|
||||
return (
|
||||
<TopologyOverlayContext.Provider value={{ mode, setMode }}>
|
||||
{children}
|
||||
</TopologyOverlayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTopologyOverlay(): TopologyOverlayContextValue {
|
||||
return useContext(TopologyOverlayContext);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// V1 attempt-3 Phase 3 bounce-fix A5 — useWorkspaceName.
|
||||
//
|
||||
// Reads the configured workspace root from ConfigStore (via useSettings)
|
||||
// and returns its basename for display. When unset (or settings endpoint
|
||||
// is unreachable on shipped daemon < v0.3.0), returns null so consumers
|
||||
// render an honest "No workspace connected" empty-state.
|
||||
|
||||
import { useSettings } from "./useSettings.js";
|
||||
|
||||
export interface WorkspaceNameResult {
|
||||
/** Live basename of the configured workspace root, or null when unset/unreachable. */
|
||||
name: string | null;
|
||||
/** The full configured root path; null when unset/unreachable. */
|
||||
root: string | null;
|
||||
/** Settings endpoint reachable (daemon supports /api/config). */
|
||||
settingsAvailable: boolean;
|
||||
/** True while initial settings request is in flight. */
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function basename(p: string): string {
|
||||
if (!p) return "";
|
||||
const trimmed = p.replace(/\/+$/, "");
|
||||
const slash = trimmed.lastIndexOf("/");
|
||||
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
|
||||
}
|
||||
|
||||
export function useWorkspaceName(): WorkspaceNameResult {
|
||||
const { data, isLoading, error } = useSettings();
|
||||
|
||||
if (error || !data || !data.settings || typeof data.settings !== "object") {
|
||||
return {
|
||||
name: null,
|
||||
root: null,
|
||||
settingsAvailable: false,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = data.settings["workspace.root"];
|
||||
const rawValue = resolved?.value;
|
||||
const root = typeof rawValue === "string" && rawValue.length > 0 ? rawValue : null;
|
||||
|
||||
return {
|
||||
name: root ? basename(root) : null,
|
||||
root,
|
||||
settingsAvailable: true,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// V1 attempt-3 Phase 3 bounce-fix — A3 fix.
|
||||
//
|
||||
// Smarter formatter for activity-feed event payloads. Original Phase 3
|
||||
// LogPanel + RecentActivity only checked `payload.summary` and rendered
|
||||
// "—" for events that don't carry that field — most events DO NOT.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Try common message-style keys (summary, body, detail, message, title).
|
||||
// 2. Build a compact key=value preview from up to 4 top-level payload keys
|
||||
// (skipping noise keys like timestamps and ULIDs).
|
||||
// 3. Final fallback: JSON.stringify(payload).slice(0, 200).
|
||||
|
||||
const MESSAGE_KEYS = ["summary", "body", "detail", "message", "title", "text", "description"] as const;
|
||||
|
||||
const NOISE_KEYS = new Set([
|
||||
"ts",
|
||||
"ts_created",
|
||||
"ts_updated",
|
||||
"ts_emitted",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"received_at",
|
||||
"stream_sort_key",
|
||||
"audit_pointer",
|
||||
]);
|
||||
|
||||
function shorten(s: string, max = 40): string {
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max - 1) + "…";
|
||||
}
|
||||
|
||||
function valuePreview(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === "string") {
|
||||
const trimmed = v.trim();
|
||||
return trimmed.length > 0 ? shorten(trimmed) : null;
|
||||
}
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return null; // skip nested objects/arrays in the compact preview
|
||||
}
|
||||
|
||||
export function formatEventPayload(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") return "—";
|
||||
const obj = payload as Record<string, unknown>;
|
||||
|
||||
// Pass 1: explicit message keys.
|
||||
for (const key of MESSAGE_KEYS) {
|
||||
const v = obj[key];
|
||||
if (typeof v === "string" && v.trim().length > 0) {
|
||||
return shorten(v.trim(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: compact key=value preview of top-level scalar fields.
|
||||
const previewParts: string[] = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (NOISE_KEYS.has(k)) continue;
|
||||
const preview = valuePreview(v);
|
||||
if (preview === null) continue;
|
||||
previewParts.push(`${k}=${preview}`);
|
||||
if (previewParts.length >= 4) break;
|
||||
}
|
||||
if (previewParts.length > 0) return previewParts.join(" · ");
|
||||
|
||||
// Pass 3: JSON fallback (truncated).
|
||||
try {
|
||||
const json = JSON.stringify(obj);
|
||||
return shorten(json, 200);
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// V1 attempt-3 Phase 3 bounce-fix — A3 formatter tests.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatEventPayload } from "../src/lib/format-event-payload.js";
|
||||
|
||||
describe("formatEventPayload (Phase 3 bounce-fix A3)", () => {
|
||||
it("prefers payload.summary when present", () => {
|
||||
expect(formatEventPayload({ summary: "Slice PL-014 shipped" })).toBe(
|
||||
"Slice PL-014 shipped",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to body when no summary", () => {
|
||||
expect(formatEventPayload({ body: "Some body text" })).toBe("Some body text");
|
||||
});
|
||||
|
||||
it("falls back to detail / message / title", () => {
|
||||
expect(formatEventPayload({ detail: "Detail text" })).toBe("Detail text");
|
||||
expect(formatEventPayload({ message: "Msg text" })).toBe("Msg text");
|
||||
expect(formatEventPayload({ title: "Title" })).toBe("Title");
|
||||
});
|
||||
|
||||
it("returns key=value compact preview when no message key", () => {
|
||||
const result = formatEventPayload({
|
||||
qitem_id: "qitem-abc",
|
||||
state: "pending",
|
||||
destination_session: "driver@vel",
|
||||
});
|
||||
expect(result).toContain("qitem_id=qitem-abc");
|
||||
expect(result).toContain("state=pending");
|
||||
expect(result).toContain("destination_session=driver@vel");
|
||||
});
|
||||
|
||||
it("skips noise keys like timestamps", () => {
|
||||
const result = formatEventPayload({
|
||||
ts_created: "2026-05-06",
|
||||
received_at: 12345,
|
||||
qitem_id: "qitem-x",
|
||||
state: "done",
|
||||
});
|
||||
expect(result).not.toContain("ts_created");
|
||||
expect(result).not.toContain("received_at");
|
||||
expect(result).toContain("qitem_id=qitem-x");
|
||||
expect(result).toContain("state=done");
|
||||
});
|
||||
|
||||
it("limits compact preview to 4 keys", () => {
|
||||
const result = formatEventPayload({
|
||||
a: "1",
|
||||
b: "2",
|
||||
c: "3",
|
||||
d: "4",
|
||||
e: "5",
|
||||
f: "6",
|
||||
});
|
||||
expect(result.split("·").length).toBe(4);
|
||||
});
|
||||
|
||||
it("falls back to JSON.stringify when only nested objects", () => {
|
||||
const result = formatEventPayload({
|
||||
nested: { foo: "bar" },
|
||||
list: [1, 2, 3],
|
||||
});
|
||||
expect(result).toContain("{");
|
||||
});
|
||||
|
||||
it("returns '—' for null/undefined/non-object", () => {
|
||||
expect(formatEventPayload(null)).toBe("—");
|
||||
expect(formatEventPayload(undefined)).toBe("—");
|
||||
expect(formatEventPayload("a string")).toBe("—");
|
||||
expect(formatEventPayload(42)).toBe("—");
|
||||
});
|
||||
|
||||
it("truncates very long strings with ellipsis", () => {
|
||||
const long = "x".repeat(500);
|
||||
const result = formatEventPayload({ summary: long });
|
||||
expect(result.length).toBeLessThanOrEqual(200);
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user