From cb0211d4af330e7cf334319e1369c495adedc637 Mon Sep 17 00:00:00 2001 From: hshum Date: Thu, 16 Jul 2026 21:23:12 -0700 Subject: [PATCH 1/4] feat: make chat the single NodeBench workspace --- CHANGELOG/pages/redesign-chat.md | 25 + src/App.tsx | 319 +++-------- src/design/designSystem.ts | 2 +- src/features/redesign/README.md | 36 +- src/features/redesign/RedesignShell.tsx | 517 ++---------------- src/features/redesign/agent-workspace.css | 73 ++- .../redesign/components/RightInspector.tsx | 20 +- .../redesign/components/TopNav.test.tsx | 139 ++--- src/features/redesign/components/TopNav.tsx | 379 +++---------- .../redesign/lib/oneSurfaceRouting.test.ts | 48 ++ .../redesign/lib/oneSurfaceRouting.ts | 56 ++ src/features/redesign/primitives.css | 61 +-- .../AgentWorkspaceHonesty.guard.test.ts | 7 +- .../AgentWorkspaceResponsiveCss.guard.test.ts | 23 +- .../redesign/surfaces/ChatSurface.tsx | 116 +++- .../surfaces/OneSurface.guard.test.ts | 71 +++ 16 files changed, 651 insertions(+), 1241 deletions(-) create mode 100644 src/features/redesign/lib/oneSurfaceRouting.test.ts create mode 100644 src/features/redesign/lib/oneSurfaceRouting.ts create mode 100644 src/features/redesign/surfaces/OneSurface.guard.test.ts diff --git a/CHANGELOG/pages/redesign-chat.md b/CHANGELOG/pages/redesign-chat.md index 56593829..aa38aced 100644 --- a/CHANGELOG/pages/redesign-chat.md +++ b/CHANGELOG/pages/redesign-chat.md @@ -3,6 +3,31 @@ Append-only lane for the public redesign chat, reproducible answer receipts, and their transition into an authenticated live conversation. Newest entries first. +## 2026-07-16 - Contract NodeBench to one decision workspace + +NodeBench now has one primary product surface: the runtime-backed conversation at +`/redesign/chat`. The former Home, Reports, Inbox, Me, Workspace, mobile-shell, command +palette, and reproducible-answer destinations no longer mount competing application +trees. Their URLs preserve useful report, artifact, attention, settings, and receipt +context while replacing into the same conversation and keeping the composer available. +The old cockpit fallback is also removed from the main site: retired or unknown product +paths now resolve to the same chat, while read-only delivery routes and the separately +hosted Workspace keep their bounded contracts. +Main-site report graph, notebook, cards, sources, map, and brief editor URLs now carry +their report and artifact into chat instead of mounting another interactive workspace. + +The header is reduced to product identity, explicit authentication, and theme. It no +longer duplicates the composer with global search or exposes wide mode, five peer tabs, +or ambiguous `Continue` copy. Runtime sources, approvals, exports, follow-ups, receipts, +provider/model metadata, usage, cost, and failure/retry states remain protected. + +**PR / canonical main commit**: pending CI-gated candidate. + +**Evidence state**: responsive before/after evidence and the protected function ledger +remain outside git under `.qa/evidence/2026-07-16-one-surface/`. + +**Author**: Homen Shum + Codex. + ## 2026-07-16 - Normalize structured answer typography Structured answer copy now uses the compact product reading scale instead of an oversized diff --git a/src/App.tsx b/src/App.tsx index da15480e..bff9b06e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,27 +1,12 @@ -import { Authenticated, Unauthenticated, useConvexAuth } from "convex/react"; +import { useConvexAuth } from "convex/react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; -import { CockpitLayout } from "./layouts/CockpitLayout"; -import { TutorialPage } from "@/features/onboarding/views/TutorialPage"; import { useState, useEffect, useCallback, lazy, Suspense, useRef } from "react"; -import { Id } from "../convex/_generated/dataModel"; -import { ContextPillsProvider } from "./hooks/contextPills"; -import { FastAgentProvider, useFastAgent } from "@/features/agents/context/FastAgentContext"; -import { SelectionProvider } from "@/features/agents/context/SelectionContext"; -import { FeedbackListener } from "@/shared/hooks/FeedbackListener"; import { ThemeProvider } from "./contexts/ThemeContext"; -import { OracleSessionProvider } from "./contexts/OracleSessionContext"; -import { SkipLinks } from "./shared/components/SkipLinks"; -import { EvidenceProvider } from "@/features/research/contexts/EvidenceContext"; import { ErrorBoundary } from "@/shared/components/ErrorBoundary"; import { ViewSkeleton } from "@/components/skeletons/ViewSkeleton"; import { useWebMcpProvider } from "./hooks/useWebMcpProvider"; -import { getReportWorkspaceRouteFromPath } from "@/features/reports/lib/reportNotebookRouting"; import type { MainView } from "@/lib/registry/viewRegistry"; -import { buildCockpitPathForView } from "@/lib/registry/viewRegistry"; import { initErrorReporting } from "@/lib/errorReporting"; -import { FinancialOperatorOverlay } from "@/features/financialOperator/components/FinancialOperatorOverlay"; -import { WorkspaceModeToggle } from "@/features/financialOperator/components/WorkspaceModeToggle"; -import { WorkspaceModePane } from "@/features/financialOperator/components/WorkspaceModePane"; const RedesignShell = lazy(() => import("@/features/redesign/RedesignShell")); const EditionPrintPage = lazy(() => @@ -48,16 +33,6 @@ const ShareableMemoView = lazy(() => import("@/features/founder/views/ShareableM const PublicEntityShareView = lazy(() => import("@/features/share/views/PublicEntityShareView")); const PublicCompanyProfileView = lazy(() => import("@/features/founder/views/PublicCompanyProfileView")); const PublicReportView = lazy(() => import("@/features/reports/views/PublicReportView")); -const ReportNotebookDetail = lazy(() => - import("@/features/reports/views/ReportNotebookDetail").then((m) => ({ - default: m.ReportNotebookDetail, - })), -); -const ReportDetailPage = lazy(() => - import("@/features/research/views/ReportDetailPage").then((m) => ({ - default: m.ReportDetailPage, - })), -); const UniversalWorkspacePage = lazy(() => import("@/features/workspace/views/UniversalWorkspacePage").then((m) => ({ default: m.UniversalWorkspacePage, @@ -93,45 +68,8 @@ const ScratchnodePrivateBridge = lazy(() => const WikiLandingRoute = lazy(() => import("@/features/me/components/wiki/WikiLandingRoute")); const WikiPageDetailRoute = lazy(() => import("@/features/me/components/wiki/WikiPageDetailRoute")); -const FastAgentPanel = lazy(() => - import("@/features/agents/components/FastAgentPanel/FastAgentPanel").then((mod) => ({ - default: mod.FastAgentPanel, - })), -); - -/** - * GlobalFastAgentPanel - Renders FastAgentPanel connected to FastAgentContext. - * - * The cockpit shell owns the in-layout agent panel, so this global overlay only - * appears when no external handler is registered. - */ -function GlobalFastAgentPanel() { - const { isOpen, close, options, clearOptions, hasExternalHandler } = useFastAgent(); - - // Skip when the active shell is handling the panel directly. - if (hasExternalHandler) return null; - - const handleClose = () => { - close(); - clearOptions(); - }; - - return ( - Loading assistant... : null}> - - - ); -} - function App() { const location = useLocation(); - const [showTutorial, setShowTutorial] = useState(false); // Initialize global error tracking once on mount const errorInitRef = useRef(false); @@ -141,14 +79,15 @@ function App() { initErrorReporting(); } }, []); - const [selectedDocumentId, setSelectedDocumentId] = useState | null>(null); - // WebMCP provider — expose NodeBench tools to browser agents via navigator.modelContext const [webmcpEnabled] = useState(() => localStorage.getItem("nodebench_webmcp_provider_enabled") === "true"); const { isAuthenticated: webmcpIsAuth } = useConvexAuth(); const nav = useNavigate(); const handleWebMcpNavigate = useCallback((view: MainView) => { - nav(buildCockpitPathForView({ view })); + const target = view === "home" || view === "ask" || view === "chat" + ? "/redesign/chat" + : `/redesign/chat?intent=${encodeURIComponent(view)}`; + nav(target); }, [nav]); useWebMcpProvider({ enabled: webmcpEnabled, @@ -157,47 +96,18 @@ function App() { onNavigate: handleWebMcpNavigate, }); - // Deep link: ?doc=ID auto-opens a shared document - useEffect(() => { - const params = new URLSearchParams(location.search); - const docId = params.get('doc'); - if (docId) { - setSelectedDocumentId(docId as Id<"documents">); - setShowTutorial(false); - } - }, [location.search]); - - // Any navigation away from /onboarding dismisses the tutorial - useEffect(() => { - if (location.pathname.toLowerCase().startsWith("/onboarding")) { - setShowTutorial(true); - } else { - setShowTutorial(false); - } - }, [location.pathname]); - - const handleGetStarted = () => { - setShowTutorial(false); - }; - - const handleDocumentSelect = (documentId: string) => { - setSelectedDocumentId(documentId as Id<"documents">); - setShowTutorial(false); - }; - const workspaceHostname = typeof window !== "undefined" ? window.location.hostname.toLowerCase() : ""; const isWorkspaceHost = workspaceHostname === "nodebench.workspace" || workspaceHostname === "workspace.nodebenchai.com" || workspaceHostname === "nodebench-workspace.vercel.app"; - const isStandaloneWorkspaceRoute = - location.pathname === "/workspace" || - location.pathname.startsWith("/workspace/") || - (isWorkspaceHost && - (location.pathname === "/" || - location.pathname.startsWith("/w/") || - location.pathname.startsWith("/share/"))); + const isStandaloneWorkspaceRoute = isWorkspaceHost && + (location.pathname === "/" || + location.pathname === "/workspace" || + location.pathname.startsWith("/workspace/") || + location.pathname.startsWith("/w/") || + location.pathname.startsWith("/share/")); if (isStandaloneWorkspaceRoute) { return ( @@ -214,29 +124,38 @@ function App() { const rootParams = new URLSearchParams(location.search); const rootSurface = rootParams.get("surface"); - // QA fix (2026-05-08): the / → /redesign redirect (PR #260) broke mobile - // because mobile clients lost the legacy MobileHomeSurface — which the - // audit (NODEBENCH_DESIGN_PRINCIPLES_AUDIT.md) explicitly called out as - // the closest existing thing to the Daily Intelligence Pulse vision. - // Detect mobile via UA + viewport so mobile / continues to render - // HomeLanding → MobileHomeSurface, restoring the smoke contract - // (live-smoke-mobile.spec.ts:35,273) without losing the redesign route - // for desktop users who actively prefer it. - const isMobileViewport = typeof window !== "undefined" && ( - window.matchMedia?.("(max-width: 767px)").matches || - /Android|iPhone|iPad|iPod|Mobile/i.test(window.navigator.userAgent) - ); - const shouldUseRedesignLanding = - location.pathname === "/" && - !isMobileViewport && - (rootSurface === null || rootSurface === "home") && - !rootParams.has("doc") && - !rootParams.has("session") && - !rootParams.has("reportId"); + // The root is a compatibility entry point into the one decision workspace. + // Preserve useful legacy context while removing device-specific product trees. + const shouldUseRedesignLanding = location.pathname === "/"; if (shouldUseRedesignLanding) { + const legacySurface = rootSurface; rootParams.delete("surface"); + + const legacyReportId = rootParams.get("reportId"); + if (legacyReportId && !rootParams.has("report")) { + rootParams.set("report", legacyReportId); + rootParams.delete("reportId"); + } + + if ( + legacySurface && + legacySurface !== "home" && + legacySurface !== "ask" && + legacySurface !== "chat" && + legacySurface !== "workspace" + ) { + const intent = legacySurface === "packets" || legacySurface === "reports" + ? "reports" + : legacySurface === "history" || legacySurface === "inbox" + ? "attention" + : legacySurface === "me" + ? "account" + : legacySurface; + rootParams.set("intent", intent); + } + const nextSearch = rootParams.toString(); - return ; + return ; } // Phase 7c — print-friendly edition route at /redesign/edition/print. @@ -369,72 +288,30 @@ function App() { ); } - // Standalone route: /reports/:reportId/graph renders the canonical - // entity graph workspace. Must match BEFORE the /report/ startsWith - // check below (which would also match /reports/). - const graphRouteMatch = location.pathname.match( - /^\/reports\/([^/]+)\/graph\/?$/, + // Retired main-site report editors are context aliases, not peer surfaces. + // Recursive report editing remains available only on the Workspace host. + const reportWorkspaceRouteMatch = location.pathname.match( + /^\/reports\/([^/]+)(?:\/(brief|cards|notebook|sources|graph|map))?\/?$/, ); - if (graphRouteMatch) { - return ( - - - }> -
- -
-
-
-
- ); - } - - // without any cockpit shell — the workspace owns its own header. - // Lightweight web report notebook. This stays on nodebenchai.com for quick - // memo cleanup while full recursive work remains in Workspace. - const reportNotebookRouteMatch = location.pathname.match( - /^\/reports\/([^/]+)\/notebook\/?$/, - ); - if (reportNotebookRouteMatch) { - return ( - - - }> -
- -
-
-
-
- ); - } - - const reportWorkspaceRouteMatch = getReportWorkspaceRouteFromPath( - location.pathname, - ); - if (reportWorkspaceRouteMatch) { - return ( - - - }> -
- -
-
-
-
- ); + if (reportWorkspaceRouteMatch && !isWorkspaceHost) { + let reportId = reportWorkspaceRouteMatch[1] ?? ""; + try { + reportId = decodeURIComponent(reportId); + } catch { + // Invalid HTTP percent escapes are rejected before React in production; + // keep this in-memory route boundary total for tests and BrowserRouter. + } + const artifact = reportWorkspaceRouteMatch[2] === "graph" + ? "map" + : reportWorkspaceRouteMatch[2] ?? "brief"; + const nextParams = new URLSearchParams(location.search); + nextParams.set("report", reportId); + nextParams.set("artifact", artifact); + nextParams.delete("tab"); + return ; } + // Singular /report/:id is a bounded public delivery route for guests. const isReportRoute = location.pathname.startsWith("/report/"); if (isReportRoute) { if (webmcpIsAuth) { @@ -595,75 +472,11 @@ function App() { ); } - return ( - - - -
- - - - - - - - - - }> - { }} - /> - - - {/* Global Fast Agent Panel for guests */} - - - - - - - - - - - - - }> - {showTutorial ? ( - - ) : ( - - )} - - - {/* Global Fast Agent Panel - controlled via context */} - - {/* Global Feedback Listener for audio/visual cues */} - - - - - - -
-
-
- ); + // The main site has no default cockpit. Any retired or unknown product path + // contracts into the same decision workspace. Purpose-built read-only + // delivery routes above, plus the dedicated Workspace hostname, retain their + // separate runtime contracts without becoming peer application surfaces. + return ; } export default App; diff --git a/src/design/designSystem.ts b/src/design/designSystem.ts index 7672eecf..1ce8f112 100644 --- a/src/design/designSystem.ts +++ b/src/design/designSystem.ts @@ -350,7 +350,7 @@ export function getNodeBenchAiDesignManifest(): AiDesignManifest { "no primitive drives a live stream — Convex hooks remain the data source (honesty contract)", "success-green is reserved for completed state and never used for selection", "source-merged, visual-proof-complete, and production-live-verified are distinct evidence states", - "web navigation remains Home - Reports - Chat - Inbox - Me; Workspace remains a separate deployed surface", + "NodeBench exposes one canonical decision workspace; reports, attention, settings, and receipts remain contextual states of that conversation", ], }, }; diff --git a/src/features/redesign/README.md b/src/features/redesign/README.md index 269118c7..112ab2e7 100644 --- a/src/features/redesign/README.md +++ b/src/features/redesign/README.md @@ -1,6 +1,6 @@ # `src/features/redesign/` -> Current NodeBench redesign surface mounted at `/redesign`. It is route-distinct from the ExactKit cockpit at `/?surface=...` and from ScratchNode's event room at `scratchnode.live`. +> NodeBench's canonical decision workspace, mounted at `/redesign/chat`. Every primary NodeBench entry point resolves to this same runtime-backed conversation; saved reports, attention, settings, artifacts, and receipts are contextual states rather than peer destinations. > > Read [docs/architecture/REDESIGN_ROADMAP.md](../../../docs/architecture/REDESIGN_ROADMAP.md) for the full architecture and journey. This README is the developer-facing inventory + extension cookbook. > @@ -12,17 +12,20 @@ ```bash npm run dev -# Open http://localhost:5200/redesign -# Toggle the floating "Phone" FAB (bottom-right) to preview the mobile shell -# Toggle the "◐" FAB next to it to switch light/dark mode +# Open http://localhost:5200/redesign/chat ``` -All redesign code is scoped to `[data-redesign]`. It shares live Convex runtime primitives with the product, but route ownership is explicit: `/redesign/*` is the current redesign, `/?surface=*` is the ExactKit cockpit, and `scratchnode.live/e/:slug` is the ScratchNode event room. +All redesign code is scoped to `[data-redesign]`. The active shell shares the live Convex runtime primitives with the product. Legacy `/redesign/*` paths preserve useful URL context, while retired or unknown main-site product paths replace into `/redesign/chat`; none mount the old cockpit. Purpose-built read-only delivery routes remain available, and Workspace stays isolated on its dedicated hostname rather than appearing as another main-site surface. --- ## File map +The active runtime tree is `RedesignShell` → `TopNav` + `ChatSurface` + the conditional +`RightInspector`. The remaining historical surface modules are retained only as +unmounted migration references while their reusable runtime fragments are folded into +the conversation; adding them back to `RedesignShell` violates the one-surface guard. + ``` src/features/redesign/ ├── README.md this file @@ -54,22 +57,19 @@ src/features/redesign/ | Path | Renders | |---|---| -| `/redesign` | HomeSurface | -| `/redesign/reports` | ReportsSurface | -| `/redesign/chat` | ChatSurface (with `RightInspector` Agent Runtime Inspector on desktop) | -| `/redesign/inbox` | InboxSurface | -| `/redesign/me` | MeSurface | -| `/redesign/workspace` | WorkspaceSurface (6-tab — Brief default) | +| `/redesign/chat` | The canonical `ChatSurface`, with a contextual runtime inspector after a run starts | +| `/redesign/chat/r/:hash` | The same conversation with an immutable receipt loaded as context and the composer still available | +| `/redesign`, `/redesign/reports/*`, `/redesign/inbox`, `/redesign/me`, `/redesign/workspace` | Context-preserving replace into `/redesign/chat` | +| Main-site `/reports/:id` and its brief/cards/notebook/sources/graph/map tabs | Report and artifact context in `/redesign/chat`; recursive editing stays on the Workspace hostname | +| Other retired or unknown main-site product paths | Replace into `/redesign/chat` without mounting the old cockpit | -Wired in [src/App.tsx](../../App.tsx) as a standalone route check before `/memo` — bypasses the cockpit entirely. +Compatibility aliases accept valid URL-encoded paths. Invalid percent-encoded HTTP +request targets are rejected with `400` by the hosting boundary before React runs; +NodeBench does not present those invalid targets as application deep links. -Important route split: +Wired in [src/App.tsx](../../App.tsx) as a standalone route check before `/memo` — bypasses the cockpit entirely. -| Route | Do not confuse with | -|---|---| -| `/redesign/chat` | `/?surface=chat` ExactKit cockpit | -| `/redesign/reports` | `/?surface=reports` ExactKit cockpit | -| `scratchnode.live/e/:slug` | NodeBench redesign or cockpit routes | +ScratchNode's `scratchnode.live/e/:slug` event room remains a separate product and runtime contract. --- diff --git a/src/features/redesign/RedesignShell.tsx b/src/features/redesign/RedesignShell.tsx index 8820a5f1..84dab4c9 100644 --- a/src/features/redesign/RedesignShell.tsx +++ b/src/features/redesign/RedesignShell.tsx @@ -1,512 +1,113 @@ /** - * RedesignShell — top-level container for the /redesign showcase. + * RedesignShell — NodeBench's single canonical decision workspace. * - * Mounts tokens.css + primitives.css, owns the URL-driven surface state, - * lays out the rail + main + (optional) right rail. - * - * Routes (parsed from useLocation, no nested router needed): - * /redesign → Home - * /redesign/reports → Reports - * /redesign/chat → Chat - * /redesign/inbox → Inbox - * /redesign/me → Me - * /redesign/workspace[/...] → Workspace (separate full-height surface) + * Every primary NodeBench entry point resolves to the same live ChatSurface. + * Saved reports, attention queues, settings, and reproducible receipts are + * contextual states of the conversation rather than competing destinations. */ import { useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { useConvexAuth } from "convex/react"; - -import { useViewportMobile } from "@/hooks/useViewportMobile"; import "./tokens.css"; import "./primitives.css"; import "./agent-workspace.css"; import { RightInspector, type AgentRailSnapshot } from "./components/RightInspector"; -import { MobileShell } from "./components/MobileShell"; import { TopNav } from "./components/TopNav"; -import { ReportNotebookView } from "./components/ReportNotebookView"; -import { CommandPalette, useCommandPalette } from "./components/CommandPalette"; -import { ShortcutsOverlay } from "./components/ShortcutsOverlay"; -import { - HomeV2BriefingRail, - HomeV2EditionRail, - HomeV2Surface, - PrototypeV2Center, - PrototypeV2LeftRail, - PrototypeV2RightRail, - type PrototypeSurface, -} from "./surfaces/HomeV2PrototypeSurface"; -import { ReportsSurface } from "./surfaces/ReportsSurface"; import { ChatSurface } from "./surfaces/ChatSurface"; -import { InboxSurface } from "./surfaces/InboxSurface"; -import { MeSurface } from "./surfaces/MeSurface"; -import { WorkspaceSurface } from "./surfaces/WorkspaceSurface"; -import { ReproducibleChatPage } from "./pages/ReproducibleChatPage"; -import { useLiveArtifacts, type LiveArtifactDetail } from "./hooks/useLiveArtifacts"; -import type { ReportCardData, SurfaceId } from "./fixtures"; +import type { LiveArtifactDetail } from "./hooks/useLiveArtifacts"; import { parseChatLaunchParams } from "./lib/chatContinuation"; +import { canonicalRedesignChatTarget, pathToChatHash } from "./lib/oneSurfaceRouting"; -const PATH_TO_SURFACE: Record = { - "": "home", - "/": "home", - "reports": "reports", - "chat": "chat", - "inbox": "inbox", - "me": "me", - "workspace": "workspace", -}; - -function pathToSurface(pathname: string): SurfaceId | "workspace" { - const rest = pathname.replace(/^\/redesign\/?/, "").split("/")[0] ?? ""; - return (PATH_TO_SURFACE[rest] ?? "home") as SurfaceId | "workspace"; -} - -function pathToReportId(pathname: string): string | null { - // /redesign/reports/ - const match = pathname.match(/^\/redesign\/reports\/([^/]+)/); - return match?.[1] ?? null; -} - -function pathToChatHash(pathname: string): string | null { - // /redesign/chat/r/ (Phase 3 reproducibility URL) - const match = pathname.match(/^\/redesign\/chat\/r\/([A-Za-z0-9_-]+)/); - return match?.[1] ?? null; -} - -function normalizeEntityKey(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); -} - -type WorkspaceTab = "brief" | "cards" | "notebook" | "sources" | "chat" | "map"; - -function workspaceParams(search: string): { reportId?: string; tab: WorkspaceTab } { - const params = new URLSearchParams(search); - const reportId = params.get("report") || undefined; - const tab = params.get("tab"); - const workspaceTab: WorkspaceTab = - tab === "cards" || tab === "notebook" || tab === "sources" || tab === "chat" || tab === "map" - ? tab - : "brief"; - return { reportId, tab: workspaceTab }; +function readInitialTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light"; + return window.localStorage.getItem("nodebench:redesign:theme") === "dark" ? "dark" : "light"; } export default function RedesignShell() { const location = useLocation(); const navigate = useNavigate(); - const [theme, setTheme] = useState<"light" | "dark">("light"); - const [forceMobile, setForceMobile] = useState(false); - const [wideMode, setWideMode] = useState(() => { - if (typeof window === "undefined") return false; - return window.localStorage.getItem("nodebench:redesign:wide-mode") === "1"; - }); - // Phase 7d preserves the redesign-specific 760px phone breakpoint. - // The shared hook is parameterized so other call-sites (CockpitLayout, - // ExactKit) can opt into their own breakpoint without forking. - const isMobile = useViewportMobile("(max-width: 760px)") || forceMobile; - const showQaChrome = useQaChromeFlag(location.search); - const cmdk = useCommandPalette(); - - const surface = useMemo(() => pathToSurface(location.pathname), [location.pathname]); - const reportId = useMemo(() => pathToReportId(location.pathname), [location.pathname]); - const chatHash = useMemo(() => pathToChatHash(location.pathname), [location.pathname]); - const workspace = useMemo(() => workspaceParams(location.search), [location.search]); - const chatLaunch = useMemo(() => parseChatLaunchParams(location.search), [location.search]); - const focusHomeComposer = useMemo( - () => new URLSearchParams(location.search).get("focus") === "home-composer", - [location.search], - ); - const isPrototypeKit = useMemo(() => { - const params = new URLSearchParams(location.search); - return params.get("qa") === "home-v2-implementation"; - }, [location.search]); - // BUG FIX (2026-07-01): the "me" right rail's guestSafe used to be hardcoded to - // `surface === "me"`, so a fully signed-in user's own real session was always shown as - // "Sign in to sync" with locked stats and a dead "Connect account" button. Derive it from - // the real Convex auth state instead — see docs/LIVE-USER-BENCHMARK-FINDING.md. - const { isAuthenticated: isRedesignAuthenticated } = useConvexAuth(); - const shellLiveArtifacts = useLiveArtifacts(24, { enabled: !isPrototypeKit }); - const [selectedReport, setSelectedReport] = useState(null); - const [prototypeEntity, setPrototypeEntity] = useState("Anthropic"); - const [reportsRailEntityMode, setReportsRailEntityMode] = useState(false); + const [theme, setTheme] = useState<"light" | "dark">(readInitialTheme); const [activeChatDetail, setActiveChatDetail] = useState(null); const [activeChatAgentRail, setActiveChatAgentRail] = useState(null); - const goSurface = (id: SurfaceId) => { - if (id !== "reports") { - setSelectedReport(null); - setReportsRailEntityMode(false); - } - const path = id === "home" ? "/redesign" : `/redesign/${id}`; - navigate(isPrototypeKit ? `${path}?qa=home-v2-implementation` : path); - }; - const sendPromptToChat = (prompt: string, context?: { reportId?: string; artifactKey?: string }) => { - const params = new URLSearchParams(); - if (isPrototypeKit) params.set("qa", "home-v2-implementation"); - params.set("q", prompt); - if (context?.reportId) params.set("report", context.reportId); - if (context?.artifactKey) params.set("artifact", context.artifactKey); - const query = params.toString(); - navigate(`/redesign/chat?${query}`); - }; - const openTouchedReports = (reportId?: string) => { - if (reportId && !isPrototypeKit) { - navigate(`/redesign/reports/${encodeURIComponent(reportId)}`); - return; - } - navigate(isPrototypeKit ? "/redesign/reports?qa=home-v2-implementation" : "/redesign/reports"); - }; - const selectPrototypeRailEntity = (entity: string) => { - setPrototypeEntity(entity); - if (!isPrototypeKit && surface === "reports") { - setSelectedReport(null); - setReportsRailEntityMode(true); - } - }; - const selectLiveReport = (report: ReportCardData | null) => { - setReportsRailEntityMode(false); - setSelectedReport(report); - if (report) setPrototypeEntity(report.entity); - }; - const routeReport = surface === "reports" && reportId - ? shellLiveArtifacts.reports.find((report) => report.id === reportId) ?? null - : null; - const selectedRailReport = - surface === "reports" && !reportsRailEntityMode - ? routeReport ?? - (selectedReport && normalizeEntityKey(selectedReport.entity) === normalizeEntityKey(prototypeEntity) - ? selectedReport - : null) - : null; - // Optionally lock body scroll while shell is mounted - useEffect(() => { - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = prev; - }; - }, []); + const canonicalTarget = useMemo( + () => canonicalRedesignChatTarget(location.pathname, location.search), + [location.pathname, location.search], + ); + const chatLaunch = useMemo(() => parseChatLaunchParams(location.search), [location.search]); + const routeChatHash = useMemo(() => pathToChatHash(location.pathname), [location.pathname]); + const continuationHash = chatLaunch.continuationHash ?? routeChatHash ?? undefined; + const showChatInspector = Boolean(activeChatAgentRail?.hasIntent); useEffect(() => { - window.localStorage.setItem("nodebench:redesign:wide-mode", wideMode ? "1" : "0"); - }, [wideMode]); + if (canonicalTarget) navigate(canonicalTarget, { replace: true }); + }, [canonicalTarget, navigate]); useEffect(() => { - const handler = (event: KeyboardEvent) => { - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; - if (event.key.toLowerCase() !== "w") return; - const target = event.target as HTMLElement | null; - if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) return; - event.preventDefault(); - setWideMode((current) => !current); + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previous; }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); }, []); - // Phase 7d (2026-05-08): editorial is the default at /redesign on - // both mobile + desktop. Legacy is opt-out via `?classic=1` - // (canonical) or `?edition=0` (back-compat). Per - // docs/architecture/HOME_EDITORIAL_REDESIGN.md §3 Variant C - // ("mobile and desktop are identical"), the editorial single-column - // layout bypasses MobileShell at the home surface so it stays - // responsive at any width. - const isEditionFlag = - typeof window === "undefined" || - (() => { - const params = new URLSearchParams(location.search); - if (params.get("classic") === "1") return false; - if (params.get("edition") === "0") return false; - return true; - })(); - - // Chat keeps the canonical runtime-backed workspace at every breakpoint. - // The former MobileShell chat was a separate static tree with no submit/run - // contract, so it could not honestly represent or continue an agent run. - if (isMobile && surface !== "workspace" && surface !== "chat" && !isPrototypeKit) { - const mobileSurface: SurfaceId = (surface === "workspace" ? "reports" : surface) as SurfaceId; - // Edition flag bypasses MobileShell at the home surface so the - // single-column editorial layout stays responsive at any width. - if (isEditionFlag && mobileSurface === "home") { - return ( -
- - {showQaChrome && } - {showQaChrome && } - - cmdk.setOpen(false)} /> - -
- ); - } - return ( -
- goSurface(id)} /> - {showQaChrome && } - {showQaChrome && } - - cmdk.setOpen(false)} /> - -
- ); - } - - if (surface === "workspace") { - return ( -
-
- -
- {showQaChrome && } - cmdk.setOpen(false)} /> - -
- ); - } - - // Phase 3 chat hashes and report detail pages own the full width. Other - // desktop surfaces keep a right-side agent rail so the center can stay calm. - const prototypeSurface = surface as PrototypeSurface; - const isHomeV2 = surface === "home" || isPrototypeKit; - const usesV2Canvas = - surface === "home" || - surface === "reports" || - surface === "chat" || - surface === "inbox" || - surface === "me" || - isPrototypeKit; - const suppressRightRail = - !isPrototypeKit && - surface === "chat" && - Boolean(chatHash); - const showChatInspector = surface === "chat" && Boolean(activeChatAgentRail?.hasIntent); + useEffect(() => { + window.localStorage.setItem("nodebench:redesign:theme", theme); + }, [theme]); return (
goSurface(id)} - onOpenPalette={() => cmdk.setOpen(true)} theme={theme} - onToggleTheme={() => setTheme(theme === "light" ? "dark" : "light")} - wideMode={wideMode} - onToggleWideMode={() => setWideMode((current) => !current)} - prototypeMode={isPrototypeKit} + onToggleTheme={() => setTheme((current) => current === "light" ? "dark" : "light")} /> +
- {surface === "chat" && !isPrototypeKit ? null : isPrototypeKit ? ( - - ) : isHomeV2 ? ( - - ) : ( - - )} - - {suppressRightRail ? ( -
- {surface === "chat" && chatHash && } - {surface === "reports" && reportId && } -
- ) : ( -
-
- {/* Keyed wrapper forces React to remount on surface change, - triggering the rd-surface-enter CSS animation reliably. */} -
- {isPrototypeKit && ( - - )} - {!isPrototypeKit && surface === "chat" && ( - - )} - {!isPrototypeKit && surface === "home" && ( - - )} - {!isPrototypeKit && surface === "reports" && !reportId && ( - { - if (tab === "brief") navigate(`/redesign/reports/${id}`); - else navigate(`/redesign/workspace?report=${id}&tab=${tab}`); - }} - inspectedReportId={reportsRailEntityMode ? "__rail_entity_override__" : selectedReport?.id ?? null} - onSelectReport={selectLiveReport} - onRunBatch={sendPromptToChat} - /> - )} - {!isPrototypeKit && surface === "reports" && reportId && } - {!isPrototypeKit && surface === "inbox" && } - {!isPrototypeKit && surface === "me" && } -
-
- {isPrototypeKit ? ( - +
+
+ - ) : surface === "home" ? ( - - ) : surface === "chat" && showChatInspector ? ( - - ) : surface === "chat" ? ( - null - ) : ( - - )} -
- )} -
- - {showQaChrome && } - {showQaChrome && } - - - {/* Cross-surface primitives — Cmd+K palette, ? shortcuts, toasts */} - cmdk.setOpen(false)} /> - -
- ); -} - -function ReportDetailRoute({ reportId }: { reportId: string }) { - const liveArtifacts = useLiveArtifacts(60); - const liveDetail = liveArtifacts.details.find((detail) => detail.id === reportId); - return ; -} - -function useQaChromeFlag(search: string): boolean { - return useMemo(() => { - const params = new URLSearchParams(search); - if (params.get("qaChrome") === "1" || params.get("debugUi") === "1") return true; - try { - return window.localStorage.getItem("nodebench-redesign-qa-chrome") === "1"; - } catch { - return false; - } - }, [search]); -} - -function ViewportFab({ forceMobile, setForceMobile }: { forceMobile: boolean; setForceMobile: (v: boolean) => void }) { - return ( - - ); -} +
+ -function ThemeFab({ theme, setTheme }: { theme: "light" | "dark"; setTheme: (t: "light" | "dark") => void }) { - return ( - - ); -} + {showChatInspector && ( + + )} + + -function NavBanner({ pathname }: { pathname: string }) { - // Visual signal: which redesign surface is mounted (in-DOM signal for live verification) - return ( - ); } diff --git a/src/features/redesign/agent-workspace.css b/src/features/redesign/agent-workspace.css index 9c972805..e2ef54fb 100644 --- a/src/features/redesign/agent-workspace.css +++ b/src/features/redesign/agent-workspace.css @@ -5,16 +5,11 @@ grid-template-columns: minmax(0, 1fr); } -[data-redesign][data-wide="true"] .rd-shell.rd-shell--chat-focus { - grid-template-columns: minmax(0, 1fr); -} - [data-redesign] .rd-shell--chat-v3 .rd-shell__main { grid-template-columns: minmax(0, 1fr) 320px; } -[data-redesign] .rd-shell--chat-v3.rd-shell--chat-no-inspector .rd-shell__main, -[data-redesign][data-wide="true"] .rd-shell--chat-v3.rd-shell--chat-no-inspector .rd-shell__main { +[data-redesign] .rd-shell--chat-v3.rd-shell--chat-no-inspector .rd-shell__main { grid-template-columns: minmax(0, 1fr); } @@ -140,6 +135,60 @@ margin: 0 auto; } +.rd-account-trigger { + display: grid; + place-items: center; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid transparent; + border-radius: 50%; + background: var(--rd-accent-soft); + color: var(--rd-accent-strong); + font-family: var(--rd-font-mono); + font-size: 9.5px; + font-weight: 700; + cursor: pointer; +} + +.rd-account-trigger:focus-visible { + outline: 2px solid var(--rd-accent); + outline-offset: 2px; +} + +.rd-account-menu { + min-width: 170px; + border-color: var(--rd-line); + background: var(--rd-panel); + color: var(--rd-ink); + font-family: var(--rd-font-sans); +} + +[data-redesign] .rd-launch-context { + display: grid; + gap: 4px; + width: min(100%, 840px); + margin: 0 auto 12px; + padding: 11px 13px; + border: 1px solid var(--rd-line-faint); + border-left: 2px solid var(--rd-accent); + border-radius: var(--rd-r-sm); + background: var(--rd-paper-warm); +} + +[data-redesign] .rd-launch-context strong { + color: var(--rd-ink-strong); + font-size: 13px; + line-height: 1.35; +} + +[data-redesign] .rd-launch-context p { + margin: 0; + color: var(--rd-ink-mute); + font-size: 12px; + line-height: 1.45; +} + [data-redesign] .rd-run-scope { margin: 0 4px 8px; color: var(--rd-ink-mute); @@ -247,11 +296,21 @@ gap: 8px; } - [data-redesign] .rd-run-scope > summary span:nth-of-type(2), + [data-redesign] .rd-run-scope > summary span:nth-of-type(2) { + display: block; + min-width: 0; + flex: 1; + max-width: none; + } + [data-redesign] .rd-run-scope > summary span:nth-of-type(3) { display: none; } + [data-redesign] .rd-run-scope > summary span:nth-of-type(4) { + flex: 0 0 auto; + } + [data-redesign] .rd-chat-transcript__content { padding: var(--rd-s-3) var(--rd-s-3) var(--rd-s-6); } diff --git a/src/features/redesign/components/RightInspector.tsx b/src/features/redesign/components/RightInspector.tsx index 1cb07a6a..4bf98c18 100644 --- a/src/features/redesign/components/RightInspector.tsx +++ b/src/features/redesign/components/RightInspector.tsx @@ -185,7 +185,7 @@ function SessionContextPanel({ snapshot }: { snapshot: AgentRailSnapshot }) { {contract.reviewHref && (
- Open review workspace + Review sources here Inspect sources and proposed changes before any shared write.
)} @@ -426,8 +426,8 @@ function buildFallbackAgentSnapshot(detail: LiveArtifactDetail | undefined, isLo ], artifacts: hasDetail ? [ - { id: "artifact", label: detail.title, status: "done", detail: detail.kind, href: `/redesign/workspace?report=${encodeURIComponent(detail.id)}&tab=brief` }, - { id: "notebook", label: "Notebook handoff", status: detail.notebookHtml ? "done" : "queued", detail: detail.notebookHtml ? "Draft available" : "No notebook draft yet", href: `/redesign/workspace?report=${encodeURIComponent(detail.id)}&tab=notebook` }, + { id: "artifact", label: detail.title, status: "done", detail: detail.kind, href: `/redesign/chat?report=${encodeURIComponent(detail.id)}&artifact=brief` }, + { id: "notebook", label: "Notebook handoff", status: detail.notebookHtml ? "done" : "queued", detail: detail.notebookHtml ? "Draft available" : "No notebook draft yet", href: `/redesign/chat?report=${encodeURIComponent(detail.id)}&artifact=notebook` }, ] : [{ id: "empty", label: "No artifacts yet", status: "queued", detail: "Start with a question, source, or report." }], backgroundAgents: [ @@ -623,10 +623,10 @@ function GraphPanel({ detail, nodes }: { detail?: LiveArtifactDetail; nodes: Arr {detail ? ( - Open Map + Use map context ) : ( No map yet @@ -705,17 +705,17 @@ function ThreadsPanel({ detail }: { detail?: LiveArtifactDetail }) { { title: detail ? `Review ${detail.kind.toLowerCase()} claims` : "Start a research thread", when: detail?.updatedAt ?? "now", - href: detail ? `/redesign/workspace?report=${encodeURIComponent(detail.id)}&tab=chat` : "/redesign/chat", + href: detail ? `/redesign/chat?report=${encodeURIComponent(detail.id)}` : "/redesign/chat", }, { - title: detail ? "Open notebook handoff" : "Attach sources", + title: detail ? "Use notebook context" : "Attach sources", when: "next", - href: detail ? `/redesign/workspace?report=${encodeURIComponent(detail.id)}&tab=notebook` : "/redesign/workspace?tab=sources", + href: detail ? `/redesign/chat?report=${encodeURIComponent(detail.id)}&artifact=notebook` : "/redesign/chat?intent=sources", }, { - title: detail ? "Export reusable memory" : "Create report", + title: detail ? "Use brief context" : "Find saved research", when: "later", - href: detail ? `/redesign/workspace?report=${encodeURIComponent(detail.id)}&tab=brief` : "/redesign/reports", + href: detail ? `/redesign/chat?report=${encodeURIComponent(detail.id)}&artifact=brief` : "/redesign/chat?intent=reports", }, ]; diff --git a/src/features/redesign/components/TopNav.test.tsx b/src/features/redesign/components/TopNav.test.tsx index a21db818..e46daa6b 100644 --- a/src/features/redesign/components/TopNav.test.tsx +++ b/src/features/redesign/components/TopNav.test.tsx @@ -1,111 +1,64 @@ -/** - * TopNav — unified top horizontal navigation scenarios. - * - * Scenario: Returning user navigating /redesign expects parity with cockpit - * - * Who: A returning NodeBench user who has been working in the main - * cockpit (which has a sticky top horizontal nav: brand · 5 tabs - * · search · theme · profile). They land on /redesign and - * expect the same affordances — without leaving the redesign's - * editorial token system. - * What: They expect to see (a) the brand mark, (b) all 5 surface tabs - * with the active one highlighted, (c) a search chip with the - * Ctrl K keyboard hint, (d) a theme toggle, (e) a profile/sign-in - * slot. They expect aria-current on the active tab and Alt+1..5 - * to jump between surfaces. - * Scale: Single-component render. At production scale this nav - * mounts on every desktop /redesign view that isn't the - * standalone workspace; correctness here is rendered on - * every page load. - * Duration: Short-running. No state accumulation. - * Failure mode covered: missing tab, wrong active state, missing Ctrl K - * shortcut chip, palette-trigger callback silently dropped. - */ -import { describe, expect, it, vi } from "vitest"; -import { render, fireEvent } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render } from "@testing-library/react"; + import { TopNav } from "./TopNav"; -// The TopNav reaches into Convex auth (account CTA + auth state). -// In the test environment we stub these to a deterministic guest state. +const authMocks = vi.hoisted(() => ({ + signIn: vi.fn(), + signOut: vi.fn(), + state: { isAuthenticated: false, isLoading: false }, +})); + vi.mock("@convex-dev/auth/react", () => ({ - useAuthActions: () => ({ signIn: vi.fn() }), + useAuthActions: () => ({ signIn: authMocks.signIn, signOut: authMocks.signOut }), })); vi.mock("convex/react", () => ({ - useConvexAuth: () => ({ isAuthenticated: false, isLoading: false }), + useConvexAuth: () => authMocks.state, })); -function renderTopNav(overrides: Partial> = {}) { - const props: React.ComponentProps = { - active: "home", - onChange: vi.fn(), - onOpenPalette: vi.fn(), - theme: "light", - onToggleTheme: vi.fn(), - ...overrides, - }; - const utils = render( - -
- -
-
, - ); - return { ...utils, props }; -} - -describe("TopNav — unified top horizontal nav (Bug 2)", () => { - it("renders all 5 primary nav items in order", () => { - const { getByRole } = renderTopNav(); - const nav = getByRole("navigation", { name: /primary navigation/i }); - const tabs = nav.querySelectorAll("button"); - expect(tabs.length).toBe(5); - expect(Array.from(tabs).map((b) => b.textContent?.trim())).toEqual([ - "Home", - "Reports", - "Chat", - "Inbox", - "Me", - ]); +describe("TopNav — single-surface header", () => { + beforeEach(() => { + authMocks.signIn.mockReset(); + authMocks.signOut.mockReset(); + authMocks.state.isAuthenticated = false; + authMocks.state.isLoading = false; }); - it("marks the active item with aria-current=page", () => { - const { getByRole } = renderTopNav({ active: "reports" }); - const nav = getByRole("navigation", { name: /primary navigation/i }); - const current = nav.querySelectorAll('[aria-current="page"]'); - expect(current.length).toBe(1); - expect(current[0].textContent?.trim()).toBe("Reports"); + it("keeps identity and utilities without advertising another product surface", () => { + const { getByRole, getByText, queryByRole, queryByText } = render( + , + ); + + expect(getByRole("banner")).toBeTruthy(); + expect(getByRole("link", { name: "NodeBench decision workspace" })).toHaveAttribute("href", "/redesign/chat"); + expect(getByText("Decision workspace")).toBeTruthy(); + expect(getByRole("button", { name: "Sign in" })).toBeTruthy(); + expect(getByRole("button", { name: "Switch to dark mode" })).toBeTruthy(); + expect(queryByRole("navigation")).toBeNull(); + expect(queryByText("Home")).toBeNull(); + expect(queryByText("Reports")).toBeNull(); + expect(queryByText("Inbox")).toBeNull(); + expect(queryByText("Me")).toBeNull(); }); - it("exposes the Ctrl K shortcut chip on the search button", () => { - const { container } = renderTopNav(); - const searchBtn = container.querySelector( - "[data-rd-topnav-search]", - ) as HTMLButtonElement | null; - expect(searchBtn).not.toBeNull(); - expect(searchBtn?.getAttribute("aria-keyshortcuts")).toMatch(/K/); - const kbd = container.querySelector("[data-rd-topnav-kbd]"); - expect(kbd?.textContent?.trim()).toBe("Ctrl K"); + it("keeps theme switching explicit and keyboard reachable", () => { + const onToggleTheme = vi.fn(); + const { getByRole } = render(); + fireEvent.click(getByRole("button", { name: "Switch to dark mode" })); + expect(onToggleTheme).toHaveBeenCalledTimes(1); }); - it("invokes onOpenPalette when the search button is clicked", () => { - const { container, props } = renderTopNav(); - const searchBtn = container.querySelector( - "[data-rd-topnav-search]", - ) as HTMLButtonElement; - fireEvent.click(searchBtn); - expect(props.onOpenPalette).toHaveBeenCalledTimes(1); + it("labels authentication honestly", () => { + const { getByRole } = render(); + fireEvent.click(getByRole("button", { name: "Sign in" })); + expect(authMocks.signIn).toHaveBeenCalledWith("google", expect.objectContaining({ redirectTo: expect.any(String) })); }); - it("invokes onChange when a tab is clicked", () => { - const { getByRole, props } = renderTopNav(); - const nav = getByRole("navigation", { name: /primary navigation/i }); - const inboxTab = Array.from(nav.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === "Inbox", - ); - expect(inboxTab).toBeDefined(); - fireEvent.click(inboxTab!); - expect(props.onChange).toHaveBeenCalledWith("inbox"); + it("gives signed-in users a keyboard-reachable account menu", () => { + authMocks.state.isAuthenticated = true; + const { getByRole } = render(); + + expect(getByRole("button", { name: "Account menu" })).toHaveAttribute("aria-haspopup", "menu"); }); }); diff --git a/src/features/redesign/components/TopNav.tsx b/src/features/redesign/components/TopNav.tsx index fe97960e..2d492b57 100644 --- a/src/features/redesign/components/TopNav.tsx +++ b/src/features/redesign/components/TopNav.tsx @@ -1,295 +1,127 @@ /** - * TopNav — unified top horizontal bar for /redesign. - * - * Mirrors the affordances of src/layouts/ProductTopNav.tsx (the cockpit's - * top nav) but lives in the redesign token namespace (--rd-*) so it does - * not pull cockpit Tailwind classes into the isolated shell. - * - * Slots: - * left — brand mark `N` + "NodeBench AI" wordmark, links to /redesign - * center — 5 nav tabs (Home / Reports / Chat / Inbox / Me) with active - * state, aria-current, Alt+1..Alt+5 shortcuts - * right — search button (opens CommandPalette via passed callback) - * + theme toggle + profile chip (initials / "Sign in") - * - * A11y: - * -