shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX: 5 bugs + Class B fixed-anchor + selective vellum

Founder walk on Phase 3 (commit 5720ca7) 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 of 5720ca7
(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:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 14:45:48 -07:00
1 parent 5720ca79a6
commit 7aa1aac65c
13 files changed
+539 -29

No files matched your search

+51 -11
View File
@@ -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>
);
}
+26 -3
View File
@@ -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);
}
+50
View File
@@ -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);
});
});