From 4f027dba75daf1e6dbf5ff72aca851fc448cebca Mon Sep 17 00:00:00 2001 From: homen Date: Fri, 10 Jul 2026 14:41:18 -0700 Subject: [PATCH 1/6] Launch mobile terracotta and governed artifacts Covers .env, Convex, docs, e2e, index, package, scripts, src, and tests. --- .env.example | 7 + convex/_generated/api.d.ts | 4 + convex/agentJobRunner.ts | 2 +- convex/auth.config.ts | 10 + convex/auth.ts | 11 + convex/http.ts | 2 + convex/lib.ts | 9 +- convex/notebookKernel.ts | 27 + convex/rooms.ts | 84 +- convex/schema.ts | 4 + docs/design/COMPONENT_MAP.md | 77 +- docs/design/DECK_STORYBOARD_CONTRACT.md | 114 + docs/design/DESIGN_PARITY_PLAN.md | 14 +- ...OBILE_TERRACOTTA_WORK_ARTIFACT_CONTRACT.md | 108 + .../design/NOTEBOOK_WORK_ARTIFACT_CONTRACT.md | 112 + docs/design/UI_CONTRACT.md | 67 +- .../first-run/FIRST_RUN_BEHAVIOR_INVENTORY.md | 33 + .../first-run/FIRST_RUN_EXECUTION_RECEIPT.md | 121 + .../first-run/FIRST_RUN_JOURNEY_CONTRACT.md | 79 + docs/design/first-run/FIRST_RUN_QA_MATRIX.md | 54 + .../first-run/PRIVACY_LANGUAGE_CONTRACT.md | 21 + .../mobile/MOBILE_BEHAVIOR_INVENTORY.md | 104 + docs/design/mobile/MOBILE_COMPONENT_MAP.md | 30 + docs/design/mobile/MOBILE_DIFF_PLAN.md | 56 + docs/design/mobile/MOBILE_HEADER_CONTRACT.md | 79 + .../mobile/MOBILE_REFERENCE_INVENTORY.md | 92 + docs/design/mobile/MOBILE_TASTE_AUDIT.md | 75 + docs/design/mobile/MOBILE_VISUAL_QA_REPORT.md | 138 ++ .../mobile/mobile-visual-judge-after.json | 116 + .../mobile/mobile-visual-judge-before.json | 50 + ...DEROOM_MOBILE_LAUNCH_READINESS_20260710.md | 92 + .../MOBILE_TERRACOTTA_RALPH_RECEIPT.md | 196 ++ .../WORK_ARTIFACTS_BASELINE_RECEIPT.md | 66 + ...TIFACTS_IMPLEMENTATION_AND_DOGFOOD_PLAN.md | 575 +++++ .../WORK_ARTIFACTS_PROGRESS_RECEIPT.md | 666 ++++++ e2e/deployed-auth-first-user.spec.ts | 130 ++ .../demo-room-mobile-chromium-linux.png | Bin 78928 -> 49417 bytes .../demo-room-mobile-chromium-win32.png | Bin 78928 -> 52008 bytes e2e/first-run-launch.spec.ts | 91 + e2e/mobile-story-surfaces.spec.ts | 52 +- e2e/mobile-terracotta-contract.spec.ts | 249 ++ index.html | 90 +- package-lock.json | 205 ++ package.json | 8 +- scripts/convex-deploy-verify.ts | 6 +- scripts/release-worktree-guard.ts | 26 + src/app/main.tsx | 8 +- src/app/store.tsx | 41 +- src/app/styles.css | 80 +- src/auth/launchAuth.ts | 29 + src/design/designSystem.ts | 79 +- src/engine/types.ts | 4 + src/landing/boot.ts | 11 +- src/notebook/notebookKernel.ts | 291 +++ src/ui/App.tsx | 305 ++- src/ui/Chat.tsx | 113 +- src/ui/Landing.tsx | 166 +- src/ui/RoomShell.tsx | 129 +- src/ui/artifactRefs.ts | 43 +- src/ui/auth/AccountGate.tsx | 99 + src/ui/auth/accountGate.css | 84 + src/ui/graph/EntityGraphDetailPanel.tsx | 20 + src/ui/graph/semanticGraph.ts | 394 +++- src/ui/graph/semanticGraphClusters.ts | 80 + src/ui/graph/semanticGraphLayout.ts | 3 + src/ui/graph/semanticGraphPaths.ts | 164 ++ src/ui/graph/semanticGraphSelectors.ts | 6 +- src/ui/graph/semanticGraphTypes.ts | 19 + src/ui/mobile/MobileApp.tsx | 318 ++- src/ui/mobile/MobileAppLive.tsx | 242 +- src/ui/mobile/MobileChat.tsx | 55 +- src/ui/mobile/MobileDeck.tsx | 245 +- src/ui/mobile/MobileFiles.tsx | 49 +- src/ui/mobile/MobileFrame.tsx | 43 +- src/ui/mobile/MobileGapSheets.tsx | 55 +- src/ui/mobile/MobileGrid.tsx | 88 + src/ui/mobile/MobileIcons.tsx | 5 + src/ui/mobile/MobileOverlay.tsx | 70 +- src/ui/mobile/MobileRoot.tsx | 527 +++-- src/ui/mobile/MobileScreens.tsx | 53 +- src/ui/mobile/MobileSettings.tsx | 259 ++- src/ui/mobile/MobileSheets.tsx | 135 +- src/ui/mobile/RoomJoinConsent.tsx | 199 +- src/ui/mobile/mobile.css | 252 +- src/ui/mobile/mobile.shell.css | 392 ++++ src/ui/mobile/mobile.tokens.css | 167 ++ src/ui/mobile/mobileData.ts | 3 + src/ui/mobile/mobileFrame.css | 328 ++- src/ui/mobile/mobileSettings.css | 46 +- src/ui/mobile/mobileTweaks.ts | 2 +- src/ui/mobile/mobileTypes.ts | 22 + src/ui/mobile/shell/MobileHeader.tsx | 133 ++ src/ui/panels/Artifact.tsx | 59 +- src/ui/panels/KnowledgeGraph.tsx | 96 +- .../workArtifacts/DeckStoryboardWorkbench.tsx | 386 +++ .../GraphRelationshipReviewWorkbench.tsx | 115 + .../workArtifacts/LivePerformanceCenter.tsx | 96 + .../workArtifacts/NotebookDigestWorkbench.tsx | 338 +++ src/ui/workArtifacts/ProposalReviewCenter.tsx | 264 +++ src/ui/workArtifacts/TraceReplayWorkbench.tsx | 156 ++ src/ui/workArtifacts/WorkArtifactsPanel.tsx | 402 ++++ src/ui/workArtifacts/collaborativeDeck.ts | 211 ++ src/ui/workArtifacts/deckPatchPlan.ts | 159 ++ src/ui/workArtifacts/deckPdfExport.ts | 180 ++ src/ui/workArtifacts/deckPptxExport.ts | 266 +++ src/ui/workArtifacts/deckPreviewExport.ts | 150 ++ src/ui/workArtifacts/deckStoryboard.ts | 286 +++ .../workArtifacts/graphRelationshipReview.ts | 194 ++ src/ui/workArtifacts/index.ts | 23 + .../workArtifacts/livePerformanceSummary.ts | 144 ++ .../workArtifacts/notebookExecutionPreview.ts | 215 ++ src/ui/workArtifacts/notebookKernelAdapter.ts | 73 + src/ui/workArtifacts/notebookPatchDiff.ts | 80 + src/ui/workArtifacts/notebookStructure.ts | 442 ++++ src/ui/workArtifacts/notebookTypedBlocks.ts | 92 + src/ui/workArtifacts/proofBundleExport.ts | 129 + src/ui/workArtifacts/proofBundleReceipt.ts | 139 ++ src/ui/workArtifacts/traceReplaySummary.ts | 151 ++ src/ui/workArtifacts/work-artifacts.css | 2072 +++++++++++++++++ src/ui/workArtifacts/workArtifactAdapters.ts | 329 +++ src/ui/workArtifacts/workArtifactTypes.ts | 127 + src/vite-env.d.ts | 4 + tests/accountGate.test.tsx | 36 + tests/artifactRefs.test.ts | 15 +- tests/authSessionPolicy.test.ts | 62 +- tests/createRoomAtomicity.test.ts | 41 +- tests/designPrototypeParity.test.ts | 9 +- tests/designSystemManifest.test.ts | 61 +- tests/launchAuth.test.ts | 39 + tests/mobileAgentModelRouting.test.tsx | 77 +- tests/mobileDeckLive.test.tsx | 212 ++ tests/mobileGapScreens.test.tsx | 11 +- tests/mobileHeader.test.tsx | 100 + tests/mobileLiveHonesty.test.tsx | 166 ++ tests/mobileShellAdapters.test.tsx | 85 + tests/nativeNotebookProsemirror.test.ts | 17 +- tests/notebookKernel.test.ts | 80 + tests/notebookProcessingTarget.test.ts | 11 +- tests/proofloopAgentHostEnforcement.test.ts | 4 +- tests/roomFullNoFlash.test.tsx | 11 +- tests/semanticGraph.test.ts | 103 + tests/workArtifacts.test.ts | 821 +++++++ 142 files changed, 17997 insertions(+), 1350 deletions(-) create mode 100644 convex/auth.config.ts create mode 100644 convex/auth.ts create mode 100644 convex/notebookKernel.ts create mode 100644 docs/design/DECK_STORYBOARD_CONTRACT.md create mode 100644 docs/design/MOBILE_TERRACOTTA_WORK_ARTIFACT_CONTRACT.md create mode 100644 docs/design/NOTEBOOK_WORK_ARTIFACT_CONTRACT.md create mode 100644 docs/design/first-run/FIRST_RUN_BEHAVIOR_INVENTORY.md create mode 100644 docs/design/first-run/FIRST_RUN_EXECUTION_RECEIPT.md create mode 100644 docs/design/first-run/FIRST_RUN_JOURNEY_CONTRACT.md create mode 100644 docs/design/first-run/FIRST_RUN_QA_MATRIX.md create mode 100644 docs/design/first-run/PRIVACY_LANGUAGE_CONTRACT.md create mode 100644 docs/design/mobile/MOBILE_BEHAVIOR_INVENTORY.md create mode 100644 docs/design/mobile/MOBILE_COMPONENT_MAP.md create mode 100644 docs/design/mobile/MOBILE_DIFF_PLAN.md create mode 100644 docs/design/mobile/MOBILE_HEADER_CONTRACT.md create mode 100644 docs/design/mobile/MOBILE_REFERENCE_INVENTORY.md create mode 100644 docs/design/mobile/MOBILE_TASTE_AUDIT.md create mode 100644 docs/design/mobile/MOBILE_VISUAL_QA_REPORT.md create mode 100644 docs/design/mobile/mobile-visual-judge-after.json create mode 100644 docs/design/mobile/mobile-visual-judge-before.json create mode 100644 docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md create mode 100644 docs/synthesis/MOBILE_TERRACOTTA_RALPH_RECEIPT.md create mode 100644 docs/synthesis/WORK_ARTIFACTS_BASELINE_RECEIPT.md create mode 100644 docs/synthesis/WORK_ARTIFACTS_IMPLEMENTATION_AND_DOGFOOD_PLAN.md create mode 100644 docs/synthesis/WORK_ARTIFACTS_PROGRESS_RECEIPT.md create mode 100644 e2e/deployed-auth-first-user.spec.ts create mode 100644 e2e/first-run-launch.spec.ts create mode 100644 e2e/mobile-terracotta-contract.spec.ts create mode 100644 scripts/release-worktree-guard.ts create mode 100644 src/auth/launchAuth.ts create mode 100644 src/notebook/notebookKernel.ts create mode 100644 src/ui/auth/AccountGate.tsx create mode 100644 src/ui/auth/accountGate.css create mode 100644 src/ui/graph/semanticGraphClusters.ts create mode 100644 src/ui/graph/semanticGraphPaths.ts create mode 100644 src/ui/mobile/mobile.shell.css create mode 100644 src/ui/mobile/mobile.tokens.css create mode 100644 src/ui/mobile/shell/MobileHeader.tsx create mode 100644 src/ui/workArtifacts/DeckStoryboardWorkbench.tsx create mode 100644 src/ui/workArtifacts/GraphRelationshipReviewWorkbench.tsx create mode 100644 src/ui/workArtifacts/LivePerformanceCenter.tsx create mode 100644 src/ui/workArtifacts/NotebookDigestWorkbench.tsx create mode 100644 src/ui/workArtifacts/ProposalReviewCenter.tsx create mode 100644 src/ui/workArtifacts/TraceReplayWorkbench.tsx create mode 100644 src/ui/workArtifacts/WorkArtifactsPanel.tsx create mode 100644 src/ui/workArtifacts/collaborativeDeck.ts create mode 100644 src/ui/workArtifacts/deckPatchPlan.ts create mode 100644 src/ui/workArtifacts/deckPdfExport.ts create mode 100644 src/ui/workArtifacts/deckPptxExport.ts create mode 100644 src/ui/workArtifacts/deckPreviewExport.ts create mode 100644 src/ui/workArtifacts/deckStoryboard.ts create mode 100644 src/ui/workArtifacts/graphRelationshipReview.ts create mode 100644 src/ui/workArtifacts/index.ts create mode 100644 src/ui/workArtifacts/livePerformanceSummary.ts create mode 100644 src/ui/workArtifacts/notebookExecutionPreview.ts create mode 100644 src/ui/workArtifacts/notebookKernelAdapter.ts create mode 100644 src/ui/workArtifacts/notebookPatchDiff.ts create mode 100644 src/ui/workArtifacts/notebookStructure.ts create mode 100644 src/ui/workArtifacts/notebookTypedBlocks.ts create mode 100644 src/ui/workArtifacts/proofBundleExport.ts create mode 100644 src/ui/workArtifacts/proofBundleReceipt.ts create mode 100644 src/ui/workArtifacts/traceReplaySummary.ts create mode 100644 src/ui/workArtifacts/work-artifacts.css create mode 100644 src/ui/workArtifacts/workArtifactAdapters.ts create mode 100644 src/ui/workArtifacts/workArtifactTypes.ts create mode 100644 tests/accountGate.test.tsx create mode 100644 tests/launchAuth.test.ts create mode 100644 tests/mobileDeckLive.test.tsx create mode 100644 tests/mobileHeader.test.tsx create mode 100644 tests/mobileLiveHonesty.test.tsx create mode 100644 tests/mobileShellAdapters.test.tsx create mode 100644 tests/notebookKernel.test.ts create mode 100644 tests/workArtifacts.test.ts diff --git a/.env.example b/.env.example index 3095246d..e5db92d2 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ # Client (inlined into the browser bundle) VITE_CONVEX_URL= VITE_CONVEX_SITE_URL= +VITE_NODEROOM_AUTH_REQUIRED=0 +VITE_NODEROOM_AUTH_PROVIDER=github VITE_NODEROOM_FREE_ONLY=0 VITE_VOICE_STT_PROVIDER_ORDER=provider,browser VITE_VOICE_TTS_PROVIDER_ORDER=browser,provider @@ -17,6 +19,11 @@ VITE_NOTEBOOK_SYNC= # Production safety switches. Leave disabled for local deterministic/demo rooms. # Set NODEROOM_REQUIRE_CONVEX_IDENTITY=1 in production if anonymous room tokens are not acceptable. NODEROOM_REQUIRE_CONVEX_IDENTITY=0 +# Convex Auth production OAuth values live on the Convex deployment, never in +# Vite or Vercel client variables. Convex Auth also requires JWT_PRIVATE_KEY, +# JWKS, and SITE_URL; initialize them with `npx @convex-dev/auth --prod`. +AUTH_GITHUB_ID= +AUTH_GITHUB_SECRET= PROVIDER_EGRESS_REQUIRE_ALLOWLIST=0 NODEAGENT_ALLOWED_PROVIDERS=openai,anthropic,gemini,openrouter,nebius,local # Optional emergency brakes. Examples: diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 0da2c512..bf498c40 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -23,6 +23,7 @@ import type * as alwaysOnShape from "../alwaysOnShape.js"; import type * as artifacts from "../artifacts.js"; import type * as auditBundle from "../auditBundle.js"; import type * as auditLog from "../auditLog.js"; +import type * as auth from "../auth.js"; import type * as benchmarkGrade from "../benchmarkGrade.js"; import type * as captures from "../captures.js"; import type * as capturesNode from "../capturesNode.js"; @@ -57,6 +58,7 @@ import type * as nodemem from "../nodemem.js"; import type * as nodememCompile from "../nodememCompile.js"; import type * as notebookAgent from "../notebookAgent.js"; import type * as notebookGraph from "../notebookGraph.js"; +import type * as notebookKernel from "../notebookKernel.js"; import type * as notebookProcessing from "../notebookProcessing.js"; import type * as noteworthy from "../noteworthy.js"; import type * as okf from "../okf.js"; @@ -102,6 +104,7 @@ declare const fullApi: ApiFromModules<{ artifacts: typeof artifacts; auditBundle: typeof auditBundle; auditLog: typeof auditLog; + auth: typeof auth; benchmarkGrade: typeof benchmarkGrade; captures: typeof captures; capturesNode: typeof capturesNode; @@ -136,6 +139,7 @@ declare const fullApi: ApiFromModules<{ nodememCompile: typeof nodememCompile; notebookAgent: typeof notebookAgent; notebookGraph: typeof notebookGraph; + notebookKernel: typeof notebookKernel; notebookProcessing: typeof notebookProcessing; noteworthy: typeof noteworthy; okf: typeof okf; diff --git a/convex/agentJobRunner.ts b/convex/agentJobRunner.ts index f6ee728f..b7981e1e 100644 --- a/convex/agentJobRunner.ts +++ b/convex/agentJobRunner.ts @@ -133,7 +133,7 @@ type PublicAgentJobStream = { type LiveOperationKind = "action" | "query" | "mutation" | "model_call" | "tool_call" | "scheduler" | "lease" | "checkpoint"; -const QUERY_TOOLS = new Set(["snapshot", "list_artifacts", "awareness", "read_range", "search_sheet_context", "fetch_source", "read_notebook"]); +const QUERY_TOOLS = new Set(["snapshot", "list_artifacts", "awareness", "read_range", "search_sheet_context", "inspect_workbook", "verify_workbook", "fetch_source", "read_notebook"]); const MUTATION_TOOLS = new Set(["propose_lock", "release_lock", "edit_cell", "create_draft", "say", "update_wiki", "append_notebook_outline", "write_cell_result", "write_locked_cell", "write_locked_cell_result", "write_locked_cells", "write_locked_cell_results"]); function envNumber(name: string, fallback: number, min: number, max: number): number { diff --git a/convex/auth.config.ts b/convex/auth.config.ts new file mode 100644 index 00000000..30536fea --- /dev/null +++ b/convex/auth.config.ts @@ -0,0 +1,10 @@ +import type { AuthConfig } from "convex/server"; + +export default { + providers: [ + { + domain: process.env.CONVEX_SITE_URL!, + applicationID: "convex", + }, + ], +} satisfies AuthConfig; diff --git a/convex/auth.ts b/convex/auth.ts new file mode 100644 index 00000000..cccc936c --- /dev/null +++ b/convex/auth.ts @@ -0,0 +1,11 @@ +import GitHub from "@auth/core/providers/github"; +import { Password } from "@convex-dev/auth/providers/Password"; +import { convexAuth } from "@convex-dev/auth/server"; + +const githubConfigured = Boolean(process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET); + +export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ + // Production uses GitHub OAuth. Password auth exists only to make local and + // isolated preview authentication deterministic without third-party secrets. + providers: [githubConfigured ? GitHub : Password], +}); diff --git a/convex/http.ts b/convex/http.ts index c6ba4d63..5a163c55 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -15,8 +15,10 @@ import type { StreamId } from "@convex-dev/persistent-text-streaming"; import { streamingComponent } from "./streaming"; import { streamPrivateReplyText } from "./streamingModel"; import { privateAgentSystemPrompt } from "./agent"; +import { auth } from "./auth"; const http = httpRouter(); +auth.addHttpRoutes(http); const assertVoiceRequesterRef = makeFunctionReference<"query">("voice:assertVoiceRequester"); const CORS = { "Access-Control-Allow-Origin": "*", Vary: "Origin" } as const; diff --git a/convex/lib.ts b/convex/lib.ts index 3d05b0a7..f644dfcd 100644 --- a/convex/lib.ts +++ b/convex/lib.ts @@ -99,7 +99,7 @@ export async function hashToken(token: string): Promise { return `v1:${salt}:${await sha256Hex(`${salt}:${token}`)}`; } -async function verifyTokenHash(token: string, storedHash?: string): Promise { +export async function authTokenMatchesHash(token: string, storedHash?: string): Promise { requireStrongAuthToken(token); if (!storedHash) return false; if (storedHash?.startsWith("v1:")) { @@ -149,13 +149,18 @@ export async function requireActorProof(ctx: DbCtx, roomId: Id<"rooms">, proof: } if (member.revokedAt != null) throw new Error("actor_revoked"); const identity = await ctx.auth.getUserIdentity(); + if (productionIdentityRequired()) { + if (!identity) throw new Error("production_identity_required"); + if (!member.authSubject || member.authSubject !== identity.subject) throw new Error("identity_mismatch"); + return { kind: "user" as const, id: String(member._id), name: member.name }; + } if (identity && member.authSubject && member.authSubject === identity.subject) { return { kind: "user" as const, id: String(member._id), name: member.name }; } if (!token) throw new Error("invalid_actor_token"); let valid = false; try { - valid = await verifyTokenHash(token, member.authTokenHash); + valid = await authTokenMatchesHash(token, member.authTokenHash); } catch { throw new Error("invalid_actor_token"); } diff --git a/convex/notebookKernel.ts b/convex/notebookKernel.ts new file mode 100644 index 00000000..cb4b128c --- /dev/null +++ b/convex/notebookKernel.ts @@ -0,0 +1,27 @@ +"use node"; + +import { v } from "convex/values"; +import { makeFunctionReference } from "convex/server"; +import { action } from "./_generated/server"; +import { actorProofV } from "./lib"; +import { executeNotebookKernel, type NotebookKernelRequest, type NotebookKernelResult } from "../src/notebook/notebookKernel"; + +const assertMemberRef = makeFunctionReference<"query">("captures:assertMember") as any; + +export const execute = action({ + args: { + roomId: v.id("rooms"), + requester: actorProofV, + kind: v.union(v.literal("calculation"), v.literal("sql"), v.literal("chart")), + input: v.string(), + tables: v.optional(v.any()), + }, + handler: async (ctx, args): Promise => { + await ctx.runQuery(assertMemberRef, { roomId: args.roomId, requester: args.requester }); + return executeNotebookKernel({ + kind: args.kind, + input: args.input, + tables: args.tables as NotebookKernelRequest["tables"], + }, { backend: "convex", now: Date.now() }); + }, +}); diff --git a/convex/rooms.ts b/convex/rooms.ts index f5313d9f..1e8ee336 100644 --- a/convex/rooms.ts +++ b/convex/rooms.ts @@ -2,9 +2,10 @@ * (mutations are deterministic — no Math.random/uuid inside). Anonymous join is a * stand-in for `@convex-dev/auth`'s Anonymous provider (see docs/STACK.md). */ import { v } from "convex/values"; -import { mutation, query, type MutationCtx } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { internalMutation, mutation, query, type MutationCtx } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; -import { actorProofV, getRequiredProductionIdentity, hashToken, requireActorProof, type ActorValue } from "./lib"; +import { actorProofV, authTokenMatchesHash, getRequiredProductionIdentity, hashToken, requireActorProof, type ActorValue } from "./lib"; import { syncSpreadsheetIndexFromSeed } from "./spreadsheetIndexLib"; import { assertCreateArtifactLimits } from "./artifacts"; @@ -459,7 +460,7 @@ export const create = mutation({ for (const art of seedArtifacts) assertCreateArtifactLimits(art); const existing = await ctx.db.query("rooms").withIndex("by_code", (q) => q.eq("code", code)).first(); if (existing) throw new Error("room_code_taken"); - const roomId = await ctx.db.insert("rooms", { code, title: a.title, hostId: "", autoAllow: a.autoAllow ?? false, status: "live", createdAt: now }); + const roomId = await ctx.db.insert("rooms", { code, title: a.title, hostId: "", autoAllow: a.autoAllow ?? false, status: "live", createdAt: now, experience: "workspace", starterBackfill: "ready" }); const memberId = await ctx.db.insert("members", { roomId, name: a.hostName, role: "host", anon: false, color: palette[0], authTokenHash: await hashToken(a.authToken), authSubject: identity?.subject, lastSeenAt: now }); await ctx.db.patch(roomId, { hostId: memberId }); await ctx.db.insert("agentSessions", { roomId, agentId: "agent_room", agentName: "Room NodeAgent", scope: "public", status: "idle", lastAction: "started", updatedAt: now }); @@ -475,7 +476,10 @@ export const create = mutation({ }); export const createStarterRoom = mutation({ - args: { code: v.string(), title: v.string(), hostName: v.string(), authToken: v.string(), autoAllow: v.optional(v.boolean()) }, + args: { + code: v.string(), title: v.string(), hostName: v.string(), authToken: v.string(), autoAllow: v.optional(v.boolean()), + deferHeavySeed: v.optional(v.boolean()), + }, handler: async (ctx, a) => { const now = Date.now(); const identity = await getRequiredProductionIdentity(ctx); @@ -484,7 +488,17 @@ export const createStarterRoom = mutation({ if (a.title.length > MAX_TITLE_LEN || a.hostName.length > MAX_NAME_LEN) throw new Error("field_too_long"); const existing = await ctx.db.query("rooms").withIndex("by_code", (q) => q.eq("code", code)).first(); if (existing) throw new Error("room_code_taken"); - const roomId = await ctx.db.insert("rooms", { code, title: a.title, hostId: "", autoAllow: a.autoAllow ?? false, status: "live", createdAt: now }); + const deferHeavySeed = a.deferHeavySeed === true; + const roomId = await ctx.db.insert("rooms", { + code, + title: a.title, + hostId: "", + autoAllow: a.autoAllow ?? false, + status: "live", + createdAt: now, + experience: "sample", + starterBackfill: deferHeavySeed ? "pending" : "ready", + }); const memberId = await ctx.db.insert("members", { roomId, name: a.hostName, @@ -500,18 +514,45 @@ export const createStarterRoom = mutation({ await ctx.db.insert("agentSessions", { roomId, agentId: "agent_priv", agentName: "Your NodeAgent", scope: "private", ownerId: memberId, status: "idle", lastAction: "started", updatedAt: now }); const actor = { kind: "user" as const, id: String(memberId), name: a.hostName }; await ctx.db.insert("traces", { roomId, ts: now, actor, type: "room_created", summary: `${a.hostName} created the room` }); - const companyResearchId = await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Company research", seed: startupResearchSeed(), actor, now, meta: startupResearchMeta() }); await insertStarterArtifact(ctx, { roomId, kind: "note", title: "Diligence memo", seed: starterNoteSeed(), actor, now }); await insertStarterArtifact(ctx, { roomId, kind: "wall", title: "Risk / opportunity wall", seed: starterWallSeed(), actor, now }); await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Runway / milestones", seed: starterRunwaySeed(), actor, now, meta: starterRunwayMeta() }); await insertStarterArtifact(ctx, { roomId, kind: "note", title: "Open questions / workplan", seed: starterWorkplanSeed(), actor, now }); await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Q3 variance", seed: starterSheetSeed(), actor, now }); - await seedStarterMessages(ctx, { roomId, host: actor, now }); - await padStarterTraces(ctx, { roomId, artifactId: companyResearchId, now }); + if (deferHeavySeed) { + // Keep the first room frame free to render before the scale fixture starts its large write set. + await ctx.scheduler.runAfter(1_000, internal.rooms.finishStarterRoom, { roomId, memberId }); + } else { + const companyResearchId = await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Company research", seed: startupResearchSeed(), actor, now, meta: startupResearchMeta() }); + await seedStarterMessages(ctx, { roomId, host: actor, now }); + await padStarterTraces(ctx, { roomId, artifactId: companyResearchId, now }); + } return { roomId, memberId }; }, }); +export const finishStarterRoom = internalMutation({ + args: { roomId: v.id("rooms"), memberId: v.id("members") }, + handler: async (ctx, { roomId, memberId }) => { + const room = await ctx.db.get(roomId); + if (!room || room.starterBackfill !== "pending") return { ok: false as const, reason: "not_pending" as const }; + const member = await ctx.db.get(memberId); + if (!member || String(member.roomId) !== String(roomId)) return { ok: false as const, reason: "member_missing" as const }; + const now = Date.now(); + const actor: ActorValue = { kind: "user", id: String(memberId), name: member.name }; + const artifacts = await ctx.db.query("artifacts").withIndex("by_room", (q) => q.eq("roomId", roomId)).collect(); + const companyResearch = artifacts.find((artifact) => artifact.title === "Company research"); + const companyResearchId = companyResearch + ? companyResearch._id + : await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Company research", seed: startupResearchSeed(), actor, now, meta: startupResearchMeta() }); + const existingMessages = await ctx.db.query("messages").withIndex("by_room_channel", (q) => q.eq("roomId", roomId).eq("channel", "public")).take(1); + if (existingMessages.length === 0) await seedStarterMessages(ctx, { roomId, host: actor, now }); + await padStarterTraces(ctx, { roomId, artifactId: companyResearchId, now }); + await ctx.db.patch(roomId, { starterBackfill: "ready" }); + return { ok: true as const }; + }, +}); + export const ensureStarterRoomState = mutation({ args: { roomId: v.id("rooms"), requester: actorProofV }, handler: async (ctx, { roomId, requester }) => { @@ -519,6 +560,7 @@ export const ensureStarterRoomState = mutation({ if (!room) throw new Error("room_not_found"); const actor = await requireActorProof(ctx, roomId, requester); if (String(room.hostId) !== actor.id) throw new Error("host_required"); + if (room.experience === "workspace") return { ok: false as const, reason: "not_sample_workspace" as const }; const now = Date.now(); const artifacts = await ctx.db.query("artifacts").withIndex("by_room", (q) => q.eq("roomId", roomId)).collect(); let addedArtifacts = 0; @@ -570,6 +612,7 @@ export const ensureStarterRoomState = mutation({ if (publicMessages.length < 20) await seedStarterMessages(ctx, { roomId, host: actor, now }); if (companyResearch) await padStarterTraces(ctx, { roomId, artifactId: companyResearch._id, now }); if (room.title === "Blank NodeRoom") await ctx.db.patch(roomId, { title: "Startup diligence" }); + if (room.starterBackfill !== "ready") await ctx.db.patch(roomId, { starterBackfill: "ready" }); return { ok: true as const, addedArtifacts, patchedCells }; }, @@ -586,14 +629,26 @@ export const joinAnonymous = mutation({ if (a.name.length > MAX_NAME_LEN) throw new Error("field_too_long"); const existing = await ctx.db.query("members").withIndex("by_room", (q) => q.eq("roomId", room._id)).collect(); const activeMembers = existing.filter((m) => m.revokedAt == null); + const identityMatch = identity ? activeMembers.find((member) => member.authSubject === identity.subject) : undefined; + // Lost-response recovery: retrying with the same room-scoped token resumes the + // original member (including the host) instead of consuming another seat. + // Authenticated users also resume across browsers without consuming another + // room seat or relying on the first browser's room token. + const tokenMatches = identityMatch ? [] : await Promise.all(activeMembers.map((member) => authTokenMatchesHash(a.authToken, member.authTokenHash))); + const resumed = identityMatch ?? activeMembers[tokenMatches.findIndex(Boolean)]; + if (resumed) { + await ctx.db.patch(resumed._id, { lastSeenAt: now }); + return { roomId: room._id, memberId: resumed._id, name: resumed.name, resumed: true as const }; + } // Abuse gates: room capacity + join-rate window (joins are members created in the last 60s). if (activeMembers.length >= MAX_MEMBERS_PER_ROOM) return { error: "room_full" as const }; const recentJoins = existing.filter((m) => m._creationTime > now - 60_000).length; if (recentJoins >= MAX_JOINS_PER_MINUTE) return { error: "join_rate_limited" as const }; const count = activeMembers.length; - const memberId = await ctx.db.insert("members", { roomId: room._id, name: a.name, role: "member", anon, color: palette[count % palette.length], authTokenHash: await hashToken(a.authToken), authSubject: identity?.subject, lastSeenAt: now }); + const authTokenHash = await hashToken(a.authToken); + const memberId = await ctx.db.insert("members", { roomId: room._id, name: a.name, role: "member", anon, color: palette[count % palette.length], authTokenHash, authSubject: identity?.subject, lastSeenAt: now }); await ctx.db.insert("traces", { roomId: room._id, ts: now, actor: { kind: "user", id: memberId, name: a.name }, type: "member_joined", summary: `${a.name} joined${anon ? " (anon)" : ""}` }); - return { roomId: room._id, memberId }; + return { roomId: room._id, memberId, name: a.name }; }, }); @@ -603,6 +658,9 @@ export const leave = mutation({ const actor = await requireActorProof(ctx, roomId, requester); const member = await ctx.db.get(actor.id as Id<"members">); if (!member || String(member.roomId) !== String(roomId)) throw new Error("actor_not_in_room"); + if (member.role === "host") { + return { ok: false as const, reason: "host_transfer_required" as const }; + } const now = Date.now(); await ctx.db.patch(member._id, { lastSeenAt: now, revokedAt: now }); await ctx.db.insert("traces", { @@ -637,7 +695,7 @@ export const byCode = query({ args: { code: v.string() }, handler: async (ctx, { code }) => { const r = await ctx.db.query("rooms").withIndex("by_code", (q) => q.eq("code", code.toUpperCase())).first(); - return r ? { roomId: r._id } : null; + return r ? { roomId: r._id, experience: r.experience ?? "workspace" as const } : null; }, }); @@ -692,7 +750,7 @@ export const full = query({ }; }); return { - room: { id: room._id, code: room.code, title: room.title, hostId: room.hostId, autoAllow: room.autoAllow, status: room.status, createdAt: room.createdAt }, + room: { id: room._id, code: room.code, title: room.title, hostId: room.hostId, autoAllow: room.autoAllow, status: room.status, createdAt: room.createdAt, experience: room.experience, starterBackfill: room.starterBackfill }, members, artifacts, locks, sessions, drafts, }; }, @@ -742,7 +800,7 @@ export const meta = query({ }; }); return { - room: { id: room._id, code: room.code, title: room.title, hostId: room.hostId, autoAllow: room.autoAllow, status: room.status, createdAt: room.createdAt }, + room: { id: room._id, code: room.code, title: room.title, hostId: room.hostId, autoAllow: room.autoAllow, status: room.status, createdAt: room.createdAt, experience: room.experience, starterBackfill: room.starterBackfill }, members, artifacts, locks, sessions, drafts, }; }, diff --git a/convex/schema.ts b/convex/schema.ts index e2b1e5d8..4fb4d4ec 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -16,6 +16,7 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +import { authTables } from "@convex-dev/auth/server"; import { refutationVerdictV } from "./lib"; import { notificationEventsTable, watchesTable } from "./watchesTables"; @@ -143,6 +144,7 @@ const agentArtifactStatusV = v.union( ); export default defineSchema({ + ...authTables, rooms: defineTable({ code: v.string(), title: v.string(), @@ -150,6 +152,8 @@ export default defineSchema({ autoAllow: v.boolean(), status: v.union(v.literal("live"), v.literal("ended")), createdAt: v.number(), + experience: v.optional(v.union(v.literal("workspace"), v.literal("sample"))), + starterBackfill: v.optional(v.union(v.literal("pending"), v.literal("ready"))), }).index("by_code", ["code"]), members: defineTable({ diff --git a/docs/design/COMPONENT_MAP.md b/docs/design/COMPONENT_MAP.md index d1cadfcb..36fc334c 100644 --- a/docs/design/COMPONENT_MAP.md +++ b/docs/design/COMPONENT_MAP.md @@ -2,8 +2,9 @@ Captured: 2026-07-08 -Purpose: map the Cloud Design visual contract onto the current production app -without changing behavior. `src/features` is not a distinct folder in this repo; +Purpose: map the approved UI contract onto the current production app without +changing behavior. Design artifacts are evidence to critique, not visual +authority to reproduce. `src/features` is not a distinct folder in this repo; today the behavior-bearing UI lives mostly under `src/ui`, `src/app`, `convex`, and `src/engine`. During the migration, shared primitives and shell wrappers should absorb presentation changes before feature files are touched. @@ -11,8 +12,9 @@ should absorb presentation changes before feature files are touched. ## Migration Boundaries - Behavior source of truth: latest production/main app. -- Visual source of truth: `docs/design/UI_CONTRACT.md` plus the source - screenshots under `docs/design/ui-contract/`. +- Visual source of truth: `docs/design/UI_CONTRACT.md` and its approved + surface-specific contracts. Source screenshots and standalone exports are + evidence that may be kept, refined, or rejected; similarity is not approval. - Preferred migration pattern: add wrapper/adaptor components that preserve current props, callbacks, state transitions, store calls, Convex calls, and DOM test contracts. @@ -34,6 +36,20 @@ should absorb presentation changes before feature files are touched. | `src/ui/PeoplePanel.tsx` | People dialog, follow mode, live location, grouped members. | `PeoplePanel`, `PresenceRow`, `FollowPill`. | Open/close behavior, outside click handling, follow polling, artifact tab activation, group expansion. | Panel shell, row density, status chips, focus-visible state. | | `src/ui/NotificationsInbox.tsx` and `src/ui/insights/**` | Bell, passive room intelligence, noteworthy inbox. | `Popover`, `InboxList`, `InsightChip`, `BatchActionBar`. | Watch toggles, mark-read actions, research/add/practice/dismiss flows, cost preview, policy controls. | Popover surface, chip tone hierarchy, row density, batch bar styling. | | `src/ui/CommandPalette.tsx` | Keyboard command launcher. | `CommandPalette`, `CommandItem`. | Keyboard navigation, commands, artifact opening, chat focusing, existing ARIA roles. | Overlay styling, item spacing, active row state, token-aligned shadows. | + +## Mobile Shell Map + +The detailed mobile contract lives in +`docs/design/mobile/MOBILE_COMPONENT_MAP.md`. The shell boundary is: + +| Region | Production owner | Approved adapter | Preserved behavior | +|---|---|---|---| +| Route/bootstrap | `src/ui/App.tsx`, `src/ui/mobile/MobileRoot.tsx` | No visual rewrite | Universal phone routing, memory/live split, create/join/demo/leave, consent, session restore. | +| Theme | `src/ui/mobile/mobile.tokens.css` | Semantic light/dark selectors | Light canonical default, explicit dark opt-in, terracotta/attention/success/danger semantics. | +| Header | `src/ui/mobile/shell/MobileHeader.tsx` | `MobileHeader` | Room switching, Review, Jobs, People, Trace, Share, Settings, activity/usage access; stable command meanings. | +| Safe area/frame | `src/ui/mobile/MobileFrame.tsx`, `mobile.shell.css`, `mobileFrame.css` | Production bleed plus explicit preview frame | Real safe-area insets in production; synthetic status chrome only in explicit device preview. | +| Navigation/composer | `src/ui/mobile/MobileApp.tsx` | Existing bottom navigation and contextual composer/FAB | Every tab, composer mode, agent route, attachment, model, voice, and quick action remains reachable. | +| Sheets | `MobileSheets.tsx`, `MobileGapSheets.tsx`, `MobileDeck.tsx`, `MobileFiles.tsx` | Existing bottom-sheet contracts | Review, jobs, trace, people, share, settings, rooms, governed artifacts, approvals, receipts, and honest fallbacks. | | `src/ui/primitives/FocusTrapDialog.tsx` | Shared modal behavior. | `Modal`, `DialogPanel`, `Scrim`. | Focus trap, Escape/scrim close, ARIA modal semantics, restore path where provided. | Modal radius/shadow/backdrop tokens only. | | `src/ui/mobile/**` | Mobile shell, live bootstrap, sheets, chat, grid, settings. | `MobileShell`, `BottomSheet`, `MobileTabs`, `MobileComposer`, `MobileGrid`. | Mobile router, live create/join consent, room URL semantics, gestures, sheets, mobile-only state handling. | Translate desktop rails to bottom sheets/tabs, improve density, preserve real-phone constraints. | | `src/alwayson/**` | Public read-only room, cards, tabs, subscribe modal. | `PublicRoomFrame`, `PublicRoomTabs`, `SubscribeDialog`, `PublicCard`. | Demo/live source stamp, read-only state, tabs, ops gate, unknown slug, double opt-in honesty, modal dismissal. | Public-room chrome, cards, mobile card surfaces, focus states. | @@ -61,6 +77,31 @@ Implemented primitive files in this slice: | `src/ui/tokens.css` | Cloud token aliases for surfaces, hairlines, accent, radius, and elevation. | Imported globally from `src/app/main.tsx`. | | `src/ui/primitives/designSystem.tsx` | `Button`, `IconButton`, `Switch`, `Panel`, `Badge`, `Tabs`, `TextInput`, `SearchField`, `EmptyState`, `LoadingState`, `ErrorState`, `Popover`, `Modal`. | `RoomShell`, `LeftRail`, `Chat`, `Artifact`; available for remaining feature slices. | | `src/ui/primitives/primitives.css` | Late-loaded visual skin for shell, panels, overlays, forms, popovers, trace, chat, artifact, mobile-adjacent, and Always-On states. | Global presentation layer; keeps existing data-testid and behavior classes. | +| `src/ui/workArtifacts/workArtifactTypes.ts` | `WorkArtifactViewModel`, receipt/action/ref contracts for spreadsheet, notebook, wall, deck, graph, trace, proposal, and export artifacts. | Unified artifact proof bundle; read-only adapter layer over existing room state. | +| `src/ui/workArtifacts/workArtifactAdapters.ts` | Engine artifact, proposal, trace, semantic graph, deck, and export mappers. | `WorkArtifactsPanel`; preserves existing artifact/proposal/trace data and callbacks. | +| `src/ui/workArtifacts/deckStoryboard.ts` | Storyboard-first deck plan and deck artifact input conversion. | `WorkArtifactsPanel`; first deck slice, no backend/schema changes. | +| `src/ui/workArtifacts/deckPatchPlan.ts` | Deterministic reviewer patch plan for storyboard gaps, unsupported claims, and linked proposals. | `DeckStoryboardWorkbench`; read-only review/export artifact, no deck mutation. | +| `src/ui/workArtifacts/deckPreviewExport.ts` | Deterministic storyboard-to-HTML deck preview/export. | `DeckStoryboardWorkbench`; derived preview file, no collaborative state changes. | +| `src/ui/workArtifacts/deckPdfExport.ts` | Deterministic storyboard-to-PDF export. | `DeckStoryboardWorkbench`; client-side binary download, no backend render job. | +| `src/ui/workArtifacts/deckPptxExport.ts` | Deterministic storyboard-to-PPTX OpenXML export. | `DeckStoryboardWorkbench`; client-side binary download, no backend writes. | +| `src/ui/workArtifacts/graphRelationshipReview.ts` | Deterministic source-backed vs needs-confirmation relationship review over semantic graph edges. | `GraphRelationshipReviewWorkbench`; read-only review/export artifact, no graph storage mutation. | +| `src/ui/workArtifacts/livePerformanceSummary.ts` | Public chat and NodeAgent live-performance summary model. | `LivePerformanceCenter`; derives from existing messages, traces, run/job telemetry, attempts, and stream detail receipts. | +| `src/ui/workArtifacts/notebookExecutionPreview.ts` | Read-only calculation/SQL/chart execution preview for typed notebook blocks. | `NotebookDigestWorkbench`; safe arithmetic/parser preview only, no kernel or editor mutation. | +| `src/ui/workArtifacts/notebookStructure.ts` | Notebook block/section/source digest for legacy HTML and ProseMirror-like note documents. | Notebook work-artifact receipts; read-only, no editor/sync mutation changes. | +| `src/ui/workArtifacts/notebookPatchDiff.ts` | Word-level before/after notebook patch diff model. | `NotebookDigestWorkbench`; proposal preview only, no editor mutation. | +| `src/ui/workArtifacts/notebookTypedBlocks.ts` | Typed analytical notebook block classification. | `NotebookDigestWorkbench`; read-only text/evidence/calculation/decision/open-question/etc. chips over existing notebook blocks. | +| `src/ui/workArtifacts/proofBundleReceipt.ts` | Deterministic proof-bundle receipt sidecar model. | `WorkArtifactsPanel`; future export flows should reuse this receipt contract. | +| `src/ui/workArtifacts/traceReplaySummary.ts` | Deterministic trace replay phase summary. | Future trace/export receipts; groups existing trace rows without changing trace storage. | +| `src/ui/workArtifacts/DeckStoryboardWorkbench.tsx` | Openable storyboard-first deck review surface. | `WorkArtifactsPanel`; opens generated deck plans in-place and routes source actions back to real artifacts. | +| `src/ui/workArtifacts/NotebookDigestWorkbench.tsx` | Openable notebook digest and patch-preview surface. | `WorkArtifactsPanel`; opens derived notebook block/section/source receipts in-place, shows proposal-backed patch previews, and routes `Open editor` back to the real notebook artifact. | +| `src/ui/workArtifacts/ProposalReviewCenter.tsx` | Agent workpaper review center and proposal filter model. | `WorkArtifactsPanel`; derives review cards from existing proposals and calls existing proposal resolver callbacks. | +| `src/ui/workArtifacts/proofBundleExport.ts` | Proof-bundle JSON sidecar manifest. | `WorkArtifactsPanel`; downloads receipt/replay/artifact manifest without backend writes. | +| `src/ui/workArtifacts/TraceReplayWorkbench.tsx` | Openable trace replay and live-performance summary surface. | `WorkArtifactsPanel`; opens trace phases/critical path/recent events in-place and routes artifact refs back to real artifacts. | +| `src/ui/workArtifacts/LivePerformanceCenter.tsx` | Public chat, NodeAgent job/run telemetry, and stream receipt strip. | `WorkArtifactsPanel`; summarizes public messages and existing telemetry, and opens trace replay. | +| `src/ui/workArtifacts/WorkArtifactsPanel.tsx` | Room proof bundle panel. | `src/ui/panels/Artifact.tsx` Artifacts pseudo-tab. | +| `src/ui/graph/semanticGraphPaths.ts` | Ranked relevant connection paths for selected graph nodes. | `EntityGraphDetailPanel`; highlights person/company/evidence/project/source relationships without changing graph storage. | +| `src/ui/graph/semanticGraph.ts` | Derived semantic proof graph for artifacts, people, evidence, traces, proposals, decks, slides, and claims. | `KnowledgeGraph`, `WorkArtifactsPanel`; remains read-only over current room state. | +| `../NodeGraph/src/relationshipReview.ts` | Public package mirror of graph relationship confirmation review. | `NodeGraph` npm/public repo consumers; same source-backed vs needs-confirmation receipt model outside NodeRoom. | Migrated component mappings in this slice: @@ -73,8 +114,34 @@ Migrated component mappings in this slice: | Work surface tabs/grid/notebook/trace entry | Cloud work-surface skin | `src/ui/panels/Artifact.tsx`, `src/ui/primitives/primitives.css` | Tab switching, grid editing, selected cells, trace/notebook/wall/report surfaces, inline rename. | | Spreadsheet, notebook, proposal, coach, and specialized trace interiors | Cloud interior skin | `src/ui/primitives/primitives.css`, `src/ui/panels/notebook-paper.css`, `src/ui/panels/trace-run.css`, `tests/notebookPaper.test.tsx` | Cell editing, selected cells, render windowing, proposal approval/rejection, notebook ProseMirror sync/blur commit, trace record tabs, flow selection, observability export, run span expansion. | | Command palette, people panel, guided tour, dialogs, form states | Shared overlay/form/state skin | `src/ui/primitives/primitives.css`, `src/ui/RoomShell.tsx` | Keyboard navigation, focus trap, outside click/Escape close, follow behavior, ARIA/test contracts. | -| Standalone mobile surface | Cloud mobile token overlay and frame chrome | `src/ui/mobile/mobile.css`, `src/ui/mobile/mobileFrame.css` | Mobile router, sample/live modes, sheets, gestures, composer, note capture, join/consent mechanics. | +| Standalone mobile surface (the 2026-07-08 Cloud overlay is superseded) | Approved semantic terracotta shell and explicit device-preview frame | `src/ui/mobile/mobile.tokens.css`, `src/ui/mobile/mobile.shell.css`, `src/ui/mobile/MobileFrame.tsx` | Mobile router, sample/live modes, sheets, gestures, composer, note capture, join/consent mechanics. | | Always-On public room modal/forms/errors | Shared public-room/overlay skin | `src/ui/primitives/primitives.css` | Public tabs, subscribe form validation, double-opt-in honesty, modal close/focus behavior. | +| Mixed work-artifact proof bundle | `WorkArtifactsPanel`, `WorkArtifactViewModel`, artifact receipts | `src/ui/workArtifacts/**`, `src/ui/panels/Artifact.tsx` | Existing artifacts, proposals, traces, semantic graph, exports, and artifact-open callbacks remain data-backed. | +| Storyboard-first deck artifact | `DeckStoryboard`, `DeckArtifactInput`, contract doc | `src/ui/workArtifacts/deckStoryboard.ts`, `docs/design/DECK_STORYBOARD_CONTRACT.md` | Storyboard derives from room artifacts/traces/proposals and flags unsupported claims as review, without replacing slide/editor behavior. | +| Storyboard-derived deck patch plan | `DeckPatchPlan`, patch counts, before/after review rows, Patch JSON action | `src/ui/workArtifacts/deckPatchPlan.ts`, `src/ui/workArtifacts/DeckStoryboardWorkbench.tsx`, `src/ui/workArtifacts/work-artifacts.css`, `tests/workArtifacts.test.ts` | Patch plans derive from existing storyboard gaps, unverified claims, source refs, traces, and proposals; they do not apply edits, resolve proposals, or create collaborative deck state. | +| Storyboard-derived deck preview/export | `DeckPreviewExport`, HTML preview action, cleaned claim text | `src/ui/workArtifacts/deckPreviewExport.ts`, `src/ui/workArtifacts/deckStoryboard.ts`, `src/ui/workArtifacts/DeckStoryboardWorkbench.tsx`, `src/ui/workArtifacts/work-artifacts.css` | Preview/export derives from the deck plan and does not become collaborative state; existing slide/editor paths remain untouched. | +| Storyboard-derived portable PPTX export | `DeckPptxExport`, PPTX download action, deterministic OpenXML package | `src/ui/workArtifacts/deckPptxExport.ts`, `src/ui/workArtifacts/DeckStoryboardWorkbench.tsx`, `tests/workArtifacts.test.ts` | PPTX export derives from existing storyboard claims/gaps/receipts, downloads client-side, and does not create backend deck state or alter slide editor behavior. | +| Storyboard-derived portable PDF export | `DeckPdfExport`, PDF download action, deterministic PDF package | `src/ui/workArtifacts/deckPdfExport.ts`, `src/ui/workArtifacts/DeckStoryboardWorkbench.tsx`, `tests/workArtifacts.test.ts` | PDF export derives from existing storyboard claims/gaps/receipts, downloads client-side, and does not create backend render jobs, storage writes, or deck editor state. | +| Notebook work artifact digest | `NotebookArtifactStructure`, notebook contract doc | `src/ui/workArtifacts/notebookStructure.ts`, `docs/design/NOTEBOOK_WORK_ARTIFACT_CONTRACT.md` | Existing notebook ProseMirror/legacy editor, keyboard, sync, read-model, and governed NodeAgent write paths remain untouched. | +| Typed notebook block adapter | `NotebookTypedBlock`, type summary chips, unique digest block ids | `src/ui/workArtifacts/notebookTypedBlocks.ts`, `src/ui/workArtifacts/notebookStructure.ts`, `src/ui/workArtifacts/NotebookDigestWorkbench.tsx` | Type labels derive from existing block text/source/proposal state and do not create executable notebook runtimes or mutate editor content. | +| Entity graph relevant paths | `SemanticGraphConnectionPath`, `rankSemanticConnectionPaths`, detail panel path rows | `src/ui/graph/semanticGraphPaths.ts`, `src/ui/graph/semanticGraphSelectors.ts`, `src/ui/graph/EntityGraphDetailPanel.tsx`, `src/ui/graph/semanticGraph.ts` | Graph remains derived from current room artifacts/traces/proposals; selected people/companies now surface ranked source-backed paths and notebook-block citations. | +| Proof-bundle receipt sidecar | `ProofBundleReceipt`, `buildProofBundleReceipt` | `src/ui/workArtifacts/proofBundleReceipt.ts`, `src/ui/workArtifacts/WorkArtifactsPanel.tsx` | Receipt hashes and known gaps derive from current work artifacts; no backend export or durable state changes. | +| Trace replay summary | `TraceReplaySummary`, `buildTraceReplaySummary` | `src/ui/workArtifacts/traceReplaySummary.ts` | Existing trace rows are grouped into room, chat, agent, edit, review, and notebook phases; no trace schema/runtime changes. | +| Openable storyboard-first deck workbench | `DeckStoryboardWorkbench`, selected deck rows, source buttons | `src/ui/workArtifacts/DeckStoryboardWorkbench.tsx`, `src/ui/workArtifacts/WorkArtifactsPanel.tsx`, `src/ui/workArtifacts/workArtifactAdapters.ts`, `src/ui/workArtifacts/work-artifacts.css` | Deck plans remain derived/read-only; row opens in-place, and source buttons use existing artifact-open callbacks to real artifacts. | +| Openable notebook digest workbench | `NotebookDigestWorkbench`, selected notebook rows, editor handoff, patch previews | `src/ui/workArtifacts/NotebookDigestWorkbench.tsx`, `src/ui/workArtifacts/WorkArtifactsPanel.tsx`, `src/ui/workArtifacts/notebookStructure.ts`, `src/ui/workArtifacts/work-artifacts.css` | Notebook digests and patch previews remain derived/read-only; row opens in-place, `Open editor` uses the existing artifact-open callback, and proposals remain governed by existing resolver behavior. | +| Notebook patch diff previews | `NotebookPatchDiff`, before/after added/removed token rows | `src/ui/workArtifacts/notebookPatchDiff.ts`, `src/ui/workArtifacts/NotebookDigestWorkbench.tsx`, `src/ui/workArtifacts/work-artifacts.css`, `tests/workArtifacts.test.ts` | Diffs derive from existing proposal values and block text, render only in the digest preview, and do not alter ProseMirror editor state or proposal resolution. | +| Notebook execution preview | `NotebookExecutionPreview`, safe arithmetic results, SQL/chart intent cards | `src/ui/workArtifacts/notebookExecutionPreview.ts`, `src/ui/workArtifacts/NotebookDigestWorkbench.tsx`, `src/ui/workArtifacts/work-artifacts.css`, `tests/workArtifacts.test.ts` | Execution previews derive from typed notebook blocks and never run arbitrary code, start kernels, mutate editor content, or write backend state. | +| Agent workpaper review center | `ProposalReviewCenter`, proposal review filters, host approve/reject buttons | `src/ui/workArtifacts/ProposalReviewCenter.tsx`, `src/ui/workArtifacts/WorkArtifactsPanel.tsx`, `src/ui/panels/Artifact.tsx`, `src/ui/workArtifacts/work-artifacts.css` | Proposal cards derive from existing `store.listProposals`; approve/reject delegates to `store.resolveProposal`; source buttons open real artifacts. | +| Proof-bundle export sidecar | `ProofBundleExportManifest`, Receipt JSON action | `src/ui/workArtifacts/proofBundleExport.ts`, `src/ui/workArtifacts/WorkArtifactsPanel.tsx`, `src/ui/workArtifacts/work-artifacts.css` | JSON export derives from current artifacts, receipt, and replay summary; no backend state or existing XLSX/file export behavior changes. | +| Openable trace replay workbench | `TraceReplayWorkbench`, selected trace rows, critical path/event summaries | `src/ui/workArtifacts/TraceReplayWorkbench.tsx`, `src/ui/workArtifacts/WorkArtifactsPanel.tsx`, `src/ui/workArtifacts/traceReplaySummary.ts`, `src/ui/workArtifacts/work-artifacts.css` | Trace replay remains derived/read-only; row opens in-place, and artifact refs use existing artifact-open callbacks when present. | +| Public chat/live performance summary | `LivePerformanceCenter`, `LivePerformanceSummary`, trace replay handoff | `src/ui/workArtifacts/livePerformanceSummary.ts`, `src/ui/workArtifacts/LivePerformanceCenter.tsx`, `src/ui/workArtifacts/WorkArtifactsPanel.tsx`, `src/ui/workArtifacts/work-artifacts.css` | Public message counts, NodeAgent run/job telemetry, stream/detail receipts, and trace counts derive from existing store APIs; private chat and backend behavior are untouched. | +| Storyboard-backed proof graph paths | `deck`, `deck_slide`, `deck_claim` semantic nodes; claim-source paths | `src/ui/graph/semanticGraph.ts`, `src/ui/graph/semanticGraphTypes.ts`, `src/ui/graph/semanticGraphPaths.ts`, `src/ui/graph/semanticGraphSelectors.ts`, `src/ui/graph/semanticGraphLayout.ts`, `src/ui/panels/KnowledgeGraph.tsx` | Deck/storyboard graph nodes derive from room artifacts/traces/proposals and connect back to real source artifacts, notebook blocks, evidence facts, trace steps, and proposal nodes without backend graph mutations. | +| Graph relationship confirmation review | `GraphRelationshipReviewPlan`, graph row open state, Review JSON action | `src/ui/workArtifacts/graphRelationshipReview.ts`, `src/ui/workArtifacts/GraphRelationshipReviewWorkbench.tsx`, `src/ui/workArtifacts/WorkArtifactsPanel.tsx`, `src/ui/workArtifacts/work-artifacts.css`, `tests/workArtifacts.test.ts` | Relationship review derives from the current semantic graph and classifies source-backed versus inferred/proposal-linked edges without writing graph storage, Neo4j/Cognee state, or backend mutations. | +| Public NodeGraph relationship review | `buildGraphRelationshipReviewPlan` exported from NodeGraph | `../NodeGraph/src/relationshipReview.ts`, `../NodeGraph/src/index.ts`, `../NodeGraph/tests/semanticGraph.test.ts`, `../NodeGraph/README.md` | Public repo receives the same pure relationship-review receipt primitive while NodeRoom-specific deck/storyboard contracts stay in NodeRoom. | +| Collaborative deck adaptor | `CollaborativeDeck`, CAS version receipt, deck presence, create/save/duplicate/delete/reorder | `src/ui/workArtifacts/collaborativeDeck.ts`, `src/ui/workArtifacts/DeckStoryboardWorkbench.tsx`, `src/ui/workArtifacts/WorkArtifactsPanel.tsx` | Persist through the existing note/artifact store, retain source actions and all exports, and never replace room data with static slides. | +| Safe notebook kernel adaptor | `NotebookKernelRequest`, calculation/SQL/chart result receipts | `src/notebook/notebookKernel.ts`, `convex/notebookKernel.ts`, `src/ui/workArtifacts/notebookKernelAdapter.ts`, notebook workbench files | Keep the editor as source of truth; allow only bounded arithmetic, read-only SQL intent, and chart intent; persist traceable outputs without arbitrary execution. | +| Semantic graph cluster controls | cluster selector, hop depth, relevant-path focus, controlled drag measurements | `src/ui/graph/semanticGraphClusters.ts`, `src/ui/panels/KnowledgeGraph.tsx` | Derive clusters from the current semantic graph, preserve source-backed paths and node dragging, and do not mutate graph/backend state. | +| Scoped chat context picker | artifact, deck-slide, proposal, and trace references | `src/ui/artifactRefs.ts`, `src/ui/Chat.tsx` | Preserve public/private lanes, send/edit/retry/stream/attachment/NodeAgent behavior; route selected context through the existing message path as an openable reference. | ## PR-Sized Build Order diff --git a/docs/design/DECK_STORYBOARD_CONTRACT.md b/docs/design/DECK_STORYBOARD_CONTRACT.md new file mode 100644 index 00000000..fc11656e --- /dev/null +++ b/docs/design/DECK_STORYBOARD_CONTRACT.md @@ -0,0 +1,114 @@ +# Deck Storyboard Contract + +Status: implementation contract + +Source prototype: `C:/Users/hshum/Downloads/NodeAgent-handoff_07062026/nodeagent/project/mobile/app-terracotta/na-deck.jsx` + +## Product Role + +The deck surface is a governed work artifact, not a generic slide editor. NodeRoom should turn room evidence into a reviewable narrative plan first, then render slides from that approved plan. Every material deck claim must remain connected to the room artifact, evidence, trace, or proposal that produced it. + +## Behavior Contract + +Required flow: + +1. Plan first: NodeAgent proposes a storyboard before slide generation. +2. Human approval: the user approves, revises, or rejects the storyboard before slides become the working preview. +3. Sandboxed preview: slides render as preview artifacts. HTML/PPTX/PDF are derived outputs, not collaborative source of truth. +4. Element targeting: users can select a slide component or whole slide as the comment/patch target. +5. Scoped agent patch: NodeAgent returns a localized patch with before, after, and evidence. +6. Human gate: patch changes only apply after accept. Reject logs the request without mutating the slide. +7. Evidence tab: every cited claim shows verified or needs-review evidence state. +8. Export tab: exports include version and planned-vs-actual receipt metadata. +9. Present mode: full-deck viewing is supported without changing source state. +10. Version history: restore is a governed action and should point to a trace receipt. + +## Source Of Truth + +The durable source should be structured deck-plan JSON: + +- `deckId` +- `roomId` +- `title` +- `audience` +- `objective` +- `privacy` +- `storyboardStatus` +- `slides[]` +- `claims[]` +- `requiredEvidence[]` +- `unresolvedGaps[]` +- `sourceArtifactIds[]` +- `traceIds[]` +- `proposalIds[]` +- `planHash` +- `version` + +HTML preview, PPTX, PDF, thumbnails, and screenshots are derived from the deck plan. They should not become the collaborative state object. + +## Minimum Slide Plan Shape + +Each slide plan should include: + +- stable `slideId` +- `title` +- `purpose` +- `claims[]` +- `sourceArtifactIds[]` +- `evidenceIds[]` +- `unresolvedGaps[]` +- `speakerNote` +- `status`: `draft`, `approved`, or `needs_review` + +Each claim should include: + +- stable `claimId` +- `text` +- `status`: `verified`, `manual`, or `needs_review` +- `sourceArtifactId` +- optional `traceId` +- optional `proposalId` +- optional `evidenceId` + +## Patch Contract + +NodeAgent deck edits should be represented as a deck delta, not a full deck rewrite: + +- `patchId` +- `deckId` +- `slideId` +- optional `componentId` +- `targetLabel` +- `before` +- `after` +- `reason` +- `evidence[]` +- `status`: `pending`, `approved`, or `rejected` +- `traceId` + +Accepted patches update the deck plan version and append a trace receipt. Rejected patches stay visible in the workpaper/proposal log. + +## Visual Contract + +The deck workbench should follow the Cloud Design direction: + +- calm dark shell; +- thin dividers instead of nested boxes; +- dense thumbnail rail; +- large preview area; +- compact evidence and status chips; +- visible but quiet accept/reject controls; +- no decorative hero treatment; +- no static fake deck content in product routes. + +## First Implementation Slice + +The first implementation slice is intentionally read-only: + +- derive a storyboard from existing room artifacts, traces, and proposals; +- expose it as a `deck` work artifact in the unified proof bundle; +- mark unsupported or proposal-backed claims as `needs_review`; +- link storyboard sections to artifact, trace, and proposal receipts; +- avoid schema/backend changes. + +Future slices can add the editor/preview/export loop after this contract is proven by tests and live dogfood. diff --git a/docs/design/DESIGN_PARITY_PLAN.md b/docs/design/DESIGN_PARITY_PLAN.md index 62e156fc..05eca5ac 100644 --- a/docs/design/DESIGN_PARITY_PLAN.md +++ b/docs/design/DESIGN_PARITY_PLAN.md @@ -1,5 +1,11 @@ # Design → Prod UI/UX Parity Plan +> Authority notice (2026-07-10): this is a historical implementation inventory, +> not a visual source of truth. Latest production/main owns behavior. The +> approved `docs/design/UI_CONTRACT.md` and surface-specific taste contracts own +> visual decisions. Standalone artifacts and similarity scores are evidence +> only; pixel parity cannot approve a design or satisfy the taste gate. + ## Status refresh — after the parity ship (`298fa06f` on main, 2026-07-03) A large parity push landed after this plan was first written. Re-verified @@ -194,7 +200,7 @@ Effort: 2–3 days + the metrics query. 5. Cell↔facet freshness join for stale chips (data exists; needs a query). Everything else is rendering data that already exists. -## Design-token reconciliation (blocked on one click) +## Design-token reconciliation (historical note) The design files import `assets/colors_and_type.css` (DM Sans + DM Serif Display; desktop `#101317/#171B20/#D97757`; mobile terracotta-on-cream @@ -203,8 +209,10 @@ files (plus `feature-map/fmap-app.jsx` — the 55-feature × 12-system parity checklist with live specimens — and the `scale/` state specimens) requires granting the Claude Design connector: **claude.ai/design/settings → Connect to Claude Design**. Until then this plan is component-accurate but not -pixel-exact; after consent, each workstream should start by lifting the -exact CSS from the corresponding design source directory. +pixel-exact. The linked exports have since been located and inventoried. Each +workstream must classify source decisions as KEEP, REFINE, REJECT, or NEEDS +PRODUCT DECISION and implement only the approved contract. Exact CSS is never +lifted by default. ## Build order (per the handoff) diff --git a/docs/design/MOBILE_TERRACOTTA_WORK_ARTIFACT_CONTRACT.md b/docs/design/MOBILE_TERRACOTTA_WORK_ARTIFACT_CONTRACT.md new file mode 100644 index 00000000..a55968d8 --- /dev/null +++ b/docs/design/MOBILE_TERRACOTTA_WORK_ARTIFACT_CONTRACT.md @@ -0,0 +1,108 @@ +# Mobile Terracotta Work Artifact Contract + +Status: active build contract +Date: 2026-07-09 + +This contract scopes the mobile terracotta + governed work-artifact push. The +mobile source design is a product reference, not a static mock replacement: +production mobile must remain backed by the live room store, proposal state, +trace rows, evidence, and work-artifact adapters. + +## Source References + +- Source design root: `C:/Users/hshum/Downloads/NodeAgent-handoff_07062026/nodeagent/project/mobile/app-terracotta/` +- Main prototype controller: `na-app.jsx` +- Governed deck workbench: `na-deck.jsx` +- Prototype data model: `na-data.js` +- Prototype skin: `na.css` +- Captured reference screenshot: `docs/design/ui-contract/20260707-design-source/mobile-terracotta-390x844.png` +- Current production mobile proof screenshot: `docs/design/ui-contract/20260708-migration-proof/after-feature-skin-mobile-390x844.png` +- Work-artifact plan: `docs/synthesis/WORK_ARTIFACTS_IMPLEMENTATION_AND_DOGFOOD_PLAN.md` +- Work-artifact progress receipt: `docs/synthesis/WORK_ARTIFACTS_PROGRESS_RECEIPT.md` + +## Product Rule + +Mobile is the review and approval surface for governed artifacts. Deep +composition can remain desktop-first, but mobile must let a reviewer understand +what changed, inspect evidence, ask for a scoped patch, accept or reject the +patch, and audit the trace or receipt. + +## Visual Contract + +The target mobile skin is the light terracotta design: + +- Cream app and sheet surfaces. +- Terracotta primary accent and selection color. +- Warm ink text, muted clay borders, and restrained shadows. +- Serif display accents for room and artifact titles. +- iOS-style bottom navigation, floating action, sheet handles, and safe-area + spacing. +- Artifact-card home with compact type signatures: deck filmstrip, sheet grid, + plan checklist, evidence source stack. +- Deck sheet with thumbnail strip, sandboxed slide preview, scoped composer, + patch tray, evidence tab, export tab, and present overlay. + +Dark mobile mode may remain available as a setting, but the default terracotta +reference is light. + +## Governed Deck Contract + +The deck flow must stay review-first: + +1. Plan first: show goal, reads, non-reads, creates, cost, and guardrails. +2. Preview only: slide rendering is sandboxed and does not mutate room state. +3. Scoped request: tapping/clicking a slide element scopes the composer to that + element. +4. Patch proposal: NodeAgent returns before/after text plus evidence and risk. +5. Human gate: the slide changes only after accept; rejection keeps the original + and records the request. +6. Evidence tab: sources and gaps come from live room evidence where available. +7. Export tab: export state is honest; real files require a receipt, and + unavailable export stays labeled as pending or preview-only. +8. Receipt: planned-vs-actual data, trace ids, proposal ids, source gaps, and + versions are visible. + +## Live Data Contract + +Production mobile may use sample data only in standalone/demo mode. In a live +room: + +- `MobileAppLive` is the source for room name/code, members, public/private + chat, recents, proposals, jobs, plan, evidence, coach, pipeline, trace rows, + people groups, invite code, watches, and offline holds. +- `MobileDeck` should prefer live deck/work-artifact input when present. +- If no live deck exists, the deck sheet must show an honest empty/review state + or derived storyboard preview, not the CardioNova sample deck as if it were + the room output. +- Proposal accept/reject must use existing proposal callbacks where possible. +- Agent writes must remain proposal-first and behind existing room tools. + +## Current Gap + +As of this contract, `src/ui/mobile/MobileDeck.tsx` is still primarily backed by +`D.DECK` from `src/ui/mobile/mobileData.ts`. `MobileAppLive.tsx` already derives +live plan, evidence, proposals, jobs, recents, trace rows, and pipeline data, +but the deck workbench does not yet consume a live deck/storyboard payload. + +## Non-Goals + +- Do not rewrite mobile routing, join/create flow, session handling, or store + boot behavior. +- Do not change Convex/backend schemas for this slice. +- Do not change NodeAgent core/frame behavior unless a focused test requires it. +- Do not revert existing proofloop/eval/work-artifact lane changes. +- Do not fake export success, source coverage, or proposal resolution. + +## Acceptance Gates + +- Focused tests pass: + - `npm test -- --run tests/mobileGapScreens.test.tsx tests/mobileAgentModelRouting.test.tsx tests/workArtifacts.test.ts tests/semanticGraph.test.ts` +- Typecheck passes: + - `npm run typecheck -- --pretty false` +- NodeAgent smoke gates pass: + - `npm run nodeagent:frame:smoke` + - `npm run omnigent:nodeagent:smoke` +- Mobile browser proof shows the light terracotta shell at 390x844. +- Mobile deck proof shows governed plan/preview/patch/evidence/export behavior + with honest live or fallback state. +- Receipts list baseline failures, screenshots, known gaps, and resume commands. diff --git a/docs/design/NOTEBOOK_WORK_ARTIFACT_CONTRACT.md b/docs/design/NOTEBOOK_WORK_ARTIFACT_CONTRACT.md new file mode 100644 index 00000000..35f7349c --- /dev/null +++ b/docs/design/NOTEBOOK_WORK_ARTIFACT_CONTRACT.md @@ -0,0 +1,112 @@ +# Notebook Work Artifact Contract + +Status: implementation contract + +Source surfaces: + +- `src/ui/panels/Artifact.tsx` +- `src/ui/panels/notebook-paper.css` +- `src/notebook/extensions.ts` +- `src/notebook/blockOps.ts` +- `tests/notebookPaper.test.tsx` +- `tests/notebookBlockOps.test.ts` +- `tests/notebookAgentOutline.test.ts` + +## Product Role + +The notebook is the room's durable thinking surface: human prose, agent notes, +evidence citations, assumptions, decisions, and open questions. It should feel +like calm paper inside the dark Cloud shell, but its behavior is governed by the +existing ProseMirror, Convex sync, block identity, read-model, and proposal +contracts. + +## Source Of Truth + +The collaborative source remains the notebook ProseMirror document and its +existing checkpoint mirror: + +- synced ProseMirror document when live sync is enabled; +- legacy `elements["doc"]` HTML checkpoint for fallback/read compatibility; +- stable `data-blockid` / `attrs.blockId` per editable block; +- attribution attrs: `authorKind`, `runId`, and `status`; +- notebook read-model rows generated by the existing dirty-event processor. + +The work-artifact layer is a read-only adapter. It can summarize notebook +sections, blocks, sources, trace ids, and proposal ids, but it must not mutate +the editor, bypass the sync document, or replace the notebook read model. + +## Behavior Contract + +Required behavior to preserve: + +1. Human keyboard editing stays inside the current TipTap/ProseMirror editor. +2. Synced and legacy editor modes continue to render inside `NotebookPaperFrame`. +3. Agent-authored blocks preserve block ids, run ids, author attribution, and + review status in data attrs. +4. Claims without usable evidence stay visible as `needs_review`. +5. Agent notebook writes use existing governed write/proposal paths. +6. Block transforms use block id plus text hash/CAS semantics where available. +7. Private notebook visibility remains owner-gated. +8. Notebook read-model processing remains passive and trace-backed. +9. Citations and footnotes are derived from real links/evidence only. +10. Work-artifact summaries link back to artifact, block, trace, proposal, and + source ids instead of creating static mock content. + +## Work-Artifact Shape + +The read-only notebook digest should include: + +- `artifactId` +- `title` +- `status` +- `blockCount` +- `sectionCount` +- `agentBlockCount` +- `humanBlockCount` +- `needsReviewCount` +- `citationCount` +- `evidenceCount` +- `sourceIds[]` +- `traceIds[]` +- `proposalIds[]` +- `blocks[]` +- `sections[]` + +Each block digest should include: + +- stable `blockId` where present; +- fallback digest `id`; +- source `elementId`; +- display `index`; +- `kind`: heading, paragraph, list item, quote, code, or unknown; +- `role`: human, agent, or unknown; +- `status`: draft, accepted, or needs_review; +- text excerpt; +- linked `sourceIds`, `traceIds`, and `proposalIds`. + +## Visual Contract + +The notebook interior should keep the Cloud direction already established by +`notebook-paper.css`: + +- paper-like reading/editing area inside the dark shell; +- calm neutral frame; +- fewer nested boxes; +- citations and review states as quiet inline chips; +- agent-authored blocks marked with subtle ink/provenance cues; +- strong content focus over decorative chrome; +- no static fake notebook content in product routes. + +## First Implementation Slice + +The first slice is intentionally read-only: + +- derive a notebook structure from existing note artifacts; +- support legacy HTML notes and ProseMirror JSON-like documents; +- expose block, section, citation, source, trace, and proposal metadata through + the unified work-artifact adapter; +- preserve all existing notebook editor, sync, keyboard, and mutation behavior; +- cover the adapter with deterministic unit tests. + +Future slices can add richer block-level workpaper cards, export receipts, and +NodeAgent patch previews after the read-only contract is proven in dogfood. diff --git a/docs/design/UI_CONTRACT.md b/docs/design/UI_CONTRACT.md index 9ddc6567..e1cf6a89 100644 --- a/docs/design/UI_CONTRACT.md +++ b/docs/design/UI_CONTRACT.md @@ -2,10 +2,28 @@ Captured: 2026-07-08 -This contract is the first migration artifact for a behavior-preserving visual -refresh. Product behavior remains sourced from the current production/main app. -The Cloud Design artifacts are visual references only: they define shell, -surface, token, and component direction, not new product logic. +This contract governs a behavior-preserving visual refresh. Product behavior +remains sourced from the current production/main app. Cloud Design artifacts +are design evidence only: they may inform shell, surface, token, and component +decisions, but they neither define product logic nor become visual authority +without an explicit taste review and approval in this contract. + +## Visual Authority Order + +1. Latest production/main is the behavioral source of truth. +2. For mobile, `docs/design/mobile/MOBILE_TASTE_AUDIT.md` and + `docs/design/mobile/MOBILE_HEADER_CONTRACT.md` are the approved visual source + of truth. +3. Standalone HTML, Cloud Design captures, screenshots, and legacy prototypes + are reference evidence. They may be kept, refined, or rejected on taste, + semantic, accessibility, and mobile-native grounds. +4. CSS cascade accidents, stale snapshots, and prototype-only device chrome are + not design decisions. + +Desktop and mobile have separate canonical defaults over shared semantics: +desktop keeps the restrained Cloud-dark workspace direction; `#mobile` is +light terracotta by default with dark as an explicit opt-in. Both themes must +override the same semantic token names. Import order may not select a theme. ## Source Artifacts @@ -125,9 +143,10 @@ Direction: expansion, or when a receipt matters. - Fewer boxed containers. Use hairline dividers, quiet surfaces, and stable layout regions instead of stacked cards. -- Less saturated page background. Prefer flat near-black/default app surfaces - over decorative glows. Subtle source-artifact gradients are references, not a - requirement for production default. +- Less saturated page background. Desktop prefers flat near-black/default app + surfaces over decorative glows. Mobile uses a flat light terracotta app + surface by default. Subtle source-artifact gradients are references, not a + requirement for either production default. - Larger central work surface, with binder and Copilot behaving as supporting rails. - Clear focus and active states, especially for keyboard users. @@ -140,7 +159,7 @@ Tokens extracted from the standalone artifact: | Accent | Terracotta selection/focus: `#D97757`; hover: `#C76648`; warm ink: `#E59579`/`#AD5F45`. | | Secondary signal | Indigo `#5E6AD2`/`#8C92E0` for non-primary status or agent metadata, not as a page-wide theme. | | Semantic colors | Success green only for completed/healthy states. Warning amber for held/review states. Danger red only for errors/failures. | -| Backgrounds | Dark workspace base around `#101317`, app surface around `#09090b` to `#111418`, secondary surface around `#171b20`. Light theme remains white/gray tokenized. | +| Backgrounds | Desktop: dark workspace base around `#101317`, app surface around `#09090b` to `#111418`. Mobile default: `#FBF4E7` app and `#F3E8D8` surface, with dark available only through an explicit theme selector. | | Typography scale | 11, 12, 13, 14, 15, 17, 20, 26, 31, 40 px. | | Radius scale | 4, 6, 8, 10, 12, 16, pill. Compact controls should stay near 8 px; large shell/panel frames may use 12-16 px when matching the source. | | Shadows | Default to flat or low elevation. Use strong shadows only for overlays, dialogs, popovers, or dragged/floating surfaces. | @@ -217,9 +236,11 @@ Implemented in the 2026-07-08 visual slice: live-room frame from the reference, not the standalone gallery page wrapper: no page-level hero/context band, one maximized rounded room frame, flatter side/work/chat panes, quieter binder rows, and a darker app canvas. -- Migrated the standalone mobile surface from the old warm paper palette to - Cloud dark tokens while preserving the mobile router, sheets, gestures, - composer, and join/consent flows. +- The 2026-07-08 slice temporarily applied Cloud-dark tokens to the standalone + mobile surface. That historical direction is superseded by the approved + mobile contract above: light terracotta is canonical, and dark remains an + explicit opt-in while router, sheets, gestures, composer, and join/consent + behavior stay intact. - Preserved the honest absence of notifications on in-memory rooms: `NotificationsInbox` still renders only for Convex rooms with proof. @@ -250,6 +271,30 @@ Proof screenshots written under - `after-composition-parity-trace-1456x940.png` - `after-composition-parity-company-research-1456x940.png` +## Work-Artifact Completion Addendum + +Implemented and live-verified on 2026-07-09: + +- Collaborative deck editing stays inside the maximized center work surface. + Slide order, selection, presence, save state, and export actions use compact + controls and do not introduce a second page shell or nested card canvas. +- Notebook kernel output is a quiet receipt region below the notebook content. + Calculation, read-only SQL, and chart intent use the same typography and + divider hierarchy as trace receipts; arbitrary execution is never implied. +- Graph cluster, hop-depth, and relevant-path focus controls form one compact + toolbar. The canvas remains full-bleed inside the work region and draggable + nodes retain stable dimensions while moving. +- Chat context is selected from the pinned composer and appears as a compact, + openable reference on the sent message. It must not resize the right rail or + hide send, attachment, streaming, retry, or NodeAgent controls. + +Completion proof images: + +- `docs/synthesis/proof/m24-deck-collaboration-proof.png` +- `docs/synthesis/proof/m25-notebook-kernel-proof.png` +- `docs/synthesis/proof/m26-graph-cluster-drag-proof.png` +- `docs/synthesis/proof/m27-chat-context-proof.png` + ## Proof Requirements Design parity is not complete until all are true: diff --git a/docs/design/first-run/FIRST_RUN_BEHAVIOR_INVENTORY.md b/docs/design/first-run/FIRST_RUN_BEHAVIOR_INVENTORY.md new file mode 100644 index 00000000..889148bb --- /dev/null +++ b/docs/design/first-run/FIRST_RUN_BEHAVIOR_INVENTORY.md @@ -0,0 +1,33 @@ +# NodeRoom First-Run Baseline Behavior Inventory + +Captured before implementation: 2026-07-10 + +This table is the baseline receipt. Completed outcomes and current blockers are +recorded in `FIRST_RUN_EXECUTION_RECEIPT.md`; do not read the baseline column as +the current implementation. + +| Entry/action | Current behavior | Durable call/state | Required result | +|---|---|---|---| +| Public SSR Create | Links directly to `?create=1&surface=desktop` | URL triggers app mutation | Route to a preflight intent; never force desktop or mutate from the link. | +| React desktop Create | Opens title/name dialog | `rooms.createStarterRoom`, auto-allow true | Empty `rooms.create`, review-first, truthful code-access copy. | +| Desktop sample | Memory demo or live starter mutation | `rooms.createStarterRoom` | Explicit sample preflight and persistent sample identity. | +| Desktop Join | Code, then guest-name dialog | `rooms.joinAnonymous` | Preserve code and show code-access/audience truth. | +| Mobile root | Join form plus demo | local state | Add explicit Create and keep Join/Sample distinct. | +| Mobile Create URL | Can stage a create request immediately | `rooms.create` | Stage create form and policy confirmation before mutation. | +| Mobile demo URL | Stages policy consent | `rooms.createStarterRoom` | Keep consent, default to Review, label sample. | +| Room creation | Desktop seeds starter room | room/member/agents/artifacts/traces | Blank create is empty; sample create alone seeds fixtures. | +| Session restore | Per-room token in localStorage | `noderoom:live:` | Preserve across reload; invalid/revoked proof returns to Join with a reason. | +| First room | Desktop Home plus broad controls | live room/store | One primary task; advanced systems remain reachable but secondary. | +| Mobile first join | Non-modal welcome | sessionStorage | Label sample/live truth and preserve composer interaction. | +| Public chat | Room channel | message + agent job paths | Label `Everyone in this room`; never imply public internet visibility. | +| Private chat | User-owned private lane | private message/agent path | Label it private inside NodeRoom and disclose that requests/context go to the configured model provider. | +| Agent edits | Auto-allow or proposals | room policy + CAS/proposals | Review-first default; audience and mutation authority visible before send. | +| Export XLSX | Real browser download and filename status | client workbook build | Add format, row count, timestamp, and failure/retry receipt. | +| Leave | Revokes/clears current session | `rooms.leave`, localStorage | Revoke an ordinary member and return to entry; keep a host active until ownership can be transferred. | + +## Dirty-Lane Boundary + +The working tree contains mobile, work-artifact, graph, proofloop, benchmark, +Convex, and release changes from other lanes. Do not revert them. Preview and +production deployment must come from an isolated, reviewed snapshot; the mixed +working directory is not itself a deployable artifact. diff --git a/docs/design/first-run/FIRST_RUN_EXECUTION_RECEIPT.md b/docs/design/first-run/FIRST_RUN_EXECUTION_RECEIPT.md new file mode 100644 index 00000000..e4272b71 --- /dev/null +++ b/docs/design/first-run/FIRST_RUN_EXECUTION_RECEIPT.md @@ -0,0 +1,121 @@ +# NodeRoom First-Run And Mobile Execution Receipt + +Captured: 2026-07-10 (America/Los_Angeles) + +Status: superseded by the clean release-branch receipt; production migration blocked + +Current launch status and authentication work are recorded in +`docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md`. This earlier +receipt remains the implementation audit for the original mixed worktree. + +## Scope Receipt + +Existing lane work preserved: + +- terracotta source exports, mobile shell migration, stable header, and six-tab + navigation; +- governed storyboard/work-artifact projection and deck model; +- graph, benchmark, proofloop, desktop Cloud-token, and other dirty-tree work. + +This pass added or completed: + +- explicit desktop and phone Create/Join/Sample intents and review-first + preflight before every mutation; +- empty workspace versus persistent synthetic sample provenance; +- token-idempotent join recovery, host-safe leave, and sample backfill repair; +- mobile live people, jobs, files, generic sheet rows, proposals, traces, + plan/evidence requests, scoped deck requests, and real PPTX export receipts; +- honest unavailable states for live capture, upload, voice, historical deck + restore, unbacked source attachment, and unprojected artifacts; +- fresh empty-room NodeAgent task, privacy/provider copy, focus trapping, + keyboard/Escape behavior, 44px targets, reduced motion, contrast-safe + terracotta, and narrow-phone geometry; +- XLSX receipt details and tests for duplicate/lost-response room creation. + +No NodeAgent core, immutable scorer, or certification threshold was weakened. +The design audit was updated only to recognize the accessibility-approved +terracotta token and the equivalent `URLSearchParams` room route construction. + +## Deterministic Proof + +| Gate | Result | +|---|---| +| App TypeScript | Pass | +| Convex TypeScript | Pass | +| Production Vite build | Pass; existing large-chunk warning | +| Focused mobile/work-artifact/session Vitest | Pass, 99/99 | +| Final live-honesty/mobile adapters subset | Pass, 49/49 | +| Full Vitest | 2066/2072 pass | +| First-run + story + terracotta + live Convex Playwright | Pass, 28/28 | +| NodeAgent frame smoke | Pass | +| Omnigent NodeAgent smoke | Pass; external `omni` CLI unavailable | +| Design-system audit | Pass; advisory drift warnings remain | +| QA matrix / content fluency | Pass / pass | +| Security gate / production dependency audit | Pass / 0 vulnerabilities | +| ProofLoop doctor | Pass, 11/11 | +| ProofLoop `official-scores` gate | Pass from persisted ledger | + +The six full-suite failures are confined to the existing official-score lane: + +- `proofloopAdapterBlockers.test.ts` (one); +- `proofloopBenchmarkNormalization.test.ts` (two); +- `proofloopOfficialScorePreflight.test.ts` (one); +- `proofloopOfficialScoreReceipts.test.ts` (one); +- `proofloopPromoteOfficialScore.test.ts` (one). + +They disagree about whether Finch/FinAuditing receipts are accepted or +externally blocked. Certification receipts were not rewritten by this pass. +The machine report is `.proofloop/full-vitest-2026-07-10.json`. + +## Visual Receipts + +- `.proofloop/visuals/first-run-terracotta-390x844.png` +- `.proofloop/visuals/first-run-create-preflight-390x844.png` +- `.proofloop/visuals/mobile-live-terracotta-390x844.png` +- `.proofloop/visuals/production-stale-mobile-390x844.png` + +The local live capture is a real Convex development room at 390x844. The +production capture is a fresh-origin read-only inspection; this pass created no +production QA room because the deployed CTA and backend topology failed the +preconditions for an honest launch test. + +## Production Blockers + +1. `noderoom.live` is Vercel deployment + `dpl_4hTRY4E5FqvYS8hrsM2B4KfvrzA2` and reports Ready, but serves the stale + landing: `Diligence that shows its work`, no Join/Sample choice, and a Create + link containing `surface=desktop`. +2. Vercel production reads Convex development deployment + `dev:zealous-goshawk-766`. The actual production deployment is + `aromatic-bass-102`; development exposes 311 functions while production has + 236. Production lacks `rooms.finishStarterRoom` and + `rooms.ensureStarterRoomState`. +3. The current `main` checkout matched `origin/main` at `5f67d8e1` but had 306 + changed paths (189 tracked, 117 untracked) before this receipt. It is not a + clean or reviewable deploy artifact. +4. `src/app/main.tsx` mounts plain `ConvexProvider`. There is no Clerk/Auth0 or + equivalent adapter, so enabling `NODEROOM_REQUIRE_CONVEX_IDENTITY=1` would + reject every first-time user instead of producing an authenticated journey. +5. The independent taste judgment remains below its ship threshold after its + allowed three loops, despite the deterministic contract judgment passing. + +## Safe Resume Sequence + +1. Reconcile the six official-score expectations in their owning lane without + changing scorer truth, then require `npm test -- --run` to pass. +2. Produce a clean reviewed `codex/` release branch that intentionally includes + the mobile/work-artifact dependencies and excludes unrelated WIP. +3. Choose the canonical Convex production database, write and rehearse any data + migration, and add a real production deploy command. Do not use the current + `npm run convex:deploy`; it runs `convex dev --once`. +4. Add and test an account auth provider, then enable the production identity + requirement only after anonymous, invited, returning, and revoked-user + journeys have explicit policy decisions. +5. Deploy backend to an isolated preview, deploy the matching Vercel preview, + and rerun the 28-case browser gate against that URL. +6. Promote the matched backend/frontend release, then run one authenticated + fresh-phone Create journey, one invited-member journey, reload recovery, + governed proposal accept/reject, trace, and export receipt. Record deployment + IDs and screenshots before claiming launch-ready. + +Local dogfood remains available at `http://127.0.0.1:4173`. diff --git a/docs/design/first-run/FIRST_RUN_JOURNEY_CONTRACT.md b/docs/design/first-run/FIRST_RUN_JOURNEY_CONTRACT.md new file mode 100644 index 00000000..731775fc --- /dev/null +++ b/docs/design/first-run/FIRST_RUN_JOURNEY_CONTRACT.md @@ -0,0 +1,79 @@ +# NodeRoom First-Run Journey Contract + +Captured: 2026-07-10 + +## Authority + +Latest production/main owns behavior. This contract owns the approved first-run +experience. Design artifacts and fixture rooms are evidence, not authority. + +## Four Questions + +At every step a first-time user must be able to answer: + +1. What is NodeRoom? +2. What should I do next? +3. Who can see this and what can NodeAgent change? +4. Did my action work? + +## Entry Paths + +The public landing exposes three distinct intents before any room mutation: + +- **Create a room**: create an empty, code-access workspace. +- **Join with a code**: enter an existing room without creating another one. +- **Try a sample room**: create an explicitly labeled workspace containing + synthetic diligence artifacts. + +Phone-sized Create, Join, and Sample intents must route into the mobile shell. +`surface=desktop` is reserved for deterministic QA and is never emitted by a +normal user-facing call to action. + +## Pre-Mutation Contract + +Create and Sample require an explicit confirmation surface before the mutation. +It must show: + +- room title and display name; +- that allowed visitors with the room code or invite link join as editors and can edit shared content, upload files, use room chat, and run NodeAgent; +- that the route is unlisted but not access-controlled by `noindex`; +- Review every edit as the default NodeAgent policy; +- Auto-approve as an explicit, reversible higher-authority choice; +- whether the room starts empty or with synthetic sample data. + +Legacy `?create=` and `?demo=` links must stage this confirmation rather than +silently minting a room. + +## First Success + +An empty room opens on a calm Home surface with one primary task. Desktop can +offer the artifact creator directly; mobile, where live upload/creation is not +yet wired, must use an honest governed plan rather than a fake artifact: + +> Plan your first source-backed artifact + +The mobile action opens the room-visible NodeAgent lane with a read-only prompt +that states what source is needed. Chat and Load sample workspace remain +secondary alternatives. A sample room +opens with a persistent `Sample workspace` indicator and one guided task that +opens the primary artifact. The indicator must survive reload and invite join. + +The first governed success is: + +`artifact -> scoped NodeAgent request -> sourced proposal -> accept/reject -> trace -> export receipt` + +## Reliability + +- Show acknowledgement immediately after confirmation. +- Creation and join are idempotent across lost responses and reload. +- A failed attempt remains visible and can be retried explicitly; it never loops. +- A blank room never claims that sample seeding is in progress. +- Reload restores the room session, draft, current artifact, and durable work. +- Export receipt includes filename, format, row count, and timestamp. + +## Definition Of Done + +Fresh desktop and phone contexts complete Create, Join, Sample, first governed +action, export, and reload against the deployed Convex-backed product. Memory +mode, fixture assertions, screenshots, and agent persona reviews cannot replace +that production proof. diff --git a/docs/design/first-run/FIRST_RUN_QA_MATRIX.md b/docs/design/first-run/FIRST_RUN_QA_MATRIX.md new file mode 100644 index 00000000..0dfc4846 --- /dev/null +++ b/docs/design/first-run/FIRST_RUN_QA_MATRIX.md @@ -0,0 +1,54 @@ +# NodeRoom First-Run QA Matrix + +Captured: 2026-07-10 + +## Journeys + +| Journey | Required production proof | +|---|---| +| New desktop creator | Landing -> Create preflight -> empty room -> artifact -> governed action -> trace -> XLSX -> reload. | +| New mobile creator | Landing -> mobile Create -> policy -> empty room -> composer/artifact -> reload. | +| Invited desktop/mobile guest | Invite URL -> correct room -> guest name -> join without creating another room. | +| Sample desktop/mobile user | Explicit Sample preflight -> labeled synthetic room -> primary artifact -> proposal/trace. | +| Returning user | Reload/deep link restores session and does not replay creation or blocking onboarding. | +| Recovery user | Delayed/failing create, offline transition, expired/revoked session, room full, failed export. | + +## Viewports And Inputs + +- Desktop Chromium and WebKit-equivalent. +- 320x568, 375x812, 390x844, and 430x932. +- Keyboard-only, screen reader semantics, 200% zoom, reduced motion. +- Mobile software keyboard, browser Back, rotation/background/reload, download. +- Normal and delayed network; offline before and after confirmation. + +## Blocking Assertions + +- No production CTA contains `surface=desktop`. +- No Create or Sample mutation occurs before explicit confirmation. +- Review is selected by default. +- Sample and empty rooms are distinguishable after reload and invite join. +- Visible critical controls are at least 44x44 on phone widths. +- No body or app overflow beyond one pixel at 320px. +- Composer, send, audience, and navigation remain visible with the keyboard. +- Back closes a sheet/dialog before leaving the room. +- No console error, failed required request, duplicate room, or lost draft. +- Creation acknowledgement is under one second and room-ready p95 is under + eight seconds across the recorded preview run set. +- Export receipt and downloaded workbook agree on filename and row count. +- Deployed asset/version receipt identifies exactly what was tested. + +Agent persona audits and model taste reviews are supplementary. They are never +reported as unassisted human usability validation. + +## 2026-07-10 Execution Status + +- Local first-run and mobile live-development browser gate: pass, 28/28. +- Phone widths: pass at 320, 375, 390, and 430 CSS pixels in light and dark. +- Real Convex development sample create: pass; provenance survives an older + backend that omits the new room `experience` field. +- Production fresh-origin inspection: fail; the deployed landing is stale and + still emits `surface=desktop` from its Create CTA. +- Authenticated production first-user journey: blocked; the app mounts plain + `ConvexProvider` and has no account auth adapter. +- Production release: blocked by the mixed 306-path worktree and the current + Vercel-production-to-Convex-development topology. See the execution receipt. diff --git a/docs/design/first-run/PRIVACY_LANGUAGE_CONTRACT.md b/docs/design/first-run/PRIVACY_LANGUAGE_CONTRACT.md new file mode 100644 index 00000000..026239a0 --- /dev/null +++ b/docs/design/first-run/PRIVACY_LANGUAGE_CONTRACT.md @@ -0,0 +1,21 @@ +# NodeRoom First-Run Privacy Language Contract + +Captured: 2026-07-10 + +Use only claims supported by the current access model. + +| Concept | Approved language | Rejected language | +|---|---|---| +| Room access | `Anyone allowed by this deployment who has the room code or invite link can join as a member and edit shared content.` | `Private room`, `read-only guest`, or email allow-list claims without that enforcement. | +| Discoverability | `Room routes are unlisted from search engines.` | `noindex keeps the room private.` | +| Invite link | `Treat the invite link like an access code.` | `Only invited email addresses can enter.` | +| Public chat | `Everyone in this room can read this message.` | `Public on the internet.` | +| Private lane | `Only you can read this lane in NodeRoom. Requests and allowed room context are sent to the configured model provider.` | `End-to-end encrypted` or claims that no third-party model provider receives the request. | +| Review policy | `NodeAgent proposes edits for a host to approve.` | `NodeAgent cannot change anything` when auto-allow can be enabled. | +| Auto-approve | `NodeAgent may commit conflict-free edits; every write remains traced.` | `Fully autonomous` or `always safe`. | +| Sample room | `Synthetic sample data. Do not treat it as live company research.` | Any unlabeled fixture presented as real output. | +| Session | `This browser stores a room-scoped session so reload can restore your place.` | `Account-backed` when the user joined anonymously. | + +SEO directives are never rendered as security proof points. UI copy must keep +discoverability, room membership, artifact visibility, and message audience as +separate concepts. diff --git a/docs/design/mobile/MOBILE_BEHAVIOR_INVENTORY.md b/docs/design/mobile/MOBILE_BEHAVIOR_INVENTORY.md new file mode 100644 index 00000000..f69861c7 --- /dev/null +++ b/docs/design/mobile/MOBILE_BEHAVIOR_INVENTORY.md @@ -0,0 +1,104 @@ +# NodeRoom Mobile Behavior Inventory + +Captured: 2026-07-09 + +This is the pre-implementation preservation ledger. `MobileHeader` and shell +CSS may adapt presentation only. Store/Convex calls remain in `MobileRoot` and +`MobileAppLive`. + +## Entry And Session + +| UI entry | Current transition/callback | Durable call | Source | Existing proof | Must survive | +|---|---|---|---|---|---| +| Phone-sized public URL | `normalizeMobileLandingUrl()` before app boot | none | route | `mobile-story-surfaces.spec.ts` | Standard room/create/demo intents normalize into `#mobile`; `surface=desktop` escapes. | +| `#mobile?mode=memory` | `MobileRoot -> MobileApp` | none | sample | mobile story + mobile gap tests | Deterministic visual/demo path stays explicitly sample-backed. | +| Live mobile route | `MobileRoot -> MobileLiveRoot` | Convex room query | live | design audit + routing tests | Live mode must never silently fall back to sample room data. | +| Join room | `JoinForm.onJoin -> start("join")` | `rooms.byCode`, `rooms.joinAnonymous` | live | routing/model tests; live e2e when env enabled | Validation, room-full errors, proof token, URL replacement. | +| Create route | `initialReq -> create` | `rooms.create` | live | design audit | Title/name/code and auto-allow policy preserved. | +| Start demo | `JoinForm.onDemo -> RoomJoinConsent` | `rooms.createStarterRoom` after consent | live | `mobileGapScreens`, live e2e | No room mutation before explicit auto/review choice. | +| Session restore | `loadSession(liveKey(code))` | localStorage + room subscription | live | routing tests | Existing live session resumes by code. | +| Leave | `leave()` | `rooms.leave`, clear local session | live | behavior audit | Leave remains reachable and returns to join state. | +| Live failure | `ErrorBoundary -> leave()` | cleanup only | live | component coverage | Revoked/stale proof does not strand a blank phone. | + +## Shell And Navigation + +| UI entry | Current transition | Durable call | Source | Existing proof | Preservation decision | +|---|---|---|---|---|---| +| Header mark | `setTab("home")` | none | both | indirect | Home stays reachable through bottom navigation; remove duplicate header command. | +| Room selector | `openSheet("rooms")` | none | both | mobile surface e2e | Becomes one transparent room-context control. | +| Dynamic header action | Jobs, Review, or notification toast based on tab/count | none | both | missing semantic regression | Replace with stable Review plus stable Overflow. | +| Pulse people | `openSheet("manage")` | none | both | gap screens | Move to Overflow > People. | +| Pulse agents | `openPulse("agents")` | none | both | gap screens | Move to Overflow > Room activity. | +| Pulse cost/jobs | `openPulse("cost")` or `openSheet("jobs")` | none | sample/live | gap screens | Move to stable Overflow > Usage/Jobs; no persistent strip. | +| Bottom tabs | `setTab(home/capture/room/agent/inbox/files)` | none | both | mobile story/full UX | Remain the only primary navigation set. | +| Quick-action FAB | Contextual action fan | callbacks below | both | full modern UX bar | Remains contextual; remove duplicate `Go to` navigation tier only. | + +## Work And Communication + +| Surface/action | State/callback | Live call | Honest sample behavior | Existing proof | +|---|---|---|---|---| +| Note capture/edit | `setNote`, extraction effects, save states | no live persistence exposed here | local sample extraction | mobile surface tests | +| Public message | `sendComposer` | `store.postMessage(public)` | appends local room message | mobile routing/full UX | +| Private agent | `sendAgent(private)` | private post + `store.askPrivateAgent` | local scripted reply | model-routing tests | +| Room agent | `sendAgent(room, model)` | public post + `store.askAgent(modelSelection)` | local scripted reply | `mobileAgentModelRouting.test.tsx` | +| Model selection | `mobileAgentModelSelection` | forwarded only for room agent route | visible selector | model-routing tests | +| Optimistic retry | `retryMessage(id)` | repost failed message | re-add local message | behavior path present; focused failure-state test remains desirable | +| Attachments | add/remove local attachment choices | no upload backend in mobile adapter | explicit local attachment UI | full UX path | +| Voice | `startVoice/stopVoice` and draft fill | no microphone backend claim | timed local affordance | interaction path present | +| Scope/lane | `scope`, `agentLane`, `composerMode` | selects public/private call | local state | model-routing tests | + +## Artifacts And Governance + +| UI entry | Callback/state | Live call | Live/sample status | Existing proof | +|---|---|---|---|---| +| Home recents | open deck/sheet/plan/evidence/room | live artifacts projected by `buildRecents`; otherwise sample | both | mobile deck/gap tests | +| Files | artifact list and open sheet | live recents where available | both; no fake live deck | `mobileDeckLive.test.tsx` | +| Row field edit | `editRowField` | `store.applyEdit` with CAS base version | live only; offline returns reason | agent routing/work-artifact tests | +| Flag review | `flagRowNeedsReview` | CAS status edit | live only | mobile gap tests | +| Deck review | plan/slides/comments/evidence, element request | derived live storyboard; proposal/trace IDs | live read-only until persistence/export receipt; sample only in memory | mobile deck live tests | +| Export | export intent in live; sample download copy in memory | no real live PPTX receipt yet | honest limitation | mobile deck live tests | +| Proposal open | `openInbox(item)` currently branches to artifact sheets | none until decision | both; live plan proposals do not resolve from that sheet | gap screens; adapter repair required | +| Proposal accept/reject | `resolveProposalById` | `store.resolveProposal` | host-only live; local memory resolution | agent routing/mobile deck tests | +| Jobs | `JobsSheet` currently reads static `D.JOBS`; `ctx.jobs/jobAct` already exist | existing cancel/retry long-free-job methods are not reached by current sheet | sample UI even in live mode; adapter repair required | gap screens | +| Trace | Trace list uses `ctx.traceRows`, but overlay currently resolves only static `D.TRACES` | live trace projection exists but live IDs can open an empty overlay | mixed; adapter repair required | work-artifact/semantic graph tests | +| Evidence/source | `openSource`, `startSearch`, `beginRun` | room agent/read-only run in live path | explicit source/fallback state | mobile deck/full UX | +| Plan/coach | open sheet/run prompt | agent request where invoked | live projection/sample | focused mobile tests | + +## Collaboration, Policy, And Resilience + +| UI entry | Callback | Live call | Status | Existing proof | +|---|---|---|---|---| +| People/presence | manage sheet, mention, pin | live member/session reads | live or labeled sample | gap screens | +| Share | copy invite URL/code | browser clipboard | live code or sample code | gap screens | +| Auto-allow | `setAutoAllow` | `store.toggleAutoAllow` only on desired-state change | live; local memory toggle | gap screens | +| Watches | `watchRow` | Convex `setWatch` with requester proof | live; local memory set | gap screens | +| Notification tiers | settings rows | backed only when live proof exists | memory explicitly says preview-only | gap screens | +| Offline-held edits | banner/conflict acknowledgement | existing offline queue callbacks | live snapshot only | gap screens | +| Gestures | swipe/watch/review affordances | delegates to watch/review callbacks | both | `mobileGapScreens.test.tsx` | +| First join | once-per-session overlay | sessionStorage marker only | live only | live mobile e2e | +| Room switch/join/leave sheet | `switchRoom`, `joinRoom`, `leaveRoom` | live leave callback where bound | both | mobile surface tests | +| Settings | tweak state + `saveTweaks` | localStorage only | both | settings rendering in gap tests | + +## Pre-Existing Adapter Integrity Issues To Repair + +- Review entry points must route to the real Inbox decision controls; opening a + plan sheet and running the memory-mode animation is not equivalent to + `store.resolveProposal`. +- Jobs must render `ctx.jobs` and call `ctx.jobAct` instead of hard-coded + `D.JOBS` plus a toast. +- Live trace IDs need an honest summary overlay when no rich local `D.TRACES` + fixture exists. +- The Ask visibility toggle must update composer route and agent lane together; + it may never say `Private to you` while `composerMode === "room"` will post to + the public channel. +- Live room switching remains a single-room binding. Join-another-room currently + returns to the join form only through Leave; this limitation must be labeled + or the existing leave callback reused, not represented as a successful switch. + +## Preservation Gate + +The migration may remove duplicate entry points, but not the destination or its +callback. The post-change tests must prove all six tabs, stable Review, Jobs, +People, activity, usage, Trace, Share, Settings, room switching, quick actions, +composer/model/voice, proposal decisions, live route wiring, offline state, and +governed artifact sheets remain reachable. diff --git a/docs/design/mobile/MOBILE_COMPONENT_MAP.md b/docs/design/mobile/MOBILE_COMPONENT_MAP.md new file mode 100644 index 00000000..06d7e512 --- /dev/null +++ b/docs/design/mobile/MOBILE_COMPONENT_MAP.md @@ -0,0 +1,30 @@ +# NodeRoom Mobile Component Map + +Captured: 2026-07-09 + +| Region | Current owner | Approved owner | Behavior boundary | +|---|---|---|---| +| Universal entry | `src/ui/App.tsx` | unchanged | Normalize phone URLs while preserving `surface=desktop` QA escape. | +| Live/memory bootstrap | `MobileRoot.tsx` | unchanged except NodeRoom naming | Memory mode, live query/mutations, consent, create/join/demo/session/leave. | +| Live projection | `MobileAppLive.tsx` | unchanged | Store reads/writes, Convex watches, proposals, traces, governed storyboard, honest fallback state. | +| Theme | duplicated blocks in `mobile.css` | `mobile.tokens.css` | One semantic token vocabulary with light default and explicit dark selector. | +| Header | inline `MobileApp.tsx` markup | `shell/MobileHeader.tsx` | Callback-only room, Review, Jobs, People, activity, usage, Trace, Share, Settings adapter. | +| Shell/safe area | `mobile.css`, `MobileFrame.tsx` | `mobile.shell.css`, `MobileFrame.tsx` | 52px header, real safe areas, full-bleed production, explicit device preview. | +| Feature surfaces | `MobileScreens.tsx`, `MobileChat.tsx`, `MobileFiles.tsx`, `MobileDeck.tsx`, `MobileGrid.tsx` | existing owners | No state/store rewrite; presentation uses semantic tokens and calmer container rules. | +| Governance sheets | `MobileGapSheets.tsx`, `MobileSheets.tsx` | existing owners | Proposal review, trace, people, share, settings, watches, offline holds, auto-allow. | +| Settings | `MobileSettings.tsx` | existing owner | Theme remains primary; design-iteration variants move under Advanced while policy controls stay visible. | +| Design audit | `src/design/designSystem.ts` | same owner | Parse/check canonical token and shell files; reject late theme overrides and synthetic production chrome. | +| Proof | Vitest and Playwright | focused mobile suites | Component semantics, callback reachability, computed styles, geometry, screenshots, console/network/overflow receipts. | + +## Import Order + +`mobile.tokens.css` is imported first, feature CSS second, and +`mobile.shell.css` last. Correctness does not depend on that order: feature CSS +must not redeclare theme tokens, and the design audit rejects later token +blocks that do. + +## Non-Owners + +This migration does not move logic into the header or CSS and does not modify +`convex/**`, `src/nodeagent/**`, `src/app/store.tsx`, room-store contracts, +proofloop, trace persistence, auth, schema, or durable collaboration behavior. diff --git a/docs/design/mobile/MOBILE_DIFF_PLAN.md b/docs/design/mobile/MOBILE_DIFF_PLAN.md new file mode 100644 index 00000000..5959990c --- /dev/null +++ b/docs/design/mobile/MOBILE_DIFF_PLAN.md @@ -0,0 +1,56 @@ +# NodeRoom Mobile Diff Plan + +Captured: 2026-07-09 + +## Safe Sequence + +1. Lock visual authority and behavioral inventory in `docs/design/mobile/`. +2. Add `mobile.tokens.css` and bridge existing legacy variable names to the + semantic mobile token vocabulary. +3. Add `shell/MobileHeader.tsx` and `mobile.shell.css`; replace only the inline + header markup in `MobileApp.tsx`. +4. Remove the persistent pulse strip and route its existing destinations into + stable overflow actions. Keep bottom navigation as the only primary-nav set. +5. Make device preview explicit in `MobileFrame.tsx`; production becomes + full-bleed with real safe-area insets. +6. Remove duplicate theme blocks, late token overlays, and the global + `letter-spacing: 0 !important` reset from `mobile.css`/`mobileFrame.css`. +7. Rename the live join identity from NodeAgent Mobile to NodeRoom without + changing mutations, consent, or session behavior. +8. Move experimental appearance/navigation/tone knobs under Advanced while + leaving theme and governed agent policy controls direct. +9. Repair existing mobile adapters that already have live contracts but do not + reach them: Inbox decisions, Jobs actions, live trace fallback, and Ask + visibility/delivery alignment. +10. Harden `designSystem.ts`, Vitest, and Playwright checks before visual proof. +11. Run at most three implementation/proof/judge repair loops. + +## File Scope + +| File | Change class | +|---|---| +| `docs/design/UI_CONTRACT.md` | Authority clarification only. | +| `docs/design/COMPONENT_MAP.md` | Mobile shell ownership map only. | +| `docs/design/mobile/**` | Contracts, inventories, judgments, QA receipts. | +| `src/ui/mobile/mobile.tokens.css` | New canonical semantic tokens. | +| `src/ui/mobile/mobile.shell.css` | New shell/header/safe-area styles. | +| `src/ui/mobile/shell/MobileHeader.tsx` | New callback-only adapter. | +| `src/ui/mobile/MobileApp.tsx` | Header adapter wiring, scroll state, stable secondary actions, remove duplicate nav tier. | +| `src/ui/mobile/MobileFrame.tsx` | Explicit preview mode; no production synthetic status chrome. | +| `src/ui/mobile/mobile.css` | Remove duplicate tokens/header rules/late typography reset; retain feature styling. | +| `src/ui/mobile/mobileFrame.css` | Remove duplicate late route-theme overlay; use semantic frame tokens. | +| `src/ui/mobile/MobileRoot.tsx` | Product name correction only. | +| `src/ui/mobile/MobileSettings.tsx` | Advanced grouping only; callbacks unchanged. | +| `src/ui/mobile/MobileChat.tsx`, `MobileOverlay.tsx` | Existing live Jobs/trace adapter wiring only. | +| `src/design/designSystem.ts` | Structural mobile contract checks. | +| `tests/designSystemManifest.test.ts` | Regression fixtures for forbidden cascade/status/header patterns. | +| `tests/mobileHeader.test.tsx` | Header semantics, counts, stable callbacks, overflow actions. | +| `e2e/mobile-story-surfaces.spec.ts` | Multi-width production/preview geometry and computed-style proof. | + +## Stop Conditions + +No blocker/high mobile issue, topbar taste >= 8.5, overall taste >= 8, +contract adherence >= 9, no 320px overflow, 44px targets, no production +synthetic status bar, and typecheck/build/design audit/focused Vitest/mobile +Playwright all pass. A missing external judge or undeployed production build is +recorded honestly and cannot be converted into a passing external receipt. diff --git a/docs/design/mobile/MOBILE_HEADER_CONTRACT.md b/docs/design/mobile/MOBILE_HEADER_CONTRACT.md new file mode 100644 index 00000000..25a4267d --- /dev/null +++ b/docs/design/mobile/MOBILE_HEADER_CONTRACT.md @@ -0,0 +1,79 @@ +# NodeRoom Mobile Header Contract + +Approved: 2026-07-09 + +## Purpose + +The header must establish NodeRoom and room context immediately without +competing with the work. It is an adapter over existing callbacks. It owns no +store, Convex, auth, proposal, or routing logic. + +## Geometry + +- Production height: `52px + env(safe-area-inset-top)`. +- Production top padding: `calc(env(safe-area-inset-top) + 6px)`. +- Horizontal application inset: 15px, acceptable range 14-16px. +- Header row: 52px with 44px minimum interactive targets. +- Default background: flat `--mobile-bg-app`. +- Default divider and shadow: none. +- Scrolled state: one `1px` bottom hairline only; no drop shadow. +- No decorative gradient, translucent capsule, or persistent telemetry row. + +## Room Context + +- One coherent transparent control occupies the left flexible track. +- The mark is 29x29px with an 8px radius and no gradient or shadow. +- Mark text is `N`; product naming elsewhere remains NodeRoom. +- Room title is 15px/600, single line, ellipsized, and `min-width: 0`. +- Chevron is 14px, quiet but visible. +- A 6px success dot may indicate a live room. It is not the brand accent. +- The room control always opens room switching. It never changes meaning. +- A long title may shrink only its text track. It may not move, hide, or + overlap the right-side actions at 320px. + +## Commands + +- Review is a dedicated 44x44 ghost button and always opens Review/Inbox. +- Review count belongs only to Review. It is amber/attention, not terracotta. +- Badge diameter is 17px; values display as `1` through `9` and `9+`. +- Overflow is a dedicated 44x44 ghost button and never carries a count. +- Overflow always opens secondary commands with stable meanings: Jobs, People, + room activity, usage, Trace, Share, and Settings. +- Primary tabs remain in bottom navigation and are not duplicated in overflow. +- No command silently changes among Jobs, History, Notifications, or Review in + response to an unrelated tab or count. + +## Color Semantics + +- Terracotta: primary, active selection, or provenance only. +- Green: live, healthy, or completed only. +- Amber: review, held, or attention only. +- Red: error, failure, or destructive only. +- Neutral ink/surfaces: navigation, overflow, ordinary metadata. + +## States + +| State | Required result | +|---|---| +| Zero reviews | Review button remains a stable Inbox command; no badge. | +| Four reviews | Badge reads `4` and has accessible name `Review inbox, 4 items`. | +| 99+ reviews | Badge reads `9+`; accessible name reports the real count. | +| Offline | Header geometry is unchanged; offline details stay in the existing banner/sheet. | +| Long title | Title ellipsizes before either command moves. | +| Scrolled content | One hairline appears; no elevation jump. | +| Dark theme | Same geometry and semantic token names; only token values change. | +| Production phone | No synthetic clock, signal, battery, island, or home indicator. | +| Device preview | Synthetic device chrome is allowed only with explicit preview mode. | + +## Accessibility And Test Contract + +- All controls are native buttons with visible `:focus-visible` treatment. +- Minimum target is 44x44 CSS pixels. +- Required stable selectors: + `mobile-header`, `mobile-room-context`, `mobile-room-title`, + `mobile-review-action`, `mobile-review-badge`, `mobile-overflow-action`, and + `mobile-overflow-menu`. +- Escape closes overflow. Selecting an item closes it before invoking the + callback. +- At 320, 375, 390, and 430px: body overflow is at most 1px and header actions + remain fully inside the viewport. diff --git a/docs/design/mobile/MOBILE_REFERENCE_INVENTORY.md b/docs/design/mobile/MOBILE_REFERENCE_INVENTORY.md new file mode 100644 index 00000000..f6cf5228 --- /dev/null +++ b/docs/design/mobile/MOBILE_REFERENCE_INVENTORY.md @@ -0,0 +1,92 @@ +# NodeRoom Mobile Reference Inventory + +Captured: 2026-07-09 + +## Authority + +- Behavioral authority: current production/main implementation. +- Approved visual authority: `MOBILE_TASTE_AUDIT.md` and + `MOBILE_HEADER_CONTRACT.md`. +- Reference-only evidence: standalone exports, Cloud Design captures, legacy + prototypes, and prior screenshots. + +The catalog/index is not treated as an implementation. The actual frozen +standalone exports were located and inspected. The smaller files under +`project/mobile/` are dependency-loading source entrypoints, not frozen +standalones. + +## Located Frozen Standalone Exports + +Source root: +`C:/Users/hshum/Downloads/NodeAgent-handoff_07062026/nodeagent/project/exports` + +| Surface | Entry file | Bytes | SHA-256 | Use | +|---|---|---:|---|---| +| Terracotta app | `NodeRoom Mobile - App (Terracotta, standalone).html` | 1,851,265 | `03267F8C43E09097C17CAB71848E95782A06FB4ADF83146CE2321DBC68A65EB3` | Primary frozen visual and interaction reference. | +| Regular mobile app | `NodeRoom Mobile - App (standalone).html` | 1,849,792 | `2DE151AF67B8AB6CB86B216469DE3492AD8C2B7DAE246229559AB8335B12C76D` | Frozen dark predecessor comparison. | + +## Located Source Entrypoints + +Source root: +`C:/Users/hshum/Downloads/NodeAgent-handoff_07062026/nodeagent/project/mobile` + +| Surface | Entry file | Bytes | SHA-256 | Use | +|---|---|---:|---|---| +| Terracotta source entry | `app-terracotta/app-terracotta.html` | 4,532 | `44C2E628F62F9B34B9B2017DFEDFA1BC40C8F7541AE34D5991D9C9A4718C8B23` | Loads component sources and injects a cross-document preview pill; not a production chrome target. | +| Regular source entry | `app/app.html` | 2,867 | `BC26AFCAB76E28959CFE2F26D6C85DC4FD9B64375EF922EDBD79E83A82B0DB65` | Loads the dark predecessor sources. | +| At Scale | `at-scale/at-scale.html` | 2,631 | `36014789861BA277FF47E0A9B6323F14E87D9F18DC6B366D13B172058FD3D10B` | Long-title, large-count, many-row, and touch-grammar stress reference. | +| Gap Pack | `gap-pack/gap-pack.html` | 2,875 | `21A8675CABF3FD58F886DC09503DD8E2C262C80F4607C95660AF39B67ADAFA8D` | Review, trace, people, share, settings, offline, and first-join coverage. | +| Capture prototype | `capture-prototype/capture-prototype.html` | 73,040 | `48E16B56A34BCAA2F4040028AE47CEF22B77763B7CE7B6436B37574C7DEA9C83` | Note capture and extraction behavior reference. | + +Supporting source modules under the same root were also inspected, including +`app-terracotta/a-app.jsx`, `a.css`, `ios-frame.jsx`, `na-deck.jsx`, and the +Gap Pack/At Scale JSX and CSS files. + +`project/NodeRoom - Index.html` is the catalog that links these entries and +exports. Its black cross-document preview pill is catalog chrome and is +explicitly rejected for the product shell. + +## Repository Captures + +- Broad source captures: + `docs/design/ui-contract/20260707-design-source/` +- 2026-07-08 migration evidence: + `docs/design/ui-contract/20260708-migration-proof/` +- Terracotta work-artifact parity evidence: + `docs/design/ui-contract/20260709-mobile-terracotta-proof/` +- This migration baseline: + `docs/design/mobile/artifacts/before/` + +The 2026-07-09 source captures prove that cream `#FBF4E7` and terracotta +`#C56A3C` are intentional reference values. They do not approve the synthetic +status bar, boxed room pill, filled overflow control, or ambiguous count badge. + +## Production Check + +`https://noderoom.live/` was inspected in a clean 390x844 mobile browser +context on 2026-07-09. The deployed root rendered the public NodeRoom landing +with the NodeRoom identity, a primary create-room action, and a product proof +preview. The first inspection did not expose `.na-app` on the direct memory +route. + +A fresh 2026-07-10 deployment recheck found a newer but still incomplete +state. Direct `#mobile` renders the live join form, and a phone-sized +`?demo=review&name=FirstTimeQA` intent normalizes to `#mobile` and renders the +review-every-edit consent dialog before any room mutation. Direct +`#mobile?mode=memory` now mounts the light terracotta `.na-app`, stable header, +zero horizontal overflow, and no synthetic status chrome. However, the +deployed build still has the obsolete duplicate `.na-fab-badge`, starts on the +Capture surface, and has no `mobile-bottom-nav`. The public landing's primary +Create link also contains `surface=desktop`. Production parity therefore still +cannot be claimed. + +## Missing Or Limited Evidence + +- Production serves an older partial mobile shell, not the locally proven + final contract. +- No authenticated live-room production mutation was performed during the + design inventory. +- The standalone files use synthetic iOS chrome by design; that chrome is + preview evidence only, not a production requirement. +- Some source screenshots are catalog pages containing embedded phone mockups, + so geometry scores are based on the actual linked exports where available. diff --git a/docs/design/mobile/MOBILE_TASTE_AUDIT.md b/docs/design/mobile/MOBILE_TASTE_AUDIT.md new file mode 100644 index 00000000..11037e10 --- /dev/null +++ b/docs/design/mobile/MOBILE_TASTE_AUDIT.md @@ -0,0 +1,75 @@ +# NodeRoom Mobile Taste Audit + +Captured: 2026-07-09 + +This audit judges whether each reference deserves reproduction. Similarity is +not quality. Scores are 0-10 and were assigned before implementation. + +## Reference Scores + +| Dimension | Current local mobile | Terracotta export | Regular app | At Scale | Gap Pack | Capture prototype | +|---|---:|---:|---:|---:|---:|---:| +| Visual hierarchy | 6.4 | 6.8 | 4.8 | 7.3 | 7.0 | 6.6 | +| Semantic clarity | 5.2 | 5.4 | 5.0 | 7.4 | 7.5 | 6.8 | +| Geometry consistency | 5.8 | 6.0 | 4.5 | 7.0 | 6.8 | 6.5 | +| Typography | 7.0 | 7.4 | 4.8 | 7.1 | 7.0 | 6.9 | +| Color discipline | 7.2 | 7.5 | 4.2 | 7.0 | 7.1 | 6.7 | +| Density | 6.0 | 6.4 | 4.5 | 7.8 | 7.3 | 6.8 | +| Calmness | 5.4 | 5.8 | 3.8 | 6.8 | 6.7 | 6.3 | +| Mobile-native fit | 4.8 | 5.2 | 4.1 | 7.5 | 7.0 | 6.9 | +| Navigation predictability | 4.2 | 4.8 | 4.4 | 6.8 | 6.6 | 6.2 | +| Accessibility | 5.5 | 5.2 | 4.0 | 6.8 | 7.4 | 6.5 | +| First-two-second impression | 6.5 | 7.0 | 4.2 | 7.2 | 6.8 | 6.7 | +| First-ten-second comprehension | 6.6 | 6.8 | 5.0 | 7.6 | 7.4 | 7.2 | +| Product identity | 6.8 | 6.6 | 5.2 | 7.5 | 7.0 | 6.5 | +| Scale resilience | 4.7 | 4.5 | 3.8 | 8.1 | 7.8 | 6.6 | + +The public `noderoom.live` landing scores 7.6 for first-two-second identity and +7.3 for first-ten-second proof, but it did not expose the deployed `.na-app` +mobile workroom on the inspected route and therefore is not scored as a shell +reference. + +## Decisions + +| Decision | Classification | Rationale and approved replacement | +|---|---|---| +| Cream/terracotta light default | KEEP | Distinctive, legible, and already proven by screenshots/tests. Use semantic tokens, not cascade order. | +| Serif artifact titles | KEEP | Gives durable artifacts identity; keep it away from compact operational controls. | +| Artifact-card home | REFINE | Strong first-ten-second proof, but reduce nested borders/pills and protect 320px height. | +| Governed deck sheet | KEEP | Best demonstration of plan -> preview -> scoped request -> proposal -> receipt. Preserve honest live limitations. | +| Boxed gradient N mark | REJECT | Reads as a decorative logo button and duplicates Home. Use a flat 29px mark inside room context. | +| Outlined room-selector pill | REJECT | Consumes width and makes context look like a filter. Use one transparent 44px room-context control. | +| Filled overflow/action button | REJECT | Overstates a secondary command and makes terracotta mean navigation. Use a neutral ghost icon action. | +| Badge on overflow | REJECT | The number has no stable object. Attach amber count only to Review. | +| Dynamic top-right semantics | REJECT | A button cannot become Jobs, Notifications, or Review based on unrelated state. Split stable Review from stable Overflow. | +| Persistent pulse strip | REJECT | Costs 40-82px, repeats room state, and pushes work below the fold. Move People/Activity/Usage/Jobs into secondary commands. | +| Mixed 11/12/16px compact radii | REFINE | Use approximately 8px for controls, 12px for sheets/individual repeated items, pill only for real compact state. | +| Synthetic status bar on phones | REJECT | Competes with browser/OS chrome and reserves 56px. Restrict all synthetic device chrome to explicit preview mode. | +| Bottom navigation | KEEP | Predictable primary destinations; do not duplicate it in overflow or the FAB. | +| Contextual quick-action FAB | REFINE | Useful for work actions; remove its duplicate navigation tier and keep labels stable. | +| Heavy card/border/shadow usage | REFINE | Cards remain for repeated artifacts and modal/sheet tools; structural sections become flat with spacing/hairlines. | +| Decorative gradients/orbs | REJECT | They weaken the work-focused product; use flat surfaces. | +| Theme selector | KEEP | Light is canonical; dark is explicit and overrides the same semantic names. | +| Theme chosen by late CSS block | REJECT | Design correctness must be structural and audited. | +| Global `letter-spacing: 0 !important` | REJECT | Erases intentional mono/eyebrow/type treatment and makes CSS harder to reason about. | +| Accent/density/nav/tone/motion matrix as primary settings | REJECT | These are design-iteration controls, not core product settings. Retain under Advanced/Labs if still useful. | +| Auto-allow and notification policy | KEEP | Product governance, not visual tweaking; keep direct and honest about backend status. | +| One quiet live dot | KEEP | Communicates room health without a telemetry dashboard in the header. | + +## Original Topbar Critique + +The current local header is 134px tall at every target phone width: 56px is +reserved for a fake status area, 38px for controls, and the remainder for a +multi-segment pulse strip. The room pill is only 34px tall, below the approved +touch target. The only right-side action is labeled `Agent jobs` in markup but +changes to Jobs, Review, or a notification toast. At 320px the content still +fits, but the fit is achieved through small targets and excessive apparatus, +not resilient geometry. + +## Approved Direction + +Adopt the measurable contract in `MOBILE_HEADER_CONTRACT.md`: a flat 52px row +plus real safe area, one transparent room-context control, stable Review and +Overflow actions, no telemetry strip, no production status simulation, and +semantic light/dark tokens. This is intentionally better than the source +export rather than a pixel match. diff --git a/docs/design/mobile/MOBILE_VISUAL_QA_REPORT.md b/docs/design/mobile/MOBILE_VISUAL_QA_REPORT.md new file mode 100644 index 00000000..1b382468 --- /dev/null +++ b/docs/design/mobile/MOBILE_VISUAL_QA_REPORT.md @@ -0,0 +1,138 @@ +# NodeRoom Mobile Visual QA Report + +Status: local implementation and live-development proof pass; production blocked + +Captured: 2026-07-09 + +## Scope And Lane Boundary + +This pass implements the critic-first mobile shell migration on top of the +existing terracotta/work-artifacts lane. The earlier lane already supplied the +live storyboard projection, mobile work-artifact types, and first terracotta +proof recorded in `docs/synthesis/MOBILE_TERRACOTTA_RALPH_RECEIPT.md`. This +pass preserves those edits and adds the semantic shell, stable header, +production/preview frame boundary, honest sheet adapters, governed-deck +structure repair, navigation, accessibility gates, and visual certification. + +No backend, NodeAgent core, immutable certification asset, or proof-loop gate +was weakened or reverted. The worktree was already broadly dirty; unrelated +desktop, benchmark, proofloop, graph, and work-artifact changes remain owned by +their existing lanes. + +## Sources And Decision + +`MOBILE_REFERENCE_INVENTORY.md` records the inspected Terracotta App, regular +Mobile App, At Scale, Gap Pack, and Capture Prototype exports with byte counts +and SHA-256 hashes. The actual exports were inspected; the catalog was not used +as a substitute. + +KEEP: light terracotta identity, artifact-card home, serif artifact titles, +bottom navigation, governed deck/evidence sheets, and honest live fallbacks. + +REFINE: card density, quick actions, semantic color roles, dark contrast, +settings hierarchy, and narrow-width geometry. + +REJECT: production synthetic status chrome, boxed gradient mark, outlined room +pill, dynamic header-action semantics, persistent pulse telemetry, duplicate +FAB navigation, late theme overlays, and primary design-tuning controls. + +## Implemented Contract + +- `mobile.tokens.css` owns semantic light/dark/accent tokens. +- `mobile.shell.css` owns the full-height shell, stable frame geometry, header, + navigation, touch targets, and production/preview chrome boundary. +- `MobileHeader.tsx` provides one room-context control, stable Review, stable + ellipsis Overflow, bounded `9+`, and long-title truncation. +- `MobileFrame.tsx` renders synthetic device chrome only in explicit preview + mode; production is full bleed. +- `MobileApp.tsx` keeps all six destinations reachable through a persistent, + layout-reserved bottom navigation and keeps quick actions contextual. +- Jobs, Trace, Review, People, Room activity, Usage, Share, and Settings use + live context where available and label unavailable data honestly. +- `MobileDeck.tsx` keeps plan, thumbnails, slide preview, element-scoped + request, proposal decisions, evidence, and export/receipt affordances as + siblings. Live rooms never substitute the CardioNova fixture for missing + live storyboard data. +- Mobile settings expose product settings first; visual tuning is under + Advanced. Light/dark selection resolves through one token layer. + +## Behavior Preservation + +The preservation ledger remains `MOBILE_BEHAVIOR_INVENTORY.md`. Focused tests +cover route normalization, join/create/demo consent, live sample separation, +all six tabs, stable header actions, proposal accept/reject, live jobs and +trace adapters, public/private/room-agent routing, model selection, governed +deck structure, offline states, and secondary sheets. NodeAgent and Omnigent +smokes pass without mobile code mutating their cores. + +Known product limitations remain explicit: + +- A governed live storyboard exports real PPTX bytes with filename, slide + count, timestamp, and integrity receipt. Historical restore/download remains + desktop-only and is labeled as such. +- Live deck revision requests go through the room agent and land in the real + proposal/trace path; proposal accept/reject remains in mobile Inbox. +- Live mobile capture/upload is not wired. Empty rooms guide the user into a + room-visible read-only NodeAgent plan instead of simulating persistence. +- Account-authenticated proof is blocked because the app has no auth provider; + the passing live test is a room-token development session. +- A 2026-07-10 fresh-origin production recheck proved the public deployment is + stale: it uses the old abstract H1, exposes no Join/Sample choice, and its + Create CTA still requests `surface=desktop`. Deployment parity is not claimed. + +## Visual Proof + +Before captures are in `docs/design/mobile/artifacts/before/`. Final captures, +long-title stress captures, governed-deck proof, videos, and Playwright traces +are in `docs/design/mobile/artifacts/final/`. + +The browser contract checks 320x568, 375x812, 390x844, and 430x932 in light +and dark modes. It asserts no body overflow beyond one pixel, no production +synthetic status bar, a 52px header row plus safe area, stable Review and +Overflow actions, at least 44px visible button targets, six reachable bottom +destinations, non-overlapping shell geometry, deck sibling structure, and all +secondary sheets. + +## Gate Ledger + +| Gate | Result | +|---|---| +| Behavior inventory before code | Pass | +| Source exports and hashes | Pass | +| NodeAgent frame smoke | Pass | +| Omnigent NodeAgent smoke | Pass; external `omni` CLI not installed | +| Typecheck | Pass | +| Production build | Pass; existing chunk-size warning | +| Design audit | Pass | +| Focused Vitest mobile/work-artifact/session suite | Pass, 99 tests; final honesty subset 49/49 | +| Mobile Playwright contract/story/first-run proof | Pass, 28/28 including live Convex development create | +| Four-width light/dark screenshots | Pass | +| Focused mobile release-bar case | Pass | +| ProofLoop doctor | Pass, 11 checks | +| Full Vitest | 2066/2072 pass; six official-score/proofloop expectations remain in the other lane | +| Independent contract judgment | Pass, 10.0 | +| Independent taste judgment | Blocked, 5.5 taste / 6.0 topbar after 3 loops | +| Deployed mobile-shell verification | Fail: stale public landing and desktop-forcing Create CTA; no release was made from the mixed tree | + +## Independent Judgments + +The exact loop record is `mobile-visual-judge-after.json`. + +Gemini taste judgment A scored the successive loops at 6.0, 5.5, and 5.5. +The final response identified one medium navigation-density concern plus two +low concerns, but still scored taste 5.5 and topbar 6.0. The maximum three +loops were exhausted, so no further prompt-tuning or unbounded visual churn was +performed. + +A separate Gemini contract judgment B scored taste 8.5, topbar 8.5, contract +10.0, reference similarity 9.5, and `READY`, with no listed issues. The +contract result proves the implementation bar but does not override the +independent taste gate. + +## Final Decision + +The mobile implementation is locally usable and deterministically proven, +including a real development-room creation. The combined ship gate does not +pass. Release remains blocked on the taste decision, a clean reviewed release, +Convex production alignment, account auth, and post-deploy authenticated proof. +No full-completion claim is made. diff --git a/docs/design/mobile/mobile-visual-judge-after.json b/docs/design/mobile/mobile-visual-judge-after.json new file mode 100644 index 00000000..a1eeaf5e --- /dev/null +++ b/docs/design/mobile/mobile-visual-judge-after.json @@ -0,0 +1,116 @@ +{ + "schema": 1, + "phase": "after", + "captured_at": "2026-07-09", + "provider": "Gemini Pro web", + "loop_limit": 3, + "shipping_policy": "Taste judgment controls shipping. Reference similarity is diagnostic evidence only.", + "taste_loops": [ + { + "loop": 1, + "taste_score": 6.0, + "topbar_taste_score": 6.5, + "contract_adherence_score": 6.0, + "reference_similarity_score": 5.0, + "ship_readiness": false, + "repairs_taken": [ + "Added persistent six-destination bottom navigation without overlaying content.", + "Removed the duplicate Review badge from the quick-action control.", + "Replaced the hamburger-like overflow glyph with an ellipsis.", + "Separated artifact type colors from verification-state colors.", + "Kept the governed deck composer and governance disclosure visible." + ] + }, + { + "loop": 2, + "taste_score": 5.5, + "topbar_taste_score": 6.5, + "contract_adherence_score": 6.5, + "reference_similarity_score": 6.0, + "ship_readiness": false, + "repairs_taken": [ + "Captured canonical screenshots with a normal room title and kept long-title stress proof separate.", + "Increased dark-mode muted-text contrast.", + "Compacted the 320px artifact grid to avoid a partial third-card sliver.", + "Changed the final navigation label from Artifacts to Files." + ] + }, + { + "loop": 3, + "taste_score": 5.5, + "topbar_taste_score": 6.0, + "contract_adherence_score": 6.5, + "reference_similarity_score": 3.5, + "ship_readiness": false, + "topbar_issues": [ + { + "severity": "low", + "issue": "Header layout is dense." + } + ], + "semantic_issues": [ + { + "severity": "low", + "issue": "Dark-mode border contrast is low." + } + ], + "geometry_issues": [ + { + "severity": "medium", + "issue": "Navigation hit areas appear tight." + } + ], + "navigation_issues": [], + "recommended_fixes": [ + "Verify 44px targets.", + "Enhance dark-mode contrast." + ], + "regressions": [], + "positive_observations": [ + "Flat safe-area header.", + "Stable Review badge.", + "Correct column scaling." + ] + } + ], + "judgment_a": { + "provider": "Gemini Pro", + "judgment": "taste_product_quality_loop_3", + "taste_score": 5.5, + "topbar_taste_score": 6.0, + "contract_adherence_score": 6.5, + "reference_similarity_score": 3.5, + "ship_readiness": false + }, + "judgment_b": { + "provider": "Gemini Pro", + "judgment": "approved_contract_adherence", + "taste_score": 8.5, + "topbar_taste_score": 8.5, + "contract_adherence_score": 10.0, + "reference_similarity_score": 9.5, + "ship_readiness": "READY", + "issues": [], + "positive_observations": [ + "Light and dark mobile widths were represented in the evidence set.", + "Body overflow stayed within the one-pixel tolerance.", + "The header uses the approved 52px row plus safe area.", + "All six navigation destinations remain reachable.", + "Production mode has no synthetic device chrome." + ] + }, + "combined_gate": { + "status": "blocked", + "reason": "The independent taste judgment remained below threshold after the maximum three repair loops. The passing contract judgment does not override the taste gate.", + "thresholds": { + "taste_score": 8.0, + "topbar_taste_score": 8.5, + "contract_adherence_score": 9.0 + }, + "final_scores": { + "taste_score": 5.5, + "topbar_taste_score": 6.0, + "contract_adherence_score": 10.0 + } + } +} diff --git a/docs/design/mobile/mobile-visual-judge-before.json b/docs/design/mobile/mobile-visual-judge-before.json new file mode 100644 index 00000000..31d31618 --- /dev/null +++ b/docs/design/mobile/mobile-visual-judge-before.json @@ -0,0 +1,50 @@ +{ + "schema": 1, + "phase": "before", + "captured_at": "2026-07-09", + "judge_provider": "local_critic_baseline", + "external_gemini_status": "completed_after_implementation", + "taste_score": 5.9, + "topbar_taste_score": 4.8, + "contract_adherence_score": 3.1, + "reference_similarity_score": 7.4, + "ship_readiness": "needs_work", + "topbar_issues": [ + "Header is 134px tall before content at phone widths.", + "Synthetic clock, signal, Wi-Fi, and battery render in production phone mode.", + "Room context is an outlined pill with a 34px target.", + "The only right action changes meaning among Jobs, Review, and Notifications.", + "Persistent people, agent, review, and cost telemetry competes with the artifact home." + ], + "semantic_issues": [ + "Review count is not attached to a stable Review command.", + "Theme tokens are declared twice and can be selected by cascade order.", + "Global letter-spacing important reset erases intentional typography." + ], + "geometry_issues": [ + "34px room and 38px icon controls miss the approved 44px target.", + "Long-title resilience relies on a centered pill rather than a minmax title track." + ], + "navigation_issues": [ + "Header action semantics are dynamic.", + "The FAB Go-to tier duplicates bottom navigation.", + "Pulse destinations duplicate sheets and room status navigation." + ], + "timestamped_observations": [ + "2026-07-09: four local viewports had zero body overflow but all rendered synthetic production status chrome.", + "2026-07-09: noderoom.live exposed the public landing but not the deployed mobile workroom on the inspected hash route." + ], + "recommended_fixes": [ + "Adopt MOBILE_HEADER_CONTRACT.md.", + "Extract semantic tokens and shell styles.", + "Make preview chrome explicit.", + "Add stable Review and Overflow commands and remove the pulse strip.", + "Strengthen static and computed-style regression tests." + ], + "regressions": [], + "positive_observations": [ + "Terracotta palette and artifact-card home are distinctive.", + "The governed deck/evidence surfaces demonstrate the product quickly.", + "All baseline viewports had no horizontal body overflow or console errors." + ] +} diff --git a/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md b/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md new file mode 100644 index 00000000..1705f170 --- /dev/null +++ b/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md @@ -0,0 +1,92 @@ +# NodeRoom Mobile Launch Readiness Receipt + +Captured: 2026-07-10 (America/Los_Angeles) + +Branch: `codex/mobile-terracotta-launch` + +Status: authenticated preview proven; production migration blocked + +Pull request: `#190` (`codex/mobile-terracotta-launch`) + +## Included Release Scope + +- light terracotta mobile shell and governed deck/work-artifact review; +- explicit Create, Join, and Sample first-run intents on phone and desktop; +- empty workspace versus persistent synthetic sample provenance; +- live people, jobs, artifacts, proposals, traces, storyboard, and export receipts; +- host-safe leave, idempotent join recovery, and account-bound cross-browser recovery; +- GitHub OAuth production integration through Convex Auth; +- password authentication only for isolated local or preview QA; +- fail-closed production identity authorization with no room-token fallback; +- account sign-out that invalidates auth and removes account-bound room sessions; +- a true production Convex deploy command with clean-worktree and post-deploy verification; +- concurrent workbook inspect/verify trace labels from the active work-artifact lane. + +Generated benchmark receipts, videos, trace archives, and unrelated evaluation WIP are intentionally excluded from this release branch. + +## Deterministic Proof + +| Gate | Result | +|---|---| +| Application TypeScript | Pass | +| Convex TypeScript | Pass | +| Production Vite build | Pass; existing large-chunk advisory | +| Full release-branch Vitest | Pass, 297 files / 2,033 tests | +| Auth/session focused tests | Pass | +| First-run + story + terracotta + live Convex Playwright | Pass, 28/28 | +| Deployed authenticated fresh-phone Playwright | Pass, 2/2 at 390x844 | +| Accounting ProofLoop | Pass, 100/85 | +| Notion SDR/BDR ProofLoop | Pass, 100/80 | +| Linux + Windows mobile visual baselines | Reviewed and refreshed; local gate 3/3 | +| NodeAgent frame smoke | Pass | +| Omnigent NodeAgent smoke | Pass; outer `omni` CLI unavailable | +| Design-system audit | Pass; advisory drift remains | +| Security gate including built output | Pass | +| Production dependency audit | Pass, 0 vulnerabilities | +| QA matrix / content fluency | Pass / pass | +| ProofLoop doctor | Pass, 11/11 | + +The owning shared lane separately passed 309 Vitest files / 2,073 tests and its persisted `official-scores` gate. The clean product release does not copy that lane's mutable goal ledger, so `proofloop gate --goal official-scores` correctly reports that the goal is absent here. + +## Authenticated Preview Receipt + +- Isolated Convex preview: `hushed-jellyfish-969`, with strict account identity enforcement enabled. +- Matching Vercel preview deployment: `dpl_2L37KLrWLmQRDnCybZKruzefWzJC`, built from release revision `7ef26a1a`. +- Fresh-phone Create proof: landing explanation, review-first consent, account creation, empty room, public message, reload persistence, sign-out, and fail-closed rejoin. +- Fresh-phone Sample proof: explicit synthetic-data consent, account creation, live governed deck, Plan to Slides to scoped request, accepted job receipt without a fabricated patch, Evidence, PPTX download, and integrity receipt. +- The deployed run found and repaired three product defects: missing governed-deck recents, review state resetting on reactive object identity, and oversized provenance requests rejected by the server. +- The final post-preview delta is confined to an honestly marked memory-mode `Create a room` entry contract, its focused test, and reviewed visual baselines; production-path behavior is unchanged. + +The preview is launch evidence, not a production promotion. Its deployment protection bypass is temporary and is revoked after testing. + +## Production Auth Receipt + +- A dedicated `NodeRoom Production` GitHub OAuth application was created with homepage `https://noderoom.live` and callback on the actual production Convex site. +- The first browser-exposed secret was treated as compromised, rotated, and deleted. +- The replacement client credentials and a matching JWT signing-key pair were configured directly on the production Convex deployment. +- The temporary local credential handoff file was deleted. +- `NODEROOM_REQUIRE_CONVEX_IDENTITY` remains disabled. It must not be enabled until matching backend and frontend revisions are deployed and the authenticated production journey passes. + +## Fail-Closed Production Blockers + +1. `noderoom.live` still serves the stale public build and points at Convex development deployment `zealous-goshawk-766`. +2. The actual Convex production deployment remains `aromatic-bass-102` with an older function/schema surface. +3. The public-facing development deployment contains at least 1,000 rooms and is the authoritative current data source. +4. A storage-inclusive development export exceeded 10 minutes and produced no archive. +5. A database-only development export also exceeded 10 minutes and produced no archive. +6. No destructive import, production backend deploy, frontend promotion, or identity enforcement is permitted without a verified rollback artifact or an approved non-destructive migration. +7. Vercel production still needs explicit hosted `VITE_CONVEX_URL`, `VITE_CONVEX_SITE_URL`, `VITE_NODEROOM_AUTH_REQUIRED=1`, and `VITE_NODEROOM_AUTH_PROVIDER=github` configuration. +8. The independent taste gate previously remained at 5.5/6.0 and still requires its owning review before launch promotion. + +## Safe Resume Sequence + +1. Obtain a verified Convex export through the dashboard/support path, including file storage, or approve a non-destructive dual-read migration. +2. Compare development and production snapshots without exposing room codes or content. +3. Preserve the passing isolated preview receipt while production rollback work proceeds. +4. Obtain the owning independent taste-gate approval. +5. Deploy the production Convex revision and verify its function spec before changing Vercel production coordinates. +6. Deploy the matching frontend, test GitHub sign-in, then enable production identity enforcement. +7. Run fresh-phone Create, invited-member Join, reload recovery, proposal accept/reject, trace, and export receipt against `https://noderoom.live`. +8. Promote only when the production journey, rollback proof, and independent taste gate all pass. + +Local clean-branch dogfood: `http://127.0.0.1:4175` diff --git a/docs/synthesis/MOBILE_TERRACOTTA_RALPH_RECEIPT.md b/docs/synthesis/MOBILE_TERRACOTTA_RALPH_RECEIPT.md new file mode 100644 index 00000000..e26437ce --- /dev/null +++ b/docs/synthesis/MOBILE_TERRACOTTA_RALPH_RECEIPT.md @@ -0,0 +1,196 @@ +# Mobile Terracotta RALPH Receipt + +Date: 2026-07-09 +Loop id: `loop_f2f59c09-fa51-4a37-a8d6-d4c908b89b48` +Goal status: active + +This receipt tracks the mobile terracotta + governed work-artifact push. It +does not claim completion until the proof gates pass. + +## R - Reality / Research + +### Dirty Worktree Boundary + +The worktree was dirty before this mobile loop started. Existing changes span: + +- `.proofloop/lanes/**` +- benchmark/eval docs and scripts +- `src/ui/workArtifacts/**` +- `src/ui/graph/**` +- `src/ui/mobile/MobileGapSheets.tsx` +- `src/ui/mobile/mobile.css` +- `src/ui/App.tsx`, `src/ui/RoomShell.tsx`, `src/ui/panels/Artifact.tsx` +- mobile/e2e/proofloop tests + +Execution rule: this loop must preserve and integrate those changes, not revert +them. New mobile edits should be scoped to mobile UI, mobile tests, design/docs, +work-artifact adapters only when needed, and proof receipts. + +### Source Design Inventory + +- `app-terracotta/na-app.jsx`: mobile controller and sheet routing. +- `app-terracotta/na-deck.jsx`: governed deck artifact workbench. +- `app-terracotta/na-data.js`: CardioNova seed model and deck data. +- `app-terracotta/na.css`: light terracotta skin. +- `docs/design/ui-contract/20260707-design-source/mobile-terracotta-390x844.png`: captured reference. +- `docs/design/ui-contract/20260708-migration-proof/after-feature-skin-mobile-390x844.png`: current migrated mobile proof, currently dark Cloud-token oriented. + +### Current Implementation Inventory + +- `src/ui/mobile/MobileApp.tsx` owns mobile routing, tabs, sheets, composer, + theme/density/accent attributes, and `ArtifactSheet` mounting. +- `src/ui/mobile/MobileAppLive.tsx` maps live store state into `MobileLive`: + recents, proposals, jobs, plan, evidence, coach, pipeline, trace rows, + people groups, invite code, watches, offline holds, and proposal resolution. +- `src/ui/mobile/MobileDeck.tsx` is a strict TSX port of the prototype deck + workbench, but still consumes `D.DECK` and `D.EVIDENCE` directly. +- `src/ui/mobile/mobileData.ts` contains the seed deck, evidence, plan, inbox, + recents, and CardioNova sample data. +- `src/ui/mobile/mobile.css` contains the terracotta token system and deck + workbench styles, but the current captured production proof is dark. +- `src/ui/workArtifacts/**` already contains a read-only work-artifact layer, + deck storyboard derivation, and notebook digest layer. + +### Baseline Commands + +| Command | Status | +| --- | --- | +| `npm run proofloop -- doctor --json` | pass | +| `npm run typecheck -- --pretty false` | pass | +| `npm run nodeagent:frame:smoke` | pass | +| `npm run omnigent:nodeagent:smoke` | pass, with Omnigent CLI noted as not locally installed | +| `npm test -- --run tests/mobileGapScreens.test.tsx tests/mobileAgentModelRouting.test.tsx tests/workArtifacts.test.ts tests/semanticGraph.test.ts` | pass, 54 tests | +| `npm run proofloop -- manifest --dense` | pass | +| `npm run proofloop -- ui contract --dense` | pass | + +## A - Acceptance Bar + +The loop passes only when: + +1. Mobile default visual target is the light terracotta reference, especially + artifact-card home and governed deck sheet. +2. Mobile deck review is live/proof-backed where live data exists: + plan -> slide preview -> element-scoped request -> sourced patch proposal -> + accept/reject -> evidence -> export/receipt. +3. Live rooms do not present sample-only CardioNova success as real output. +4. Existing NodeAgent, Omnigent, proofloop, mobile routing, join/create, + proposal, trace, and collaboration behavior remains intact. +5. Other-lane work is preserved and integrated. + +## L - Live Build Plan + +Initial safe build order: + +1. Add the mobile contract doc. +2. Add live deck/work-artifact input shapes to mobile types. +3. Derive a mobile deck/storyboard payload from live artifacts/proposals/traces + in `MobileAppLive.tsx`. +4. Update `MobileDeck.tsx` to prefer live payloads and show honest empty state + when no deck/storyboard exists. +5. Keep standalone/demo behavior backed by `D.DECK`. +6. Tighten terracotta default proof without breaking optional dark mode. +7. Add focused tests for live vs sample deck behavior. + +## P - Proof Run Plan + +Required proof before completion: + +- `npm run nodeagent:frame:smoke` +- `npm run omnigent:nodeagent:smoke` +- `npm run typecheck -- --pretty false` +- `npm test -- --run tests/mobileGapScreens.test.tsx tests/mobileAgentModelRouting.test.tsx tests/workArtifacts.test.ts tests/semanticGraph.test.ts` +- Mobile browser screenshot at 390x844. +- Mobile deck interaction proof or a narrower Playwright/Vitest test if full + browser automation is blocked. +- ProofLoop doctor/manifest status in this receipt. + +## H - Harden Plan + +Before claiming done: + +- Record changed files. +- Record screenshots and commands. +- Record known gaps and blockers. +- Record whether full ProofLoop gate passed, or why it remains out of scope. +- Preserve resume command: + `npm run sfn -- loop resume --loop-id loop_f2f59c09-fa51-4a37-a8d6-d4c908b89b48` + +## L - Live Build Result + +Implemented: + +- Added `MobileLive.deck` / `MobileCtx.liveDeck` as an optional live-derived + mobile deck artifact. +- Derived `liveDeck` in `MobileAppLive.tsx` from the existing + `buildDeckStoryboardFromRoom` work-artifact adapter using live artifacts, + proposals, and traces. +- Updated `MobileDeck.tsx` to use live deck/evidence payloads in live rooms. +- Added an honest live empty state when no deck/storyboard exists; sample + CardioNova slides stay hidden in live rooms. +- Kept standalone/memory demo mode backed by `D.DECK`. +- Changed live deck export to "intent recorded" only until a real file receipt + exists. +- Restored light terracotta defaults for app and route chrome while preserving + dark mode behind settings. +- Set the default mobile surface to Home and aligned Settings accent options to + supported CSS accents. + +Focused tests added/updated: + +- `tests/mobileDeckLive.test.tsx` +- `tests/mobileAgentModelRouting.test.tsx` +- `e2e/mobile-story-surfaces.spec.ts` + +## P - Proof Run Result + +| Command | Status | +| --- | --- | +| `npm run nodeagent:frame:smoke` | pass | +| `npm run omnigent:nodeagent:smoke` | pass; Omnigent CLI still not installed locally | +| `npm run typecheck -- --pretty false` | pass | +| `npm test -- --run tests/mobileGapScreens.test.tsx tests/mobileAgentModelRouting.test.tsx tests/mobileDeckLive.test.tsx tests/workArtifacts.test.ts tests/semanticGraph.test.ts` | pass, 61 tests | +| `npx playwright test e2e/mobile-story-surfaces.spec.ts -g "#mobile\|mobile universal"` | pass, 3 passed and live Convex mobile proof skipped by env gate | +| `npm run proofloop -- doctor --json` | pass, 11 checks | +| `npm run proofloop -- manifest --dense` | pass; official-scores status `needs_scaffold_or_run` | +| `npm run proofloop -- gate --goal official-scores` | blocked; broader official score lanes still need model-run/judge/scorer receipts | +| `npm run sfn -- loop verify --milestone P` | blocked; `official_upload`, `real_export`, and `official_scorer` must pass for the global verifier | + +Screenshot proof: + +- `docs/design/ui-contract/20260709-mobile-terracotta-proof/source-app-terracotta-home-390x844.png` +- `docs/design/ui-contract/20260709-mobile-terracotta-proof/source-app-terracotta-deck-390x844.png` +- `docs/design/ui-contract/20260709-mobile-terracotta-proof/current-mobile-memory-home-390x844.png` +- `docs/design/ui-contract/20260709-mobile-terracotta-proof/current-mobile-memory-deck-390x844.png` +- `docs/design/ui-contract/20260709-mobile-terracotta-proof/screenshot-receipt.json` + +Both source and current screenshots report: + +- `--bg-app: #FBF4E7` +- `--accent-primary: #C56A3C` +- `overflowX: 0` + +## H - Harden Result + +RALPH receipts written: + +- `.solo/receipts/L-live-build/agent-layer-delta.json` +- `.solo/receipts/L-live-build/app-ui-delta.json` +- `.solo/receipts/L-live-build/transfer-plan.json` +- `.solo/receipts/P-proof-run/live-ui-proof.json` +- `.solo/receipts/P-proof-run/scorer-receipt.json` +- `.solo/proof-verdict.json` +- `.solo/receipts/H-harden/cost-ledger.json` +- `.solo/receipts/H-harden/improvement-candidates.json` + +Known gaps: + +- RALPH loop status is blocked at P. L is verified; H receipts are written but + H is not started because P cannot verify without global proof gates. +- Live mobile export is still intent-only until a real PPTX/file export job + returns a receipt. +- Live deck patch accept/reject is preview-local; persistent deck patch writes + need a proposal/work-artifact mutation path. +- Full live Convex mobile Playwright proof was not run because + `PLAYWRIGHT_EXPECT_MOBILE_LIVE=1` was not set. +- Global ProofLoop `official-scores` gate is blocked by benchmark scoring lanes + outside this mobile slice. diff --git a/docs/synthesis/WORK_ARTIFACTS_BASELINE_RECEIPT.md b/docs/synthesis/WORK_ARTIFACTS_BASELINE_RECEIPT.md new file mode 100644 index 00000000..70bc83d6 --- /dev/null +++ b/docs/synthesis/WORK_ARTIFACTS_BASELINE_RECEIPT.md @@ -0,0 +1,66 @@ +# Work Artifacts Baseline Receipt + +Date: 2026-07-09 + +Scope: baseline before implementing `docs/synthesis/WORK_ARTIFACTS_IMPLEMENTATION_AND_DOGFOOD_PLAN.md`. + +## Repository State + +- Branch: `main` +- Upstream: `origin/main` +- Working tree: dirty before this implementation slice. +- Notable pre-existing dirty areas: + - `.proofloop/lanes/**` + - `docs/eval/**` + - `scripts/proofloop-*` + - `src/eval/proofloop*` + - `src/ui/App.tsx` + - `src/ui/RoomShell.tsx` + - `src/ui/panels/Artifact.tsx` + - mobile and e2e files + +This receipt does not claim those changes. New implementation work should stay isolated unless a touched file is required for the artifact layer. + +## Baseline Commands + +| Command | Status | Notes | +| --- | --- | --- | +| `npm run typecheck -- --pretty false` | pass | TypeScript baseline clean. | +| `npm run nodeagent:frame:smoke` | pass | Frame smoke completed with `rf_adopt_minimal_write_note`. | +| `npm run omnigent:nodeagent:smoke` | pass | YAML compatibility checks pass; outer Omnigent CLI is not installed locally. | +| `npm run proofloop -- doctor --json` | pass | 11 pass, 0 warn, 0 fail. | +| `npm run proofloop -- manifest --dense` | pass | Manifest reports `official-scores:needs_scaffold_or_run`. | +| `npm run proofloop -- ui contract --dense` | pass | Stable UI selector contract printed. | +| `npm test -- --run` | fail | 291 test files passed, 4 failed; 1980 tests passed, 4 failed. | + +## Baseline Test Failures + +These failures existed before new work-artifact implementation code was added in this slice: + +- `tests/multiUserCoordinationProof.test.ts` + - Assertion: `proof.summary.passed` expected `true`, received `false`. +- `tests/proofloopChartPack.test.ts` + - Assertion: model-performance data expected to include `deepseek/deepseek-v4-pro`; received current free-route model set plus policy rows. +- `tests/proofloopCi.test.ts` + - Assertion: repo should not contain `.github/workflows/proofloop-gate.yml`; current repo does contain it. +- `tests/workflowEvals.test.ts` + - Assertion: `runAgent` stop reason expected `done`, received `step_budget`. + +## High-Risk Areas + +Avoid broad edits here unless a specific feature slice requires them: + +- `convex/**`: backend schema, auth, mutation, query, and workflow behavior. +- `src/nodeagent/core/**`: canonical NodeAgent runtime/frame behavior. +- `src/nodeagent/traces/**`: trace workpaper contract and replay receipts. +- `src/engine/**`: room engine state and artifact mutation semantics. +- `.proofloop/**`: generated proofloop state and locked certification outputs. +- existing dirty files from the current worktree unless the diff is reviewed first. + +## Initial Implementation Guardrails + +- Add adapters/wrappers first. +- Preserve existing props, callbacks, mutations, and state transitions. +- Keep new artifact code read-only until explicitly wired to existing proposal/review actions. +- Add deterministic unit tests for artifact mapping before UI integration. +- Treat full `npm test -- --run` failures as baseline until a new slice touches the failing area. diff --git a/docs/synthesis/WORK_ARTIFACTS_IMPLEMENTATION_AND_DOGFOOD_PLAN.md b/docs/synthesis/WORK_ARTIFACTS_IMPLEMENTATION_AND_DOGFOOD_PLAN.md new file mode 100644 index 00000000..a321bbf8 --- /dev/null +++ b/docs/synthesis/WORK_ARTIFACTS_IMPLEMENTATION_AND_DOGFOOD_PLAN.md @@ -0,0 +1,575 @@ +# NodeRoom Work Artifacts Implementation And Dogfood Plan + +Status: planning contract + +This is the consolidated plan for the next NodeRoom product push: first-class collaborative work artifacts, governed agent edits, proof graphs, deck composition, Hex-like notebooks, and the dogfood system that proves those features work in real NodeRoom rooms. + +## Sources Of Truth + +- Functional source of truth: current production/main NodeRoom behavior. +- Product thesis source: the market and feature planning note in the 2026-07 attachment. +- Design source: + - `docs/design/design-source/` + - `C:/Users/hshum/Downloads/NodeRoom Workspace (standalone).html` + - `C:/Users/hshum/Downloads/NodeAgent-handoff_07062026/nodeagent/project/mobile/app-terracotta/na-deck.jsx` +- Existing dogfood references: + - `docs/qa/NODEROOM_DOGFOOD_MATRIX.md` + - `docs/qa/LIVE_DOGFOOD_RESULTS.md` + - `docs/audit/E2E_DOGFOOD_DESIGN.md` + - `docs/eval/proofloop-codegraph-dogfood.md` +- Existing capability references: + - `docs/AGENT_ARTIFACTS.md` + - `docs/architecture/NOTEBOOK_AGENT_TRANSFORM.md` + - `src/nodeagent/traces/` + - `src/nodeagent/core/` + +## Category Thesis + +NodeRoom should not compete as a generic AI chat workspace, notebook, deck tool, or graph viewer. The defensible category is a proof-native collaborative AI workroom for high-stakes analytical work. + +The broader market already covers search, workflow automation, artifact generation, and agent debugging. NodeRoom should own the combined workflow where a team asks an agent to perform analytical work, sees the work evolve as governed artifacts, reviews evidence, collaborates in-room, exports deliverables, and keeps a traceable proof receipt. + +The missing product layer is therefore not one feature. It is the connective tissue between: + +- agent proposals, +- workpapers, +- spreadsheet grids, +- notebooks, +- slideshow/storyboard composition, +- proof graphs, +- chat, +- approvals, +- exports, +- benchmark rooms, +- and replayable traces. + +## Current Substrate + +The repo already appears to have parts of this platform: + +- Agent artifacts: proposal-like generated outputs and artifact metadata. +- Notebook operations: block-level editor behavior and agent transformation hooks. +- Semantic graph types/selectors: graph-shaped knowledge and entity relationships. +- NodeAgent frame runner and trace spine: durable proof receipts and evidence provenance. +- Room collaboration surfaces: public/private chat, presence, room traces, shared room state. +- Design prototypes: mobile deck workflow with plan-first generation, slide preview, comments, accept/reject patches, evidence tabs, export, presentation mode, and version history. +- ProofLoop tooling: deterministic certification loop, exploration loop, memory, receipts, and promotion ledger. + +The implementation plan should preserve that substrate and add a reusable product layer on top rather than replacing working logic. + +## Product Target + +NodeRoom should let a team create a room, ask NodeAgent to research or analyze a real task, and receive a set of governed work artifacts: + +- a proof graph showing entities, people, companies, evidence, claims, work products, and trace links; +- a notebook with typed blocks, calculations, sources, and agent-generated sections; +- a storyboard that turns findings into a narrative plan before slides are generated; +- a deck/slideshow artifact that can be reviewed, patched, exported, and presented; +- proposal cards/workpapers that show what NodeAgent wants to change and why; +- room traces that replay every material action with evidence and approval state. + +## Feature Implementation Plan + +### 1. Unified Work Artifact Layer + +Purpose: give NodeRoom one product model for notebooks, decks, spreadsheets, traces, graphs, proposals, and exports. + +Implementation: + +- Define a small artifact view model that wraps existing feature state instead of rewriting each feature. +- Support artifact kinds: `spreadsheet`, `notebook`, `deck`, `graph`, `trace`, `proposal`, `export`. +- Add shared metadata: title, room id, source ids, trace id, version, status, owner, last edited, evidence count, approval state. +- Add common actions: open, pin, comment, ask NodeAgent, propose patch, accept, reject, export, view trace. +- Keep writes behind existing room tools, Convex mutations, and NodeAgent frame boundaries. + +Tests/proof: + +- Typecheck artifact adapters. +- Unit test artifact status transitions. +- Add fixture coverage for each artifact kind. +- Add a smoke path where a room renders mixed artifact types without replacing feature logic. + +Dogfood hook: + +- Every dogfood run must produce at least one artifact bundle with notebook, deck, graph, trace, and export entries. + +### 2. Agent Workpapers And Proposal Review Center + +Purpose: make NodeAgent changes visible, reviewable, and auditable before they mutate user work. + +Implementation: + +- Convert existing proposal cards into reusable workpaper cards. +- Show proposed changes with evidence, affected artifacts, confidence, reviewer state, and trace receipt. +- Add a review center panel that filters proposals by status: pending, accepted, rejected, needs evidence, failed. +- Preserve all existing proposal action callbacks and mutation flows. +- Link every accepted/rejected proposal to a trace id. + +Tests/proof: + +- Proposal accept/reject regression tests. +- Trace receipt tests for accepted and rejected proposals. +- Browser test for filtering and reviewing proposal cards. + +Dogfood hook: + +- Dogfood must include at least three proposal types: notebook edit, deck patch, graph relationship confirmation. + +### 3. Governed Deck And Slideshow Composition + +Purpose: turn NodeRoom findings into a board-ready narrative artifact without becoming a generic slide editor. + +Implementation: + +- Extract the deck contract from `na-deck.jsx`: storyboard first, sandboxed slide preview, thumbnail rail, comments, NodeAgent patch requests, accept/reject, evidence, export, present mode, version history. +- Add a first-class `deck` artifact adapter. +- Implement a storyboard phase before slide generation: + - objective, + - audience, + - narrative arc, + - section outline, + - claims, + - required evidence, + - unresolved gaps. +- Add slide components that are generated from room artifacts, not static mock data. +- Let users request NodeAgent changes at storyboard, slide, and element levels. +- Keep exports traceable: PPTX/PDF export should include a proof receipt or receipt sidecar. + +Tests/proof: + +- Storyboard creation test. +- Deck render smoke test. +- Accept/reject slide patch test. +- Export smoke test. +- Visual regression against the Cloud Design direction. + +Dogfood hook: + +- A vertical dogfood run must produce a storyboard, then a deck, then a reviewer-requested patch, then an export. + +### 4. Hex-Like Work Notebooks + +Purpose: make notebooks a serious analytical work surface rather than just text blocks. + +Implementation: + +- Preserve existing notebook block behavior. +- Add typed blocks through adapters: + - text, + - insight, + - table, + - chart, + - calculation, + - SQL/data query, + - evidence, + - decision, + - open question, + - agent proposal. +- Add block-level provenance and trace links. +- Add NodeAgent actions: summarize evidence, create calculation, turn table into chart, convert notebook section to storyboard. +- Support review states per block where appropriate. + +Tests/proof: + +- Existing notebook tests must continue to pass. +- Add block transform tests for at least three block types. +- Add browser smoke for editing, agent patching, and trace linking. + +Dogfood hook: + +- The dogfood room must use a notebook to record assumptions, analysis, evidence, and final decisions before deck generation. + +### 5. Proof Graph And NodeGraph Integration + +Purpose: show why conclusions are connected, not just what the answer is. + +Implementation: + +- Keep graph components reusable enough for the public NodeGraph repo. +- Add NodeRoom-specific adapters for room entities, claims, companies, people, documents, tasks, traces, notebook blocks, deck slides, and proposals. +- Highlight relevant connections: + - person researched company, + - person authored or approved workpaper, + - company has evidence clusters, + - person has project and achievement clusters, + - claim is supported by source, + - slide uses claim, + - notebook block derives from evidence, + - proposal modified artifact. +- Support focus mode, neighborhood expansion, path finding, cluster grouping, search, pinning, and drag interactions. +- Preserve live updates where existing room state supports them. + +Tests/proof: + +- NodeGraph example app runs locally. +- NodeRoom graph route renders from real room data. +- Browser smoke covers drag, expand, search, and selection. +- Streamlit bridge remains a showcase path, not the production NodeRoom graph implementation. + +Dogfood hook: + +- Dogfood must include a graph review task where a user asks NodeAgent why a deck claim exists and the graph highlights the source path. + +### 6. Collaboration, Chat, And Mobile Review + +Purpose: make artifact work feel live and governed across desktop and mobile. + +Implementation: + +- Preserve public/private chat behavior and NodeAgent mentions. +- Add artifact-aware chat references: comment on slide, notebook block, graph node, trace event, proposal. +- Show presence on artifacts and focused elements. +- Support mobile review for deck comments, proposal accept/reject, evidence checks, and lightweight chat. +- Preserve keyboard behavior and existing message send semantics. + +Tests/proof: + +- Chat send/receive smoke. +- NodeAgent mention smoke. +- Presence regression where available. +- Mobile viewport screenshot and interaction test for review flows. + +Dogfood hook: + +- Include a mobile reviewer persona that comments on a slide and rejects one unsupported claim. + +### 7. Export, Replay, And Receipts + +Purpose: make outputs portable while preserving proof. + +Implementation: + +- Export deck, notebook, and room proof bundle. +- Add a receipt index for exported artifacts: + - artifact id, + - version, + - trace id, + - evidence ids, + - accepted proposals, + - unresolved gaps, + - generated file paths, + - model/cost metadata where available. +- Reopen exported or archived artifacts when possible. + +Tests/proof: + +- Export generation smoke. +- Receipt schema test. +- Reopen/import fixture test where supported. + +Dogfood hook: + +- Every dogfood completion gate should require an export bundle and receipt. + +### 8. Vertical Room Templates + +Purpose: make NodeRoom immediately useful for repeatable high-value workflows. + +Implementation: + +- Add templates for: + - startup diligence, + - accounting reconciliation, + - underwriting review, + - product/market research, + - incident or audit review, + - benchmark evaluation room. +- Each template should define default artifacts, starter tasks, graph schema hints, notebook sections, deck storyboard outline, and proof requirements. +- Templates must be data-backed and editable, not static marketing demos. + +Tests/proof: + +- Template creation smoke. +- Route-level smoke for opening each template. +- Fixture test that required artifacts are created. + +Dogfood hook: + +- The main dogfood run should use at least one template end to end and compare it against a blank room baseline. + +### 9. Customer Benchmark Rooms And NodeTasks + +Purpose: turn evaluation and customer onboarding into a product surface. + +Implementation: + +- Integrate NodeTasks as searchable benchmark tasks. +- Rank tasks by steps, cost, difficulty, domain, tags, required artifacts, and expected proof. +- Let users create a benchmark room from a task. +- Run NodeAgent against the task and produce scorecards, traces, clips, and receipts. +- Keep certification fixtures locked and exploration proposals separate. + +Tests/proof: + +- Task search test. +- Task-to-room creation smoke. +- ProofLoop gate integration for at least one benchmark room. + +Dogfood hook: + +- Use NodeRoom to manage NodeRoom's own benchmark tasks and publish selected proof clips. + +### 10. Narrow Enterprise Connectors + +Purpose: support the minimum real data paths needed for high-value rooms without becoming a broad connector company. + +Implementation: + +- Prioritize connector targets by room workflow need: + - docs and uploaded files, + - spreadsheet data, + - CRM/account context, + - accounting exports, + - issue/project trackers, + - cloud drive files. +- Each connector should land data into artifacts with evidence/provenance. +- Do not let connector work block the artifact layer; use fixture-backed adapters first. + +Tests/proof: + +- Connector fixture ingest tests. +- Evidence provenance tests. +- Failure state tests for missing permissions and stale data. + +Dogfood hook: + +- Dogfood should include at least one messy intake source and one connector-like fixture import. + +## Milestone Plan + +### M0: Contract And Baseline + +- Inventory current routes, panels, user actions, NodeAgent flows, notebook flows, graph flows, export flows, and trace flows. +- Extract deck and mobile artifact contract from design prototypes. +- Lock baseline typecheck, lint, test, and smoke status. +- Create fixture rooms for startup diligence, accounting reconciliation, and product research. + +Exit gate: + +- Baseline status recorded. +- Artifact contract documented. +- No feature code changed yet. + +### M1: Artifact Shell And Workpaper Inbox + +- Add unified artifact adapters and shared artifact shell UI. +- Migrate proposal cards into workpaper cards. +- Add artifact review center. +- Preserve existing action handlers. + +Exit gate: + +- Existing proposal flows still work. +- Mixed artifact list renders from real room state. +- Tests/typecheck pass or pre-existing failures are documented. + +### M2: Deck Vertical Slice + +- Implement storyboard-first deck artifact. +- Render deck workbench with thumbnails, slide preview, evidence, comments, and NodeAgent patch requests. +- Wire accept/reject patch flow through existing proposal/trace patterns. +- Add export stub or real export depending on existing export infrastructure. + +Exit gate: + +- User can generate or open a storyboard, render slides, request a patch, accept/reject it, and export with a receipt. + +### M3: Notebook Interior Upgrade + +- Add typed notebook block adapters. +- Add block-level provenance and trace links. +- Add NodeAgent block transforms. +- Preserve keyboard and editing behavior. + +Exit gate: + +- Existing notebook editing still works. +- At least three typed block transforms work in a live room. + +### M4: Proof Graph Interior Upgrade + +- Integrate NodeGraph components where appropriate. +- Add room graph adapters and connection highlighting. +- Add focus, cluster, search, drag, and path explanation interactions. + +Exit gate: + +- A claim can be traced from deck slide to notebook block to evidence to source through the graph. + +### M5: Collaboration And Mobile Review + +- Add artifact-aware comments/references. +- Validate live chat and NodeAgent mentions. +- Add mobile review flow for comments and approvals. + +Exit gate: + +- Two users/personas can collaborate on one artifact and leave traceable review actions. + +### M6: Export And Replay + +- Produce deck/notebook/proof bundle exports. +- Add receipt sidecar. +- Add replay summary from trace events. + +Exit gate: + +- Exported bundle can be inspected and mapped back to room evidence and trace ids. + +### M7: Templates And Benchmark Rooms + +- Add vertical room templates. +- Add NodeTasks search/ranking room flow. +- Connect ProofLoop gate output to benchmark room artifacts. + +Exit gate: + +- A user can start from a benchmark task, run the room, inspect artifacts, and view a proof receipt. + +### M8: Production Dogfood Gate + +- Run the full vertical dogfood scenario. +- Capture screenshots and clips. +- Record failures, repairs, costs, and trace receipts. +- Publish a concise proof report. + +Exit gate: + +- Deterministic gate passes, or failures are documented as known blockers with linked repair tasks. + +## Full Vertical Dogfood Run + +The dogfood run should use NodeRoom to build NodeRoom. + +### Room Setup + +- Product command room: owns roadmap, market thesis, decisions, and proof report. +- Feature build rooms: deck, notebook, graph, workpapers, templates, NodeTasks. +- Certification rooms: locked scenarios that verify finished behavior. +- Exploration rooms: messy scenarios and red-team tasks that propose future tests. + +### Required Inputs + +- Market planning note. +- Cloud Design artifact. +- Mobile deck prototype. +- Existing NodeRoom routes and current production behavior. +- Existing proofloop suites. +- NodeGraph and NodeTasks public repo artifacts where applicable. + +### Required Sequence + +1. Ingest inputs into a room. +2. Ask NodeAgent to summarize the market gap and missing feature map. +3. Create a proof graph of products, personas, features, claims, and evidence. +4. Create a notebook with assumptions, analysis, calculations, and open questions. +5. Generate a storyboard before slides. +6. Review and edit storyboard with a human. +7. Generate a deck from the approved storyboard. +8. Ask NodeAgent to patch a slide based on reviewer feedback. +9. Accept one patch and reject one patch. +10. Show graph path from a slide claim back to evidence. +11. Collaborate through public chat and at least one artifact comment. +12. Export deck/notebook/proof bundle. +13. Replay trace summary. +14. Run the deterministic gate and publish the proof report. + +### Required Outputs + +- Work artifact bundle. +- Storyboard. +- Deck. +- Notebook. +- Proof graph. +- Proposal/workpaper review log. +- Chat transcript excerpt. +- Export bundle. +- Trace receipt. +- Cost ledger. +- Screenshots and clips. +- Known gaps and repair queue. + +## Persona Test Matrix + +| Persona | Task | Pass Criteria | +| --- | --- | --- | +| Founder | Turn market note into board-ready story | Storyboard and deck reflect evidence-backed thesis | +| Analyst | Build notebook and graph from messy inputs | Claims link to sources and calculations | +| Banker | Review diligence room under time pressure | Can find risks, companies, and evidence quickly | +| Reviewer | Accept/reject NodeAgent edits | Proposal state, comments, and trace receipts are correct | +| Mobile collaborator | Review slides from phone | Can comment, ask for patch, and approve/reject | +| Auditor | Inspect why a decision was made | Trace and proof graph reconstruct the chain | +| New user | Start from template or benchmark task | Room creates expected artifacts without manual setup | +| Admin | Verify cost and run quality | Cost ledger, model routes, and completion gate are inspectable | + +## Acceptance Gates + +Functionality: + +- Existing routes and user actions continue to work. +- Existing NodeAgent frame and trace tests continue to pass. +- Notebook, graph, chat, proposal, and export behavior remain data-backed. + +Evidence: + +- Material claims link to evidence. +- Agent mutations link to trace ids. +- Accepted and rejected proposals remain visible. +- Unsupported claims are flagged rather than hidden. + +Collaboration: + +- Public chat and NodeAgent mentions work. +- Comments can target artifacts or artifact elements. +- Presence does not break artifact state. + +Export: + +- Deck/notebook/proof bundle can be exported. +- Receipt sidecar identifies artifacts, evidence, versions, proposals, trace ids, and known gaps. + +Visual/design: + +- Work artifacts follow the Cloud Design direction. +- Interiors are calm, dense, clear, and less boxy. +- Spreadsheet grid, notebook editor, proposal cards, graph, traces, and chat all receive region-by-region visual migration. + +Benchmarks: + +- Dogfood tasks are searchable and ranked. +- Certification loop remains locked. +- Exploration loop can propose new scenarios but cannot self-promote. + +## Metrics + +- Time from messy input to reviewable artifact. +- Number of material claims with evidence links. +- Number of unsupported claims detected. +- Proposal acceptance/rejection rate. +- Export success rate. +- Trace completeness. +- Graph path explanation success. +- Mobile review completion rate. +- Run cost by model route. +- Human review time saved. + +## Engineering Policy + +- Do not rewrite the application. +- Do not replace working product logic with static mock data. +- Do not remove functionality because the design prototype omits it. +- Keep backend, auth, schema, Convex, and business logic unchanged unless a small compatibility change is required for a first-class artifact. +- Prefer adapters and wrappers over feature rewrites. +- Add deterministic tests or smokes for every behavior-changing artifact migration. +- Keep generated proofloop memory and local run outputs out of git unless explicitly promoted. + +## First PR-Sized Slice + +1. Land this plan as the canonical contract. +2. Extract a deck-specific UI/product contract from `na-deck.jsx`. +3. Add read-only artifact adapters for existing proposals, notebooks, spreadsheets, traces, and graph entries. +4. Render a mixed artifact review surface from real room state. +5. Add a storyboard-first deck artifact behind a feature flag or fixture-backed route. +6. Wire one NodeAgent patch proposal into deck or notebook review. +7. Add tests for artifact adapter status transitions and proposal trace linking. +8. Run typecheck, relevant tests, and a browser dogfood smoke. diff --git a/docs/synthesis/WORK_ARTIFACTS_PROGRESS_RECEIPT.md b/docs/synthesis/WORK_ARTIFACTS_PROGRESS_RECEIPT.md new file mode 100644 index 00000000..5bef8cda --- /dev/null +++ b/docs/synthesis/WORK_ARTIFACTS_PROGRESS_RECEIPT.md @@ -0,0 +1,666 @@ +# Work Artifacts Progress Receipt + +Date: 2026-07-09 + +Goal status: blocked only on interactive Azure authentication for the final +Finch judge. Product implementation, live dogfood, SpreadsheetBench coverage, +FinAuditing, and MBABench certification slices are complete; this receipt does +not claim the final ProofLoop gate until Finch is promoted. + +## Completed Slices + +### M0 Baseline + +- Recorded baseline in `docs/synthesis/WORK_ARTIFACTS_BASELINE_RECEIPT.md`. +- Confirmed typecheck, NodeAgent frame smoke, Omnigent compatibility smoke, + proofloop doctor, manifest, and UI contract commands. +- Full `npm test -- --run` had 4 pre-existing failures before this slice. + +### M1 Unified Work-Artifact Layer + +- Added `src/ui/workArtifacts/workArtifactTypes.ts`. +- Added `src/ui/workArtifacts/workArtifactAdapters.ts`. +- Added `src/ui/workArtifacts/WorkArtifactsPanel.tsx`. +- Added `src/ui/workArtifacts/work-artifacts.css`. +- Added read-only Artifacts pseudo-tab in `src/ui/panels/Artifact.tsx`. +- Preserved existing artifact open callbacks and existing proposal, trace, + graph, and export data sources. + +### M2 Storyboard-First Deck Slice + +- Added `docs/design/DECK_STORYBOARD_CONTRACT.md`. +- Added `src/ui/workArtifacts/deckStoryboard.ts`. +- Derived deck storyboard artifacts from real room artifacts, traces, and + proposals. +- Unsupported/manual/proposal-backed deck claims are marked `needs_review`. +- No backend, schema, auth, Convex, NodeAgent core, or slide editor behavior was + changed. + +### M3 Notebook Work-Artifact Digest + +- Added `docs/design/NOTEBOOK_WORK_ARTIFACT_CONTRACT.md`. +- Added `src/ui/workArtifacts/notebookStructure.ts`. +- Derived notebook block, section, citation, source, trace, and proposal + metadata from existing note artifacts. +- Supports legacy HTML notes and ProseMirror-like JSON documents. +- Existing notebook editor, keyboard behavior, sync behavior, read-model + processing, and governed NodeAgent write paths remain untouched. + +### M4 Entity Graph Relevant Paths + +- Added `src/ui/graph/semanticGraphPaths.ts`. +- Added `SemanticGraphConnectionPath` to the semantic graph selection contract. +- Selection detail now shows ranked `Relevant Paths` for selected graph nodes. +- Semantic graph derivation now uses notebook block digests for note artifacts, + so graph paths can include notebook blocks, citations, review gaps, and + source links instead of collapsing a notebook into one doc node. +- No graph storage, backend, Convex, auth, or NodeAgent core behavior changed. + +### M6 Proof-Bundle Receipt Model + +- Added `src/ui/workArtifacts/proofBundleReceipt.ts`. +- Added deterministic proof-bundle receipt hashes across mixed artifacts. +- Receipt aggregates artifact kind counts, status counts, evidence ids, source + ids, trace ids, proposal ids, and known gaps. +- Artifacts panel now shows the short receipt hash in the header. +- This is the export sidecar contract; it does not replace existing export + mechanisms or create backend state. + +### M6 Trace Replay Summary Adapter + +- Added `src/ui/workArtifacts/traceReplaySummary.ts`. +- Existing trace rows are grouped into room, chat, agent, edit, review, and + notebook phases. +- Replay summaries aggregate trace ids, proposal ids, artifact ids, critical + path phases, status rollups, and deterministic replay hash. +- This is read-only and does not alter trace storage, chat behavior, NodeAgent + runtime, or backend contracts. + +### M7 Openable Deck Storyboard Workbench + +- Added `src/ui/workArtifacts/DeckStoryboardWorkbench.tsx`. +- Deck work-artifact rows now open a storyboard workbench inside the Artifacts + surface instead of trying to open a non-existent generated artifact id. +- Source actions inside the storyboard workbench open the real source artifacts + through the existing artifact-open callback. +- Existing slide editor/export behavior remains untouched; this is a read-only + storyboard planning surface backed by current room artifacts, traces, and + proposals. + +### M8 Agent Workpaper Review Center + +- Added `src/ui/workArtifacts/ProposalReviewCenter.tsx`. +- Mounted a reusable proposal/workpaper review center in the Artifacts surface. +- Review center derives pending workpapers from existing `store.listProposals`, + artifacts, and traces. +- Host approve/reject buttons call the existing `store.resolveProposal` + callback and preserve the current conflict/host-required feedback semantics. +- Source actions open real source artifacts through the existing artifact-open + callback. +- No proposal schemas, Convex functions, RoomEngine contracts, or NodeAgent + runtime paths were changed. + +### M9 Proof-Bundle Export Sidecar + +- Added `src/ui/workArtifacts/proofBundleExport.ts`. +- Added a `Receipt JSON` action to the Artifacts panel. +- The downloaded manifest includes the deterministic proof-bundle receipt, trace + replay summary, artifact refs/actions, export intent, known gaps, and a + manifest integrity hash. +- The browser download is client-side only and does not create backend state. +- Existing XLSX/file export paths remain untouched. + +### M10 Openable Notebook Digest Workbench + +- Added `src/ui/workArtifacts/NotebookDigestWorkbench.tsx`. +- Notebook work-artifact rows now open a digest workbench inside the Artifacts + surface before handing off to the editor. +- The digest renders existing notebook block, section, authorship, source, + trace, proposal, and review metadata from `buildNotebookArtifactStructure`. +- The `Open editor` action routes to the real notebook artifact through the + existing artifact-open callback. +- Existing notebook editor, sync, keyboard, read-model, and NodeAgent-governed + write behavior remain untouched. + +### M11 Openable Trace Replay Workbench + +- Added `src/ui/workArtifacts/TraceReplayWorkbench.tsx`. +- Trace work-artifact rows now open a read-only replay workbench inside the + Artifacts surface. +- The replay renders existing trace phases, critical path, recent events, + replay hash, proposal counts, and artifact counts from + `buildTraceReplaySummary`. +- Artifact actions inside replay phases/events route through the existing + artifact-open callback when trace refs include artifact ids. +- Existing trace storage, trace strip behavior, Run trace tab, chat behavior, + and NodeAgent runtime paths remain untouched. + +### M12 Live Performance Center + +- Added `src/ui/workArtifacts/livePerformanceSummary.ts`. +- Added `src/ui/workArtifacts/LivePerformanceCenter.tsx`. +- Mounted a public-room chat and NodeAgent live-performance summary in the + Artifacts surface. +- The center derives message counts, agent message counts, grouped run ids, + trace counts, last-run telemetry, long-job telemetry, attempt telemetry, and + stream/detail receipt counts from existing store APIs. +- The center reads public room chat only; it does not read private chat or + create new backend subscriptions. +- The `Trace replay` action opens the latest trace replay workbench through + existing Artifacts-panel state. + +### M13 Deck Preview HTML Export + +- Added `src/ui/workArtifacts/deckPreviewExport.ts`. +- Added an HTML preview/export action and preview card to + `DeckStoryboardWorkbench`. +- The preview/export is derived from the structured storyboard plan, includes a + deterministic integrity hash, and does not become collaborative state. +- Deck storyboard claim extraction now strips legacy note HTML before creating + deck claims, so the workbench and exported preview do not display raw tags. +- Existing slide editor/export code paths remain untouched. + +### M14 Notebook Patch Preview Section + +- Added notebook block-level patch preview derivation in + `NotebookDigestWorkbench`. +- Patch previews match existing proposals to notebook blocks by element id, + block id, or digest id and display the proposed value without applying it. +- Empty patch state is visible in the notebook digest side rail when the room + has no notebook proposals. +- Selected deck/notebook/trace workbenches now render above the artifact grid + and reserve flex height, preventing active interiors from overlapping later + sections. +- Existing proposal resolver, notebook editor, sync, keyboard, and NodeAgent + write paths remain untouched. + +### M15 Typed Notebook Block Adapter + +- Added `src/ui/workArtifacts/notebookTypedBlocks.ts`. +- Notebook digest blocks are classified as typed analytical blocks such as + text, insight, calculation, evidence, decision, open question, table, chart, + SQL, and agent proposal. +- Notebook digest UI now shows per-block type chips and a side-rail block type + summary. +- Fixed notebook digest identity for HTML blocks without `data-blockid` by + generating stable per-block ids instead of reusing the parent element id. +- Existing notebook editor document values, keyboard behavior, and sync behavior + remain unchanged. + +### M16 Storyboard-Backed Proof Graph Paths + +- Added deck, deck-slide, and deck-claim node kinds to the derived semantic + proof graph. +- Storyboard claims now connect back to source artifacts, evidence facts, trace + steps, proposal nodes, and notebook blocks through existing graph refs. +- The live Entity graph route now receives the same storyboard-derived graph + input as the Work Artifacts proof bundle, so graph users can search and select + deck/storyboard nodes directly. +- Graph controls now expose `Deck`, `Slide`, and `Claim` node filters/labels. +- This remains a read-only graph derivation; no backend graph storage, Neo4j + sync, Convex schema, auth, or NodeAgent runtime behavior changed. + +### M17 Portable PPTX Deck Export + +- Added `src/ui/workArtifacts/deckPptxExport.ts`. +- Deck storyboard workbench now offers a `PPTX` export alongside the HTML preview + export. +- The PPTX export is a deterministic client-side OpenXML package built with the + existing `jszip` dependency. +- Exported slides use the existing storyboard titles, purposes, claim status, + unresolved gaps, plan hash, and receipt metadata. +- The export does not create collaborative deck state, does not alter slide + editor behavior, and does not write to backend/storage. + +### M18 Portable PDF Deck Export + +- Added `src/ui/workArtifacts/deckPdfExport.ts`. +- Deck storyboard workbench now offers a deterministic `PDF` export alongside + HTML and PPTX. +- The PDF export is a self-contained client-side PDF writer over the storyboard + slides, claims, and review gaps. +- It does not add runtime dependencies, backend render jobs, collaborative deck + state, or storage writes. + +### M19 Notebook Patch Diff Preview + +- Added `src/ui/workArtifacts/notebookPatchDiff.ts`. +- Notebook patch previews now include a deterministic word-level before/after + diff model for proposal-backed notebook block changes. +- The digest renders removed and added tokens in the patch preview surface when + a pending notebook proposal exists. +- This does not touch the ProseMirror editor internals, notebook sync, proposal + resolution, or NodeAgent write path. + +### M20 Deck Patch Plan + +- Added `src/ui/workArtifacts/deckPatchPlan.ts`. +- Deck storyboard workbench now derives a deterministic reviewer patch plan from + storyboard claims, unresolved gaps, linked proposals, traces, and source + artifact ids. +- The workbench renders patch counts, before/after patch rows, source handoff + buttons, and a `Patch JSON` client-side export. +- This does not apply deck edits, create collaborative slide state, resolve + proposals, or mutate backend/storage state. + +### M21 Graph Relationship Review + +- Added `src/ui/workArtifacts/graphRelationshipReview.ts` and + `src/ui/workArtifacts/GraphRelationshipReviewWorkbench.tsx`. +- The Proof graph work-artifact row now opens a relationship review surface that + classifies graph edges as source-backed confirmations or relationships needing + reviewer confirmation. +- The workbench renders relationship counts, review rows, source handoff + buttons, and a `Review JSON` client-side export. +- This remains derived from the existing semantic graph and does not create + graph storage, Neo4j/Cognee sync state, or backend mutations. + +### M22 Notebook Execution Preview + +- Added `src/ui/workArtifacts/notebookExecutionPreview.ts`. +- Notebook digest now includes an `Execution Preview` card for typed + calculation, SQL, and chart-like blocks. +- Calculation previews use a small safe arithmetic parser; SQL previews parse + `SELECT ... FROM ...` intent; chart previews parse chart intent and + source/series readiness. +- This is a read-only preview layer. It does not execute arbitrary code, run + backend kernels, mutate notebook editor state, or change notebook sync. + +### M23 NodeGraph Public Package Parity + +- Updated the sibling public repo `D:/VSCode Projects/cafecorner_nodebench/nodebench_ai4/NodeGraph`. +- Added `src/relationshipReview.ts`, exporting the same deterministic graph + relationship confirmation model used by NodeRoom. +- Updated `src/index.ts`, `tests/semanticGraph.test.ts`, and `README.md` in + NodeGraph so public package consumers can build source-backed vs + needs-confirmation relationship review receipts. +- Committed and pushed NodeGraph branch + `codex/nodegraph-relationship-review` at commit `c419f34`. +- Opened public NodeGraph PR: + `https://github.com/HomenShum/NodeGraph/pull/1`. +- This mirrors the reusable graph confirmation primitive only; NodeRoom-specific + deck storyboard graph inputs remain in NodeRoom until the public package + accepts deck/storyboard contracts. + +### M24 Collaborative Deck State + +- Added `src/ui/workArtifacts/collaborativeDeck.ts` and upgraded + `DeckStoryboardWorkbench.tsx` from derived preview to a CAS-backed deck + editor adaptor. +- Decks persist through the existing note/artifact path with tag + `noderoom:deck` and element kind `deck_storyboard`; no static mock store was + introduced. +- Create, save, duplicate, delete, reorder, presence, patch-request, HTML, + PPTX, PDF, and patch-plan actions retain their existing source and export + contracts. +- Live proof created the persisted deck and completed a second CAS save at + version 3. + +### M25 Safe Notebook Kernel Receipts + +- Added `src/notebook/notebookKernel.ts`, `convex/notebookKernel.ts`, the store + adapter, and notebook work-artifact controls. +- The kernel supports bounded arithmetic, read-only SQL intent, and chart + intent. It does not use `eval`, arbitrary Python, shell execution, or + unrestricted database writes. +- Kernel results persist as traceable receipt state while the existing + notebook editor remains the content source of truth. +- Live proof appended `Calculation: 12 + 8 * 2`, ran it through the product + editor/kernel path, returned `28`, and persisted receipt hash `3aafe06e`. + +### M26 Graph Cluster Exploration and Dragging + +- Added `src/ui/graph/semanticGraphClusters.ts` and cluster/focus controls to + `KnowledgeGraph.tsx`. +- Users can select semantic clusters, expand zero/one/two hops, focus the top + one/two/three relevant paths, and drag nodes without shifting the overall + work surface. +- React Flow measurements are retained across controlled-node updates, which + removes the prior drag warning without changing graph derivation or source + paths. +- Live proof selected the Evidence cluster, expanded to 14 nodes and 7 links, + focused three paths, opened the Butterbase source path, and moved a node by + approximately 73 by 37 pixels. + +### M27 Scoped Chat Composer Context + +- Added `src/ui/artifactRefs.ts` and a context picker in `Chat.tsx` for current + artifacts, deck slides, proposals, and traces. +- Selected context is sent through the existing message path and rendered as + an openable message reference; public/private lanes, NodeAgent mentions, + streaming, retry, attachment, and cancellation behavior are unchanged. +- Live proof sent a public message scoped to `HackwithBay Demo Brief` and + verified the resulting `.r-msg-ref` embed with no new console warnings or + errors. + +### M28 Official Benchmark Harness Reliability + +- Added resumable, concurrent, receipt-preserving SpreadsheetBench chunk runs + and selective repair of only tasks missing model candidate/scorer-attempt + evidence. +- Replaced JavaScript `TypeError` crashes for malformed structural plans with + deterministic validation and reused the scorer's safe workbook reader for + unsupported XLSX package parts. +- Scoring-phase errors count as a completed model/scorer attempt only when the + generated plan, raw model output, workspace manifest, candidate manifest, + and candidate workbook receipts all exist. Candidate-generation failures + remain incomplete. +- Added reproducible Finch content-parts recovery/merge and corrected the + exporter so non-Excel bytes are never mislabeled as `.xlsx`. +- Added an MBABench compatibility launcher that preserves non-empty credential + environment variables while keeping the pinned upstream checkout clean. + +### M29 Official Score Coverage Closure + +- Completed exact model/scorer-attempt coverage for SpreadsheetBench V1 + (`912/912`), the Verified400 projection (`400/400`), and SpreadsheetBench V2 + (`321/321`). The strict ledger now reports `1,739/1,739` staged official + tasks across all five tracked coverage slices. +- Added hash-verified cross-route repair, exact task-id resume, raw model-output + preservation before parsing, JSON formula-quote repair, and explicit paid + route cost ceilings to the chunked SpreadsheetBench runner. +- Promoted the accepted FinAuditing receipt after all `332/332` FinMR rows were + judged by the pinned path. The receipt records `116,783` input tokens, + `6,308` output tokens, and estimated provider cost `$0.04181175`. +- Promoted the accepted MBABench receipt after all `38/38` locked public cases + completed through the pinned upstream Gemini judge. The receipt records mean + score `11.51315789`, `12,630,780` total tokens, and provider cost `$6.67212`. +- Replaced stale tests that expected those completed lanes to remain blocked + with exact positive assertions for coverage, scorer identity, cost, and + accepted-receipt status. +- Finch model-output and `content_parts.jsonl` coverage is complete at + `172/172`. Added a resumable pinned-Azure judge runner with a one-row probe, + whole-run cost cap, per-call reserve, raw JSONL, `results.xlsx`, and a strict + promotion contract that rejects partial or non-Azure receipts. + +## Validation + +| Command | Status | Notes | +| --- | --- | --- | +| `npm run typecheck -- --pretty false` | pass | TypeScript clean after M28 benchmark-harness reliability work. | +| `npm test -- --run tests/workArtifacts.test.ts` | pass | 27 adapter/storyboard/notebook/live-performance/export/patch-plan/graph-review/execution-preview tests passed. | +| `npm test -- --run tests/workArtifacts.test.ts tests/semanticGraph.test.ts tests/artifactRefs.test.ts` | pass | 40 tests passed after notebook execution preview. | +| `npm run typecheck` in `NodeGraph` | pass | Public package typecheck passed after adding `relationshipReview`. | +| `npm test` in `NodeGraph` | pass | 2 test files, 9 tests passed. | +| `npm run build` in `NodeGraph` | pass | Public package build completed. | +| `npm run nodeagent:frame:smoke` | pass | Reran after implementation; frame `rf_adopt_minimal_write_note` completed in 5 steps. | +| `npm run omnigent:nodeagent:smoke` | pass | Reran after implementation; YAML compatibility passed, outer Omnigent CLI still not installed locally. | +| `npm run proofloop -- doctor --json` | pass | 11 checks passed, 0 warnings, 0 failures. | +| `npm run proofloop -- manifest --dense` | pass | Reported status `official-scores:needs_scaffold_or_run`; live/gate/resume commands available. | +| `npm run proofloop -- ui contract --dense` | pass | Printed stable UI selector/action contract surface. | +| `npm run proofloop -- supervise --goal official-scores --dense` | blocked external | Every command task passes; Finch is the only remaining external scorer task. | +| `npm run benchmark:proofloop:official-preflight -- --strict` | pass | Official-score preflight wrote receipts and passed 7/7 checks without paid provider calls. | +| `npm run benchmark:official:task-coverage -- --strict` | pass | All 5 tracks complete: `1,739/1,739` staged official tasks and `1,733` exact model-run cases. V1 reports `89/912` pass (`0.097588`) and average `0.335084`; Verified400 reports `14/400` pass and average `0.259266`; V2 reports `0/321` pass and average `0.523337`. | +| `npm run proofloop -- gate --goal official-scores` | pending Finch | Completion authority remains the persisted gate; it is not claimed while the accepted Finch Azure receipt is absent. | +| `npm test -- --run` | pass | 305 test files and 2,055 tests passed after score-state, cost-guard, and stale-fixture repairs. | +| `npm run build` | pass | Typecheck and Vite production build passed; Vite reported the existing main-chunk size warning (`1246.58 kB` versus `1100 kB`). | +| lint | unavailable | `package.json` has no lint script; no lint result is claimed. | +| `python -m py_compile scripts/finch-official-judge.py scripts/finauditing-official-judge.py scripts/mbabench-official-sweep.py scripts/finch-content-parts-recovery.py` | pass | All official judge/recovery launchers compile. | +| Finch cost/promotion guard tests | pass | Failed/retried calls consume reserve and call ceilings; promotion rejects partial, non-Azure, parse-error, under-call, and over-cap receipts. | +| FinAuditing official judge | pass | Accepted pinned receipt covers `332/332` FinMR rows at estimated provider cost `$0.04181175`. | +| MBABench official judge | pass | Accepted pinned Gemini receipt covers `38/38` cases, mean score `11.51315789`, provider cost `$6.67212`. | + +## Live Browser Proof + +Local dev server: + +- `http://127.0.0.1:5173/#hackwithbay` + +Captured screenshots: + +- `docs/synthesis/proof/work-artifacts-tab.png` +- `docs/synthesis/proof/work-artifacts-storyboard-tab.png` +- `docs/synthesis/proof/work-artifacts-hackwithbay-storyboard-tab.png` +- `docs/synthesis/proof/work-artifacts-hackwithbay-notebook-digest.png` +- `docs/synthesis/proof/entity-graph-relevant-paths.png` +- `docs/synthesis/proof/work-artifacts-proof-receipt.png` +- `docs/synthesis/proof/deck-storyboard-workbench.png` +- `docs/synthesis/proof/proposal-review-center.png` +- `docs/synthesis/proof/proof-bundle-export-json.png` +- `docs/synthesis/proof/notebook-digest-workbench.png` +- `docs/synthesis/proof/trace-replay-workbench.png` +- `docs/synthesis/proof/live-performance-center.png` +- `docs/synthesis/proof/deck-preview-export.png` +- `docs/synthesis/proof/notebook-patch-preview-empty-visible.png` +- `docs/synthesis/proof/notebook-typed-blocks.png` +- `docs/synthesis/proof/m24-deck-collaboration-proof.png` +- `docs/synthesis/proof/m25-notebook-kernel-proof.png` +- `docs/synthesis/proof/m26-graph-cluster-drag-proof.png` +- `docs/synthesis/proof/m27-chat-context-proof.png` +- `docs/synthesis/proof/deck-claim-graph-paths.png` +- `docs/synthesis/proof/deck-pptx-export.png` +- `docs/synthesis/proof/hackwithbay-deck-export.pptx` +- `docs/synthesis/proof/deck-pdf-export.png` +- `docs/synthesis/proof/hackwithbay-deck-export.pdf` +- `docs/synthesis/proof/notebook-patch-diff-empty.png` +- `docs/synthesis/proof/deck-patch-plan.png` +- `docs/synthesis/proof/deck-patch-plan-header.png` +- `docs/synthesis/proof/deck-patch-plan-detail.png` +- `docs/synthesis/proof/hackwithbay-deck-patch-plan.json` +- `docs/synthesis/proof/graph-relationship-review.png` +- `docs/synthesis/proof/hackwithbay-graph-relationship-review.json` +- `docs/synthesis/proof/notebook-execution-preview-empty.png` +- `docs/synthesis/proof/notebook-execution-preview-empty-detail.png` +- `docs/synthesis/proof/hackwithbay-proof-bundle.json` + +Observed on `http://127.0.0.1:5175/#hackwithbay`: + +- Artifacts panel rendered from real seeded room state. +- Proof bundle summary rendered. +- Deck storyboard row rendered as a `deck` work artifact. +- Deck row status was `needs_review` when claims lacked evidence. +- Notebook row rendered as a `notebook` work artifact with derived block/section + summary: `5 blocks - 1 section`. +- Browser proof after M3 observed 8 rows: deck, export, graph, notebook, two + spreadsheets, and two traces. +- Entity graph opened from the trace-strip action on a sheet route. +- Selecting a graph node rendered `Relevant Paths` in the detail panel. +- Browser proof after M4 observed 22 visible graph nodes, 47 visible links, and + 8 ranked path rows. +- Artifacts panel showed proof receipt hash `5715baf1` in the live UI. +- Browser proof after M6 observed 8 work-artifact rows and receipt id + `room_52:proof-bundle:5715baf1`. +- Browser proof after M7 opened the deck storyboard workbench from the deck row, + observed 3 storyboard slides, and found 3 source buttons. +- Clicking a storyboard source button opened the real `HackwithBay Demo Brief` + notebook artifact and closed the storyboard workbench via the existing + artifact-open callback. +- Browser proof after M8 observed the `proposal-review-center` surface mounted + in the Artifacts panel with pending/agent-edit/semantic-rebase/all filters and + the zero-pending workpaper state on the HackwithBay room. +- Focused tests cover pending proposal item derivation, semantic rebase + filtering, value previews, conflict feedback, and host-required feedback. +- Browser proof after M9 clicked `Receipt JSON`, downloaded + `hackwithbay-3-0-btb-graph-agent-proof-bundle-baf347df.json`, and showed a + success status in the Artifacts panel. +- The downloaded sidecar was copied to + `docs/synthesis/proof/hackwithbay-proof-bundle.json`; it contains + `manifestVersion: 1`, receipt id `room_52:proof-bundle:5715baf1`, manifest id + `room_52:proof-bundle:5715baf1:manifest:baf347df`, 8 artifacts, 2 trace ids, + and 10 source ids. +- Browser proof after M10 opened the notebook digest workbench from the + `HackwithBay Demo Brief` notebook row, observed 5 digest blocks, 1 section, + and the selected notebook row state. +- Clicking `Open editor` closed the digest/proof panel and returned to the real + `note-editor` surface for `HackwithBay Demo Brief`. +- Browser proof after M11 opened a trace replay workbench from a trace row, + observed 2 replay phases, 2 critical path entries, 2 recent events, replay + hash `c08a86e1`, and the selected trace row state. +- The HackwithBay seeded trace events currently have 0 artifact refs in the + replay; artifact-open buttons are available for trace phases/events that carry + artifact ids. +- Browser proof after M12 observed the `live-performance-center` with 2 public + messages, 1 human message, 1 agent message, 1 agent trace, 2 total traces, 0 + tools, and `$0.000` cost for the seeded room. +- The live center's `Trace replay` action opened the trace replay workbench and + selected the latest trace row. +- Browser proof after M13 opened the deck storyboard workbench, observed 3 + slides, the preview card, export hash `be6ecf65`, and no raw `

/

/` + tags in the visible storyboard text. +- Clicking `HTML` showed a success status for + `hackwithbay-3-0-btb-graph-agent-readout-deck-preview-be6ecf65.html`. +- Browser proof after M14 opened the notebook digest workbench, observed the + visible `Patch Previews` section, confirmed 0 patch cards and the honest + `No pending notebook patches.` empty state on HackwithBay. +- Layout proof after M14 confirmed the notebook workbench no longer overlaps + the live-performance section (`overlaps: false` in browser check). +- Browser proof after M15 observed the typed block summary `text 1` and + `evidence 4`, with the heading block labeled `text` after the unique block id + fix. +- Browser proof after M16 opened Entity graph through the existing command + palette action (`Open Graph`) after the tab was hidden in the current layout. +- The graph UI reported `Deck 1`, `Slide 3`, and `Claim 7` filter counts for + the HackwithBay room. +- Searching `readout` rendered the storyboard deck and 3 slide nodes in the + React Flow canvas. +- Searching `Demo claim` rendered a deck-claim node beside the real notebook + block source. +- Selecting the deck claim opened `Relevant Paths` showing the chain from deck + claim to `HackwithBay Demo Brief`, to the source notebook block, and back to + storyboard slide/deck context. +- Browser proof after M17 opened the deck storyboard workbench, observed both + `HTML` and `PPTX` export controls, clicked `PPTX`, and showed success status + for `hackwithbay-3-0-btb-graph-agent-readout-deck-67eac2c8.pptx`. +- The downloaded PPTX was copied to + `docs/synthesis/proof/hackwithbay-deck-export.pptx`. +- Local package validation confirmed the copied PPTX has `PK` magic, 28 ZIP + entries, `ppt/presentation.xml`, `ppt/slides/slide1.xml`, and slide 1 contains + `HackwithBay Demo Brief`. +- Browser proof after M18 observed `HTML`, `PPTX`, and `PDF` export controls, + clicked `PDF`, and showed success status for + `hackwithbay-3-0-btb-graph-agent-readout-deck-67eac2c8.pdf`. +- The downloaded PDF was copied to + `docs/synthesis/proof/hackwithbay-deck-export.pdf`. +- Local PDF validation confirmed `%PDF-1.4`, 3 page objects for the 3-slide + HackwithBay storyboard, and slide text containing `HackwithBay Demo Brief`. +- Browser proof after M19 reopened the notebook digest and confirmed the route + is healthy with 0 patch cards and the honest `No pending notebook patches.` + empty state in the current HackwithBay seed. +- The non-empty notebook patch diff path is covered by deterministic tests that + assert removed text (`needs source`) and added text (`now cites`) for a pending + notebook proposal. +- Browser proof after M20 opened the deck storyboard workbench, observed the + `Patch JSON` export control, the meta chip `patches 7`, and the `Patch Plan` + card with 7 patch rows, 0 ready-for-review patches, and 7 source-needed + patches for the current HackwithBay seed. +- Clicking `Patch JSON` showed a success status for + `hackwithbay-3-0-btb-graph-agent-readout-deck-patch-plan-6097d2b7.json`. +- The downloaded patch plan was copied to + `docs/synthesis/proof/hackwithbay-deck-patch-plan.json`; local validation + confirmed `patchVersion: 1`, `patchCount: 7`, `needsSourceCount: 7`, + `readyForReviewCount: 0`, and integrity hash `6097d2b7`. +- Browser proof after M21 opened the Proof graph work-artifact row and observed + the `Graph relationship review` workbench with 89 nodes, 199 edges, 199 + relationships, 55 confirmed relationships, 144 relationships needing + confirmation, and review hash `de2f8659`. +- Clicking `Review JSON` showed a success status for + `room-52-semantic-graph-relationship-review-de2f8659.json`. +- The downloaded relationship review was copied to + `docs/synthesis/proof/hackwithbay-graph-relationship-review.json`; local + validation confirmed `reviewVersion: 1`, `nodeCount: 89`, `edgeCount: 199`, + `relationshipCount: 199`, `confirmedCount: 55`, `needsConfirmationCount: + 144`, and integrity hash `de2f8659`. +- Browser proof after M22 opened the `HackwithBay Demo Brief` notebook digest and + observed the new `Execution Preview` card with the honest current seed state: + 0 executable blocks, 0 ready previews, and 0 blocked previews. +- Deterministic tests cover the non-empty preview paths: safe arithmetic + calculation (`12 + 8 * 2 -> 28`), SQL intent parsing + (`select company, funding from diligence`), and line chart intent parsing. + +## Preserved Functionality Checklist + +- [x] Backend additions are limited to the explicitly requested notebook + kernel receipt path; existing API, data, auth, and realtime contracts were + not replaced. +- [x] No auth/session logic changed. +- [x] No NodeAgent runtime/frame/core files changed. +- [x] Existing artifact tabs remain available. +- [x] Existing notebook editor code remains the editor source of truth. +- [x] Existing proposal objects remain pending/approved/rejected source of + truth. +- [x] Existing trace events remain the proof receipt source. +- [x] Existing read-only receipt/review workbenches remain derived; the deck + and notebook kernel slices write only through their scoped store/Convex + adaptors and preserve CAS/version receipts. +- [x] Unsupported deck and notebook claims stay visible as review work instead + of being hidden. +- [x] Entity graph remains derived from current room state and now exposes + ranked relevant paths for selected nodes. +- [x] Proof-bundle receipt model is deterministic and read-only. +- [x] Trace replay summary model is deterministic and read-only. +- [x] Deck storyboard workbench is openable and source actions route to real + artifacts instead of synthetic storyboard ids. +- [x] Proposal review center preserves existing `resolveProposal` behavior and + source artifact opening paths. +- [x] Proof-bundle export sidecar downloads a manifest built from the same + receipt and trace replay contracts shown in the Artifacts panel. +- [x] Notebook digest workbench is openable and preserves the real notebook + editor handoff through the existing artifact-open callback. +- [x] Trace replay workbench is openable and preserves artifact-open callbacks + for trace refs that include artifact ids. +- [x] Live performance center derives from existing public messages, run/job + telemetry, traces, and stream/detail receipts without reading private chat or + mutating state. +- [x] Deck preview/export derives from the storyboard plan, strips legacy HTML + text before rendering claims, and does not change slide/editor state. +- [x] Notebook patch previews derive from existing proposal objects and do not + apply or resolve proposal changes. +- [x] Typed notebook block classification derives from existing block text, + source ids, role, status, and proposals without changing editor state. +- [x] Storyboard proof graph nodes derive from the deck plan and connect back to + existing source artifacts, notebook blocks, trace steps, proposal nodes, and + evidence refs without mutating graph/backend state. +- [x] PPTX deck export derives from the storyboard plan and downloads as a + client-side file without creating backend state or replacing the slide editor. +- [x] PDF deck export derives from the storyboard plan and downloads as a + client-side file without backend render jobs or storage writes. +- [x] Notebook patch previews now include before/after word diffs for pending + notebook proposals without changing editor sync or proposal resolution. +- [x] Deck patch plans derive from storyboard gaps, unverified claims, linked + proposals, source artifact ids, and trace refs without applying deck edits or + resolving proposals. +- [x] Graph relationship review derives from the current semantic graph and + classifies source-backed vs inferred/proposal-linked relationships without + changing graph storage or backend state. +- [x] Notebook execution preview derives from typed notebook blocks and safely + previews calculation/SQL/chart intent without arbitrary code execution, + backend kernels, or editor mutations. +- [x] Collaborative deck edits persist through the existing artifact contract + and preserve create/save/duplicate/delete/reorder/export callbacks. +- [x] Notebook kernel execution is bounded to calculation, read-only SQL, and + chart intent and links persisted outputs to receipt state. +- [x] Graph cluster/focus controls remain derived from room state and preserve + draggable React Flow behavior and source-backed relevant paths. +- [x] Chat context selection preserves the existing composer/send/stream path + and adds openable artifact/proposal/trace references. + +## Known Gaps + +- Deck patch plans are review/export artifacts only; applying those patches to a + deck still requires reviewer action through the governed request path. +- Notebook block-level patch previews and digest-level word diffs are + implemented; inline visual diffs inside the ProseMirror editor are still + future work. +- The safe notebook kernel intentionally does not provide arbitrary Python, + shell, package installation, or unrestricted SQL execution. +- Proof graph cluster/focus controls are complete locally; persisted + Neo4j/Cognee synchronization and public deck/storyboard graph contracts are + separate integration work. +- Chat context migration is complete for artifact/deck/proposal/trace refs; + visual parity for every legacy receipt subtype remains iterative design work. +- The full vertical product dogfood passed for the four new feature slices and + every non-Finch official-score lane is complete. The separate + `official-scores` gate remains pending only the accepted Finch Azure receipt. + +## Next Required Gate + +Before claiming this goal complete: + +1. Complete Microsoft device authentication and discover an existing Azure + OpenAI deployment without creating cloud resources. +2. Run the capped one-row Finch transport/parser probe, then resume the pinned + judge to accepted `172/172` coverage. +3. Promote that exact receipt, regenerate derived ledgers, and rerun the full + validation/build pass. +4. Run `npm run proofloop -- gate --goal official-scores` and claim completion + only when it exits zero with status `passed`. diff --git a/e2e/deployed-auth-first-user.spec.ts b/e2e/deployed-auth-first-user.spec.ts new file mode 100644 index 00000000..9f28d297 --- /dev/null +++ b/e2e/deployed-auth-first-user.spec.ts @@ -0,0 +1,130 @@ +import { expect, test } from "./fixtures"; + +const deployedAuthEnabled = process.env.PLAYWRIGHT_DEPLOYED_AUTH === "1"; + +async function configurePreviewProtection(page: import("@playwright/test").Page): Promise { + const protectionBypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + if (!protectionBypass) return; + await page.setExtraHTTPHeaders({ + "x-vercel-protection-bypass": protectionBypass, + "x-vercel-set-bypass-cookie": "true", + }); +} + +test.describe("deployed authenticated first-user journey", () => { + test.skip(!deployedAuthEnabled, "Set PLAYWRIGHT_DEPLOYED_AUTH=1 against an isolated authenticated deployment."); + + test("a fresh phone creates an account, creates a room, persists, and fails closed after sign-out", async ({ page }) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 390, height: 844 }); + await configurePreviewProtection(page); + + const nonce = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const email = `mobile-proof-${nonce}@example.test`; + const password = "NodeRoom-Preview-190!"; + const message = `Fresh phone proof ${nonce}`; + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { name: /Work with AI/i })).toBeVisible({ timeout: 30_000 }); + + await page.getByRole("link", { name: "Create a room", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Create this workspace?" })).toBeVisible(); + await expect(page.getByRole("radio", { name: /Review every edit/i })).toBeChecked(); + await page.getByTestId("mobile-create-confirm").click(); + + await expect(page.getByTestId("account-auth-gate")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Sign in to create this workspace" })).toBeVisible(); + await page.getByRole("button", { name: "Create account", exact: true }).click(); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(password); + await page.getByTestId("sign-in-password").click(); + + await expect(page.getByTestId("mobile-header")).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId("mobile-room-title")).toHaveText("My workspace"); + await expect(page.getByTestId("gap-firstjoin")).toBeVisible(); + await page.getByRole("button", { name: "Dismiss first-join welcome" }).click(); + + const roomUrl = page.url(); + const sessionKeys = await page.evaluate(() => Object.keys(localStorage).filter((key) => key.startsWith("noderoom:live:"))); + expect(sessionKeys).toHaveLength(1); + + await page.getByTestId("mobile-nav-room").click(); + await page.getByLabel("Message everyone in this room").fill(message); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByLabel("Room messages").getByText(message, { exact: true })).toBeVisible({ timeout: 30_000 }); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("mobile-header")).toBeVisible({ timeout: 60_000 }); + await page.getByTestId("mobile-nav-room").click(); + await expect(page.getByLabel("Room messages").getByText(message, { exact: true })).toBeVisible({ timeout: 30_000 }); + + await page.getByTestId("mobile-room-context").click(); + const signOut = page.getByRole("button", { name: "Sign out of NodeRoom" }); + await signOut.scrollIntoViewIfNeeded(); + await signOut.click(); + await expect(page.getByRole("heading", { name: "NodeRoom" })).toBeVisible({ timeout: 30_000 }); + expect(await page.evaluate(() => Object.keys(localStorage).filter((key) => key.startsWith("noderoom:live:")))).toEqual([]); + + await page.goto(roomUrl, { waitUntil: "domcontentloaded" }); + await expect(page.getByLabel("Room code")).not.toHaveValue(""); + await page.getByTestId("mobile-join-submit").click(); + await expect(page.getByTestId("account-auth-gate")).toBeVisible(); + await expect(page.getByTestId("mobile-header")).toHaveCount(0); + }); + + test("an authenticated sample room routes a scoped deck request and exports a governed receipt", async ({ page }, testInfo) => { + test.setTimeout(180_000); + await page.setViewportSize({ width: 390, height: 844 }); + await configurePreviewProtection(page); + + const nonce = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByRole("link", { name: "Try a sample room", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Create this sample room?" })).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("mobile-sample-confirm").click(); + + await expect(page.getByTestId("account-auth-gate")).toBeVisible(); + await page.getByRole("button", { name: "Create account", exact: true }).click(); + await page.getByLabel("Email").fill(`mobile-deck-proof-${nonce}@example.test`); + await page.getByLabel("Password").fill("NodeRoom-Preview-190!"); + await page.getByTestId("sign-in-password").click(); + + await expect(page.getByTestId("mobile-header")).toBeVisible({ timeout: 60_000 }); + const firstJoin = page.getByTestId("gap-firstjoin"); + await expect(firstJoin).toBeVisible(); + await page.getByRole("button", { name: "Dismiss first-join welcome" }).click(); + await expect(page.getByTestId("mobile-sample-banner")).toContainText("Sample workspace.", { timeout: 90_000 }); + + const deckCard = page.locator('.na-rcard[data-kind="deck"]'); + await expect(deckCard).toHaveCount(1, { timeout: 60_000 }); + await deckCard.click(); + + const sheet = page.locator('.na-sheet[data-open="true"]'); + await expect(sheet).toBeVisible(); + await page.getByRole("tab", { name: "Plan" }).click(); + await expect(sheet.locator(".na-todos")).toBeVisible(); + await page.getByRole("tab", { name: "Slides" }).click(); + await expect(sheet.locator("iframe.na-slide")).toBeVisible(); + + await sheet.getByRole("button", { name: "Scope revision request to the slide title" }).click(); + await sheet.getByPlaceholder(/Describe the change for this element/i).fill("Clarify this title using only attached room evidence."); + await sheet.getByRole("button", { name: "Send", exact: true }).click(); + await expect(sheet.getByText(/Live request accepted/i)).toBeVisible({ timeout: 60_000 }); + await expect(sheet.getByRole("button", { name: /Accept patch/i })).toHaveCount(0); + + await page.getByRole("tab", { name: "Evidence" }).click(); + await expect(sheet.locator(".na-answer")).toBeVisible(); + await expect(sheet.locator(".na-srcclaim")).toBeVisible(); + + const screenshotPath = testInfo.outputPath("deployed-auth-mobile-deck-390x844.png"); + await page.screenshot({ path: screenshotPath, fullPage: false }); + await testInfo.attach("deployed-auth-mobile-deck", { path: screenshotPath, contentType: "image/png" }); + + await page.getByRole("tab", { name: "Export" }).click(); + const downloadPromise = page.waitForEvent("download"); + await sheet.getByRole("button", { name: "Download PPTX" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/\.pptx$/i); + await expect(page.getByTestId("mobile-deck-export-receipt")).toContainText(/Downloaded .* integrity/i, { timeout: 30_000 }); + }); +}); diff --git a/e2e/design-baseline.spec.ts-snapshots/demo-room-mobile-chromium-linux.png b/e2e/design-baseline.spec.ts-snapshots/demo-room-mobile-chromium-linux.png index 4778a1223efc682b180f4cfd8c1786e0047d7fe3..f9346b415e6ca6515b99ee8df52c8cc4a172bd14 100644 GIT binary patch literal 49417 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 HhQ9v~-bmCO literal 78928 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⪇h4$a*JQE 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 KY_+sm@P7eMhq7n@ diff --git a/e2e/design-baseline.spec.ts-snapshots/demo-room-mobile-chromium-win32.png b/e2e/design-baseline.spec.ts-snapshots/demo-room-mobile-chromium-win32.png index 4778a1223efc682b180f4cfd8c1786e0047d7fe3..6bfd987710606f0dcef47a9ef4756d02998aa7db 100644 GIT binary patch literal 52008 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 literal 78928 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⪇h4$a*JQE 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 KY_+sm@P7eMhq7n@ diff --git a/e2e/first-run-launch.spec.ts b/e2e/first-run-launch.spec.ts new file mode 100644 index 00000000..275cc411 --- /dev/null +++ b/e2e/first-run-launch.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "./fixtures"; + +async function liveSessionKeys(page: import("@playwright/test").Page): Promise { + return page.evaluate(() => Object.keys(localStorage).filter((key) => key.startsWith("noderoom:live:"))); +} + +test.describe("fresh-user launch contract", () => { + test("a fresh phone stays on the explanatory landing until Create is chosen", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: /Work with AI/i })).toBeVisible({ timeout: 30_000 }); + expect(page.url()).not.toContain("#mobile"); + expect(await liveSessionKeys(page)).toEqual([]); + + await page.getByRole("link", { name: "Create a room", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Create this workspace?" })).toBeVisible({ timeout: 30_000 }); + expect(page.url()).toContain("#mobile?intent=create"); + await expect(page.getByRole("radio", { name: /Review every edit/i })).toBeChecked(); + expect(page.url()).not.toContain("confirmed=1"); + expect(await liveSessionKeys(page)).toEqual([]); + + await page.getByRole("button", { name: "Back" }).click(); + await expect(page.getByRole("heading", { name: "NodeRoom" })).toBeVisible(); + await page.getByTestId("mobile-sample-room").click(); + await expect(page.getByRole("heading", { name: "Create this sample room?" })).toBeVisible(); + await expect(page.getByText(/demonstration artifacts and trace data/i)).toBeVisible(); + expect(await liveSessionKeys(page)).toEqual([]); + }); + + test("opening a phone invite stages identity and access instead of autojoining", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/?room=NRNOPE1", { waitUntil: "domcontentloaded" }); + + await expect(page.getByLabel("Room code")).toHaveValue("NRNOPE1", { timeout: 30_000 }); + expect(page.url()).toContain("#mobile?room=NRNOPE1"); + await expect(page.getByText(/join and edit shared room content/i)).toBeVisible(); + expect(page.url()).not.toContain("confirmed=1"); + expect(await liveSessionKeys(page)).toEqual([]); + }); + + test("desktop Create and invite links also require explicit confirmation", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto("/?room=NRNOPE2&surface=desktop", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "Join this room" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/Invite holders join as editors/i)).toBeVisible(); + expect(page.url()).not.toContain("confirmed=1"); + expect(await liveSessionKeys(page)).toEqual([]); + + await page.getByRole("button", { name: "Close" }).click(); + await page.getByTestId("create-room").click(); + await expect(page.getByRole("heading", { name: "Start with an empty workspace" })).toBeVisible(); + await expect(page.getByRole("radio", { name: /Review every artifact edit/i })).toBeChecked(); + expect(await liveSessionKeys(page)).toEqual([]); + }); + + test("compact Create preflight remains scrollable and the confirm action is reachable", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await page.goto("/#mobile?intent=create", { waitUntil: "domcontentloaded" }); + + const confirm = page.getByTestId("mobile-create-confirm"); + await expect(confirm).toBeAttached({ timeout: 30_000 }); + await confirm.scrollIntoViewIfNeeded(); + await expect(confirm).toBeVisible(); + const overflow = await page.locator(".na-join").evaluate((node) => ({ + horizontal: node.scrollWidth - node.clientWidth, + vertical: node.scrollHeight - node.clientHeight, + })); + expect(overflow.horizontal).toBeLessThanOrEqual(1); + expect(overflow.vertical).toBeGreaterThanOrEqual(0); + }); + + test("mobile sheets trap focus, close on Escape, and restore the trigger", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/#mobile?mode=memory", { waitUntil: "domcontentloaded" }); + + const deckCard = page.locator('.na-rcard[data-kind="deck"]'); + await expect(deckCard).toHaveCount(1, { timeout: 30_000 }); + await deckCard.click(); + const dialog = page.locator('.na-sheet[data-open="true"]'); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveAttribute("role", "dialog"); + await expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(await dialog.evaluate((node) => node.contains(document.activeElement))).toBe(true); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(deckCard).toBeFocused(); + }); +}); diff --git a/e2e/mobile-story-surfaces.spec.ts b/e2e/mobile-story-surfaces.spec.ts index 7f3fe430..c3d503ce 100644 --- a/e2e/mobile-story-surfaces.spec.ts +++ b/e2e/mobile-story-surfaces.spec.ts @@ -101,17 +101,16 @@ test.describe("#room-tour — scripted desktop walkthrough (Room.html port)", () }); test.describe("#mobile — terra surface renders (memory mode)", () => { - test("cream surface, live room name, Home sections, FAB, no skeleton leak", async ({ page }) => { + test("light terracotta surface, live room name, Home sections, FAB, no skeleton leak", async ({ page }) => { await page.goto("/#mobile?mode=memory"); const na = page.locator(".na-app"); await expect(na).toBeVisible({ timeout: 30_000 }); - // terra cream page surface (#FBF4E7). await expect(na).toHaveCSS("background-color", "rgb(251, 244, 231)"); - await expect(page.locator(".na-roomsw .nm")).toHaveText("Q3 Diligence"); - // The mobile app is capture-first — #mobile lands on the note capture screen, - // not the library Home. The "N" mark (aria-label="Home") opens the Home - // library; assert its Recents section there (the surface this test protects). - await page.locator('.na-mark[aria-label="Home"]').click(); + await expect(page.getByTestId("mobile-room-title")).toHaveText("Q3 Diligence"); + await expect(page.getByTestId("mobile-header")).toBeVisible(); + await expect(page.locator(".na-preview-status, .na-preview-island, .na-preview-home-indicator")).toHaveCount(0); + // The terracotta source design is Home-first; assert the artifact-card library + // surface directly instead of entering through Capture. await expect(page.locator(".na-kicker").filter({ hasText: "Recents" })).toBeVisible(); // FAB lives in the dock (may sit below the phone fold) — assert presence, not visibility. await expect(page.locator(".na-fab-btn")).toHaveCount(1); @@ -121,23 +120,23 @@ test.describe("#mobile — terra surface renders (memory mode)", () => { }); test.describe("mobile universal landing router", () => { - test("phone-sized public URLs land in the terracotta mobile shell", async ({ page }) => { + test("phone-sized public room URLs preserve their destination", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/?mode=memory#rooms/expositio-pulse", { waitUntil: "domcontentloaded" }); - await expect.poll(() => page.url()).toContain("#mobile?mode=memory&from=rooms%2Fexpositio-pulse"); - const app = page.locator(".na-app"); - await expect(app).toBeVisible({ timeout: 30_000 }); - await expect(app).toHaveCSS("background-color", "rgb(251, 244, 231)"); - await expect(page.locator('[data-testid="ao-room"]')).toHaveCount(0); + await expect.poll(() => page.url()).toContain("#rooms/expositio-pulse"); + await expect(page.locator('[data-testid="ao-room"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator(".na-app")).toHaveCount(0); }); - test("phone-sized standard live intents normalize before the mobile app boots", async ({ page }) => { + test("phone-sized Create intent reaches review-first mobile preflight", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/?mode=memory&create=NRMOB1&name=Codex&title=Mobile%20Room", { waitUntil: "domcontentloaded" }); + await page.goto("/?intent=create", { waitUntil: "domcontentloaded" }); - await expect.poll(() => page.url()).toContain("#mobile?mode=memory&create=NRMOB1&name=Codex&title=Mobile+Room"); - await expect(page.locator(".na-app")).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => page.url()).toContain("#mobile?intent=create"); + await expect(page.getByRole("heading", { name: "Create this workspace?" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("radio", { name: /Review every edit/i })).toBeChecked(); + expect(page.url()).not.toContain("confirmed=1"); }); }); @@ -155,8 +154,8 @@ test.describe("#mobile - terra surface renders (live Convex room)", () => { await expect.poll(() => page.url(), { message: "standard live URL should normalize into #mobile on phone viewports" }).toContain("#mobile?demo=review&name=Codex"); await expect(page.locator(".na-join")).toBeVisible({ timeout: 30_000 }); - await expect(page.locator(".na-join")).toContainText(/Let agents commit edits/i); - await page.getByRole("button", { name: /Continue with review-every-edit/i }).click(); + await expect(page.locator(".na-join")).toContainText(/Create this sample room/i); + await page.getByTestId("mobile-sample-confirm").click(); const app = page.locator(".na-app"); await expect(app).toBeVisible({ timeout: 45_000 }); @@ -164,9 +163,16 @@ test.describe("#mobile - terra surface renders (live Convex room)", () => { expect(page.url(), "live mobile proof must still avoid memory mode after room creation").not.toContain("mode=memory"); await expect(app).toHaveCSS("background-color", "rgb(251, 244, 231)"); await expect(app).toContainText(/Startup Banking Diligence War Room/i); + await expect(page.getByTestId("mobile-sample-banner")).toContainText(/Sample (workspace|still loading)/i); await expect(page.locator('[data-testid="ao-room"]')).toHaveCount(0); - await expect(app).toContainText(/1 person is & 1 agent here/i); - await page.getByRole("button", { name: /Got it/i }).click(); + const firstJoin = page.getByTestId("gap-firstjoin"); + await expect(firstJoin).toContainText(/1 person is & 1 agent here/i); + await expect(firstJoin).not.toHaveAttribute("aria-modal", "true"); + const dockInput = page.locator(".na-dock-input"); + await expect(dockInput).toBeVisible(); + await dockInput.fill("mobile onboarding is non-blocking"); + await expect(dockInput).toHaveValue("mobile onboarding is non-blocking"); + await firstJoin.getByRole("button", { name: /Dismiss first-join welcome/i }).click(); await expect(app).not.toContainText(/1 person is & 1 agent here/i); const metrics = await page.evaluate(() => { @@ -183,8 +189,8 @@ test.describe("#mobile - terra surface renders (live Convex room)", () => { }); expect(metrics).toMatchObject({ hasNaApp: true, - bgApp: "#FBF4E7", - accentPrimary: "#C56A3C", + bgApp: "#fbf4e7", + accentPrimary: "#9f4f2a", }); expect(metrics.overflowX, "mobile live room should not horizontally overflow").toBeLessThanOrEqual(1); diff --git a/e2e/mobile-terracotta-contract.spec.ts b/e2e/mobile-terracotta-contract.spec.ts new file mode 100644 index 00000000..7efc60f2 --- /dev/null +++ b/e2e/mobile-terracotta-contract.spec.ts @@ -0,0 +1,249 @@ +import { expect, test, type Page } from "@playwright/test"; + +const viewports = [ + { width: 320, height: 568 }, + { width: 375, height: 812 }, + { width: 390, height: 844 }, + { width: 430, height: 932 }, +] as const; + +const longRoomTitle = "Governed diligence room with an_unbroken_identifier_that_must_not_move_review_or_overflow"; + +async function visibleButtonMetrics(page: Page, rootSelector: string) { + return page.locator(rootSelector).evaluate((root) => { + const visible = (element: Element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const controls = Array.from(root.querySelectorAll("button, [role='button'], [role='menuitem']")) + .filter(visible) + .map((element) => { + const rect = element.getBoundingClientRect(); + return { + label: (element.getAttribute("aria-label") || element.textContent || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 100), + className: element.className, + width: Math.round(rect.width * 10) / 10, + height: Math.round(rect.height * 10) / 10, + }; + }); + return { + controls, + smallControls: controls.filter((control) => control.width < 44 || control.height < 44), + overflowX: root.scrollWidth - root.clientWidth, + }; + }); +} + +for (const theme of ["light", "dark"] as const) { + for (const viewport of viewports) { + test(`${theme} production shell ${viewport.width}x${viewport.height}`, async ({ page }, testInfo) => { + await page.setViewportSize(viewport); + await page.addInitScript(({ dark }) => { + localStorage.setItem("noderoom:mobile:tweaks:v2", JSON.stringify({ dark })); + }, { dark: theme === "dark" }); + await page.goto("/#mobile?mode=memory", { waitUntil: "domcontentloaded" }); + + const app = page.locator(".na-app"); + const header = page.getByTestId("mobile-header"); + const room = page.getByTestId("mobile-room-context"); + const roomTitle = page.getByTestId("mobile-room-title"); + const review = page.getByTestId("mobile-review-action"); + const overflow = page.getByTestId("mobile-overflow-action"); + const navigation = page.getByTestId("mobile-bottom-nav"); + await expect(app).toBeVisible({ timeout: 30_000 }); + await expect(app).toHaveAttribute("data-theme", theme); + await expect(page.locator('.na-ios-bleed[data-device-preview="false"]')).toHaveCount(1); + await expect(page.locator(".na-preview-status, .na-preview-island, .na-preview-home-indicator")).toHaveCount(0); + await expect(header).toBeVisible(); + await expect(page.getByTestId("mobile-review-badge")).toHaveText("4"); + await expect(page.locator(".na-fab-badge")).toHaveCount(0); + await expect(navigation).toBeVisible(); + await expect(navigation.locator(".na-nav-item")).toHaveCount(6); + + await roomTitle.evaluate((element, title) => { + element.textContent = title; + }, longRoomTitle); + + const metrics = await page.evaluate(() => { + const box = (selector: string) => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height }; + }; + const mobile = document.querySelector(".na-app"); + const rootStyle = mobile ? getComputedStyle(mobile) : null; + return { + viewport: { width: window.innerWidth, height: window.innerHeight }, + overflowX: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - window.innerWidth, + appOverflowX: mobile ? mobile.scrollWidth - mobile.clientWidth : null, + header: box('[data-testid="mobile-header"]'), + room: box('[data-testid="mobile-room-context"]'), + review: box('[data-testid="mobile-review-action"]'), + overflow: box('[data-testid="mobile-overflow-action"]'), + tokens: rootStyle ? { + bg: rootStyle.getPropertyValue("--mobile-bg-app").trim(), + accent: rootStyle.getPropertyValue("--mobile-accent").trim(), + attention: rootStyle.getPropertyValue("--mobile-attention").trim(), + } : null, + }; + }); + + expect(metrics.overflowX).toBeLessThanOrEqual(1); + expect(metrics.appOverflowX).toBeLessThanOrEqual(1); + expect(metrics.header?.height).toBe(52); + for (const target of [metrics.room, metrics.review, metrics.overflow]) { + expect(target?.height).toBeGreaterThanOrEqual(44); + expect(target?.left).toBeGreaterThanOrEqual(0); + expect(target?.right).toBeLessThanOrEqual(viewport.width); + } + expect(metrics.room!.right).toBeLessThanOrEqual(metrics.review!.left); + // Light terracotta uses the contrast-safe production ink; dark keeps the + // brighter prototype accent against the dark surface. + expect(metrics.tokens?.accent).toBe(theme === "dark" ? "#d97757" : "#9f4f2a"); + expect(metrics.tokens?.attention).not.toBe(metrics.tokens?.accent); + const navigationMetrics = await visibleButtonMetrics(page, '[data-testid="mobile-bottom-nav"]'); + expect(navigationMetrics.overflowX).toBeLessThanOrEqual(1); + expect(navigationMetrics.smallControls).toEqual([]); + + await overflow.click(); + const menu = page.getByTestId("mobile-overflow-menu"); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Agent jobs" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "People" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Trace" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Share" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Settings" })).toBeVisible(); + const menuMetrics = await menu.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + itemHeights: Array.from(element.querySelectorAll('[role="menuitem"]')).map((item) => item.getBoundingClientRect().height), + }; + }); + expect(menuMetrics.left).toBeGreaterThanOrEqual(0); + expect(menuMetrics.right).toBeLessThanOrEqual(viewport.width); + expect(menuMetrics.itemHeights.every((height) => height >= 44)).toBe(true); + + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await app.evaluate((element) => element.style.setProperty("--mobile-safe-top", "24px")); + const safeTop = await header.evaluate((element) => element.getBoundingClientRect().top); + expect(safeTop).toBe(24); + await overflow.evaluate((element) => (element as HTMLElement).blur()); + + const longTitleScreenshotPath = testInfo.outputPath(`mobile-long-title-${theme}-${viewport.width}x${viewport.height}.png`); + await page.screenshot({ path: longTitleScreenshotPath, fullPage: false }); + await testInfo.attach("mobile-long-title-screenshot", { path: longTitleScreenshotPath, contentType: "image/png" }); + await roomTitle.evaluate((element) => { element.textContent = "Q3 Diligence"; }); + await expect(roomTitle).toHaveText("Q3 Diligence"); + + const screenshotPath = testInfo.outputPath(`mobile-${theme}-${viewport.width}x${viewport.height}.png`); + await page.screenshot({ path: screenshotPath, fullPage: false }); + await testInfo.attach("mobile-contract-metrics", { + body: JSON.stringify({ ...metrics, navigation: navigationMetrics }, null, 2), + contentType: "application/json", + }); + await testInfo.attach("mobile-contract-screenshot", { path: screenshotPath, contentType: "image/png" }); + }); + } +} + +test("synthetic device chrome is explicit preview-only", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 1000 }); + await page.goto("/#mobile?mode=memory&preview=device", { waitUntil: "domcontentloaded" }); + + const frame = page.getByTestId("mobile-device-preview"); + await expect(frame).toBeVisible({ timeout: 30_000 }); + await expect(frame).toHaveAttribute("data-device-preview", "true"); + await expect(page.locator(".na-preview-status")).toHaveCount(1); + await expect(page.locator(".na-preview-island")).toHaveCount(1); + await expect(page.locator(".na-preview-home-indicator")).toHaveCount(1); + await expect(page.locator('.na-ios-bleed[data-device-preview="false"]')).toHaveCount(0); + const box = await frame.boundingBox(); + expect(box?.width).toBe(402); + expect(box?.height).toBe(874); +}); + +test("governed deck keeps thumbnails, preview, and review controls as mobile siblings", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/#mobile?mode=memory", { waitUntil: "domcontentloaded" }); + await expect(page.locator(".na-app")).toBeVisible({ timeout: 30_000 }); + + const deckCard = page.locator('.na-rcard[data-kind="deck"]'); + await expect(deckCard).toHaveCount(1); + await deckCard.click(); + + const sheet = page.locator('.na-sheet[data-open="true"]'); + await expect(sheet).toBeVisible(); + await expect(sheet.locator(".na-sheet-body > .na-thumbs")).toBeVisible(); + await expect(sheet.locator(".na-sheet-body > .na-slide-toolbar")).toBeVisible(); + await expect(sheet.locator(".na-sheet-body > .na-slidewrap iframe.na-slide")).toBeVisible(); + await expect(sheet.locator(".na-thumbs > .na-slide-toolbar, .na-thumbs > .na-slidewrap")).toHaveCount(0); + + const metrics = await visibleButtonMetrics(page, '.na-sheet[data-open="true"]'); + expect(metrics.overflowX).toBeLessThanOrEqual(1); + expect(metrics.smallControls).toEqual([]); + await expect.poll(() => sheet.evaluate((element) => getComputedStyle(element).transform)).toBe("matrix(1, 0, 0, 1, 0, 0)"); + + const screenshotPath = testInfo.outputPath("mobile-governed-deck-390x844.png"); + await page.screenshot({ path: screenshotPath, fullPage: false }); + await testInfo.attach("mobile-governed-deck-metrics", { + body: JSON.stringify(metrics, null, 2), + contentType: "application/json", + }); + await testInfo.attach("mobile-governed-deck", { path: screenshotPath, contentType: "image/png" }); +}); + +test("review and secondary mobile surfaces keep tappable controls", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/#mobile?mode=memory", { waitUntil: "domcontentloaded" }); + await expect(page.locator(".na-app")).toBeVisible({ timeout: 30_000 }); + + await page.getByTestId("mobile-review-action").click(); + await expect(page.locator(".na-viewtoggle")).toBeVisible(); + const reviewMetrics = await visibleButtonMetrics(page, ".na-app"); + expect(reviewMetrics.overflowX).toBeLessThanOrEqual(1); + expect(reviewMetrics.smallControls).toEqual([]); + + const surfaces: Array<{ label: string; sheet: string }> = [ + { label: "Agent jobs", sheet: "Agent jobs" }, + { label: "People", sheet: "Manage people" }, + { label: "Room activity", sheet: "Agents" }, + { label: "Usage", sheet: "Spend today" }, + { label: "Trace", sheet: "Room trace" }, + { label: "Share", sheet: "Share room" }, + { label: "Settings", sheet: "Settings" }, + ]; + const receipts: Record>> = {}; + + for (const surface of surfaces) { + await page.getByTestId("mobile-overflow-action").click(); + const menu = page.getByTestId("mobile-overflow-menu"); + await expect(menu).toBeVisible(); + await menu.getByRole("menuitem", { name: surface.label }).click(); + + const sheet = page.locator('.na-sheet[data-open="true"]'); + await expect(sheet).toBeVisible(); + await expect(sheet).toContainText(surface.sheet); + const metrics = await visibleButtonMetrics(page, '.na-sheet[data-open="true"]'); + receipts[surface.label] = metrics; + expect(metrics.overflowX, surface.label).toBeLessThanOrEqual(1); + expect(metrics.smallControls, surface.label).toEqual([]); + + const close = sheet.getByRole("button", { name: "Close", exact: true }); + await expect(close).toHaveCount(1); + await close.click(); + await expect(sheet).toBeHidden(); + } + + await testInfo.attach("mobile-secondary-surface-metrics", { + body: JSON.stringify({ review: reviewMetrics, surfaces: receipts }, null, 2), + contentType: "application/json", + }); +}); diff --git a/index.html b/index.html index f0f8a659..05fa7da4 100644 --- a/index.html +++ b/index.html @@ -54,10 +54,37 @@ })(); + + +

+
+

NodeRoom deck preview

+

${escapeHtml(storyboard.title)}

+

${escapeHtml(storyboard.objective)}

+
+ plan ${escapeHtml(storyboard.planHash)} + ${storyboard.slides.length} slides + ${needsReviewCount} review items + ${storyboard.traceIds.length} traces +
+
+ ${slideHtml(storyboard)} +
+ +`; + + return { + exportVersion: 1, + deckId: storyboard.deckId, + planHash: storyboard.planHash, + title: storyboard.title, + generatedAt, + slideCount: storyboard.slides.length, + needsReviewCount, + sourceArtifactIds: storyboard.sourceArtifactIds, + traceIds: storyboard.traceIds, + proposalIds: storyboard.proposalIds, + integrityHash, + html, + }; +} diff --git a/src/ui/workArtifacts/deckStoryboard.ts b/src/ui/workArtifacts/deckStoryboard.ts new file mode 100644 index 00000000..e6f3c289 --- /dev/null +++ b/src/ui/workArtifacts/deckStoryboard.ts @@ -0,0 +1,286 @@ +import type { Artifact, CellEvidence, CellPayload, Proposal, TraceEvent } from "../../engine/types"; +import type { DeckArtifactInput, DeckStoryboardSection, WorkArtifactStatus } from "./workArtifactTypes"; + +export type DeckClaimStatus = "verified" | "manual" | "needs_review"; +export type DeckStoryboardStatus = "draft" | "approved" | "needs_review"; + +export interface DeckStoryboardClaim { + claimId: string; + text: string; + status: DeckClaimStatus; + sourceArtifactId?: string; + traceId?: string; + proposalId?: string; + evidenceId?: string; +} + +export interface DeckSlidePlan { + slideId: string; + title: string; + purpose: string; + claims: DeckStoryboardClaim[]; + sourceArtifactIds: string[]; + evidenceIds: string[]; + unresolvedGaps: string[]; + speakerNote?: string; + status: DeckStoryboardStatus; +} + +export interface DeckStoryboard { + deckId: string; + roomId: string; + title: string; + audience: string; + objective: string; + privacy: "room" | "private" | "public"; + storyboardStatus: DeckStoryboardStatus; + slides: DeckSlidePlan[]; + requiredEvidence: string[]; + unresolvedGaps: string[]; + sourceArtifactIds: string[]; + traceIds: string[]; + proposalIds: string[]; + planHash: string; + version: number; +} + +export interface BuildDeckStoryboardInput { + roomId: string; + roomTitle?: string; + artifacts: Artifact[]; + traces?: TraceEvent[]; + proposals?: Proposal[]; + audience?: string; + objective?: string; + maxSlides?: number; +} + +function isCellPayload(value: unknown): value is CellPayload { + return typeof value === "object" && value !== null && ("status" in value || "evidence" in value || "error" in value); +} + +function payloadEvidence(value: unknown): CellEvidence[] { + return isCellPayload(value) && Array.isArray(value.evidence) ? value.evidence : []; +} + +function decodeHtml(text: string): string { + return text + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, code: string) => String.fromCharCode(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCharCode(parseInt(code, 16))); +} + +function stripHtml(html: string): string { + return decodeHtml(html.replace(//gi, "").replace(//gi, "").replace(/<[^>]+>/g, " ")) + .replace(/\s+/g, " ") + .trim(); +} + +function valueText(value: unknown): string { + if (typeof value === "string") { + const text = value.trim(); + return text.startsWith("<") ? stripHtml(text) : text; + } + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (isCellPayload(value)) return valueText(value.value); + if (typeof value === "object" && value !== null && "text" in value && typeof value.text === "string") return value.text.trim(); + return ""; +} + +function artifactTextSignals(artifact: Artifact, cap = 4): string[] { + const ids = artifact.order.length ? artifact.order : Object.keys(artifact.elements); + const seen = new Set(); + const values: string[] = []; + for (const id of ids) { + const text = valueText(artifact.elements[id]?.value).replace(/\s+/g, " ").trim(); + if (!text || text.length < 4 || seen.has(text.toLowerCase())) continue; + seen.add(text.toLowerCase()); + values.push(text.slice(0, 140)); + if (values.length >= cap) break; + } + return values; +} + +function artifactEvidence(artifact: Artifact): CellEvidence[] { + return Object.values(artifact.elements).flatMap((element) => payloadEvidence(element.value)); +} + +function artifactGaps(artifact: Artifact): string[] { + return Object.values(artifact.elements) + .map((element) => { + const text = valueText(element.value); + const status = isCellPayload(element.value) ? element.value.status : undefined; + if (status === "needs_review" || status === "gap" || status === "failed") return text || `${element.id} needs review`; + if (/\b(needs[_\s-]?review|todo|tbd|unknown|gap|missing source|unsupported)\b/i.test(text)) return text; + return ""; + }) + .filter(Boolean) + .slice(0, 8); +} + +function stableId(input: string): string { + return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "item"; +} + +function unique(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))]; +} + +function simpleHash(value: unknown): string { + const text = JSON.stringify(value); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function claimFromArtifact(args: { + artifact: Artifact; + text: string; + evidence?: CellEvidence; + trace?: TraceEvent; + proposal?: Proposal; + index: number; +}): DeckStoryboardClaim { + const status: DeckClaimStatus = args.evidence ? "verified" : args.proposal ? "needs_review" : "manual"; + return { + claimId: `claim-${stableId(args.artifact.id)}-${args.index}`, + text: args.text, + status, + sourceArtifactId: args.artifact.id, + traceId: args.trace?.id, + proposalId: args.proposal?.id, + evidenceId: args.evidence?.id, + }; +} + +function slideStatus(claims: DeckStoryboardClaim[], gaps: string[]): DeckStoryboardStatus { + if (gaps.length > 0 || claims.some((claim) => claim.status === "needs_review")) return "needs_review"; + return "draft"; +} + +function deckStatusFromSlides(slides: DeckSlidePlan[]): DeckStoryboardStatus { + return slides.some((slide) => slide.status === "needs_review" || slide.claims.some((claim) => claim.status !== "verified")) ? "needs_review" : "draft"; +} + +export function buildDeckStoryboardFromRoom(input: BuildDeckStoryboardInput): DeckStoryboard { + const traces = input.traces ?? []; + const proposals = input.proposals ?? []; + const artifacts = input.artifacts; + const slideLimit = Math.max(1, input.maxSlides ?? 5); + const sourceArtifacts = artifacts.slice(0, slideLimit); + + const slides: DeckSlidePlan[] = sourceArtifacts.map((artifact, artifactIndex) => { + const evidence = artifactEvidence(artifact); + const gaps = artifactGaps(artifact); + const relatedTrace = traces.find((trace) => trace.refs?.artifactId === artifact.id); + const relatedProposal = proposals.find((proposal) => proposal.artifactId === artifact.id); + const signals = artifactTextSignals(artifact, 3); + const claims = (signals.length ? signals : [artifact.meta?.summary ?? `${artifact.title} is part of the room evidence.`]) + .map((text, index) => claimFromArtifact({ + artifact, + text, + evidence: evidence[index], + trace: relatedTrace, + proposal: relatedProposal, + index, + })); + + return { + slideId: `slide-${artifactIndex + 1}-${stableId(artifact.title)}`, + title: artifact.title, + purpose: artifact.kind === "sheet" + ? "Summarize structured findings and unresolved cells." + : artifact.kind === "note" + ? "Convert written analysis into presentation narrative." + : "Summarize room decisions and open work.", + claims, + sourceArtifactIds: [artifact.id], + evidenceIds: unique(evidence.map((item) => item.id)), + unresolvedGaps: gaps, + speakerNote: relatedTrace?.summary, + status: slideStatus(claims, gaps), + }; + }); + + if (slides.length === 0) { + slides.push({ + slideId: "slide-1-empty-room", + title: "Room readout", + purpose: "Capture the room objective before evidence is available.", + claims: [], + sourceArtifactIds: [], + evidenceIds: [], + unresolvedGaps: ["No artifacts are available yet."], + status: "needs_review", + }); + } + + const requiredEvidence = slides.flatMap((slide) => + slide.claims + .filter((claim) => claim.status !== "verified") + .map((claim) => `${slide.title}: ${claim.text}`), + ); + const unresolvedGaps = unique([...slides.flatMap((slide) => slide.unresolvedGaps), ...requiredEvidence]).slice(0, 12); + const sourceArtifactIds = unique(slides.flatMap((slide) => slide.sourceArtifactIds)); + const traceIds = unique([...traces.map((trace) => trace.id), ...slides.flatMap((slide) => slide.claims.map((claim) => claim.traceId))]); + const proposalIds = unique([...proposals.map((proposal) => proposal.id), ...slides.flatMap((slide) => slide.claims.map((claim) => claim.proposalId))]); + const storyboardStatus = deckStatusFromSlides(slides); + const draft = { + roomId: input.roomId, + title: `${input.roomTitle ?? "NodeRoom"} readout`, + slides, + sourceArtifactIds, + traceIds, + proposalIds, + unresolvedGaps, + }; + + return { + deckId: `${input.roomId}:storyboard`, + roomId: input.roomId, + title: `${input.roomTitle ?? "NodeRoom"} readout`, + audience: input.audience ?? "room reviewers", + objective: input.objective ?? "Turn room evidence into a reviewable, source-backed narrative.", + privacy: "room", + storyboardStatus, + slides, + requiredEvidence, + unresolvedGaps, + sourceArtifactIds, + traceIds, + proposalIds, + planHash: simpleHash(draft), + version: 1, + }; +} + +export function deckArtifactInputFromStoryboard(storyboard: DeckStoryboard): DeckArtifactInput { + const sections: DeckStoryboardSection[] = storyboard.slides.map((slide) => ({ + id: slide.slideId, + title: slide.title, + claimCount: slide.claims.length, + evidenceCount: slide.evidenceIds.length, + unresolvedCount: slide.unresolvedGaps.length + slide.claims.filter((claim) => claim.status !== "verified").length, + })); + const status: WorkArtifactStatus = storyboard.storyboardStatus === "needs_review" || storyboard.requiredEvidence.length > 0 ? "needs_review" : "ready"; + return { + id: storyboard.deckId, + roomId: storyboard.roomId, + title: storyboard.title, + status, + version: storyboard.version, + storyboardStatus: storyboard.storyboardStatus, + sections, + traceIds: storyboard.traceIds, + sourceIds: storyboard.sourceArtifactIds, + proposalIds: storyboard.proposalIds, + }; +} diff --git a/src/ui/workArtifacts/graphRelationshipReview.ts b/src/ui/workArtifacts/graphRelationshipReview.ts new file mode 100644 index 00000000..4f68bf7e --- /dev/null +++ b/src/ui/workArtifacts/graphRelationshipReview.ts @@ -0,0 +1,194 @@ +import type { SemanticGraphEdge, SemanticGraphEdgeKind, SemanticGraphRef, SemanticGraphStatus, SemanticGraphViewModel } from "../graph/semanticGraphTypes"; + +export type GraphRelationshipReviewStatus = "confirmed" | "needs_confirmation"; + +export interface GraphRelationshipReviewItem { + relationshipId: string; + edgeId: string; + edgeKind: SemanticGraphEdgeKind; + graphStatus: SemanticGraphStatus; + reviewStatus: GraphRelationshipReviewStatus; + sourceNodeId: string; + targetNodeId: string; + sourceLabel: string; + targetLabel: string; + relationshipLabel: string; + reason: string; + confirmationText: string; + sourceArtifactIds: string[]; + proposalIds: string[]; + traceIds: string[]; + evidenceIds: string[]; + sourceUrls: string[]; + refs: SemanticGraphRef[]; + weight: number; +} + +export interface GraphRelationshipReviewPlan { + reviewVersion: 1; + graphId: string; + nodeCount: number; + edgeCount: number; + relationshipCount: number; + confirmedCount: number; + needsConfirmationCount: number; + sourceArtifactIds: string[]; + proposalIds: string[]; + traceIds: string[]; + evidenceIds: string[]; + sourceUrls: string[]; + integrityHash: string; + items: GraphRelationshipReviewItem[]; +} + +function stableId(input: string): string { + return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "item"; +} + +function unique(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))]; +} + +function uniqueRefs(refs: SemanticGraphRef[]): SemanticGraphRef[] { + const seen = new Set(); + const result: SemanticGraphRef[] = []; + for (const ref of refs) { + const key = JSON.stringify(ref); + if (seen.has(key)) continue; + seen.add(key); + result.push(ref); + } + return result; +} + +function simpleHash(value: unknown): string { + const text = JSON.stringify(value); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function reviewStatus(edge: SemanticGraphEdge, refs: SemanticGraphRef[]): GraphRelationshipReviewStatus { + if (edge.status === "needs_review" || edge.status === "failed" || edge.status === "rejected") return "needs_confirmation"; + if (edge.kind === "reviewed" || edge.kind === "proposed" || edge.kind === "blocked") return "needs_confirmation"; + if (edge.status === "source_backed" || edge.kind === "supported_by" || edge.kind === "cited") return "confirmed"; + if (edge.kind === "derived_from" && refs.some((ref) => ref.artifactId || ref.evidenceId || ref.traceId || ref.sourceUrl)) return "confirmed"; + return "needs_confirmation"; +} + +function reasonFor(status: GraphRelationshipReviewStatus, edge: SemanticGraphEdge): string { + if (status === "confirmed") { + if (edge.kind === "supported_by") return "The relationship is backed by a cited evidence node."; + if (edge.kind === "derived_from") return "The relationship is derived from an existing source, artifact, trace, or receipt ref."; + return "The graph relationship carries a source-backed status."; + } + if (edge.kind === "reviewed" || edge.kind === "proposed") return "A proposal or review edge still needs human confirmation."; + if (edge.kind === "blocked") return "A blocker edge needs reviewer confirmation before it should be treated as resolved."; + return "This inferred graph relationship is not source-backed yet."; +} + +function confirmationText(status: GraphRelationshipReviewStatus, source: string, label: string, target: string): string { + if (status === "confirmed") return `${source} ${label} ${target} is confirmed by the current graph refs.`; + return `Confirm, reject, or source-back this relationship: ${source} ${label} ${target}.`; +} + +function refsFromItem(edge: SemanticGraphEdge, graph: SemanticGraphViewModel): SemanticGraphRef[] { + const source = graph.nodes.find((node) => node.id === edge.source); + const target = graph.nodes.find((node) => node.id === edge.target); + return uniqueRefs([...(source?.refs ?? []), ...(target?.refs ?? []), ...edge.refs]); +} + +export function buildGraphRelationshipReviewPlan(graph: SemanticGraphViewModel, graphId = "semantic-graph"): GraphRelationshipReviewPlan { + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + const items = graph.edges + .map((edge, index) => { + const source = nodesById.get(edge.source); + const target = nodesById.get(edge.target); + if (!source || !target) return null; + const refs = refsFromItem(edge, graph); + const status = reviewStatus(edge, refs); + const sourceLabel = source.label; + const targetLabel = target.label; + const item: GraphRelationshipReviewItem = { + relationshipId: `rel-${index + 1}-${stableId(edge.id)}`, + edgeId: edge.id, + edgeKind: edge.kind, + graphStatus: edge.status, + reviewStatus: status, + sourceNodeId: source.id, + targetNodeId: target.id, + sourceLabel, + targetLabel, + relationshipLabel: edge.label, + reason: reasonFor(status, edge), + confirmationText: confirmationText(status, sourceLabel, edge.label, targetLabel), + sourceArtifactIds: unique(refs.map((ref) => ref.artifactId)), + proposalIds: unique(refs.map((ref) => ref.proposalId)), + traceIds: unique(refs.map((ref) => ref.traceId)), + evidenceIds: unique(refs.map((ref) => ref.evidenceId)), + sourceUrls: unique(refs.map((ref) => ref.sourceUrl)), + refs, + weight: edge.weight, + }; + return item; + }) + .filter((item): item is GraphRelationshipReviewItem => Boolean(item)) + .sort((a, b) => { + const statusScore = (a.reviewStatus === "needs_confirmation" ? 0 : 1) - (b.reviewStatus === "needs_confirmation" ? 0 : 1); + if (statusScore !== 0) return statusScore; + if (b.weight !== a.weight) return b.weight - a.weight; + return `${a.sourceLabel} ${a.relationshipLabel} ${a.targetLabel}`.localeCompare(`${b.sourceLabel} ${b.relationshipLabel} ${b.targetLabel}`); + }); + + const digest = items.map((item) => ({ + edgeId: item.edgeId, + edgeKind: item.edgeKind, + reviewStatus: item.reviewStatus, + sourceNodeId: item.sourceNodeId, + targetNodeId: item.targetNodeId, + sourceArtifactIds: item.sourceArtifactIds, + proposalIds: item.proposalIds, + traceIds: item.traceIds, + evidenceIds: item.evidenceIds, + sourceUrls: item.sourceUrls, + })); + const integrityHash = simpleHash({ + graphId, + nodeCount: graph.nodes.length, + edgeCount: graph.edges.length, + digest, + }); + + return { + reviewVersion: 1, + graphId, + nodeCount: graph.nodes.length, + edgeCount: graph.edges.length, + relationshipCount: items.length, + confirmedCount: items.filter((item) => item.reviewStatus === "confirmed").length, + needsConfirmationCount: items.filter((item) => item.reviewStatus === "needs_confirmation").length, + sourceArtifactIds: unique(items.flatMap((item) => item.sourceArtifactIds)), + proposalIds: unique(items.flatMap((item) => item.proposalIds)), + traceIds: unique(items.flatMap((item) => item.traceIds)), + evidenceIds: unique(items.flatMap((item) => item.evidenceIds)), + sourceUrls: unique(items.flatMap((item) => item.sourceUrls)), + integrityHash, + items, + }; +} + +export function graphRelationshipReviewJson(plan: GraphRelationshipReviewPlan): string { + return `${JSON.stringify(plan, null, 2)}\n`; +} + +export function graphRelationshipReviewFileName(graphId: string, integrityHash: string): string { + const slug = stableId(graphId).slice(0, 72) || "semantic-graph"; + return `${slug}-relationship-review-${integrityHash}.json`; +} + +export function graphRelationshipReviewMimeType(): string { + return "application/json;charset=utf-8"; +} diff --git a/src/ui/workArtifacts/index.ts b/src/ui/workArtifacts/index.ts new file mode 100644 index 00000000..82bb30f9 --- /dev/null +++ b/src/ui/workArtifacts/index.ts @@ -0,0 +1,23 @@ +export * from "./deckStoryboard"; +export * from "./collaborativeDeck"; +export * from "./deckPatchPlan"; +export * from "./deckPreviewExport"; +export * from "./deckPptxExport"; +export * from "./deckPdfExport"; +export * from "./graphRelationshipReview"; +export * from "./GraphRelationshipReviewWorkbench"; +export * from "./livePerformanceSummary"; +export * from "./LivePerformanceCenter"; +export * from "./NotebookDigestWorkbench"; +export * from "./notebookExecutionPreview"; +export * from "./notebookKernelAdapter"; +export * from "./notebookStructure"; +export * from "./notebookPatchDiff"; +export * from "./notebookTypedBlocks"; +export * from "./ProposalReviewCenter"; +export * from "./proofBundleExport"; +export * from "./proofBundleReceipt"; +export * from "./traceReplaySummary"; +export * from "./TraceReplayWorkbench"; +export * from "./workArtifactAdapters"; +export * from "./workArtifactTypes"; diff --git a/src/ui/workArtifacts/livePerformanceSummary.ts b/src/ui/workArtifacts/livePerformanceSummary.ts new file mode 100644 index 00000000..93fedaca --- /dev/null +++ b/src/ui/workArtifacts/livePerformanceSummary.ts @@ -0,0 +1,144 @@ +import type { AgentJobAttemptTelemetry, AgentJobDetailTelemetry, AgentJobTelemetry, AgentRunTelemetry } from "../../app/store"; +import type { Message, TraceEvent } from "../../engine/types"; +import type { WorkArtifactStatus } from "./workArtifactTypes"; + +export interface LivePerformanceSummary { + roomId: string; + status: WorkArtifactStatus; + messageCount: number; + humanMessageCount: number; + agentMessageCount: number; + runCount: number; + traceEventCount: number; + agentTraceCount: number; + latestActivityAt?: number; + latestAgentText?: string; + job?: { + id: string; + status: string; + runtime?: string; + modelPolicy: string; + attempts: number; + maxAttempts: number; + stopReason?: string; + nextRunAt?: number; + toolCallCount?: number; + modelCallCount?: number; + receiptCount?: number; + }; + run?: AgentRunTelemetry; + attempts: Array<{ + attempt: number; + status: string; + resolvedModel: string; + stopReason: string; + ms: number; + inputTokens: number; + outputTokens: number; + costUsd: number; + }>; + detailCounts: { + operations: number; + streamEvents: number; + streamParts: number; + reasoningFrames: number; + receipts: number; + leases: number; + draftOperations: number; + latestSteps: number; + }; +} + +const AGENT_RUN_CLIENT_MSG_ID_RE = /^(?:pubstream|privstream|final|plan-blocked)-(.+)$/; + +function unique(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))].sort(); +} + +function agentRunId(message: Message): string | null { + if (message.author.kind !== "agent") return null; + const match = AGENT_RUN_CLIENT_MSG_ID_RE.exec(message.clientMsgId ?? ""); + return match ? match[1] : null; +} + +function summaryStatus(job: AgentJobTelemetry | null | undefined, attempts: AgentJobAttemptTelemetry[], traces: TraceEvent[]): WorkArtifactStatus { + const jobStatus = job?.status.toLowerCase(); + if (jobStatus && /\b(failed|error)\b/.test(jobStatus)) return "failed"; + if (jobStatus && /\b(running|queued|pending|paused|scheduled)\b/.test(jobStatus)) return "running"; + if (attempts.some((attempt) => /\b(failed|error)\b/i.test(attempt.status))) return "needs_review"; + if (traces.some((trace) => /\b(failed|blocked|denied|conflict|error)\b/i.test(`${trace.type} ${trace.summary} ${trace.detail ?? ""}`))) return "needs_review"; + return traces.length || job || attempts.length ? "ready" : "empty"; +} + +function latestAt(messages: Message[], traces: TraceEvent[], job?: AgentJobTelemetry | null): number | undefined { + const values = [ + ...messages.map((message) => message.createdAt), + ...traces.map((trace) => trace.ts), + job?.updatedAt, + ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + return values.length ? Math.max(...values) : undefined; +} + +export function buildLivePerformanceSummary(args: { + roomId: string; + messages: Message[]; + traces: TraceEvent[]; + run?: AgentRunTelemetry | null; + job?: AgentJobTelemetry | null; + attempts?: AgentJobAttemptTelemetry[]; + detail?: AgentJobDetailTelemetry | null; +}): LivePerformanceSummary { + const messages = [...args.messages].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)); + const traces = [...args.traces].sort((a, b) => a.ts - b.ts || a.id.localeCompare(b.id)); + const agentMessages = messages.filter((message) => message.author.kind === "agent"); + const attempts = args.attempts ?? []; + const detail = args.detail ?? null; + const job = args.job ?? null; + + return { + roomId: args.roomId, + status: summaryStatus(job, attempts, traces), + messageCount: messages.length, + humanMessageCount: messages.filter((message) => message.author.kind !== "agent").length, + agentMessageCount: agentMessages.length, + runCount: unique(agentMessages.map(agentRunId)).length, + traceEventCount: traces.length, + agentTraceCount: traces.filter((trace) => trace.actor.kind === "agent" || trace.type.startsWith("agent_")).length, + latestActivityAt: latestAt(messages, traces, job), + latestAgentText: agentMessages[agentMessages.length - 1]?.text, + job: job ? { + id: job.id, + status: job.status, + runtime: job.runtime, + modelPolicy: job.modelPolicy, + attempts: job.attempts, + maxAttempts: job.maxAttempts, + stopReason: job.stopReason, + nextRunAt: job.nextRunAt, + toolCallCount: job.toolCallCount, + modelCallCount: job.modelCallCount, + receiptCount: job.receiptCount, + } : undefined, + run: args.run ?? undefined, + attempts: attempts.map((attempt) => ({ + attempt: attempt.attempt, + status: attempt.status, + resolvedModel: attempt.resolvedModel, + stopReason: attempt.stopReason, + ms: attempt.ms, + inputTokens: attempt.inputTokens, + outputTokens: attempt.outputTokens, + costUsd: attempt.costUsd, + })), + detailCounts: { + operations: detail?.operations.length ?? 0, + streamEvents: detail?.streamEvents.length ?? 0, + streamParts: detail?.streamParts.length ?? 0, + reasoningFrames: detail?.reasoningFrames.length ?? 0, + receipts: detail?.receipts.length ?? 0, + leases: detail?.leases.length ?? 0, + draftOperations: detail?.draftOperations.length ?? 0, + latestSteps: detail?.latestSteps.length ?? 0, + }, + }; +} diff --git a/src/ui/workArtifacts/notebookExecutionPreview.ts b/src/ui/workArtifacts/notebookExecutionPreview.ts new file mode 100644 index 00000000..b1f24c15 --- /dev/null +++ b/src/ui/workArtifacts/notebookExecutionPreview.ts @@ -0,0 +1,215 @@ +import type { NotebookArtifactStructure, NotebookBlockDigest } from "./notebookStructure"; +import { classifyNotebookTypedBlocks, type NotebookTypedBlockKind } from "./notebookTypedBlocks"; + +export type NotebookExecutionPreviewKind = Extract; +export type NotebookExecutionPreviewStatus = "ready" | "blocked"; + +export interface NotebookExecutionPreviewItem { + id: string; + blockId: string; + elementId: string; + index: number; + kind: NotebookExecutionPreviewKind; + status: NotebookExecutionPreviewStatus; + input: string; + result: string; + reason: string; + sourceIds: string[]; + traceIds: string[]; + proposalIds: string[]; +} + +export interface NotebookExecutionPreview { + previewVersion: 1; + artifactId: string; + executableCount: number; + readyCount: number; + blockedCount: number; + items: NotebookExecutionPreviewItem[]; +} + +type Token = { kind: "number" | "op" | "paren"; value: string }; + +function tokenizeExpression(input: string): Token[] | null { + const tokens: Token[] = []; + let index = 0; + while (index < input.length) { + const char = input[index]; + if (/\s/.test(char)) { + index += 1; + continue; + } + if (/[()+\-*/]/.test(char)) { + tokens.push({ kind: char === "(" || char === ")" ? "paren" : "op", value: char }); + index += 1; + continue; + } + const match = input.slice(index).match(/^\d+(?:\.\d+)?/); + if (!match) return null; + tokens.push({ kind: "number", value: match[0] }); + index += match[0].length; + } + return tokens.length ? tokens : null; +} + +function parseArithmetic(tokens: Token[]): number | null { + let index = 0; + const peek = () => tokens[index]; + const consume = () => tokens[index++]; + + const factor = (): number | null => { + const token = consume(); + if (!token) return null; + if (token.kind === "op" && token.value === "-") { + const value = factor(); + return value === null ? null : -value; + } + if (token.kind === "number") return Number(token.value); + if (token.kind === "paren" && token.value === "(") { + const value = expression(); + const close = consume(); + return close?.kind === "paren" && close.value === ")" ? value : null; + } + return null; + }; + + const term = (): number | null => { + let value = factor(); + while (value !== null && peek()?.kind === "op" && (peek().value === "*" || peek().value === "/")) { + const op = consume().value; + const right = factor(); + if (right === null) return null; + if (op === "/" && right === 0) return null; + value = op === "*" ? value * right : value / right; + } + return value; + }; + + function expression(): number | null { + let value = term(); + while (value !== null && peek()?.kind === "op" && (peek().value === "+" || peek().value === "-")) { + const op = consume().value; + const right = term(); + if (right === null) return null; + value = op === "+" ? value + right : value - right; + } + return value; + } + + const result = expression(); + return result !== null && index === tokens.length && Number.isFinite(result) ? result : null; +} + +function extractArithmetic(text: string): string | null { + const afterEquals = text.match(/=\s*([0-9().+\-*/\s]{3,})/); + const candidate = afterEquals?.[1] ?? text.match(/([0-9().+\-*/\s]*\d\s*[+\-*/]\s*\d[0-9().+\-*/\s]*)/)?.[1]; + if (!candidate) return null; + const compact = candidate.replace(/\s+/g, " ").trim(); + return /[+\-*/]/.test(compact) ? compact : null; +} + +function formatNumber(value: number): string { + if (Number.isInteger(value)) return String(value); + return value.toFixed(4).replace(/0+$/g, "").replace(/\.$/, ""); +} + +function calculationPreview(block: NotebookBlockDigest): Omit { + const expression = extractArithmetic(block.text); + if (!expression) { + return { + status: "blocked", + input: block.text, + result: "No safe arithmetic expression detected.", + reason: "calculation_without_expression", + }; + } + const tokens = tokenizeExpression(expression); + const result = tokens ? parseArithmetic(tokens) : null; + if (result === null) { + return { + status: "blocked", + input: expression, + result: "Expression needs review before execution.", + reason: "calculation_parse_blocked", + }; + } + return { + status: "ready", + input: expression, + result: formatNumber(result), + reason: "safe_arithmetic_preview", + }; +} + +function sqlPreview(block: NotebookBlockDigest): Omit { + const match = block.text.match(/\bselect\s+(.+?)\s+from\s+([a-z0-9_.-]+)/i); + if (!match) { + return { + status: "blocked", + input: block.text, + result: "SQL preview requires a SELECT ... FROM ... query.", + reason: "sql_shape_blocked", + }; + } + const columns = match[1].split(",").map((item) => item.trim()).filter(Boolean); + const table = match[2]; + return { + status: "ready", + input: match[0], + result: `Parsed ${columns.length || 1} column${columns.length === 1 ? "" : "s"} from ${table}.`, + reason: "sql_intent_parsed", + }; +} + +function chartPreview(block: NotebookBlockDigest): Omit { + const text = block.text.toLowerCase(); + const chartType = text.includes("bar") ? "bar" : text.includes("scatter") ? "scatter" : text.includes("line") || text.includes("trend") ? "line" : "chart"; + const hasSeries = /\b(by|vs|over|against)\b/.test(text) || block.sourceIds.length > 0; + return { + status: hasSeries ? "ready" : "blocked", + input: block.text, + result: hasSeries ? `${chartType} chart intent with ${block.sourceIds.length} source reference${block.sourceIds.length === 1 ? "" : "s"}.` : "Chart intent needs series/source context.", + reason: hasSeries ? "chart_intent_parsed" : "chart_series_blocked", + }; +} + +function itemPreview(kind: NotebookExecutionPreviewKind, block: NotebookBlockDigest) { + if (kind === "calculation") return calculationPreview(block); + if (kind === "sql") return sqlPreview(block); + return chartPreview(block); +} + +export function buildNotebookExecutionPreview(structure: NotebookArtifactStructure): NotebookExecutionPreview { + const typed = classifyNotebookTypedBlocks(structure); + const blocksById = new Map(structure.blocks.map((block) => [block.id, block])); + const executableKinds = new Set(["calculation", "sql", "chart"]); + const items = typed + .filter((block) => executableKinds.has(block.type)) + .map((typedBlock) => { + const block = blocksById.get(typedBlock.id); + if (!block) return null; + const kind = typedBlock.type as NotebookExecutionPreviewKind; + const preview = itemPreview(kind, block); + return { + id: `exec-${block.index + 1}-${block.id}`, + blockId: block.blockId ?? block.id, + elementId: block.elementId, + index: block.index, + kind, + sourceIds: block.sourceIds, + traceIds: block.traceIds, + proposalIds: block.proposalIds, + ...preview, + }; + }) + .filter((item): item is NotebookExecutionPreviewItem => Boolean(item)); + + return { + previewVersion: 1, + artifactId: structure.artifactId, + executableCount: items.length, + readyCount: items.filter((item) => item.status === "ready").length, + blockedCount: items.filter((item) => item.status === "blocked").length, + items, + }; +} diff --git a/src/ui/workArtifacts/notebookKernelAdapter.ts b/src/ui/workArtifacts/notebookKernelAdapter.ts new file mode 100644 index 00000000..1f0ba068 --- /dev/null +++ b/src/ui/workArtifacts/notebookKernelAdapter.ts @@ -0,0 +1,73 @@ +import type { Artifact, DataframeColumn } from "../../engine/types"; +import type { NotebookKernelResult, NotebookKernelScalar, NotebookKernelTable } from "../../notebook/notebookKernel"; + +export const NOTEBOOK_KERNEL_OUTPUT_PREFIX = "notebook_kernel:"; + +export type NotebookKernelStoredOutput = { + blockId: string; + input: string; + result: NotebookKernelResult; +}; + +export function buildNotebookKernelTables(artifacts: Artifact[]): Record { + const sheets = artifacts.filter((artifact) => artifact.kind === "sheet").slice(0, 6); + const tables: Record = {}; + for (const artifact of sheets) { + const table = tableFromArtifact(artifact); + if (!table) continue; + for (const alias of [artifact.title, artifact.id, slug(artifact.title)]) tables[slug(alias)] = table; + } + if (sheets.length === 1) { + const only = tables[slug(sheets[0].title)]; + if (only) for (const alias of ["data", "sheet", "diligence", "room_data"]) tables[alias] = only; + } + return tables; +} + +export function notebookKernelOutputElementId(blockId: string): string { + return `${NOTEBOOK_KERNEL_OUTPUT_PREFIX}${blockId}`; +} + +export function readNotebookKernelOutputs(artifact: Artifact): Record { + const outputs: Record = {}; + for (const [elementId, element] of Object.entries(artifact.elements)) { + if (!elementId.startsWith(NOTEBOOK_KERNEL_OUTPUT_PREFIX) || !isStoredOutput(element.value)) continue; + outputs[element.value.blockId] = element.value; + } + return outputs; +} + +function tableFromArtifact(artifact: Artifact): NotebookKernelTable | null { + const configured = artifact.meta?.dataframe?.columns ?? []; + const columns = configured.length ? [...configured].sort((a, b) => a.order - b.order) : inferColumns(artifact); + if (!columns.length) return null; + const rowIds = unique(Object.keys(artifact.elements).map((id) => id.includes("__") ? id.split("__", 1)[0] : undefined)); + const labels = unique(columns.map((column) => column.label || column.id)); + const rows = rowIds.slice(0, 500).map((rowId) => Object.fromEntries(columns.slice(0, 40).map((column) => [column.label || column.id, scalar(artifact.elements[`${rowId}__${column.id}`]?.value)]))); + return { name: artifact.title, columns: labels.slice(0, 40), rows }; +} + +function inferColumns(artifact: Artifact): DataframeColumn[] { + const ids = unique(Object.keys(artifact.elements).map((id) => id.includes("__") ? id.slice(id.indexOf("__") + 2) : undefined)); + return ids.slice(0, 40).map((id, order) => ({ id, label: id, order })); +} + +function scalar(value: unknown): NotebookKernelScalar { + if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; + if (value && typeof value === "object" && "value" in value) return scalar((value as { value?: unknown }).value); + return value === undefined ? null : JSON.stringify(value).slice(0, 500); +} + +function isStoredOutput(value: unknown): value is NotebookKernelStoredOutput { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return typeof candidate.blockId === "string" && typeof candidate.input === "string" && candidate.result?.schema === 1; +} + +function unique(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))]; +} + +function slug(value: string): string { + return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); +} diff --git a/src/ui/workArtifacts/notebookPatchDiff.ts b/src/ui/workArtifacts/notebookPatchDiff.ts new file mode 100644 index 00000000..bcf44dd3 --- /dev/null +++ b/src/ui/workArtifacts/notebookPatchDiff.ts @@ -0,0 +1,80 @@ +export type NotebookPatchDiffKind = "unchanged" | "removed" | "added"; + +export interface NotebookPatchDiffPart { + kind: NotebookPatchDiffKind; + text: string; +} + +export interface NotebookPatchDiff { + before: string; + after: string; + changed: boolean; + addedText: string; + removedText: string; + parts: NotebookPatchDiffPart[]; +} + +function words(value: string): string[] { + return value.replace(/\s+/g, " ").trim().split(" ").filter(Boolean).slice(0, 160); +} + +function compact(parts: NotebookPatchDiffPart[]): NotebookPatchDiffPart[] { + const out: NotebookPatchDiffPart[] = []; + for (const part of parts) { + const last = out[out.length - 1]; + if (last?.kind === part.kind) { + last.text = `${last.text} ${part.text}`.trim(); + } else { + out.push({ ...part }); + } + } + return out; +} + +export function buildNotebookPatchDiff(beforeValue: string | undefined, afterValue: string | undefined): NotebookPatchDiff { + const before = (beforeValue ?? "").replace(/\s+/g, " ").trim(); + const after = (afterValue ?? "").replace(/\s+/g, " ").trim(); + const a = words(before); + const b = words(after); + const dp: number[][] = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0)); + for (let i = a.length - 1; i >= 0; i -= 1) { + for (let j = b.length - 1; j >= 0; j -= 1) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + const parts: NotebookPatchDiffPart[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + parts.push({ kind: "unchanged", text: a[i] }); + i += 1; + j += 1; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + parts.push({ kind: "removed", text: a[i] }); + i += 1; + } else { + parts.push({ kind: "added", text: b[j] }); + j += 1; + } + } + while (i < a.length) { + parts.push({ kind: "removed", text: a[i] }); + i += 1; + } + while (j < b.length) { + parts.push({ kind: "added", text: b[j] }); + j += 1; + } + + const compacted = compact(parts); + return { + before, + after, + changed: before !== after, + addedText: compacted.filter((part) => part.kind === "added").map((part) => part.text).join(" "), + removedText: compacted.filter((part) => part.kind === "removed").map((part) => part.text).join(" "), + parts: compacted, + }; +} diff --git a/src/ui/workArtifacts/notebookStructure.ts b/src/ui/workArtifacts/notebookStructure.ts new file mode 100644 index 00000000..0f986372 --- /dev/null +++ b/src/ui/workArtifacts/notebookStructure.ts @@ -0,0 +1,442 @@ +import type { Artifact, CellEvidence, CellPayload, Element, Proposal, TraceEvent } from "../../engine/types"; +import type { WorkArtifactStatus } from "./workArtifactTypes"; + +export type NotebookBlockRole = "human" | "agent" | "unknown"; +export type NotebookBlockKind = "heading" | "paragraph" | "list_item" | "quote" | "code" | "unknown"; +export type NotebookBlockStatus = "draft" | "accepted" | "needs_review"; + +export interface NotebookBlockDigest { + id: string; + blockId?: string; + elementId: string; + index: number; + kind: NotebookBlockKind; + role: NotebookBlockRole; + status: NotebookBlockStatus; + depth: number; + text: string; + traceIds: string[]; + proposalIds: string[]; + sourceIds: string[]; +} + +export interface NotebookSectionDigest { + id: string; + title: string; + startIndex: number; + blockCount: number; + agentBlockCount: number; + needsReviewCount: number; +} + +export interface NotebookArtifactStructure { + artifactId: string; + title: string; + status: WorkArtifactStatus; + summary: string; + blockCount: number; + sectionCount: number; + agentBlockCount: number; + humanBlockCount: number; + needsReviewCount: number; + citationCount: number; + evidenceCount: number; + sourceIds: string[]; + traceIds: string[]; + proposalIds: string[]; + blocks: NotebookBlockDigest[]; + sections: NotebookSectionDigest[]; +} + +type PmNodeJson = { + type?: string; + text?: string; + attrs?: Record; + content?: PmNodeJson[]; +}; + +const LEAF_PM_TYPES = new Set(["paragraph", "heading", "codeBlock"]); +const CONTAINER_PM_TYPES = new Set(["listItem", "blockquote", "bulletList", "orderedList"]); +const HTML_BLOCK_RE = /<(h[1-6]|p|li|blockquote|pre)\b([^>]*)>([\s\S]*?)<\/\1>/gi; +const HREF_RE = /\bhref\s*=\s*["']([^"']+)["']/gi; +const URL_RE = /\bhttps?:\/\/[^\s<>"')]+/gi; +const MAX_NOTEBOOK_BLOCKS = 240; +const MAX_SOURCE_IDS = 80; + +function isCellPayload(value: unknown): value is CellPayload { + return typeof value === "object" && value !== null && ("status" in value || "evidence" in value || "confidence" in value || "error" in value); +} + +function unique(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))]; +} + +function orderedElements(artifact: Artifact): Element[] { + const ids = (artifact.order.length ? artifact.order : Object.keys(artifact.elements)).filter((id) => !id.startsWith("notebook_kernel:")); + const seen = new Set(ids); + const extras = Object.keys(artifact.elements).filter((id) => !seen.has(id) && !id.startsWith("notebook_kernel:")); + return [...ids, ...extras].map((id) => artifact.elements[id]).filter((element): element is Element => Boolean(element)); +} + +function sourceIdsFromEvidence(evidence: CellEvidence[] | undefined): string[] { + return unique((evidence ?? []).map((item) => item.sourceArtifactId ?? item.sourceStorageId ?? item.providerFileId ?? item.url ?? item.source ?? item.id)); +} + +function stableHash(input: string): string { + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function decodeHtml(text: string): string { + return text + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, code: string) => String.fromCharCode(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCharCode(parseInt(code, 16))); +} + +function stripHtml(html: string): string { + return decodeHtml(html.replace(//gi, "").replace(//gi, "").replace(/<[^>]+>/g, " ")) + .replace(/\s+/g, " ") + .trim(); +} + +function attr(attrs: string, name: string): string | undefined { + const pattern = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i"); + const match = attrs.match(pattern); + return match?.[1]; +} + +function pmAttr(node: PmNodeJson | undefined, name: string): string | undefined { + const value = node?.attrs?.[name]; + return typeof value === "string" && value ? value : undefined; +} + +function statusFromRaw(value: unknown): NotebookBlockStatus { + const raw = typeof value === "string" ? value.toLowerCase() : ""; + if (raw === "needs_review" || raw === "gap" || raw === "failed") return "needs_review"; + if (raw === "accepted" || raw === "synced" || raw === "complete") return "accepted"; + return "draft"; +} + +function kindFromHtmlTag(tag: string): NotebookBlockKind { + if (/^h[1-6]$/i.test(tag)) return "heading"; + if (tag === "li") return "list_item"; + if (tag === "blockquote") return "quote"; + if (tag === "pre") return "code"; + if (tag === "p") return "paragraph"; + return "unknown"; +} + +function kindFromPmType(type: string | undefined): NotebookBlockKind { + if (type === "heading") return "heading"; + if (type === "listItem") return "list_item"; + if (type === "blockquote") return "quote"; + if (type === "codeBlock") return "code"; + if (type === "paragraph") return "paragraph"; + return "unknown"; +} + +function roleFromAttrs(authorKind?: string, runId?: string): NotebookBlockRole { + if (authorKind === "agent" || runId) return "agent"; + if (authorKind === "user" || authorKind === "human") return "human"; + return "human"; +} + +function sourceIdsFromText(text: string): string[] { + return unique(text.match(URL_RE) ?? []); +} + +function sourceIdsFromHtml(attrs: string, body: string): string[] { + const hrefs: string[] = []; + let match: RegExpExecArray | null; + while ((match = HREF_RE.exec(attrs + body))) hrefs.push(match[1]); + return unique([...hrefs, ...sourceIdsFromText(stripHtml(body))]).slice(0, MAX_SOURCE_IDS); +} + +function looksLikePmDoc(value: unknown): value is PmNodeJson { + return typeof value === "object" && value !== null && (value as PmNodeJson).type === "doc" && Array.isArray((value as PmNodeJson).content); +} + +function inlinePmText(node: PmNodeJson): string { + if (typeof node.text === "string") return node.text; + return (node.content ?? []).map(inlinePmText).join(""); +} + +function relatedTraceIds(traces: TraceEvent[], artifactId: string, elementId: string, blockId?: string): string[] { + return traces + .filter((trace) => + trace.refs?.artifactId === artifactId && + (!trace.refs?.elementId || trace.refs.elementId === elementId || trace.refs.elementId === blockId)) + .map((trace) => trace.id); +} + +function relatedProposalIds(proposals: Proposal[], artifactId: string, elementId: string, blockId?: string): string[] { + return proposals + .filter((proposal) => proposal.artifactId === artifactId && (!proposal.op.elementId || proposal.op.elementId === elementId || proposal.op.elementId === blockId)) + .map((proposal) => proposal.id); +} + +function makeBlock(args: { + artifact: Artifact; + element: Element; + index: number; + kind: NotebookBlockKind; + text: string; + depth?: number; + blockId?: string; + role?: NotebookBlockRole; + status?: NotebookBlockStatus; + sourceIds?: string[]; + traces: TraceEvent[]; + proposals: Proposal[]; +}): NotebookBlockDigest | null { + const text = args.text.replace(/\s+/g, " ").trim(); + if (!text) return null; + const blockId = args.blockId; + const id = blockId || `${args.element.id}:${stableHash(`${args.index}:${text}`)}`; + return { + id, + blockId, + elementId: args.element.id, + index: args.index, + kind: args.kind, + role: args.role ?? "human", + status: args.status ?? "draft", + depth: args.depth ?? 0, + text, + traceIds: relatedTraceIds(args.traces, args.artifact.id, args.element.id, blockId), + proposalIds: relatedProposalIds(args.proposals, args.artifact.id, args.element.id, blockId), + sourceIds: unique(args.sourceIds ?? []), + }; +} + +function blocksFromPmDoc(args: { + artifact: Artifact; + element: Element; + doc: PmNodeJson; + startIndex: number; + inheritedSources: string[]; + traces: TraceEvent[]; + proposals: Proposal[]; +}): NotebookBlockDigest[] { + const blocks: NotebookBlockDigest[] = []; + const walk = (node: PmNodeJson, depth: number, inherited: { blockId?: string; authorKind?: string; runId?: string; status?: string }) => { + if (args.startIndex + blocks.length >= MAX_NOTEBOOK_BLOCKS) return; + const type = node.type ?? ""; + const own = { + blockId: pmAttr(node, "blockId") ?? inherited.blockId, + authorKind: pmAttr(node, "authorKind") ?? inherited.authorKind, + runId: pmAttr(node, "runId") ?? inherited.runId, + status: pmAttr(node, "status") ?? inherited.status, + }; + if (LEAF_PM_TYPES.has(type)) { + const block = makeBlock({ + artifact: args.artifact, + element: args.element, + index: args.startIndex + blocks.length, + kind: kindFromPmType(type), + text: inlinePmText(node), + depth, + blockId: own.blockId, + role: roleFromAttrs(own.authorKind, own.runId), + status: statusFromRaw(own.status), + sourceIds: args.inheritedSources, + traces: args.traces, + proposals: args.proposals, + }); + if (block) blocks.push(block); + return; + } + const nextDepth = CONTAINER_PM_TYPES.has(type) ? depth + 1 : depth; + const nextInherited = CONTAINER_PM_TYPES.has(type) ? own : inherited; + for (const child of node.content ?? []) walk(child, nextDepth, nextInherited); + }; + for (const child of args.doc.content ?? []) walk(child, 0, {}); + return blocks; +} + +function blocksFromHtml(args: { + artifact: Artifact; + element: Element; + html: string; + startIndex: number; + inheritedSources: string[]; + traces: TraceEvent[]; + proposals: Proposal[]; +}): NotebookBlockDigest[] { + const blocks: NotebookBlockDigest[] = []; + let match: RegExpExecArray | null; + while ((match = HTML_BLOCK_RE.exec(args.html)) && args.startIndex + blocks.length < MAX_NOTEBOOK_BLOCKS) { + const [, tag, attrs, body] = match; + const text = stripHtml(body); + const blockId = attr(attrs, "data-blockid"); + const runId = attr(attrs, "data-run-id"); + const authorKind = attr(attrs, "data-author-kind"); + const block = makeBlock({ + artifact: args.artifact, + element: args.element, + index: args.startIndex + blocks.length, + kind: kindFromHtmlTag(tag.toLowerCase()), + text, + blockId, + role: roleFromAttrs(authorKind, runId), + status: statusFromRaw(attr(attrs, "data-status")), + sourceIds: unique([...args.inheritedSources, ...sourceIdsFromHtml(attrs, body)]), + traces: args.traces, + proposals: args.proposals, + }); + if (block) blocks.push(block); + } + if (blocks.length === 0) { + const text = stripHtml(args.html); + const block = makeBlock({ + artifact: args.artifact, + element: args.element, + index: args.startIndex, + kind: "paragraph", + text, + sourceIds: unique([...args.inheritedSources, ...sourceIdsFromText(text)]), + traces: args.traces, + proposals: args.proposals, + }); + if (block) blocks.push(block); + } + return blocks; +} + +function blocksFromElement(args: { + artifact: Artifact; + element: Element; + startIndex: number; + traces: TraceEvent[]; + proposals: Proposal[]; +}): NotebookBlockDigest[] { + const raw = isCellPayload(args.element.value) ? args.element.value.value : args.element.value; + const inheritedSources = isCellPayload(args.element.value) ? sourceIdsFromEvidence(args.element.value.evidence) : []; + if (looksLikePmDoc(raw)) { + return blocksFromPmDoc({ ...args, doc: raw, inheritedSources }); + } + if (typeof raw === "string") { + const text = raw.trim(); + if (text.startsWith("<")) return blocksFromHtml({ ...args, html: text, inheritedSources }); + const block = makeBlock({ + ...args, + index: args.startIndex, + kind: "paragraph", + text, + sourceIds: unique([...inheritedSources, ...sourceIdsFromText(text)]), + status: isCellPayload(args.element.value) ? statusFromRaw(args.element.value.status) : "draft", + }); + return block ? [block] : []; + } + if (typeof raw === "object" && raw !== null && "text" in raw && typeof raw.text === "string") { + const maybe = raw as { text: string; status?: string; authorKind?: string; runId?: string; blockType?: string }; + const block = makeBlock({ + ...args, + index: args.startIndex, + kind: kindFromPmType(maybe.blockType) === "unknown" ? "paragraph" : kindFromPmType(maybe.blockType), + text: maybe.text, + role: roleFromAttrs(maybe.authorKind, maybe.runId), + status: statusFromRaw(maybe.status ?? (isCellPayload(args.element.value) ? args.element.value.status : undefined)), + sourceIds: unique([...inheritedSources, ...sourceIdsFromText(maybe.text)]), + }); + return block ? [block] : []; + } + if (typeof raw === "number" || typeof raw === "boolean") { + const block = makeBlock({ + ...args, + index: args.startIndex, + kind: "paragraph", + text: String(raw), + sourceIds: inheritedSources, + status: isCellPayload(args.element.value) ? statusFromRaw(args.element.value.status) : "draft", + }); + return block ? [block] : []; + } + return []; +} + +function buildSections(blocks: NotebookBlockDigest[]): NotebookSectionDigest[] { + const headings = blocks.filter((block) => block.kind === "heading"); + const anchors = headings.length ? headings : blocks.slice(0, 1); + return anchors.map((heading, index) => { + const next = anchors[index + 1]?.index ?? blocks.length; + const sectionBlocks = blocks.filter((block) => block.index >= heading.index && block.index < next); + return { + id: heading.blockId ?? heading.id, + title: heading.kind === "heading" ? heading.text : "Notebook body", + startIndex: heading.index, + blockCount: sectionBlocks.length, + agentBlockCount: sectionBlocks.filter((block) => block.role === "agent").length, + needsReviewCount: sectionBlocks.filter((block) => block.status === "needs_review").length, + }; + }); +} + +export function buildNotebookArtifactStructure( + artifact: Artifact, + related: { traces?: TraceEvent[]; proposals?: Proposal[] } = {}, +): NotebookArtifactStructure { + const traces = related.traces ?? []; + const proposals = related.proposals ?? []; + const blocks: NotebookBlockDigest[] = []; + for (const element of orderedElements(artifact)) { + if (blocks.length >= MAX_NOTEBOOK_BLOCKS) break; + blocks.push(...blocksFromElement({ + artifact, + element, + startIndex: blocks.length, + traces, + proposals, + }).slice(0, MAX_NOTEBOOK_BLOCKS - blocks.length)); + } + const sections = buildSections(blocks); + const needsReviewCount = blocks.filter((block) => block.status === "needs_review" || /\b(needs[_\s-]?review|todo|tbd|unknown|gap|missing source|unsupported)\b/i.test(block.text)).length; + const agentBlockCount = blocks.filter((block) => block.role === "agent").length; + const sourceIds = unique(blocks.flatMap((block) => block.sourceIds)).slice(0, MAX_SOURCE_IDS); + const citationCount = sourceIds.filter((sourceId) => /^https?:\/\//i.test(sourceId)).length; + const traceIds = unique([ + ...traces.filter((trace) => trace.refs?.artifactId === artifact.id).map((trace) => trace.id), + ...blocks.flatMap((block) => block.traceIds), + ]); + const proposalIds = unique([ + ...proposals.filter((proposal) => proposal.artifactId === artifact.id).map((proposal) => proposal.id), + ...blocks.flatMap((block) => block.proposalIds), + ]); + const evidenceCount = sourceIds.length; + const status: WorkArtifactStatus = + blocks.length === 0 ? "empty" : needsReviewCount > 0 || proposalIds.length > 0 ? "needs_review" : "ready"; + const parts = [`${blocks.length} block${blocks.length === 1 ? "" : "s"}`]; + if (sections.length > 0) parts.push(`${sections.length} section${sections.length === 1 ? "" : "s"}`); + if (agentBlockCount > 0) parts.push(`${agentBlockCount} agent`); + if (evidenceCount > 0) parts.push(`${evidenceCount} sources`); + if (needsReviewCount > 0) parts.push(`${needsReviewCount} review`); + + return { + artifactId: artifact.id, + title: artifact.title, + status, + summary: parts.join(" - "), + blockCount: blocks.length, + sectionCount: sections.length, + agentBlockCount, + humanBlockCount: blocks.filter((block) => block.role === "human").length, + needsReviewCount, + citationCount, + evidenceCount, + sourceIds, + traceIds, + proposalIds, + blocks, + sections, + }; +} diff --git a/src/ui/workArtifacts/notebookTypedBlocks.ts b/src/ui/workArtifacts/notebookTypedBlocks.ts new file mode 100644 index 00000000..6afbd75d --- /dev/null +++ b/src/ui/workArtifacts/notebookTypedBlocks.ts @@ -0,0 +1,92 @@ +import type { NotebookArtifactStructure, NotebookBlockDigest } from "./notebookStructure"; + +export type NotebookTypedBlockKind = + | "text" + | "insight" + | "table" + | "chart" + | "calculation" + | "sql" + | "evidence" + | "decision" + | "open_question" + | "agent_proposal"; + +export interface NotebookTypedBlock { + id: string; + blockId: string; + elementId: string; + index: number; + type: NotebookTypedBlockKind; + confidence: number; + reasons: string[]; +} + +export interface NotebookTypedBlockSummary { + total: number; + counts: Partial>; + reviewCount: number; + sourceBackedCount: number; + agentAuthoredCount: number; +} + +const TYPE_ORDER: NotebookTypedBlockKind[] = [ + "agent_proposal", + "open_question", + "decision", + "evidence", + "calculation", + "sql", + "table", + "chart", + "insight", + "text", +]; + +function score(block: NotebookBlockDigest): Array<{ type: NotebookTypedBlockKind; points: number; reason: string }> { + const text = block.text.toLowerCase(); + const rows: Array<{ type: NotebookTypedBlockKind; points: number; reason: string }> = []; + if (block.proposalIds.length > 0 || (block.role === "agent" && block.status === "needs_review")) rows.push({ type: "agent_proposal", points: 6, reason: "proposal_or_agent_review" }); + if (/\b(open question|question|unknown|todo|tbd|missing|gap|needs source|needs review)\b/.test(text) || /\?$/.test(block.text.trim())) rows.push({ type: "open_question", points: 5, reason: "question_or_gap_language" }); + if (/\b(decision|approved|rejected|recommend|go\/no-go|chosen|accepted)\b/.test(text)) rows.push({ type: "decision", points: 5, reason: "decision_language" }); + if (block.sourceIds.length > 0 || /\b(source|citation|evidence|cited|transcript|memo|filing|url)\b/.test(text) || /https?:\/\//.test(text)) rows.push({ type: "evidence", points: 5, reason: "source_reference" }); + if (/\b(runway|cash|burn|margin|variance|growth|ratio|calculation|formula)\b/.test(text) || /(?:\d+(?:\.\d+)?\s*[%x/+-])|(?:[$]\s?\d)/.test(block.text)) rows.push({ type: "calculation", points: 4, reason: "numeric_or_formula_language" }); + if (/\bselect\b[\s\S]+\bfrom\b/.test(text)) rows.push({ type: "sql", points: 5, reason: "sql_query" }); + if (/\|.+\|/.test(block.text) || /\t/.test(block.text)) rows.push({ type: "table", points: 4, reason: "tabular_text" }); + if (/\b(chart|graph|plot|axis|series|trendline|visualize)\b/.test(text)) rows.push({ type: "chart", points: 3, reason: "chart_language" }); + if (block.role === "agent" || /\b(claim|finding|signal|insight|thesis|risk|opportunity)\b/.test(text)) rows.push({ type: "insight", points: 3, reason: "insight_language" }); + rows.push({ type: "text", points: 1, reason: "default_text" }); + return rows; +} + +export function classifyNotebookTypedBlock(block: NotebookBlockDigest): NotebookTypedBlock { + const ranked = score(block).sort((a, b) => b.points - a.points || TYPE_ORDER.indexOf(a.type) - TYPE_ORDER.indexOf(b.type)); + const best = ranked[0]; + const reasons = ranked.filter((item) => item.type === best.type || item.points >= 5).map((item) => item.reason); + return { + id: block.id, + blockId: block.blockId ?? block.id, + elementId: block.elementId, + index: block.index, + type: best.type, + confidence: Math.min(1, best.points / 6), + reasons: [...new Set(reasons)], + }; +} + +export function classifyNotebookTypedBlocks(structure: NotebookArtifactStructure): NotebookTypedBlock[] { + return structure.blocks.map(classifyNotebookTypedBlock); +} + +export function summarizeNotebookTypedBlocks(structure: NotebookArtifactStructure): NotebookTypedBlockSummary { + const typed = classifyNotebookTypedBlocks(structure); + const counts: Partial> = {}; + for (const block of typed) counts[block.type] = (counts[block.type] ?? 0) + 1; + return { + total: typed.length, + counts, + reviewCount: structure.needsReviewCount, + sourceBackedCount: structure.blocks.filter((block) => block.sourceIds.length > 0).length, + agentAuthoredCount: structure.agentBlockCount, + }; +} diff --git a/src/ui/workArtifacts/proofBundleExport.ts b/src/ui/workArtifacts/proofBundleExport.ts new file mode 100644 index 00000000..e52db50f --- /dev/null +++ b/src/ui/workArtifacts/proofBundleExport.ts @@ -0,0 +1,129 @@ +import type { ProofBundleReceipt } from "./proofBundleReceipt"; +import type { TraceReplaySummary } from "./traceReplaySummary"; +import type { WorkArtifactAction, WorkArtifactRef, WorkArtifactViewModel } from "./workArtifactTypes"; + +export interface ProofBundleExportArtifact { + id: string; + kind: WorkArtifactViewModel["kind"]; + sourceKind?: WorkArtifactViewModel["sourceKind"]; + title: string; + summary?: string; + status: WorkArtifactViewModel["status"]; + version?: string | number; + ownerName?: string; + receipt: WorkArtifactViewModel["receipt"]; + refs: WorkArtifactRef[]; + actions: Array>; + meta?: WorkArtifactViewModel["meta"]; +} + +export interface ProofBundleExportManifest { + manifestVersion: 1; + roomId: string; + manifestId: string; + generatedAt: number; + receipt: ProofBundleReceipt; + traceReplay: TraceReplaySummary; + artifacts: ProofBundleExportArtifact[]; + exportIntents: Array<{ + kind: "proof_bundle_sidecar"; + format: "json"; + receiptId: string; + replayHash: string; + knownGapCount: number; + }>; + integrityHash: string; +} + +function stableHash(value: unknown): string { + const text = JSON.stringify(value); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function sortedRefs(refs: WorkArtifactRef[]): WorkArtifactRef[] { + return [...refs].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); +} + +function exportArtifact(item: WorkArtifactViewModel): ProofBundleExportArtifact { + return { + id: item.id, + kind: item.kind, + sourceKind: item.sourceKind, + title: item.title, + summary: item.summary, + status: item.status, + version: item.version, + ownerName: item.owner?.name, + receipt: item.receipt, + refs: sortedRefs(item.refs), + actions: item.actions.map((action) => ({ + id: action.id, + label: action.label, + tone: action.tone, + disabled: action.disabled, + reason: action.reason, + })), + meta: item.meta, + }; +} + +export function buildProofBundleExportManifest(args: { + roomId: string; + artifacts: WorkArtifactViewModel[]; + receipt: ProofBundleReceipt; + traceReplay: TraceReplaySummary; + generatedAt?: number; +}): ProofBundleExportManifest { + const artifacts = [...args.artifacts].sort((a, b) => a.id.localeCompare(b.id)).map(exportArtifact); + const exportIntents: ProofBundleExportManifest["exportIntents"] = [{ + kind: "proof_bundle_sidecar", + format: "json", + receiptId: args.receipt.receiptId, + replayHash: args.traceReplay.replayHash, + knownGapCount: args.receipt.knownGaps.length, + }]; + const payloadForHash = { + roomId: args.roomId, + receiptId: args.receipt.receiptId, + receiptHash: args.receipt.integrityHash, + replayHash: args.traceReplay.replayHash, + artifacts: artifacts.map((artifact) => ({ + id: artifact.id, + kind: artifact.kind, + status: artifact.status, + refs: artifact.refs, + actions: artifact.actions.map((action) => action.id), + })), + exportIntents, + }; + const integrityHash = stableHash(payloadForHash); + return { + manifestVersion: 1, + roomId: args.roomId, + manifestId: `${args.receipt.receiptId}:manifest:${integrityHash}`, + generatedAt: args.generatedAt ?? args.receipt.generatedAt, + receipt: args.receipt, + traceReplay: args.traceReplay, + artifacts, + exportIntents, + integrityHash, + }; +} + +export function proofBundleManifestJson(manifest: ProofBundleExportManifest): string { + return `${JSON.stringify(manifest, null, 2)}\n`; +} + +export function proofBundleManifestFileName(roomTitle: string | undefined, manifest: ProofBundleExportManifest): string { + const base = (roomTitle || manifest.roomId || "room") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + || "room"; + return `${base}-proof-bundle-${manifest.integrityHash}.json`; +} diff --git a/src/ui/workArtifacts/proofBundleReceipt.ts b/src/ui/workArtifacts/proofBundleReceipt.ts new file mode 100644 index 00000000..8b9b33dd --- /dev/null +++ b/src/ui/workArtifacts/proofBundleReceipt.ts @@ -0,0 +1,139 @@ +import type { WorkArtifactKind, WorkArtifactStatus, WorkArtifactViewModel } from "./workArtifactTypes"; + +export interface ProofBundleReceiptItem { + id: string; + kind: WorkArtifactKind; + status: WorkArtifactStatus; + title: string; + version?: string | number; + traceIds: string[]; + proposalIds: string[]; + sourceIds: string[]; + evidenceCount: number; + unresolvedCount: number; +} + +export interface ProofBundleKnownGap { + itemId: string; + title: string; + status: WorkArtifactStatus; + unresolvedCount: number; + reason: string; +} + +export interface ProofBundleReceipt { + receiptVersion: 1; + roomId: string; + receiptId: string; + generatedAt: number; + artifactCount: number; + kindCounts: Record; + statusCounts: Record; + evidenceCount: number; + unresolvedCount: number; + traceIds: string[]; + proposalIds: string[]; + sourceIds: string[]; + items: ProofBundleReceiptItem[]; + knownGaps: ProofBundleKnownGap[]; + integrityHash: string; +} + +const KIND_ORDER: WorkArtifactKind[] = ["spreadsheet", "notebook", "wall", "deck", "graph", "trace", "proposal", "export"]; +const STATUS_ORDER: WorkArtifactStatus[] = ["empty", "ready", "running", "needs_review", "failed", "pending", "approved", "rejected"]; + +function zeroKinds(): Record { + return Object.fromEntries(KIND_ORDER.map((kind) => [kind, 0])) as Record; +} + +function zeroStatuses(): Record { + return Object.fromEntries(STATUS_ORDER.map((status) => [status, 0])) as Record; +} + +function unique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))].sort(); +} + +function stableHash(value: unknown): string { + const text = JSON.stringify(value); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function itemGapReason(item: WorkArtifactViewModel): string | null { + if (item.status === "failed" || item.status === "rejected") return "failed_or_rejected"; + if (item.status === "needs_review" || item.status === "pending") return "human_review_required"; + if (item.receipt.unresolvedCount > 0) return "unresolved_receipt_items"; + return null; +} + +export function buildProofBundleReceipt(args: { + roomId: string; + artifacts: WorkArtifactViewModel[]; + generatedAt?: number; +}): ProofBundleReceipt { + const sorted = [...args.artifacts].sort((a, b) => a.id.localeCompare(b.id)); + const kindCounts = zeroKinds(); + const statusCounts = zeroStatuses(); + const items: ProofBundleReceiptItem[] = sorted.map((artifact) => { + kindCounts[artifact.kind] += 1; + statusCounts[artifact.status] += 1; + return { + id: artifact.id, + kind: artifact.kind, + status: artifact.status, + title: artifact.title, + version: artifact.version, + traceIds: unique(artifact.receipt.traceIds), + proposalIds: unique(artifact.receipt.proposalIds), + sourceIds: unique(artifact.receipt.sourceIds), + evidenceCount: artifact.receipt.evidenceCount, + unresolvedCount: artifact.receipt.unresolvedCount, + }; + }); + const knownGaps: ProofBundleKnownGap[] = sorted.flatMap((artifact) => { + const reason = itemGapReason(artifact); + return reason ? [{ + itemId: artifact.id, + title: artifact.title, + status: artifact.status, + unresolvedCount: artifact.receipt.unresolvedCount, + reason, + }] : []; + }); + const generatedAt = args.generatedAt ?? Date.now(); + const traceIds = unique(items.flatMap((item) => item.traceIds)); + const proposalIds = unique(items.flatMap((item) => item.proposalIds)); + const sourceIds = unique(items.flatMap((item) => item.sourceIds)); + const payloadForHash = { + roomId: args.roomId, + items, + knownGaps, + traceIds, + proposalIds, + sourceIds, + }; + const integrityHash = stableHash(payloadForHash); + + return { + receiptVersion: 1, + roomId: args.roomId, + receiptId: `${args.roomId}:proof-bundle:${integrityHash}`, + generatedAt, + artifactCount: items.length, + kindCounts, + statusCounts, + evidenceCount: items.reduce((sum, item) => sum + item.evidenceCount, 0), + unresolvedCount: items.reduce((sum, item) => sum + item.unresolvedCount, 0), + traceIds, + proposalIds, + sourceIds, + items, + knownGaps, + integrityHash, + }; +} diff --git a/src/ui/workArtifacts/traceReplaySummary.ts b/src/ui/workArtifacts/traceReplaySummary.ts new file mode 100644 index 00000000..c773efdb --- /dev/null +++ b/src/ui/workArtifacts/traceReplaySummary.ts @@ -0,0 +1,151 @@ +import type { Proposal, TraceEvent, TraceType } from "../../engine/types"; +import type { WorkArtifactStatus } from "./workArtifactTypes"; + +export type TraceReplayPhaseKind = "room" | "agent" | "edit" | "review" | "notebook" | "chat"; + +export interface TraceReplayPhase { + id: TraceReplayPhaseKind; + label: string; + status: WorkArtifactStatus; + traceIds: string[]; + artifactIds: string[]; + proposalIds: string[]; + startAt?: number; + endAt?: number; + summary: string; +} + +export interface TraceReplaySummary { + roomId: string; + eventCount: number; + status: WorkArtifactStatus; + traceIds: string[]; + proposalIds: string[]; + artifactIds: string[]; + phases: TraceReplayPhase[]; + criticalPath: TraceReplayPhase[]; + replayHash: string; +} + +const PHASE_LABEL: Record = { + room: "Room setup", + agent: "Agent work", + edit: "Artifact edits", + review: "Review decisions", + notebook: "Notebook read model", + chat: "Chat and messages", +}; + +const PHASE_ORDER: TraceReplayPhaseKind[] = ["room", "chat", "agent", "edit", "review", "notebook"]; + +function phaseOf(type: TraceType): TraceReplayPhaseKind { + if (type === "room_created" || type === "member_joined" || type === "auto_allow_toggled") return "room"; + if (type === "message") return "chat"; + if (type.startsWith("notebook_") || type === "agent_work_plan_proposed" || type === "agent_work_plan_approved") return "notebook"; + if (type.includes("proposal") || type.includes("draft") || type.includes("conflict")) return "review"; + if (type.includes("edit") || type.includes("lock") || type === "schema_changed") return "edit"; + return "agent"; +} + +function traceStatus(trace: TraceEvent): WorkArtifactStatus { + const text = `${trace.type} ${trace.summary} ${trace.detail ?? ""}`; + if (/\b(failed|blocked|denied|conflict|error)\b/i.test(text)) return "failed"; + if (/\b(proposal|proposed|review|pending|needs[_\s-]?review|draft)\b/i.test(text)) return "needs_review"; + if (trace.type === "agent_status" && /\b(working|running|started)\b/i.test(text)) return "running"; + return "ready"; +} + +function rollup(statuses: WorkArtifactStatus[]): WorkArtifactStatus { + if (statuses.includes("failed")) return "failed"; + if (statuses.includes("running")) return "running"; + if (statuses.includes("needs_review") || statuses.includes("pending")) return "needs_review"; + return statuses.length ? "ready" : "empty"; +} + +function unique(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))].sort(); +} + +function stableHash(value: unknown): string { + const text = JSON.stringify(value); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function phaseSummary(kind: TraceReplayPhaseKind, traces: TraceEvent[], proposals: Proposal[]): string { + if (traces.length === 0) return "No events recorded."; + const failed = traces.filter((trace) => traceStatus(trace) === "failed").length; + const review = traces.filter((trace) => traceStatus(trace) === "needs_review").length; + const proposalCount = unique([...traces.map((trace) => trace.refs?.proposalId), ...proposals.map((proposal) => proposal.id)]).length; + const parts = [`${traces.length} event${traces.length === 1 ? "" : "s"}`]; + if (proposalCount > 0 && (kind === "review" || kind === "edit")) parts.push(`${proposalCount} proposal${proposalCount === 1 ? "" : "s"}`); + if (review > 0) parts.push(`${review} review`); + if (failed > 0) parts.push(`${failed} failed`); + return parts.join(" - "); +} + +export function buildTraceReplaySummary(args: { + roomId: string; + traces: TraceEvent[]; + proposals?: Proposal[]; +}): TraceReplaySummary { + const proposals = args.proposals ?? []; + const traces = [...args.traces].sort((a, b) => a.ts - b.ts || a.id.localeCompare(b.id)); + const byPhase = new Map(); + for (const kind of PHASE_ORDER) byPhase.set(kind, []); + for (const trace of traces) byPhase.get(phaseOf(trace.type))?.push(trace); + + const phases = PHASE_ORDER.map((kind): TraceReplayPhase => { + const phaseTraces = byPhase.get(kind) ?? []; + const statuses = phaseTraces.map(traceStatus); + const traceProposalIds = unique(phaseTraces.map((trace) => trace.refs?.proposalId)); + const phaseProposals = proposals.filter((proposal) => traceProposalIds.includes(proposal.id) || phaseTraces.some((trace) => trace.refs?.artifactId === proposal.artifactId)); + return { + id: kind, + label: PHASE_LABEL[kind], + status: rollup(statuses), + traceIds: phaseTraces.map((trace) => trace.id), + artifactIds: unique(phaseTraces.map((trace) => trace.refs?.artifactId)), + proposalIds: unique([...traceProposalIds, ...phaseProposals.map((proposal) => proposal.id)]), + startAt: phaseTraces[0]?.ts, + endAt: phaseTraces[phaseTraces.length - 1]?.ts, + summary: phaseSummary(kind, phaseTraces, phaseProposals), + }; + }).filter((phase) => phase.traceIds.length > 0); + + const status = rollup(phases.map((phase) => phase.status)); + const traceIds = traces.map((trace) => trace.id); + const proposalIds = unique([...phases.flatMap((phase) => phase.proposalIds), ...proposals.map((proposal) => proposal.id)]); + const artifactIds = unique(phases.flatMap((phase) => phase.artifactIds)); + const criticalPath = phases.filter((phase) => phase.status !== "ready").length + ? phases.filter((phase) => phase.status !== "ready") + : phases.slice(-3); + const replayHash = stableHash({ + roomId: args.roomId, + traceIds, + proposalIds, + artifactIds, + phases: phases.map((phase) => ({ + id: phase.id, + traceIds: phase.traceIds, + status: phase.status, + proposalIds: phase.proposalIds, + })), + }); + + return { + roomId: args.roomId, + eventCount: traces.length, + status, + traceIds, + proposalIds, + artifactIds, + phases, + criticalPath, + replayHash, + }; +} diff --git a/src/ui/workArtifacts/work-artifacts.css b/src/ui/workArtifacts/work-artifacts.css new file mode 100644 index 00000000..bfe0ad2e --- /dev/null +++ b/src/ui/workArtifacts/work-artifacts.css @@ -0,0 +1,2072 @@ +.wa-panel { + display: flex; + flex-direction: column; + min-height: 0; + gap: 14px; + padding: 18px; + background: var(--bg, #08090b); +} + +.wa-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); +} + +.wa-eyebrow { + margin: 0 0 4px; + color: var(--muted, #8f98a6); + font-size: 11px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.wa-header h2 { + margin: 0; + color: var(--text, #f3f6fb); + font-size: 18px; + line-height: 1.2; + letter-spacing: 0; +} + +.wa-receipt { + margin: 6px 0 0; + color: var(--muted, #9aa3af); + font: 10.5px/1.2 var(--font-mono); +} + +.wa-header-side { + display: grid; + justify-items: end; + gap: 8px; +} + +.wa-summary { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + color: var(--muted, #9aa3af); + font-size: 12px; +} + +.wa-summary span { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 8px; + background: rgba(255,255,255,.025); +} + +.wa-summary b { + color: var(--text, #f3f6fb); +} + +.wa-export { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 9px; + border: 1px solid var(--accent-border, rgba(217,119,87,.28)); + border-radius: 7px; + background: var(--accent-tint, rgba(217,119,87,.12)); + color: var(--accent-ink, #e59579); + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.wa-export:hover { + background: rgba(217,119,87,.18); +} + +.wa-export-message { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid rgba(85,212,154,.22); + border-radius: 8px; + background: rgba(85,212,154,.08); + color: #55d49a; + font-size: 12px; +} + +.wa-export-message button { + margin-left: auto; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.wa-review { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.018); +} + +.wa-review-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.wa-review-head h3 { + margin: 0; + color: var(--text, #f3f6fb); + font-size: 15px; + line-height: 1.2; +} + +.wa-review-counts, +.wa-review-filters, +.wa-review-actions, +.wa-review-meta { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.wa-review-counts { + justify-content: flex-end; + color: var(--muted, #9aa3af); + font-size: 11.5px; +} + +.wa-review-counts span, +.wa-review-filters button, +.wa-review-actions button { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 7px; + background: rgba(255,255,255,.025); + color: var(--muted, #9aa3af); + font-size: 11px; +} + +.wa-review-counts b { + color: var(--text, #f3f6fb); +} + +.wa-review-filters button, +.wa-review-actions button { + cursor: pointer; + font-weight: 700; +} + +.wa-review-filters button[data-active="true"], +.wa-review-actions button:hover:not(:disabled) { + border-color: var(--accent-border, rgba(217,119,87,.28)); + color: var(--accent-ink, #e59579); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-review-actions button:disabled { + cursor: not-allowed; + opacity: .48; +} + +.wa-review-message, +.wa-review-empty, +.wa-review-host-note { + color: var(--muted, #9aa3af); + font-size: 12px; +} + +.wa-review-message, +.wa-review-empty { + display: flex; + align-items: center; + gap: 7px; +} + +.wa-review-message { + padding: 8px 10px; + border: 1px solid var(--accent-border, rgba(217,119,87,.28)); + border-radius: 8px; + color: var(--accent-ink, #e59579); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-review-message button { + margin-left: auto; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.wa-review-list { + display: grid; + gap: 7px; +} + +.wa-review-card { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.022); +} + +.wa-review-card[data-status="pending"] { + border-color: rgba(217,119,87,.2); + background: rgba(217,119,87,.04); +} + +.wa-review-icon { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border: 1px solid rgba(255,255,255,.07); + border-radius: 8px; + color: var(--accent-ink, #e59579); + background: rgba(217,119,87,.1); +} + +.wa-review-copy { + display: grid; + gap: 5px; + min-width: 0; +} + +.wa-review-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--text, #f3f6fb); + font-size: 13px; + font-weight: 700; +} + +.wa-review-title span:first-child, +.wa-review-copy p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-review-title span[data-status] { + flex: 0 0 auto; + color: var(--accent-ink, #e59579); + font: 10px/1.2 var(--font-mono); +} + +.wa-review-copy p { + margin: 0; + color: var(--muted, #9aa3af); + font-size: 12px; + line-height: 1.35; +} + +.wa-review-meta { + color: var(--muted, #9aa3af); + font: 10.5px/1.25 var(--font-mono); +} + +.wa-review-meta span { + min-width: 0; +} + +.wa-live { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.018); +} + +.wa-live-head, +.wa-live-grid { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.wa-live-head h3 { + margin: 0; + color: var(--text, #f3f6fb); + font-size: 15px; + line-height: 1.2; +} + +.wa-live-trace { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 9px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 7px; + background: rgba(255,255,255,.025); + color: var(--muted, #9aa3af); + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.wa-live-trace:hover:not(:disabled) { + border-color: var(--accent-border, rgba(217,119,87,.28)); + color: var(--accent-ink, #e59579); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-live-trace:disabled { + cursor: not-allowed; + opacity: .48; +} + +.wa-live-status { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 7px; + min-width: 230px; + color: var(--muted, #9aa3af); + font-size: 12px; +} + +.wa-live-status > span { + color: var(--text, #f3f6fb); + font-size: 12px; + font-weight: 800; +} + +.wa-live-status p { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-live-status[data-status="ready"] { + color: #55d49a; +} + +.wa-live-status[data-status="running"], +.wa-live-status[data-status="needs_review"] { + color: var(--accent-ink, #e59579); +} + +.wa-live-metrics, +.wa-live-detail { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.wa-live-metrics { + justify-content: flex-end; + color: var(--muted, #9aa3af); + font-size: 11.5px; +} + +.wa-live-metrics span { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 999px; + background: rgba(255,255,255,.025); +} + +.wa-live-metrics b { + color: var(--text, #f3f6fb); +} + +.wa-live-card { + display: grid; + flex: 1 1 260px; + gap: 5px; + min-width: 0; + padding: 9px 10px; + border: 1px solid rgba(255,255,255,.055); + border-radius: 8px; + background: rgba(255,255,255,.018); +} + +.wa-live-card h4 { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--text, #f3f6fb); + font-size: 12.5px; + line-height: 1.25; +} + +.wa-live-card p { + margin: 0; + color: var(--muted, #9aa3af); + font-size: 11.5px; + line-height: 1.35; +} + +.wa-live-card p b { + color: var(--text, #f3f6fb); +} + +.wa-live-latest { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-grid { + display: grid; + gap: 2px; + min-height: 0; + overflow: auto; + padding-right: 2px; +} + +.wa-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + min-height: 72px; + padding: 10px 12px; + border-bottom: 1px solid rgba(255,255,255,.055); +} + +.wa-row[data-status="needs_review"], +.wa-row[data-status="pending"] { + background: linear-gradient(90deg, rgba(217,119,87,.09), transparent 44%); +} + +.wa-main { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + align-items: center; + gap: 12px; + width: 100%; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.wa-main:disabled { + cursor: default; +} + +.wa-kind-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + color: var(--accent-ink, #e59579); + background: rgba(255,255,255,.045); + border: 1px solid rgba(255,255,255,.07); +} + +.wa-copy { + display: grid; + gap: 4px; + min-width: 0; +} + +.wa-title { + color: var(--text, #f3f6fb); + font-size: 14px; + font-weight: 700; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-meta, +.wa-description { + color: var(--muted, #98a2b3); + font-size: 12px; + line-height: 1.25; +} + +.wa-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.wa-description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-proof { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + color: var(--muted, #98a2b3); + font-size: 12px; +} + +.wa-proof > span { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 24px; +} + +.wa-status { + padding: 0 8px; + border: 1px solid rgba(255,255,255,.08); + border-radius: 999px; +} + +.wa-status[data-tone="ready"] { + color: #55d49a; + border-color: rgba(85,212,154,.22); + background: rgba(85,212,154,.08); +} + +.wa-status[data-tone="review"], +.wa-status[data-tone="running"] { + color: var(--accent-ink, #e59579); + border-color: var(--accent-border, rgba(217,119,87,.28)); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-status[data-tone="failed"] { + color: #ff7a70; + border-color: rgba(255,122,112,.25); + background: rgba(255,122,112,.08); +} + +.wa-empty { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted, #98a2b3); + padding: 20px 0; +} + +.wa-row[data-selected="true"] { + outline: 1px solid var(--accent-border, rgba(217,119,87,.32)); + background: rgba(217,119,87,.075); +} + +.wa-deck { + display: grid; + flex: 0 0 auto; + gap: 14px; + min-height: 0; + padding-top: 12px; + border-top: 1px solid var(--border, rgba(255,255,255,.08)); +} + +.wa-deck-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.wa-deck-head-actions { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.wa-deck-head h3 { + margin: 0; + color: var(--text, #f3f6fb); + font-size: 17px; + line-height: 1.2; +} + +.wa-deck-head p:not(.wa-eyebrow) { + margin: 6px 0 0; + max-width: 760px; + color: var(--muted, #9aa3af); + font-size: 12.5px; + line-height: 1.45; +} + +.wa-deck-close { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 8px; + background: rgba(255,255,255,.035); + color: var(--muted, #9aa3af); + cursor: pointer; +} + +.wa-deck-close:hover { + color: var(--text, #f3f6fb); + background: rgba(255,255,255,.06); +} + +.wa-deck-export { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 0 9px; + border: 1px solid var(--accent-border, rgba(217,119,87,.28)); + border-radius: 7px; + background: var(--accent-tint, rgba(217,119,87,.12)); + color: var(--accent-ink, #e59579); + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.wa-deck-export:hover { + background: rgba(217,119,87,.18); +} + +.wa-deck-export:disabled { + cursor: default; + opacity: .45; +} + +.wa-deck-save:not(:disabled) { + color: #f8fafc; + border-color: var(--accent-primary, #d97757); + background: var(--accent-primary, #d97757); +} + +.wa-deck-meta { + display: flex; + flex-wrap: wrap; + gap: 7px; + color: var(--muted, #9aa3af); + font: 10.5px/1.2 var(--font-mono); +} + +.wa-deck-meta span { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 999px; + background: rgba(255,255,255,.025); +} + +.wa-deck-meta span[data-status="needs_review"] { + color: var(--accent-ink, #e59579); + border-color: var(--accent-border, rgba(217,119,87,.28)); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-deck-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(240px, 28%); + gap: 14px; + min-height: 0; +} + +.wa-deck-slides { + display: grid; + gap: 8px; + min-width: 0; +} + +.wa-deck-slide { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 12px; + padding: 11px 12px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.025); + cursor: pointer; + transition: border-color .14s ease, background .14s ease; +} + +.wa-deck-slide[data-status="needs_review"] { + border-color: rgba(217,119,87,.22); + background: rgba(217,119,87,.045); +} + +.wa-deck-slide[data-selected="true"] { + border-color: var(--accent-border, rgba(217,119,87,.38)); + background: rgba(217,119,87,.075); + box-shadow: inset 2px 0 0 var(--accent-primary, #d97757); +} + +.wa-deck-slide-num { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 8px; + color: var(--accent-ink, #e59579); + background: rgba(217,119,87,.12); + font: 700 11px/1 var(--font-mono); +} + +.wa-deck-slide-copy { + display: grid; + gap: 7px; + min-width: 0; +} + +.wa-deck-slide-copy h4, +.wa-deck-side-card h4 { + margin: 0; + color: var(--text, #f3f6fb); + font-size: 13px; + line-height: 1.25; +} + +.wa-deck-slide-copy p, +.wa-deck-side-card p, +.wa-deck-side-card li { + margin: 0; + color: var(--muted, #9aa3af); + font-size: 12px; + line-height: 1.4; +} + +.wa-deck-claims { + display: grid; + gap: 5px; +} + +.wa-deck-claim { + display: grid; + grid-template-columns: 78px minmax(0, 1fr); + gap: 8px; + align-items: baseline; +} + +.wa-deck-claim span { + color: var(--muted, #9aa3af); + font: 10px/1.25 var(--font-mono); +} + +.wa-deck-claim[data-status="verified"] span { + color: #55d49a; +} + +.wa-deck-claim[data-status="needs_review"] span { + color: var(--accent-ink, #e59579); +} + +.wa-deck-gaps { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.wa-deck-gaps span { + padding: 3px 7px; + border-radius: 999px; + color: var(--accent-ink, #e59579); + background: var(--accent-tint, rgba(217,119,87,.12)); + font: 10px/1.2 var(--font-mono); +} + +.wa-deck-slide-actions { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.wa-deck-slide-actions button { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 7px; + background: transparent; + color: var(--muted, #9aa3af); + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.wa-deck-slide-actions button:hover { + border-color: var(--accent-border, rgba(217,119,87,.28)); + color: var(--accent-ink, #e59579); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-deck-side { + display: grid; + align-content: start; + gap: 10px; + min-width: 0; +} + +.wa-deck-side-card { + display: grid; + gap: 8px; + padding: 11px 12px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.025); +} + +.wa-deck-side-card h4 { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.wa-deck-preview { + border-color: rgba(217,119,87,.18); + background: rgba(217,119,87,.045); +} + +.wa-deck-editor { + gap: 10px; + border-color: var(--accent-border, rgba(217,119,87,.28)); +} + +.wa-deck-editor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.wa-deck-editor-head > div { + display: flex; + align-items: center; + gap: 4px; +} + +.wa-deck-editor-head button, +.wa-deck-editor-claim button { + display: inline-grid; + place-items: center; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 6px; + background: transparent; + color: var(--muted, #9aa3af); +} + +.wa-deck-editor-head button:hover:not(:disabled), +.wa-deck-editor-claim button:hover:not(:disabled) { + color: var(--accent-ink, #e59579); + border-color: var(--accent-border, rgba(217,119,87,.28)); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-deck-editor-head button:disabled { + opacity: .35; + cursor: default; +} + +.wa-deck-editor label { + display: grid; + gap: 5px; + color: var(--muted, #9aa3af); + font: 10px/1.2 var(--font-mono); + text-transform: uppercase; +} + +.wa-deck-editor input, +.wa-deck-editor textarea, +.wa-deck-editor select, +.wa-deck-request textarea { + width: 100%; + min-width: 0; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 7px; + background: rgba(0,0,0,.16); + color: var(--text, #f3f6fb); + font: 11.5px/1.4 var(--font-sans, sans-serif); + padding: 7px 8px; + resize: vertical; +} + +.wa-deck-editor input:focus, +.wa-deck-editor textarea:focus, +.wa-deck-editor select:focus, +.wa-deck-request textarea:focus { + outline: none; + border-color: var(--accent-border, rgba(217,119,87,.38)); + box-shadow: var(--focus-ring, 0 0 0 3px rgba(217,119,87,.2)); +} + +.wa-deck-editor-claims { + display: grid; + gap: 6px; +} + +.wa-deck-editor-claims > span { + color: var(--muted, #9aa3af); + font: 10px/1.2 var(--font-mono); + text-transform: uppercase; +} + +.wa-deck-editor-claim { + display: grid; + grid-template-columns: minmax(0, 1fr) 92px 26px; + align-items: start; + gap: 5px; +} + +.wa-deck-editor-claim select { + height: 32px; + padding: 0 6px; +} + +.wa-deck-editor-add, +.wa-deck-request button { + justify-self: start; + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 7px; + background: transparent; + color: var(--muted, #9aa3af); + font-size: 11px; + font-weight: 750; +} + +.wa-deck-editor-add:hover, +.wa-deck-request button:hover:not(:disabled) { + color: var(--accent-ink, #e59579); + border-color: var(--accent-border, rgba(217,119,87,.28)); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-deck-request button:disabled { + opacity: .4; + cursor: default; +} + +.wa-deck-preview span { + color: var(--accent-ink, #e59579); + font: 10.5px/1.2 var(--font-mono); +} + +.wa-deck-preview strong { + color: var(--text, #f3f6fb); + font-size: 14px; + line-height: 1.25; +} + +.wa-deck-side-card ul { + display: grid; + gap: 7px; + margin: 0; + padding-left: 16px; +} + +.wa-deck-patch-plan { + border-color: rgba(217,119,87,.16); +} + +.wa-deck-patch-metrics { + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--muted, #9aa3af); + font-size: 11px; +} + +.wa-deck-patch-metrics span { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 23px; + padding: 0 7px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 999px; + background: rgba(255,255,255,.022); +} + +.wa-deck-patch-metrics b { + color: var(--text, #f3f6fb); +} + +.wa-deck-patch-list { + display: grid; + gap: 7px; +} + +.wa-deck-patch-item { + display: grid; + gap: 7px; + padding: 9px; + border: 1px solid rgba(255,255,255,.06); + border-radius: 8px; + background: rgba(255,255,255,.018); +} + +.wa-deck-patch-item[data-status="ready_for_review"] { + border-color: rgba(85,212,154,.18); + background: rgba(85,212,154,.035); +} + +.wa-deck-patch-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + color: var(--text, #f3f6fb); + font-size: 12px; + font-weight: 800; + line-height: 1.3; +} + +.wa-deck-patch-title span:first-child { + min-width: 0; +} + +.wa-deck-patch-title span:last-child { + flex: 0 0 auto; + color: var(--accent-ink, #e59579); + font: 10px/1.3 var(--font-mono); +} + +.wa-deck-patch-item[data-status="ready_for_review"] .wa-deck-patch-title span:last-child { + color: #55d49a; +} + +.wa-deck-patch-diff { + display: grid; + grid-template-columns: 46px minmax(0, 1fr); + gap: 5px 7px; + padding-top: 2px; + color: var(--muted, #9aa3af); +} + +.wa-deck-patch-diff span { + color: var(--muted, #9aa3af); + font: 10px/1.35 var(--font-mono); + text-transform: uppercase; +} + +.wa-deck-patch-diff p { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-deck-patch-item button { + justify-self: start; + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 7px; + background: transparent; + color: var(--muted, #9aa3af); + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.wa-deck-patch-item button:hover { + border-color: var(--accent-border, rgba(217,119,87,.28)); + color: var(--accent-ink, #e59579); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-graph-review { + display: grid; + flex: 0 0 auto; + gap: 12px; + min-height: 0; + padding-top: 12px; + border-top: 1px solid var(--border, rgba(255,255,255,.08)); +} + +.wa-graph-review-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.wa-graph-review-head h3 { + margin: 0; + color: var(--text, #f3f6fb); + font-size: 17px; + line-height: 1.2; +} + +.wa-graph-review-head p:not(.wa-eyebrow) { + margin: 6px 0 0; + max-width: 720px; + color: var(--muted, #9aa3af); + font-size: 12.5px; + line-height: 1.45; +} + +.wa-graph-review-actions, +.wa-graph-review-meta, +.wa-graph-review-refs, +.wa-graph-review-card-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.wa-graph-review-actions { + align-items: center; +} + +.wa-graph-review-meta { + color: var(--muted, #9aa3af); + font: 10.5px/1.2 var(--font-mono); +} + +.wa-graph-review-meta span { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 999px; + background: rgba(255,255,255,.025); +} + +.wa-graph-review-meta span[data-status="confirmed"] { + color: #55d49a; + border-color: rgba(85,212,154,.22); + background: rgba(85,212,154,.08); +} + +.wa-graph-review-meta span[data-status="needs_confirmation"] { + color: var(--accent-ink, #e59579); + border-color: var(--accent-border, rgba(217,119,87,.28)); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-graph-review-grid { + display: grid; + gap: 8px; +} + +.wa-graph-review-card { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + padding: 10px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.022); +} + +.wa-graph-review-card[data-status="needs_confirmation"] { + border-color: rgba(217,119,87,.2); + background: rgba(217,119,87,.04); +} + +.wa-graph-review-icon { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border: 1px solid rgba(255,255,255,.07); + border-radius: 8px; + color: var(--accent-ink, #e59579); + background: rgba(217,119,87,.1); +} + +.wa-graph-review-card[data-status="confirmed"] .wa-graph-review-icon { + color: #55d49a; + background: rgba(85,212,154,.08); +} + +.wa-graph-review-copy { + display: grid; + gap: 5px; + min-width: 0; +} + +.wa-graph-review-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--text, #f3f6fb); + font-size: 13px; + font-weight: 800; + line-height: 1.25; +} + +.wa-graph-review-title span:first-child, +.wa-graph-review-copy p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-graph-review-title span:last-child { + flex: 0 0 auto; + color: var(--accent-ink, #e59579); + font: 10px/1.25 var(--font-mono); +} + +.wa-graph-review-card[data-status="confirmed"] .wa-graph-review-title span:last-child { + color: #55d49a; +} + +.wa-graph-review-copy p { + margin: 0; + color: var(--muted, #9aa3af); + font-size: 12px; + line-height: 1.35; +} + +.wa-graph-review-copy p b { + color: var(--text, #f3f6fb); +} + +.wa-graph-review-refs { + color: var(--muted, #9aa3af); + font: 10.5px/1.25 var(--font-mono); +} + +.wa-graph-review-card-actions { + justify-content: flex-end; +} + +.wa-graph-review-card-actions button { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 7px; + background: transparent; + color: var(--muted, #9aa3af); + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.wa-graph-review-card-actions button:hover { + border-color: var(--accent-border, rgba(217,119,87,.28)); + color: var(--accent-ink, #e59579); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-notebook { + display: grid; + flex: 0 0 auto; + gap: 14px; + min-height: 0; + padding-top: 12px; + border-top: 1px solid var(--border, rgba(255,255,255,.08)); +} + +.wa-notebook-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.wa-notebook-head h3 { + margin: 0; + color: var(--text, #f3f6fb); + font-size: 17px; + line-height: 1.2; +} + +.wa-notebook-head p:not(.wa-eyebrow) { + margin: 6px 0 0; + max-width: 760px; + color: var(--muted, #9aa3af); + font-size: 12.5px; + line-height: 1.45; +} + +.wa-notebook-head-actions, +.wa-notebook-meta, +.wa-notebook-block-meta, +.wa-notebook-block-receipts, +.wa-notebook-source-list { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.wa-notebook-head-actions { + align-items: center; + justify-content: flex-end; +} + +.wa-notebook-open { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 0 9px; + border: 1px solid var(--accent-border, rgba(217,119,87,.28)); + border-radius: 7px; + background: var(--accent-tint, rgba(217,119,87,.12)); + color: var(--accent-ink, #e59579); + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.wa-notebook-open:hover { + background: rgba(217,119,87,.18); +} + +.wa-notebook-meta { + color: var(--muted, #9aa3af); + font: 10.5px/1.2 var(--font-mono); +} + +.wa-notebook-meta span { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 999px; + background: rgba(255,255,255,.025); +} + +.wa-notebook-meta span[data-status="ready"], +.wa-notebook-meta span[data-status="approved"] { + color: #55d49a; + border-color: rgba(85,212,154,.22); + background: rgba(85,212,154,.08); +} + +.wa-notebook-meta span[data-status="needs_review"], +.wa-notebook-meta span[data-status="pending"] { + color: var(--accent-ink, #e59579); + border-color: var(--accent-border, rgba(217,119,87,.28)); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-notebook-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(240px, 28%); + gap: 14px; + min-height: 0; +} + +.wa-notebook-blocks, +.wa-notebook-side, +.wa-notebook-section-list, +.wa-notebook-patch-list { + display: grid; + gap: 8px; + min-width: 0; +} + +.wa-notebook-side { + align-content: start; + gap: 10px; +} + +.wa-notebook-block { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; + padding: 11px 12px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.025); +} + +.wa-notebook-block[data-status="needs_review"] { + border-color: rgba(217,119,87,.22); + background: rgba(217,119,87,.045); +} + +.wa-notebook-block-index { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 8px; + color: var(--accent-ink, #e59579); + background: rgba(217,119,87,.12); + font: 700 11px/1 var(--font-mono); +} + +.wa-notebook-block-copy { + display: grid; + gap: 6px; + min-width: 0; +} + +.wa-notebook-block-copy p, +.wa-notebook-side-card p, +.wa-notebook-section p { + margin: 0; + color: var(--muted, #9aa3af); + font-size: 12px; + line-height: 1.4; +} + +.wa-notebook-block-meta, +.wa-notebook-block-receipts { + color: var(--muted, #9aa3af); + font: 10px/1.25 var(--font-mono); +} + +.wa-notebook-block-meta span[data-type] { + color: #b8c7ff; +} + +.wa-notebook-block-meta span[data-status="needs_review"] { + color: var(--accent-ink, #e59579); +} + +.wa-notebook-block-meta span[data-status="accepted"] { + color: #55d49a; +} + +.wa-notebook-block-receipts span, +.wa-notebook-source-list span { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-notebook-block-receipts span { + padding: 3px 7px; + border-radius: 999px; + color: var(--accent-ink, #e59579); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-notebook-more { + padding: 10px 12px; + border: 1px dashed var(--border, rgba(255,255,255,.08)); + border-radius: 8px; + color: var(--muted, #9aa3af); + font: 11px/1.25 var(--font-mono); +} + +.wa-notebook-side-card { + display: grid; + gap: 8px; + padding: 11px 12px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.025); +} + +.wa-notebook-side-card h4 { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--text, #f3f6fb); + font-size: 13px; + line-height: 1.25; +} + +.wa-notebook-section { + display: grid; + gap: 3px; + padding: 8px; + border-radius: 8px; + background: rgba(255,255,255,.024); +} + +.wa-notebook-section span { + min-width: 0; + overflow: hidden; + color: var(--text, #f3f6fb); + font-size: 12px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-notebook-source-list span { + padding: 5px 7px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 999px; + color: var(--muted, #9aa3af); + background: rgba(255,255,255,.025); + font: 10px/1.2 var(--font-mono); +} + +.wa-notebook-type-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.wa-notebook-type-list span { + display: inline-flex; + min-height: 24px; + align-items: center; + padding: 0 8px; + border: 1px solid rgba(184,199,255,.16); + border-radius: 999px; + color: #b8c7ff; + background: rgba(184,199,255,.07); + font: 10px/1.2 var(--font-mono); +} + +.wa-notebook-exec-metrics, +.wa-notebook-exec-refs { + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--muted, #9aa3af); + font-size: 11px; +} + +.wa-notebook-exec-metrics span { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 23px; + padding: 0 7px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 999px; + background: rgba(255,255,255,.022); +} + +.wa-notebook-exec-metrics b { + color: var(--text, #f3f6fb); +} + +.wa-notebook-exec-list { + display: grid; + gap: 7px; +} + +.wa-notebook-exec-item { + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid rgba(255,255,255,.055); + border-radius: 8px; + background: rgba(255,255,255,.024); +} + +.wa-notebook-exec-item[data-status="ready"] { + border-color: rgba(85,212,154,.18); + background: rgba(85,212,154,.035); +} + +.wa-notebook-exec-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--text, #f3f6fb); + font-size: 12px; + font-weight: 800; +} + +.wa-notebook-exec-title span:last-child { + flex: 0 0 auto; + color: var(--accent-ink, #e59579); + font: 10px/1.25 var(--font-mono); +} + +.wa-notebook-exec-item[data-status="ready"] .wa-notebook-exec-title span:last-child { + color: #55d49a; +} + +.wa-notebook-exec-item p, +.wa-notebook-exec-item strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wa-notebook-exec-item p { + margin: 0; + color: var(--muted, #9aa3af); + font-size: 11.5px; + line-height: 1.35; +} + +.wa-notebook-exec-item strong { + color: var(--text, #f3f6fb); + font-size: 12px; + line-height: 1.3; +} + +.wa-notebook-exec-refs { + font: 10px/1.25 var(--font-mono); +} + +.wa-notebook-exec-run { + justify-self: start; + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 27px; + padding: 0 8px; + border: 1px solid var(--accent-border, rgba(217,119,87,.28)); + border-radius: 7px; + background: var(--accent-tint, rgba(217,119,87,.1)); + color: var(--accent-ink, #e59579); + font-size: 11px; + font-weight: 800; +} + +.wa-notebook-exec-run:disabled { + cursor: default; + opacity: .42; +} + +.wa-notebook-kernel-output { + display: grid; + gap: 6px; + padding: 8px; + border-left: 2px solid var(--accent-primary, #d97757); + background: rgba(0,0,0,.16); +} + +.wa-notebook-kernel-output[data-status="completed"] { + border-left-color: #55d49a; +} + +.wa-notebook-kernel-output > div { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--muted, #9aa3af); + font: 9.5px/1.2 var(--font-mono); +} + +.wa-notebook-kernel-output pre { + max-height: 150px; + margin: 0; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--muted, #9aa3af); + font: 10px/1.35 var(--font-mono); +} + +.wa-notebook-kernel-message { + padding: 7px 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 7px; + color: var(--muted, #9aa3af); + font-size: 11px; + line-height: 1.35; +} + +.wa-spin { + animation: wa-spin .8s linear infinite; +} + +@keyframes wa-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .wa-spin { animation: none; } +} + +.wa-notebook-patch { + display: grid; + gap: 5px; + padding: 8px; + border: 1px solid rgba(255,255,255,.055); + border-radius: 8px; + background: rgba(255,255,255,.024); +} + +.wa-notebook-patch[data-status="pending"] { + border-color: rgba(217,119,87,.2); + background: rgba(217,119,87,.045); +} + +.wa-notebook-patch span { + color: var(--accent-ink, #e59579); + font: 10px/1.2 var(--font-mono); +} + +.wa-notebook-patch p { + margin: 0; + color: var(--muted, #9aa3af); + font-size: 11.5px; + line-height: 1.35; +} + +.wa-notebook-patch strong { + color: var(--text, #f3f6fb); + font-size: 12px; + line-height: 1.3; +} + +.wa-notebook-diff { + display: grid; + gap: 5px; + margin-top: 3px; + padding-top: 6px; + border-top: 1px solid rgba(255,255,255,.06); +} + +.wa-notebook-diff-row { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + gap: 7px; + align-items: start; +} + +.wa-notebook-diff-row > span { + color: var(--muted, #9aa3af); + font: 10px/1.35 var(--font-mono); + text-transform: uppercase; +} + +.wa-notebook-diff-row p { + display: flex; + flex-wrap: wrap; + gap: 3px; + min-width: 0; + margin: 0; + color: var(--text, #f3f6fb); +} + +.wa-notebook-diff-row p span { + color: var(--text, #f3f6fb); + font: 11px/1.45 var(--font-mono); +} + +.wa-notebook-diff-row p span[data-kind="removed"] { + color: #ff9b8f; + text-decoration: line-through; + background: rgba(255,122,112,.1); +} + +.wa-notebook-diff-row p span[data-kind="added"] { + color: #55d49a; + background: rgba(85,212,154,.1); +} + +.wa-traceplay { + display: grid; + flex: 0 0 auto; + gap: 14px; + min-height: 0; + padding-top: 12px; + border-top: 1px solid var(--border, rgba(255,255,255,.08)); +} + +.wa-traceplay-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.wa-traceplay-head h3 { + margin: 0; + color: var(--text, #f3f6fb); + font-size: 17px; + line-height: 1.2; +} + +.wa-traceplay-head p:not(.wa-eyebrow) { + margin: 6px 0 0; + max-width: 760px; + color: var(--muted, #9aa3af); + font-size: 12.5px; + line-height: 1.45; +} + +.wa-traceplay-meta, +.wa-traceplay-receipts, +.wa-traceplay-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.wa-traceplay-meta { + color: var(--muted, #9aa3af); + font: 10.5px/1.2 var(--font-mono); +} + +.wa-traceplay-meta span { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 999px; + background: rgba(255,255,255,.025); +} + +.wa-traceplay-meta span[data-status="ready"], +.wa-traceplay-phase[data-status="ready"] .wa-traceplay-phase-icon { + color: #55d49a; + border-color: rgba(85,212,154,.22); + background: rgba(85,212,154,.08); +} + +.wa-traceplay-meta span[data-status="running"], +.wa-traceplay-meta span[data-status="needs_review"], +.wa-traceplay-phase[data-status="running"] .wa-traceplay-phase-icon, +.wa-traceplay-phase[data-status="needs_review"] .wa-traceplay-phase-icon { + color: var(--accent-ink, #e59579); + border-color: var(--accent-border, rgba(217,119,87,.28)); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-traceplay-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 30%); + gap: 14px; + min-height: 0; +} + +.wa-traceplay-phases, +.wa-traceplay-side, +.wa-traceplay-events { + display: grid; + gap: 8px; + min-width: 0; +} + +.wa-traceplay-side { + align-content: start; + gap: 10px; +} + +.wa-traceplay-phase { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; + padding: 11px 12px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.025); +} + +.wa-traceplay-phase[data-critical="true"] { + border-color: rgba(217,119,87,.18); +} + +.wa-traceplay-phase[data-focus="true"] { + outline: 1px solid var(--accent-border, rgba(217,119,87,.32)); + background: rgba(217,119,87,.055); +} + +.wa-traceplay-phase-icon { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 8px; + color: var(--muted, #9aa3af); + background: rgba(255,255,255,.035); +} + +.wa-traceplay-phase-copy { + display: grid; + gap: 7px; + min-width: 0; +} + +.wa-traceplay-phase-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.wa-traceplay-phase-title h4, +.wa-traceplay-side-card h4 { + margin: 0; + color: var(--text, #f3f6fb); + font-size: 13px; + line-height: 1.25; +} + +.wa-traceplay-phase-title span, +.wa-traceplay-receipts, +.wa-traceplay-mini span, +.wa-traceplay-event span { + color: var(--muted, #9aa3af); + font: 10px/1.25 var(--font-mono); +} + +.wa-traceplay-phase-copy p, +.wa-traceplay-side-card p, +.wa-traceplay-mini p, +.wa-traceplay-event p { + margin: 0; + color: var(--muted, #9aa3af); + font-size: 12px; + line-height: 1.4; +} + +.wa-traceplay-receipts span { + padding: 3px 7px; + border-radius: 999px; + color: var(--muted, #9aa3af); + background: rgba(255,255,255,.035); +} + +.wa-traceplay-actions button, +.wa-traceplay-event button { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 0 8px; + border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: 7px; + background: transparent; + color: var(--muted, #9aa3af); + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.wa-traceplay-actions button:hover, +.wa-traceplay-event button:hover { + border-color: var(--accent-border, rgba(217,119,87,.28)); + color: var(--accent-ink, #e59579); + background: var(--accent-tint, rgba(217,119,87,.12)); +} + +.wa-traceplay-side-card { + display: grid; + gap: 8px; + padding: 11px 12px; + border: 1px solid rgba(255,255,255,.065); + border-radius: 8px; + background: rgba(255,255,255,.025); +} + +.wa-traceplay-side-card h4 { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.wa-traceplay-mini, +.wa-traceplay-event { + display: grid; + gap: 4px; + padding: 8px; + border-radius: 8px; + background: rgba(255,255,255,.024); +} + +.wa-traceplay-mini[data-status="running"], +.wa-traceplay-mini[data-status="needs_review"], +.wa-traceplay-event[data-focus="true"] { + background: rgba(217,119,87,.055); +} + +@media (max-width: 760px) { + .wa-panel { + padding: 14px; + } + + .wa-header, + .wa-review-head, + .wa-live-head, + .wa-live-grid, + .wa-row { + display: flex; + flex-direction: column; + align-items: stretch; + } + + .wa-summary, + .wa-review-counts, + .wa-live-metrics, + .wa-proof { + justify-content: flex-start; + } + + .wa-header-side { + justify-items: start; + } + + .wa-deck-grid, + .wa-notebook-grid, + .wa-traceplay-grid, + .wa-deck-slide, + .wa-notebook-block, + .wa-traceplay-phase, + .wa-review-card { + grid-template-columns: 1fr; + } + + .wa-review-actions { + justify-content: flex-start; + } + + .wa-notebook-head { + display: flex; + flex-direction: column; + align-items: stretch; + } + + .wa-notebook-head-actions { + justify-content: flex-start; + } +} diff --git a/src/ui/workArtifacts/workArtifactAdapters.ts b/src/ui/workArtifacts/workArtifactAdapters.ts new file mode 100644 index 00000000..a9c031e1 --- /dev/null +++ b/src/ui/workArtifacts/workArtifactAdapters.ts @@ -0,0 +1,329 @@ +import type { Artifact, CellEvidence, CellPayload, Element, Proposal, TraceEvent } from "../../engine/types"; +import type { SemanticGraphViewModel } from "../graph/semanticGraphTypes"; +import { buildNotebookArtifactStructure } from "./notebookStructure"; +import { + type DeckArtifactInput, + type ExportArtifactInput, + type WorkArtifactAction, + type WorkArtifactReceipt, + type WorkArtifactStatus, + type WorkArtifactViewModel, + workArtifactKindFromEngine, + workArtifactStatusFromProposal, +} from "./workArtifactTypes"; + +type BuildWorkArtifactsInput = { + artifacts?: Artifact[]; + proposals?: Proposal[]; + traces?: TraceEvent[]; + graph?: SemanticGraphViewModel; + decks?: DeckArtifactInput[]; + exports?: ExportArtifactInput[]; +}; + +const OPEN_ACTION: WorkArtifactAction = { id: "open", label: "Open" }; +const COMMENT_ACTION: WorkArtifactAction = { id: "comment", label: "Comment" }; +const ASK_ACTION: WorkArtifactAction = { id: "ask_nodeagent", label: "Ask NodeAgent" }; +const TRACE_ACTION: WorkArtifactAction = { id: "view_trace", label: "View trace" }; + +function isCellPayload(value: unknown): value is CellPayload { + return typeof value === "object" && value !== null && ("status" in value || "evidence" in value || "confidence" in value || "error" in value); +} + +function evidenceFromValue(value: unknown): CellEvidence[] { + return isCellPayload(value) && Array.isArray(value.evidence) ? value.evidence : []; +} + +function statusFromValue(value: unknown): WorkArtifactStatus | null { + if (!isCellPayload(value)) return null; + if (value.status === "running") return "running"; + if (value.status === "failed" || value.error) return "failed"; + if (value.status === "needs_review" || value.status === "gap") return "needs_review"; + if (value.status === "complete") return "ready"; + if (value.status === "empty") return "empty"; + return null; +} + +function elementText(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (isCellPayload(value)) return elementText(value.value); + if (typeof value === "object" && value !== null && "text" in value && typeof value.text === "string") return value.text; + return ""; +} + +function countUnresolvedElements(elements: Element[]): number { + return elements.filter((element) => { + const status = statusFromValue(element.value); + if (status === "needs_review" || status === "failed") return true; + return /\b(needs[_\s-]?review|todo|tbd|unknown|gap|missing source|unsupported)\b/i.test(elementText(element.value)); + }).length; +} + +function rollupStatus(statuses: WorkArtifactStatus[], unresolvedCount: number, elementCount: number): WorkArtifactStatus { + if (statuses.includes("failed")) return "failed"; + if (statuses.includes("running")) return "running"; + if (unresolvedCount > 0 || statuses.includes("needs_review")) return "needs_review"; + if (elementCount === 0 || statuses.every((status) => status === "empty")) return "empty"; + return "ready"; +} + +function unique(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))]; +} + +function artifactSummary(artifact: Artifact, evidenceCount: number, unresolvedCount: number): string { + const elementCount = artifact.order.length || Object.keys(artifact.elements).length; + const base = artifact.meta?.summary?.trim(); + if (base) return base; + const noun = artifact.kind === "sheet" ? "cell" : artifact.kind === "note" ? "block" : "item"; + const parts = [`${elementCount} ${noun}${elementCount === 1 ? "" : "s"}`]; + if (evidenceCount > 0) parts.push(`${evidenceCount} evidence`); + if (unresolvedCount > 0) parts.push(`${unresolvedCount} needs review`); + return parts.join(" - "); +} + +function proposalSummary(proposal: Proposal, artifact?: Artifact): string { + const target = artifact ? artifact.title : proposal.artifactId; + const review = proposal.review?.reason || proposal.review?.reviewerNote; + return review ? `${target}: ${review}` : `${proposal.author.name} proposed ${proposal.op.kind} on ${proposal.op.elementId}`; +} + +function emptyReceipt(overrides: Partial = {}): WorkArtifactReceipt { + return { + traceIds: [], + sourceIds: [], + proposalIds: [], + evidenceCount: 0, + unresolvedCount: 0, + ...overrides, + }; +} + +export function mapEngineArtifactToWorkArtifact(artifact: Artifact, related?: { traces?: TraceEvent[]; proposals?: Proposal[] }): WorkArtifactViewModel { + const elements = Object.values(artifact.elements); + const notebook = artifact.kind === "note" ? buildNotebookArtifactStructure(artifact, related) : null; + const evidence = elements.flatMap((element) => evidenceFromValue(element.value)); + const statuses = elements.map((element) => statusFromValue(element.value)).filter((status): status is WorkArtifactStatus => status !== null); + const unresolvedCount = notebook ? notebook.needsReviewCount : countUnresolvedElements(elements); + const relatedTraces = related?.traces?.filter((trace) => trace.refs?.artifactId === artifact.id) ?? []; + const relatedProposals = related?.proposals?.filter((proposal) => proposal.artifactId === artifact.id) ?? []; + const sourceIds = notebook?.sourceIds ?? unique(evidence.map((item) => item.sourceArtifactId ?? item.sourceStorageId ?? item.providerFileId ?? item.url ?? item.source)); + const receiptTraceIds = notebook?.traceIds ?? relatedTraces.map((trace) => trace.id); + const receiptProposalIds = notebook?.proposalIds ?? relatedProposals.map((proposal) => proposal.id); + const receiptEvidenceCount = notebook?.evidenceCount ?? evidence.length; + + return { + id: `artifact:${artifact.id}`, + roomId: artifact.roomId, + kind: workArtifactKindFromEngine(artifact.kind), + sourceKind: artifact.kind, + title: artifact.title, + summary: notebook?.summary ?? artifactSummary(artifact, evidence.length, unresolvedCount), + status: notebook?.status ?? rollupStatus(statuses, unresolvedCount, elements.length), + version: artifact.version, + owner: artifact.createdBy, + updatedAt: artifact.updatedAt, + receipt: emptyReceipt({ + traceIds: receiptTraceIds, + sourceIds, + proposalIds: receiptProposalIds, + evidenceCount: receiptEvidenceCount, + unresolvedCount, + }), + refs: [ + { artifactId: artifact.id, label: artifact.title }, + ...(notebook?.sections.slice(0, 6).map((section) => ({ artifactId: artifact.id, elementId: section.id, label: section.title })) ?? []), + ...relatedTraces.map((trace) => ({ artifactId: artifact.id, traceId: trace.id, label: trace.summary })), + ...relatedProposals.map((proposal) => ({ artifactId: artifact.id, proposalId: proposal.id, label: proposal.op.elementId })), + ], + actions: [OPEN_ACTION, COMMENT_ACTION, ASK_ACTION, { id: "propose_patch", label: "Propose patch" }, TRACE_ACTION], + meta: { + elementCount: elements.length, + visibility: artifact.visibility ?? "room", + tags: artifact.meta?.tags?.join(", "), + blockCount: notebook?.blockCount, + sectionCount: notebook?.sectionCount, + agentBlockCount: notebook?.agentBlockCount, + humanBlockCount: notebook?.humanBlockCount, + citationCount: notebook?.citationCount, + }, + }; +} + +export function mapProposalToWorkArtifact(proposal: Proposal, artifact?: Artifact, relatedTraces: TraceEvent[] = []): WorkArtifactViewModel { + const traceIds = relatedTraces + .filter((trace) => trace.refs?.proposalId === proposal.id || trace.refs?.artifactId === proposal.artifactId) + .map((trace) => trace.id); + const status = workArtifactStatusFromProposal(proposal.status); + const actions: WorkArtifactAction[] = status === "pending" + ? [OPEN_ACTION, { id: "accept", label: "Accept", tone: "success" }, { id: "reject", label: "Reject", tone: "danger" }, TRACE_ACTION] + : [OPEN_ACTION, TRACE_ACTION]; + + return { + id: `proposal:${proposal.id}`, + roomId: proposal.roomId, + kind: "proposal", + sourceKind: "proposal", + title: `Proposal: ${proposal.op.elementId}`, + summary: proposalSummary(proposal, artifact), + status, + owner: proposal.author, + createdAt: proposal.createdAt, + updatedAt: proposal.resolvedAt ?? proposal.createdAt, + receipt: emptyReceipt({ + traceIds, + proposalIds: [proposal.id], + unresolvedCount: status === "pending" ? 1 : 0, + }), + refs: [ + { artifactId: proposal.artifactId, proposalId: proposal.id, label: proposal.op.elementId }, + ...traceIds.map((traceId) => ({ artifactId: proposal.artifactId, proposalId: proposal.id, traceId })), + ], + actions, + meta: { + operation: proposal.op.kind, + reviewKind: proposal.review?.kind, + reviewStatus: proposal.review?.status, + baseVersion: proposal.op.baseVersion, + }, + }; +} + +export function mapTraceToWorkArtifact(trace: TraceEvent): WorkArtifactViewModel { + const failed = /failed|blocked|denied|conflict/i.test(trace.type) || /failed|blocked|error/i.test(trace.summary); + const needsReview = /proposal|proposed|review|conflict/i.test(trace.type); + return { + id: `trace:${trace.id}`, + roomId: trace.roomId, + kind: "trace", + sourceKind: "trace_event", + title: trace.summary, + summary: trace.detail, + status: failed ? "failed" : needsReview ? "needs_review" : "ready", + owner: trace.actor, + createdAt: trace.ts, + updatedAt: trace.ts, + receipt: emptyReceipt({ + traceIds: [trace.id], + unresolvedCount: failed || needsReview ? 1 : 0, + }), + refs: [{ artifactId: trace.refs?.artifactId, elementId: trace.refs?.elementId, traceId: trace.id, label: trace.summary }], + actions: [OPEN_ACTION, TRACE_ACTION], + meta: { + traceType: trace.type, + elementId: trace.refs?.elementId, + }, + }; +} + +export function mapSemanticGraphToWorkArtifact(roomId: string, graph: SemanticGraphViewModel): WorkArtifactViewModel { + const unresolvedCount = graph.stats.openQuestions + graph.nodes.filter((node) => node.status === "needs_review" || node.status === "failed").length; + const traceIds = unique(graph.nodes.flatMap((node) => node.refs.map((ref) => ref.traceId))); + const proposalIds = unique(graph.nodes.flatMap((node) => node.refs.map((ref) => ref.proposalId))); + const sourceIds = unique(graph.nodes.flatMap((node) => node.refs.map((ref) => ref.sourceUrl ?? ref.evidenceId ?? ref.artifactId))); + + return { + id: `graph:${roomId}`, + roomId, + kind: "graph", + sourceKind: "semantic_graph", + title: "Proof graph", + summary: `${graph.stats.nodes} nodes - ${graph.stats.edges} edges - ${graph.stats.backedFacts} backed facts`, + status: unresolvedCount > 0 ? "needs_review" : "ready", + receipt: emptyReceipt({ + traceIds, + proposalIds, + sourceIds, + evidenceCount: graph.stats.backedFacts, + unresolvedCount, + }), + refs: [ + ...traceIds.map((traceId) => ({ traceId, label: "Trace-linked graph node" })), + ...proposalIds.map((proposalId) => ({ proposalId, label: "Proposal-linked graph node" })), + ], + actions: [OPEN_ACTION, COMMENT_ACTION, ASK_ACTION, TRACE_ACTION], + meta: { + nodes: graph.stats.nodes, + edges: graph.stats.edges, + companies: graph.stats.companies, + people: graph.stats.people, + }, + }; +} + +export function mapDeckArtifactToWorkArtifact(deck: DeckArtifactInput): WorkArtifactViewModel { + const evidenceCount = deck.sections.reduce((sum, section) => sum + (section.evidenceCount ?? 0), 0); + const unresolvedCount = deck.sections.reduce((sum, section) => sum + (section.unresolvedCount ?? 0), 0); + const status = deck.status ?? (unresolvedCount > 0 || deck.storyboardStatus === "needs_review" ? "needs_review" : "ready"); + + return { + id: `deck:${deck.id}`, + roomId: deck.roomId, + kind: "deck", + sourceKind: "deck_storyboard", + title: deck.title, + summary: `${deck.sections.length} storyboard section${deck.sections.length === 1 ? "" : "s"} - ${evidenceCount} evidence`, + status, + version: deck.version, + owner: deck.owner, + createdAt: deck.createdAt, + updatedAt: deck.updatedAt, + receipt: emptyReceipt({ + traceIds: deck.traceIds ?? [], + sourceIds: deck.sourceIds ?? [], + proposalIds: deck.proposalIds ?? [], + evidenceCount, + unresolvedCount, + }), + refs: deck.sections.map((section) => ({ elementId: section.id, label: section.title })), + actions: [OPEN_ACTION, COMMENT_ACTION, ASK_ACTION, { id: "propose_patch", label: "Propose patch" }, { id: "export", label: "Export" }, TRACE_ACTION], + meta: { + storyboardStatus: deck.storyboardStatus, + sectionCount: deck.sections.length, + }, + }; +} + +export function mapExportToWorkArtifact(exportArtifact: ExportArtifactInput): WorkArtifactViewModel { + return { + id: `export:${exportArtifact.id}`, + roomId: exportArtifact.roomId, + kind: "export", + sourceKind: "export_bundle", + title: exportArtifact.title, + summary: `${exportArtifact.format.toUpperCase()} export${exportArtifact.artifactCount ? ` - ${exportArtifact.artifactCount} artifacts` : ""}`, + status: exportArtifact.status ?? "ready", + createdAt: exportArtifact.createdAt, + updatedAt: exportArtifact.updatedAt ?? exportArtifact.createdAt, + receipt: emptyReceipt({ + traceIds: exportArtifact.traceIds ?? [], + sourceIds: exportArtifact.sourceIds ?? [], + proposalIds: exportArtifact.proposalIds ?? [], + evidenceCount: exportArtifact.evidenceCount ?? 0, + unresolvedCount: exportArtifact.unresolvedCount ?? 0, + }), + refs: [{ exportId: exportArtifact.id, label: exportArtifact.title }], + actions: [OPEN_ACTION, { id: "export", label: "Download" }, TRACE_ACTION], + meta: { + format: exportArtifact.format, + artifactCount: exportArtifact.artifactCount, + }, + }; +} + +export function buildWorkArtifacts(input: BuildWorkArtifactsInput): WorkArtifactViewModel[] { + const artifacts = input.artifacts ?? []; + const proposals = input.proposals ?? []; + const traces = input.traces ?? []; + const byArtifactId = new Map(artifacts.map((artifact) => [artifact.id, artifact])); + + return [ + ...artifacts.map((artifact) => mapEngineArtifactToWorkArtifact(artifact, { traces, proposals })), + ...(input.graph ? [mapSemanticGraphToWorkArtifact(input.graph.generatedFrom.fallbackDemo ? "room" : artifacts[0]?.roomId ?? "room", input.graph)] : []), + ...(input.decks ?? []).map(mapDeckArtifactToWorkArtifact), + ...proposals.map((proposal) => mapProposalToWorkArtifact(proposal, byArtifactId.get(proposal.artifactId), traces)), + ...traces.map(mapTraceToWorkArtifact), + ...(input.exports ?? []).map(mapExportToWorkArtifact), + ]; +} diff --git a/src/ui/workArtifacts/workArtifactTypes.ts b/src/ui/workArtifacts/workArtifactTypes.ts new file mode 100644 index 00000000..00173ac8 --- /dev/null +++ b/src/ui/workArtifacts/workArtifactTypes.ts @@ -0,0 +1,127 @@ +import type { Actor, ArtifactKind, ProposalStatus } from "../../engine/types"; + +export type WorkArtifactKind = + | "spreadsheet" + | "notebook" + | "wall" + | "deck" + | "graph" + | "trace" + | "proposal" + | "export"; + +export type WorkArtifactStatus = + | "empty" + | "ready" + | "running" + | "needs_review" + | "failed" + | "pending" + | "approved" + | "rejected"; + +export type WorkArtifactActionId = + | "open" + | "pin" + | "comment" + | "ask_nodeagent" + | "propose_patch" + | "accept" + | "reject" + | "export" + | "view_trace"; + +export interface WorkArtifactAction { + id: WorkArtifactActionId; + label: string; + tone?: "default" | "review" | "danger" | "success"; + disabled?: boolean; + reason?: string; +} +export interface WorkArtifactRef { + artifactId?: string; + elementId?: string; + traceId?: string; + proposalId?: string; + sourceId?: string; + exportId?: string; + label?: string; +} + +export interface WorkArtifactReceipt { + traceIds: string[]; + sourceIds: string[]; + proposalIds: string[]; + evidenceCount: number; + unresolvedCount: number; +} + +export interface WorkArtifactViewModel { + id: string; + roomId: string; + kind: WorkArtifactKind; + sourceKind?: ArtifactKind | "semantic_graph" | "deck_storyboard" | "export_bundle" | "trace_event" | "proposal"; + title: string; + summary?: string; + status: WorkArtifactStatus; + version?: number | string; + owner?: Pick; + updatedAt?: number; + createdAt?: number; + receipt: WorkArtifactReceipt; + refs: WorkArtifactRef[]; + actions: WorkArtifactAction[]; + meta?: Record; +} + +export interface DeckStoryboardSection { + id: string; + title: string; + claimCount?: number; + evidenceCount?: number; + unresolvedCount?: number; +} + +export interface DeckArtifactInput { + id: string; + roomId: string; + title: string; + status?: WorkArtifactStatus; + version?: number | string; + updatedAt?: number; + createdAt?: number; + owner?: WorkArtifactViewModel["owner"]; + storyboardStatus?: "draft" | "approved" | "needs_review"; + sections: DeckStoryboardSection[]; + traceIds?: string[]; + sourceIds?: string[]; + proposalIds?: string[]; +} + +export interface ExportArtifactInput { + id: string; + roomId: string; + title: string; + format: "pptx" | "pdf" | "xlsx" | "docx" | "zip" | "json" | "html"; + status?: WorkArtifactStatus; + createdAt?: number; + updatedAt?: number; + traceIds?: string[]; + sourceIds?: string[]; + proposalIds?: string[]; + artifactCount?: number; + evidenceCount?: number; + unresolvedCount?: number; +} + +export function workArtifactKindFromEngine(kind: ArtifactKind): WorkArtifactKind { + if (kind === "sheet") return "spreadsheet"; + if (kind === "note") return "notebook"; + return "wall"; +} + +export function workArtifactStatusFromProposal(status: ProposalStatus): WorkArtifactStatus { + if (status === "approved") return "approved"; + if (status === "rejected") return "rejected"; + return "pending"; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8adca8ec..a999daa2 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -8,6 +8,10 @@ interface ImportMetaEnv { /** Native notebook editor mode. `prosemirror` enables Convex ProseMirror Sync; * unset keeps the legacy Tiptap HTML-on-blur editor (Option A fallback). */ readonly VITE_NOTEBOOK_SYNC?: "prosemirror"; + /** Require verified account auth before live room mutations. */ + readonly VITE_NODEROOM_AUTH_REQUIRED?: "0" | "1"; + /** Production uses GitHub; password is reserved for isolated local/preview QA. */ + readonly VITE_NODEROOM_AUTH_PROVIDER?: "github" | "password"; } interface ImportMeta { diff --git a/tests/accountGate.test.tsx b/tests/accountGate.test.tsx new file mode 100644 index 00000000..5feb1ec9 --- /dev/null +++ b/tests/accountGate.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AccountGate } from "../src/ui/auth/AccountGate"; + +const signIn = vi.fn(async (_provider: string, _params?: unknown) => ({ signingIn: true })); + +vi.mock("@convex-dev/auth/react", () => ({ + useAuthActions: () => ({ signIn, signOut: vi.fn() }), +})); + +describe("AccountGate", () => { + afterEach(() => { + signIn.mockClear(); + vi.unstubAllEnvs(); + }); + + it("starts GitHub OAuth with a same-page return target", async () => { + vi.stubEnv("VITE_NODEROOM_AUTH_PROVIDER", "github"); + render( undefined} />); + fireEvent.click(screen.getByTestId("sign-in-github")); + await waitFor(() => expect(signIn).toHaveBeenCalledWith("github", { redirectTo: window.location.href })); + expect(screen.getByText(/room code remains an invitation/i)).toBeTruthy(); + }); + + it("keeps the password provider explicit and development-labelled", async () => { + vi.stubEnv("VITE_NODEROOM_AUTH_PROVIDER", "password"); + render( undefined} />); + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "qa@noderoom.test" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "strong-password" } }); + fireEvent.click(screen.getByTestId("sign-in-password")); + await waitFor(() => expect(signIn).toHaveBeenCalled()); + expect(signIn.mock.calls[0]?.[0]).toBe("password"); + expect(screen.getByText(/development authentication/i)).toBeTruthy(); + }); +}); diff --git a/tests/artifactRefs.test.ts b/tests/artifactRefs.test.ts index df4ec82b..57e873c3 100644 --- a/tests/artifactRefs.test.ts +++ b/tests/artifactRefs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { displayArtifactRefMessage, encodeArtifactRefLine, parseArtifactRefMessage, type ArtifactRef } from "../src/ui/artifactRefs"; +import { artifactRefContextSuffix, artifactRefKey, displayArtifactRefMessage, encodeArtifactRefLine, parseArtifactRefMessage, type ArtifactRef } from "../src/ui/artifactRefs"; describe("artifact refs", () => { it("round-trips id, title, and kind from persisted reference links", () => { @@ -29,4 +29,17 @@ describe("artifact refs", () => { expect(displayArtifactRefMessage(`${encodeArtifactRefLine(refs)}\n\nPlease review this.`)).toBe("Please review this."); expect(displayArtifactRefMessage(encodeArtifactRefLine(refs))).toBe("Diligence memo"); }); + + it("round-trips scoped deck, proposal, and trace context on a real backing artifact", () => { + const refs: ArtifactRef[] = [ + { id: "deck-note-1", title: "Slide 2: Market evidence", kind: "note", contextKind: "deck_slide", contextId: "slide-2", elementId: "deck_storyboard" }, + { id: "deck-note-1", title: "Proposal: claim-3", kind: "note", contextKind: "proposal", contextId: "proposal-3", elementId: "claim-3" }, + ]; + const parsed = parseArtifactRefMessage(`${encodeArtifactRefLine(refs)}\n\n@nodeagent review these`); + + expect(parsed.refs).toEqual(refs); + expect(new Set(parsed.refs.map(artifactRefKey)).size).toBe(2); + expect(artifactRefContextSuffix(parsed.refs)).toContain("deck slide: Slide 2: Market evidence"); + expect(artifactRefContextSuffix(parsed.refs)).toContain("target proposal-3"); + }); }); diff --git a/tests/authSessionPolicy.test.ts b/tests/authSessionPolicy.test.ts index 86ee1a58..5eecdd3e 100644 --- a/tests/authSessionPolicy.test.ts +++ b/tests/authSessionPolicy.test.ts @@ -32,7 +32,54 @@ describe("auth/session production policy", () => { })).rejects.toThrow(/production_identity_required/); }); - it("revokes a member proof after leave and hides revoked members from active lists", async () => { + it("does not fall back to a room token after production identity enforcement is enabled", async () => { + process.env.NODEROOM_REQUIRE_CONVEX_IDENTITY = "1"; + const t = convexTest(schema, modules); + const host = t.withIdentity({ subject: "account-host" }); + const created = await host.mutation(api.rooms.create, { + code: "AUTH03", + title: "Identity-bound room", + hostName: "Maya", + authToken: HOST_TOKEN, + }); + const proof = { actor: { kind: "user" as const, id: String(created.memberId), name: "Maya" }, token: HOST_TOKEN }; + + await expect(t.query(api.rooms.get, { roomId: created.roomId, requester: proof })).rejects.toThrow(/production_identity_required/); + await expect(t.withIdentity({ subject: "different-account" }).query(api.rooms.get, { roomId: created.roomId, requester: proof })).rejects.toThrow(/identity_mismatch/); + await expect(host.query(api.rooms.get, { roomId: created.roomId, requester: proof })).resolves.toBeTruthy(); + }); + + it("resumes the same authenticated member across browsers without consuming another seat", async () => { + process.env.NODEROOM_REQUIRE_CONVEX_IDENTITY = "1"; + const t = convexTest(schema, modules); + const host = t.withIdentity({ subject: "account-host" }); + const member = t.withIdentity({ subject: "account-member" }); + const created = await host.mutation(api.rooms.create, { + code: "AUTH04", + title: "Cross-browser room", + hostName: "Maya", + authToken: HOST_TOKEN, + }); + const first = await member.mutation(api.rooms.joinAnonymous, { + code: "AUTH04", + name: "Sam", + authToken: MEMBER_TOKEN, + }); + if (!first || "error" in first) throw new Error("first join failed"); + const second = await member.mutation(api.rooms.joinAnonymous, { + code: "AUTH04", + name: "Different browser name", + authToken: "second-browser-session-token-0123456789", + }); + if (!second || "error" in second) throw new Error("second join failed"); + + expect(second).toMatchObject({ memberId: first.memberId, name: "Sam", resumed: true }); + const hostProof = { actor: { kind: "user" as const, id: String(created.memberId), name: "Maya" }, token: HOST_TOKEN }; + const members = await host.query(api.rooms.members, { roomId: created.roomId, requester: hostProof }); + expect(members.map((entry) => entry.name)).toEqual(["Maya", "Sam"]); + }); + + it("blocks host leave and revokes ordinary member proofs after leave", async () => { const t = convexTest(schema, modules); const created = await t.mutation(api.rooms.createStarterRoom, { code: "AUTH02", @@ -50,10 +97,15 @@ describe("auth/session production policy", () => { const memberProof = { actor: { kind: "user" as const, id: String(joined.memberId), name: "Sam" }, token: MEMBER_TOKEN }; await expect(t.query(api.rooms.get, { roomId: created.roomId, requester: hostProof })).resolves.toBeTruthy(); - await t.mutation(api.rooms.leave, { roomId: created.roomId, requester: hostProof }); + await expect(t.mutation(api.rooms.leave, { roomId: created.roomId, requester: hostProof })).resolves.toEqual({ + ok: false, + reason: "host_transfer_required", + }); + await expect(t.query(api.rooms.get, { roomId: created.roomId, requester: hostProof })).resolves.toBeTruthy(); - await expect(t.query(api.rooms.get, { roomId: created.roomId, requester: hostProof })).rejects.toThrow(/actor_revoked/); - const activeMembers = await t.query(api.rooms.members, { roomId: created.roomId, requester: memberProof }); - expect(activeMembers.map((m) => m.name)).toEqual(["Sam"]); + await expect(t.mutation(api.rooms.leave, { roomId: created.roomId, requester: memberProof })).resolves.toEqual({ ok: true }); + await expect(t.query(api.rooms.get, { roomId: created.roomId, requester: memberProof })).rejects.toThrow(/actor_revoked/); + const activeMembers = await t.query(api.rooms.members, { roomId: created.roomId, requester: hostProof }); + expect(activeMembers.map((m) => m.name)).toEqual(["Maya"]); }); }); diff --git a/tests/createRoomAtomicity.test.ts b/tests/createRoomAtomicity.test.ts index 01cf7ce1..0ed926cd 100644 --- a/tests/createRoomAtomicity.test.ts +++ b/tests/createRoomAtomicity.test.ts @@ -16,7 +16,7 @@ import { convexTest } from "convex-test"; import { describe, expect, it } from "vitest"; import schema from "../convex/schema"; -import { api } from "../convex/_generated/api"; +import { api, internal } from "../convex/_generated/api"; const modules = import.meta.glob("../convex/**/*.ts"); // Node-only agent modules aren't needed for room/artifact mutations and don't import in edge-runtime. @@ -135,6 +135,45 @@ describe("atomic room create — no orphaned rooms", () => { expect(await artifactsIn(t, first.roomId)).toHaveLength(STARTER_TITLES.length); }); + it("resumes the original host on a lost create response when the room token is retried", async () => { + const t = convexTest(schema, modules); + const created = await t.mutation(api.rooms.createStarterRoom, { + code: "RESUME1", title: "Recovery room", hostName: "Maya", authToken: HOST_TOKEN, deferHeavySeed: true, + }); + + const resumed = await t.mutation(api.rooms.joinAnonymous, { + code: "RESUME1", name: "Maya", authToken: HOST_TOKEN, anon: false, + }); + if (!resumed || "error" in resumed) throw new Error("resume failed"); + + expect(String(resumed.memberId)).toBe(String(created.memberId)); + expect(resumed.resumed).toBe(true); + expect(await membersIn(t, created.roomId)).toHaveLength(1); + expect((await tracesIn(t, created.roomId)).filter((trace) => trace.type === "member_joined")).toHaveLength(0); + }); + + it("fast live create returns the room shell before the scale fixture backfills", async () => { + const t = convexTest(schema, modules); + const created = await t.mutation(api.rooms.createStarterRoom, { + code: "FAST01", title: "Startup diligence", hostName: "Maya", authToken: HOST_TOKEN, autoAllow: true, deferHeavySeed: true, + }); + const pending = await t.query(api.rooms.meta, { + roomId: created.roomId, + requester: { actor: { kind: "user", id: String(created.memberId), name: "Maya" }, token: HOST_TOKEN }, + }); + expect(pending?.room.starterBackfill).toBe("pending"); + expect(pending?.artifacts.some((artifact) => artifact.title === "Q3 variance")).toBe(true); + expect(pending?.artifacts.some((artifact) => artifact.title === "Company research")).toBe(false); + + await t.mutation(internal.rooms.finishStarterRoom, { roomId: created.roomId, memberId: created.memberId }); + const ready = await t.query(api.rooms.meta, { + roomId: created.roomId, + requester: { actor: { kind: "user", id: String(created.memberId), name: "Maya" }, token: HOST_TOKEN }, + }); + expect(ready?.room.starterBackfill).toBe("ready"); + expect(ready?.artifacts.some((artifact) => artifact.title === "Company research")).toBe(true); + }); + it("create with seedArtifacts seeds a custom artifact (+ meta) atomically in one transaction", async () => { const t = convexTest(schema, modules); const res = await t.mutation(api.rooms.create, { diff --git a/tests/designPrototypeParity.test.ts b/tests/designPrototypeParity.test.ts index fa44036f..b9ee38e7 100644 --- a/tests/designPrototypeParity.test.ts +++ b/tests/designPrototypeParity.test.ts @@ -8,12 +8,13 @@ function source(path: string): string { describe("design prototype parity", () => { // Landing framing moved from the room prototype to landing-v2 // (design-reference/room/landing-v2.jsx, Prod Parity Handoff §1): - // "Diligence that shows its work." + looping demo + live-proof pill. + // Literal first-run value + looping demo + live-proof pill. test("desktop landing keeps the landing-v2 design framing", () => { const landing = source("src/ui/Landing.tsx"); - expect(landing).toContain("Diligence that shows its work"); - expect(landing).toContain("NodeRoom · live diligence rooms"); + expect(landing).toContain("Work with AI."); + expect(landing).toContain("Review every change."); + expect(landing).toContain("Shared workrooms for people and NodeAgents"); // The key visual is the scripted product-demo loop, every frame present. for (const frame of ["lock", "cite", "commit", "draft", "smart-merge", "v43"]) { expect(landing).toContain(`"${frame}"`); @@ -24,6 +25,8 @@ describe("design prototype parity", () => { // Entry flows survive the redesign — e2e depends on these testids. expect(landing).toContain("start-demo-room"); expect(landing).toContain("create-room-submit"); + expect(landing).toContain("Create a room"); + expect(landing).toContain('className="r-btn-context">Sample'); // The old prototype copy is fully retired, not half-migrated. expect(landing).not.toContain("NodeAgent · live collaborative rooms"); expect(landing).not.toContain("Chat, a shared workspace, and NodeAgents"); diff --git a/tests/designSystemManifest.test.ts b/tests/designSystemManifest.test.ts index 35d565de..169f92b7 100644 --- a/tests/designSystemManifest.test.ts +++ b/tests/designSystemManifest.test.ts @@ -59,25 +59,49 @@ describe("NodeRoom design-system manifest", () => { files["src/ui/LeftRail.tsx"] = files["src/ui/LeftRail.tsx"].replace('data-testid="binder-search"', 'data-testid="binder-search-missing"'); files["src/ui/primitives/FocusTrapDialog.tsx"] = files["src/ui/primitives/FocusTrapDialog.tsx"].replace('role="dialog"', 'role="presentation"'); files["src/ui/mobile/mobile.css"] = files["src/ui/mobile/mobile.css"] - .replace("--bg-app: #FBF4E7", "--bg-app: #111111") - .replace("--accent-primary: #C56A3C", "--accent-primary: #5E6AD2") - .replace("--font-serif: 'DM Serif Display'", "--font-serif: 'Inter'") .replace(".na-nav {", ".na-nav-missing {") .replace(".na-sheet {", ".na-sheet-missing {") - .replace(".na-handle", ".na-grabber-missing"); + .replace(".na-handle", ".na-grabber-missing") + .concat('\n.na-app { --mobile-bg-app: #111111; letter-spacing: 0 !important; }\n/* Cloud Design migration overlay */'); + files["src/ui/mobile/mobile.tokens.css"] = files["src/ui/mobile/mobile.tokens.css"] + .replace("--mobile-bg-app: #fbf4e7", "--mobile-bg-app: #111111") + .replace("--mobile-accent: #9f4f2a", "--mobile-accent: #5e6ad2") + .replace("--font-serif: 'DM Serif Display'", "--font-serif: 'Inter'") + .replace('.na-app[data-theme="dark"]', '.na-app[data-theme="always-dark"]') + .replace("--mobile-attention:", "--mobile-attention-missing:") + .replace("--mobile-success:", "--mobile-success-missing:") + .replace("--mobile-danger:", "--mobile-danger-missing:"); + files["src/ui/mobile/mobile.shell.css"] = files["src/ui/mobile/mobile.shell.css"] + .replace("height: 52px", "height: 40px") + .replace("width: 44px", "width: 36px") + .replace("var(--mobile-safe-top)", "0px"); + files["src/ui/mobile/shell/MobileHeader.tsx"] = files["src/ui/mobile/shell/MobileHeader.tsx"] + .replace('data-testid="mobile-room-context"', 'data-testid="mobile-room-context-missing"') + .replace('data-testid="mobile-review-action"', 'data-testid="mobile-review-action-missing"') + .replace('data-testid="mobile-overflow-action"', 'data-testid="mobile-overflow-action-missing"') + .replace('Ico("more"', 'Ico("menu"') + .replace('return count > 9 ? "9+"', 'return String(count)'); files["src/ui/mobile/MobileFrame.tsx"] = files["src/ui/mobile/MobileFrame.tsx"] .replace('const query = "(max-width: 460px)";', 'const query = "(max-width: 0px)";') - .replace("na-ios-bleed", "na-ios-framed-only"); + .replace("na-ios-bleed", "na-ios-framed-only") + .split("previewDeviceChrome = false").join("previewDeviceChrome = true") + .replace('data-device-preview="false">', 'data-device-preview="false">') + .replace('data-device-preview="false"', 'data-device-preview="missing"'); files["src/ui/mobile/MobileApp.tsx"] = files["src/ui/mobile/MobileApp.tsx"] .replace("export function MobileApp({ live }", "export function MobileApp({ demoOnlyLive }") - .replace("if (!live) { setFirstJoinSeen(true); return; }", "setFirstJoinSeen(true); return;"); + .replace("if (!live) { setFirstJoinSeen(true); return; }", "setFirstJoinSeen(true); return;") + .replace(";", "return ;") .replace("useMutation(api.rooms.create)", "undefined as never") .split('data-theme="light"').join('data-theme="dark"') .split("RoomJoinConsent").join("JoinConsentMissing") .split("MobileAppLive").join("MobileAppMemoryOnly") - .replace("history.replaceState(null, \"\", `#mobile?room=${reqCode}`", "history.replaceState(null, \"\", `#mobile?mode=memory`"); + .replace('params.set(request.kind === "join" ? "room" : request.kind, request.code)', 'params.set("mode", "memory")') + .replace('history.replaceState(null, "", `#mobile?${params.toString()}`)', 'history.replaceState(null, "", "#mobile?mode=memory")'); files["src/ui/mobile/RoomJoinConsent.tsx"] = files["src/ui/mobile/RoomJoinConsent.tsx"] .split('data-theme="light"').join('data-theme="dark"'); files["src/ui/mobile/mobileFrame.css"] = files["src/ui/mobile/mobileFrame.css"] @@ -116,6 +140,29 @@ describe("NodeRoom design-system manifest", () => { expect(codes).toContain("mobile-terracotta-cream-bg"); expect(codes).toContain("mobile-terracotta-accent"); expect(codes).toContain("mobile-terracotta-serif"); + expect(codes).toContain("mobile-dark-explicit-selector"); + expect(codes).toContain("mobile-semantic-attention"); + expect(codes).toContain("mobile-semantic-success"); + expect(codes).toContain("mobile-semantic-danger"); + expect(codes).toContain("mobile-theme-single-owner"); + expect(codes).toContain("mobile-global-letter-spacing-reset"); + expect(codes).toContain("mobile-late-theme-overlay"); + expect(codes).toContain("mobile-header-adapter"); + expect(codes).toContain("mobile-header-room-context"); + expect(codes).toContain("mobile-header-review-action"); + expect(codes).toContain("mobile-header-overflow-action"); + expect(codes).toContain("mobile-header-overflow-glyph"); + expect(codes).toContain("mobile-header-badge-bound"); + expect(codes).toContain("mobile-header-height"); + expect(codes).toContain("mobile-header-touch-target"); + expect(codes).toContain("mobile-header-safe-area"); + expect(codes).toContain("mobile-header-dynamic-command"); + expect(codes).toContain("mobile-bottom-nav-rendered"); + expect(codes).toContain("mobile-review-badge-duplicate"); + expect(codes).toContain("mobile-preview-explicit"); + expect(codes).toContain("mobile-production-frame-marker"); + expect(codes).toContain("mobile-production-synthetic-status"); + expect(codes).toContain("mobile-scope-delivery-alignment"); expect(codes).toContain("mobile-ios-nav"); expect(codes).toContain("mobile-bottom-sheet"); expect(codes).toContain("mobile-sheet-handle"); diff --git a/tests/launchAuth.test.ts b/tests/launchAuth.test.ts new file mode 100644 index 00000000..94aa167d --- /dev/null +++ b/tests/launchAuth.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { authIntentLabel, clearPersistedRoomSessions, launchAuthProvider, launchAuthRequired } from "../src/auth/launchAuth"; + +describe("launch authentication contract", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("is fail-open only when the release flag is explicitly absent", () => { + expect(launchAuthRequired({})).toBe(false); + expect(launchAuthRequired({ VITE_NODEROOM_AUTH_REQUIRED: "0" })).toBe(false); + expect(launchAuthRequired({ VITE_NODEROOM_AUTH_REQUIRED: "1" })).toBe(true); + }); + + it("defaults production-facing UI to GitHub and reserves password for explicit QA", () => { + expect(launchAuthProvider({})).toBe("github"); + expect(launchAuthProvider({ VITE_NODEROOM_AUTH_PROVIDER: "github" })).toBe("github"); + expect(launchAuthProvider({ VITE_NODEROOM_AUTH_PROVIDER: "password" })).toBe("password"); + }); + + it("uses literal first-run action labels", () => { + expect(authIntentLabel("join")).toBe("join this room"); + expect(authIntentLabel("create")).toBe("create this workspace"); + expect(authIntentLabel("demo")).toBe("start a sample room"); + }); + + it("clears every account-bound room session while preserving unrelated local state", () => { + const values = new Map([ + ["noderoom:live:ABC123", "one"], + ["noderoom:live:XYZ789", "two"], + ["theme", "dark"], + ]); + const storage = { + get length() { return values.size; }, + key(index: number) { return [...values.keys()][index] ?? null; }, + removeItem(key: string) { values.delete(key); }, + }; + clearPersistedRoomSessions(storage); + expect([...values.entries()]).toEqual([["theme", "dark"]]); + }); +}); diff --git a/tests/mobileAgentModelRouting.test.tsx b/tests/mobileAgentModelRouting.test.tsx index 61806f10..1e1da6a3 100644 --- a/tests/mobileAgentModelRouting.test.tsx +++ b/tests/mobileAgentModelRouting.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { render, cleanup } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Actor } from "../src/engine/types"; +import type { Actor, Artifact, Proposal, TraceEvent } from "../src/engine/types"; import type { MobileLive } from "../src/ui/mobile/mobileTypes"; const captured = vi.hoisted(() => ({ live: null as MobileLive | null })); @@ -32,6 +32,53 @@ import { mobileAgentModelSelection } from "../src/ui/mobile/MobileApp"; const me: Actor = { kind: "user", id: "u1", name: "Maya" }; +const liveArtifact: Artifact = { + id: "artifact-1", + roomId: "r1", + kind: "sheet", + title: "ARR bridge", + version: 1, + elements: { + A1: { + id: "A1", + version: 1, + value: { + value: "ARR bridge increased 12 percent and needs reviewer approval.", + status: "needs_review", + evidence: [{ id: "ev-1", kind: "source", label: "ARR worksheet", url: "https://example.test/arr" }], + }, + updatedAt: 2, + updatedBy: me, + }, + }, + order: ["A1"], + updatedAt: 2, + createdBy: me, + visibility: "room", + meta: { summary: "ARR bridge needs review." }, +}; + +const liveProposal: Proposal = { + id: "proposal-1", + roomId: "r1", + artifactId: "artifact-1", + op: { opId: "op-1", artifactId: "artifact-1", elementId: "A1", kind: "set", value: "approved ARR bridge", baseVersion: 1 }, + author: me, + status: "pending", + createdAt: 3, + review: { kind: "agent_edit", status: "needs_review", reason: "Reviewer approval required." }, +}; + +const liveTrace: TraceEvent = { + id: "trace-1", + roomId: "r1", + ts: 4, + actor: me, + type: "edit_proposed", + summary: "Proposed ARR bridge update", + refs: { artifactId: "artifact-1", proposalId: "proposal-1" }, +}; + function baseStore(): any { return { getRoom: () => ({ id: "r1", title: "Live Room", code: "R-123", autoAllow: false }), @@ -40,6 +87,7 @@ function baseStore(): any { listArtifacts: () => [], getArtifact: () => undefined, listProposals: () => [], + listPresence: () => [], lastLongFreeJob: () => null, listSessions: () => [], listTraces: () => [], @@ -96,4 +144,31 @@ describe("mobile agent model routing", () => { expect(storeRef.current.askPrivateAgent).toHaveBeenCalledWith({ goal: "summarize privately" }); expect(storeRef.current.askAgent).not.toHaveBeenCalled(); }); + + it("does not pass a sample deck into live mode when no live artifact or proposal exists", () => { + render(); + + expect(captured.live).toBeTruthy(); + expect(captured.live!.deck).toBeUndefined(); + }); + + it("derives the mobile live deck from work artifacts, proposals, and traces", () => { + storeRef.current = { + ...baseStore(), + listArtifacts: () => [liveArtifact], + getArtifact: () => liveArtifact, + listProposals: () => [liveProposal], + listTraces: () => [liveTrace], + }; + + render(); + + expect(captured.live?.deck).toBeTruthy(); + expect(captured.live!.deck!.title).toBe("Live Room readout"); + expect(captured.live!.deck!.sourceIds).toEqual(["artifact-1"]); + expect(captured.live!.deck!.proposalIds).toContain("proposal-1"); + expect(captured.live!.deck!.traceIds).toContain("trace-1"); + expect(captured.live!.deck!.slides[0].title).toBe("ARR bridge"); + expect(captured.live!.deck!.exportSize).toBe("receipt pending"); + }); }); diff --git a/tests/mobileDeckLive.test.tsx b/tests/mobileDeckLive.test.tsx new file mode 100644 index 00000000..492be942 --- /dev/null +++ b/tests/mobileDeckLive.test.tsx @@ -0,0 +1,212 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ArtifactSheet, buildLiveDeckRequest } from "../src/ui/mobile/MobileDeck"; +import { buildRecents } from "../src/ui/mobile/MobileAppLive"; +import { slideDoc, type Evidence } from "../src/ui/mobile/mobileData"; +import type { MobileCtx, MobileDeckArtifact } from "../src/ui/mobile/mobileTypes"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const liveEvidence: Evidence = { + claim: "Live ARR bridge is source-backed", + status: "verified", + answer: "The live room evidence supports the ARR bridge claim.", + support: [ + { kind: "cite", n: "1", text: "ARR bridge source artifact", host: "room artifact", verified: true }, + { kind: "gap", text: "Export file receipt still pending." }, + ], + followups: [], + fallback: "No additional live evidence is available yet.", +}; + +function liveDeck(): MobileDeckArtifact { + return { + id: "room-1:storyboard", + storyboard: { + deckId: "room-1:storyboard", + roomId: "room-1", + title: "Live diligence readout", + audience: "room reviewers", + objective: "Turn live room evidence into a reviewable deck.", + privacy: "room", + storyboardStatus: "needs_review", + slides: [{ + slideId: "slide-1", + title: "Live ARR bridge", + purpose: "Review the live ARR bridge claim.", + claims: [{ claimId: "claim-1", text: "ARR bridge needs reviewer approval.", status: "needs_review", sourceArtifactId: "artifact-1", traceId: "trace-1", proposalId: "proposal-1" }], + sourceArtifactIds: ["artifact-1"], + evidenceIds: [], + unresolvedGaps: ["Export file receipt still pending."], + status: "needs_review", + }], + requiredEvidence: ["Primary ARR bridge source"], + unresolvedGaps: ["Export file receipt still pending."], + sourceArtifactIds: ["artifact-1"], + traceIds: ["trace-1"], + proposalIds: ["proposal-1"], + planHash: "hash-live", + version: 1, + }, + roomId: "room-1", + workArtifactId: "room-1:storyboard", + traceIds: ["trace-1"], + sourceIds: ["artifact-1"], + proposalIds: ["proposal-1"], + readonly: true, + fallbackReason: "Derived mobile storyboard.", + title: "Live diligence readout", + audience: "room reviewers", + status: "proposed", + planHash: "hash-live", + privacy: "Room", + exportState: "not_started", + exportFormat: "PPTX", + exportSize: "receipt pending", + sourceGaps: 1, + plan: { + goal: "Turn live room evidence into a reviewable deck.", + todos: [ + { text: "Read live room artifacts", status: "done" }, + { text: "Map claims to evidence", status: "running" }, + { text: "Produce export receipt", status: "todo" }, + ], + ran: 2, + guard: "No slide write lands without proposal approval.", + willRead: ["artifact-1"], + willCreate: ["Mobile storyboard preview"], + wontWrite: ["Deck HTML"], + stats: [{ v: "1", l: "slides", mono: true }], + }, + slides: [ + { + id: "slide-1", + index: 1, + title: "Live ARR bridge", + status: "needs_review", + html: slideDoc("

Live source-backed claim

ARR bridge needs reviewer approval.

"), + }, + ], + patchSample: { + target: "Proposal proposal-1", + before: "ARR bridge needs reviewer approval.", + after: "Keep ARR bridge marked needs_review until the proposal is accepted.", + evidence: [{ n: "1", text: "ARR bridge source artifact", verified: true }], + }, + receipt: { + reads: { planned: 1, actual: 1 }, + writes: { planned: 0, actual: 0 }, + cost: { planned: "room policy", actual: "not run from mobile" }, + coverage: "1 claim needs evidence", + gaps: ["Export file receipt still pending."], + files: ["Mobile storyboard preview"], + }, + versions: [{ v: "v1", label: "Live storyboard projection", t: "now", current: true }], + }; +} + +function makeCtx(overrides: Partial = {}): MobileCtx { + return { + isLive: true, + canBack: false, + closeSheet: vi.fn(), + backSheet: vi.fn(), + openSheet: vi.fn(), + toast: vi.fn(), + openSource: vi.fn(), + liveEvidence, + ...overrides, + } as unknown as MobileCtx; +} + +describe("mobile live deck review", () => { + it("bounds live deck request provenance to the durable job goal contract", () => { + const request = buildLiveDeckRequest({ + deckId: "deck-live", + slideIndex: 1, + slideId: "slide-1", + slideTitle: "Live ARR bridge", + target: { label: "h1", text: "Current title" }, + requestedChange: "Tighten this title. ".repeat(500), + sourceIds: Array.from({ length: 50 }, (_, index) => `source-${index}`), + traceIds: Array.from({ length: 50 }, (_, index) => `trace-${index}`), + }); + + expect(request.length).toBeLessThanOrEqual(1_800); + expect(request).toContain("Do not directly mutate the deck"); + expect(request).toContain("(+46 more on storyboard)"); + expect(request).toContain("Return a sourced proposal for host review"); + }); + + it("projects the governed live storyboard into the Home artifact library", () => { + const recents = buildRecents([], liveDeck()); + + expect(recents).toHaveLength(1); + expect(recents[0]).toMatchObject({ kind: "deck", title: "Live diligence readout" }); + expect(recents[0].sig).toMatchObject({ type: "deck", count: 1, active: 0 }); + }); + + it("does not show sample CardioNova deck content when a live room has no deck", () => { + render(); + + expect(screen.getByTestId("mobile-live-deck-empty")).toBeTruthy(); + expect(screen.getByText("No live deck to review")).toBeTruthy(); + expect(screen.queryByText(/CardioNova investor update/i)).toBeNull(); + }); + + it("renders the live storyboard and live evidence instead of sample evidence", () => { + const { container } = render(); + + expect(screen.getByText("Live diligence readout")).toBeTruthy(); + expect(screen.getByText("Live ARR bridge")).toBeTruthy(); + expect(screen.queryByText(/CardioNova investor update/i)).toBeNull(); + expect(container.querySelector(".na-sheet-body > .na-thumbs")).toBeTruthy(); + expect(container.querySelector(".na-sheet-body > .na-slide-toolbar")).toBeTruthy(); + expect(container.querySelector(".na-sheet-body > .na-slidewrap")).toBeTruthy(); + expect(container.querySelector(".na-thumbs > .na-slide-toolbar")).toBeNull(); + expect(container.querySelector(".na-thumbs > .na-slidewrap")).toBeNull(); + + fireEvent.click(screen.getByText("Evidence")); + expect(screen.getByText(/Live ARR bridge is source-backed/)).toBeTruthy(); + expect(screen.getByText(/The live room evidence supports the ARR bridge claim/)).toBeTruthy(); + expect(screen.getByText("verified")).toBeTruthy(); + }); + + it("submits an element-scoped live request without claiming the patch was applied", async () => { + let resolveRequest!: (result: { ok: boolean }) => void; + const requestRoomAgent = vi.fn((_goal: string) => new Promise<{ ok: boolean }>((resolve) => { resolveRequest = resolve; })); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Scope revision request to the slide title" })); + fireEvent.change(screen.getByPlaceholderText(/Describe the change for this element/i), { target: { value: "Tighten this title" } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + await waitFor(() => expect(requestRoomAgent).toHaveBeenCalledTimes(1)); + expect(requestRoomAgent.mock.calls[0][0]).toMatch(/Element scope: h1 - "Live ARR bridge"/); + rerender(); + resolveRequest({ ok: true }); + expect(await screen.findByText("request accepted")).toBeTruthy(); + expect(screen.queryByText("patch applied")).toBeNull(); + }); + + it("downloads real live storyboard PPTX bytes and exposes a receipt", async () => { + const ctx = makeCtx({ liveDeck: liveDeck() }); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:deck") }); + Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: vi.fn() }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + render(); + + fireEvent.click(screen.getByText("Export")); + expect(screen.getByTestId("mobile-live-deck-version-note")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Restore" })).toBeNull(); + fireEvent.click(screen.getByText("Download PPTX")); + + await waitFor(() => expect(screen.getByTestId("mobile-deck-export-receipt").textContent).toMatch(/Downloaded .*\.pptx - 1 slides .* integrity/i)); + expect(ctx.toast).toHaveBeenCalledWith(expect.stringMatching(/^Downloaded .*\.pptx - 1 slides - /i)); + expect(ctx.toast).not.toHaveBeenCalledWith(expect.stringContaining("CardioNova_update.pptx downloaded")); + }); +}); diff --git a/tests/mobileGapScreens.test.tsx b/tests/mobileGapScreens.test.tsx index 0aac994f..0490de77 100644 --- a/tests/mobileGapScreens.test.tsx +++ b/tests/mobileGapScreens.test.tsx @@ -23,7 +23,7 @@ * including the diagonal-scroll rejection and the drift-cancels-long-press * rule — pinned as pure functions so the thresholds cannot silently drift. */ -import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, within, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ReviewSheet, @@ -169,16 +169,17 @@ describe("ShareSheet", () => { expect(screen.getByText(/not a scannable QR/i)).toBeTruthy(); }); - it("honestly captions role/expiry as backend-pending, not shipped", () => { + it("states the enforced editor access and non-expiring link contract", () => { render(); - expect(screen.getByTestId("gap-share-stub-caption").textContent).toMatch(/permissions backend/i); + expect(screen.getByTestId("gap-share-access-copy").textContent).toMatch(/join as a member, edit shared content/i); + expect(screen.getByTestId("gap-share-access-copy").textContent).toMatch(/does not currently expire/i); }); - it("copies the invite and toasts on tap", () => { + it("does not claim the invite was copied when the clipboard is unavailable", async () => { const ctx = makeCtx(); render(); fireEvent.click(screen.getByTestId("gap-invite-code")); - expect(ctx.toast).toHaveBeenCalled(); + await waitFor(() => expect(ctx.toast).toHaveBeenCalledWith(expect.stringMatching(/was not copied/i))); }); }); diff --git a/tests/mobileHeader.test.tsx b/tests/mobileHeader.test.tsx new file mode 100644 index 00000000..6e1873ce --- /dev/null +++ b/tests/mobileHeader.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + MobileHeader, + formatMobileReviewBadge, + type MobileHeaderAction, +} from "../src/ui/mobile/shell/MobileHeader"; + +afterEach(cleanup); + +function renderHeader(reviewCount = 0, roomName = "Q3 Diligence") { + const onSwitchRoom = vi.fn(); + const onOpenReview = vi.fn(); + const onJobs = vi.fn(); + const onPeople = vi.fn(); + const secondaryActions: MobileHeaderAction[] = [ + { id: "jobs", label: "Agent jobs", icon: "history", meta: "2", onSelect: onJobs }, + { id: "people", label: "People", icon: "users", onSelect: onPeople }, + ]; + render( + , + ); + return { onSwitchRoom, onOpenReview, onJobs, onPeople }; +} + +describe("MobileHeader", () => { + it("keeps room, Review, and Overflow as stable commands", () => { + const actions = renderHeader(0); + + expect(screen.queryByTestId("mobile-review-badge")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Switch room, current room Q3 Diligence" })); + fireEvent.click(screen.getByRole("button", { name: "Review inbox, 0 items" })); + + expect(actions.onSwitchRoom).toHaveBeenCalledOnce(); + expect(actions.onOpenReview).toHaveBeenCalledOnce(); + expect(screen.getByRole("button", { name: "More room actions" }).getAttribute("aria-expanded")).toBe("false"); + }); + + it("bounds the visible badge at 9+ while preserving the exact accessible count", () => { + const { rerender } = render( + undefined} + onOpenReview={() => undefined} + secondaryActions={[]} + />, + ); + expect(screen.getByTestId("mobile-review-badge").textContent).toContain("4"); + expect(screen.getByRole("button", { name: "Review inbox, 4 items" })).toBeTruthy(); + + rerender( + undefined} + onOpenReview={() => undefined} + secondaryActions={[]} + />, + ); + expect(screen.getByTestId("mobile-review-badge").textContent).toContain("9+"); + expect(screen.getByRole("button", { name: "Review inbox, 100 items" })).toBeTruthy(); + expect(formatMobileReviewBadge(9)).toBe("9"); + expect(formatMobileReviewBadge(10)).toBe("9+"); + }); + + it("opens only secondary commands and closes before invoking one", () => { + const actions = renderHeader(4); + fireEvent.click(screen.getByRole("button", { name: "More room actions" })); + + const menu = screen.getByTestId("mobile-overflow-menu"); + expect(within(menu).getByRole("menuitem", { name: "Agent jobs 2" })).toBeTruthy(); + expect(within(menu).queryByText("Home")).toBeNull(); + expect(within(menu).queryByText("Review")).toBeNull(); + + fireEvent.click(within(menu).getByRole("menuitem", { name: "People" })); + expect(actions.onPeople).toHaveBeenCalledOnce(); + expect(screen.queryByTestId("mobile-overflow-menu")).toBeNull(); + }); + + it("closes overflow on Escape and preserves a long room title for CSS ellipsis", () => { + const title = "A very long governed diligence room title with an_unbroken_identifier_that_must_not_move_commands"; + renderHeader(4, title); + expect(screen.getByTestId("mobile-room-title").textContent).toContain(title); + + fireEvent.click(screen.getByRole("button", { name: "More room actions" })); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByTestId("mobile-overflow-menu")).toBeNull(); + }); +}); diff --git a/tests/mobileLiveHonesty.test.tsx b/tests/mobileLiveHonesty.test.tsx new file mode 100644 index 00000000..8f5acc17 --- /dev/null +++ b/tests/mobileLiveHonesty.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SourceOverlay, TraceOverlay } from "../src/ui/mobile/MobileOverlay"; +import { Composer, RoomChat } from "../src/ui/mobile/MobileChat"; +import { projectMobileSheetRow, resolveMobileExperience } from "../src/ui/mobile/MobileAppLive"; +import { Home } from "../src/ui/mobile/MobileScreens"; +import { EvidenceSheet, PlanSheet } from "../src/ui/mobile/MobileSheets"; +import * as D from "../src/ui/mobile/mobileData"; +import type { MobileCtx } from "../src/ui/mobile/mobileTypes"; +import type { Artifact } from "../src/engine/types"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function liveCtx(overrides: Partial = {}): MobileCtx { + return { + isLive: true, + runState: "plan", + livePlan: D.PLAN, + liveEvidence: { + ...D.EVIDENCE, + claim: "Live renewal claim", + answer: "The live room currently has one cited renewal source and one unresolved gap.", + followups: [], + fallback: "No live answer is available without a room-agent run.", + }, + requestRoomAgent: vi.fn(async () => ({ ok: true })), + closeSheet: vi.fn(), + closeOverlay: vi.fn(), + openSheet: vi.fn(), + openSource: vi.fn(), + toast: vi.fn(), + recents: [], + favorites: [], + briefings: [], + loading: false, + setComposerMode: vi.fn(), + setAgentLane: vi.fn(), + setDraft: vi.fn(), + setTab: vi.fn(), + ...overrides, + } as unknown as MobileCtx; +} + +describe("mobile live-room honesty", () => { + it("routes live plan approval through the room agent instead of completing a local sample timer", async () => { + const requestRoomAgent = vi.fn(async (_goal: string) => ({ ok: true })); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Run with NodeAgent/i })); + + await waitFor(() => expect(requestRoomAgent).toHaveBeenCalledTimes(1)); + expect(requestRoomAgent.mock.calls[0][0]).toMatch(/governed room-agent job/i); + expect(await screen.findByText(/Live request accepted/i)).toBeTruthy(); + expect(screen.queryByText(/Research complete/i)).toBeNull(); + }); + + it("shows the live evidence answer and sends follow-ups through the room agent", async () => { + const requestRoomAgent = vi.fn(async (_goal: string) => ({ ok: true })); + render(); + + expect(screen.getByText(/one cited renewal source/i)).toBeTruthy(); + expect(screen.queryByText(/no primary confirmation of round size/i)).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "What supports this?" })); + + await waitFor(() => expect(requestRoomAgent).toHaveBeenCalledTimes(1)); + expect(requestRoomAgent.mock.calls[0][0]).toMatch(/Live renewal claim/); + expect(await screen.findByText(/Read the answer in the Agent tab/i)).toBeTruthy(); + }); + + it("never substitutes a sample trace when a live trace id collides", () => { + const sampleId = Object.keys(D.TRACES)[0]; + const sampleTitle = D.TRACES[sampleId].title; + render(); + + expect(screen.getByTestId("mobile-live-trace-fallback")).toBeTruthy(); + expect(screen.getByText("Live collision receipt")).toBeTruthy(); + expect(screen.queryByText(sampleTitle)).toBeNull(); + }); + + it("keeps unavailable live source actions disabled instead of reporting success", () => { + const toast = vi.fn(); + render(); + + expect(screen.getByRole("button", { name: "Original unavailable" }).hasAttribute("disabled")).toBe(true); + expect(screen.getByRole("button", { name: "Attach on desktop" }).hasAttribute("disabled")).toBe(true); + expect(toast).not.toHaveBeenCalled(); + }); + + it("guides an empty live room into a room-visible NodeAgent request", () => { + const setComposerMode = vi.fn(); + const setAgentLane = vi.fn(); + const setDraft = vi.fn(); + const setTab = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Ask NodeAgent/i })); + + expect(setComposerMode).toHaveBeenCalledWith("agent"); + expect(setAgentLane).toHaveBeenCalledWith("room"); + expect(setDraft).toHaveBeenCalledWith(expect.stringMatching(/first source-backed artifact/i)); + expect(setTab).toHaveBeenCalledWith("agent"); + }); + + it("uses live people and generic live prompts instead of CardioNova fixtures", () => { + const runQuick = vi.fn(); + const { rerender } = render(); + + expect(screen.getByText("Amina")).toBeTruthy(); + rerender(); + expect(screen.queryByText(/CardioNova/i)).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Plan a source-backed first artifact" })); + expect(runQuick).toHaveBeenCalledWith(expect.objectContaining({ text: "Plan a source-backed first artifact" })); + }); + + it("projects an arbitrary live sheet without substituting the sample company", () => { + const artifact = { + id: "sheet-live", + roomId: "room-live", + kind: "sheet", + title: "Q4 renewal review", + version: 3, + order: ["row-1__arr"], + elements: { + "row-1__arr": { + id: "row-1__arr", + version: 2, + value: { value: "$240k", status: "needs_review" }, + updatedAt: 1, + updatedBy: { kind: "user", id: "u1", name: "Amina" }, + }, + }, + updatedAt: 1, + meta: { dataframe: { columns: [{ id: "arr", label: "Renewal ARR", order: 0 }], rowCount: 1 } }, + } as Artifact; + + const row = projectMobileSheetRow(artifact); + expect(row.entity).toBe("Q4 renewal review"); + expect(row.fields).toEqual([expect.objectContaining({ k: "Renewal ARR", v: "$240k", elementId: "row-1__arr", version: 2 })]); + expect(JSON.stringify(row)).not.toContain("CardioNova"); + }); + + it("keeps session sample provenance when an older backend omits experience", () => { + expect(resolveMobileExperience(undefined, "sample")).toBe("sample"); + expect(resolveMobileExperience("workspace", "sample")).toBe("workspace"); + expect(resolveMobileExperience(undefined, undefined)).toBe("workspace"); + }); +}); diff --git a/tests/mobileShellAdapters.test.tsx b/tests/mobileShellAdapters.test.tsx new file mode 100644 index 00000000..6988c307 --- /dev/null +++ b/tests/mobileShellAdapters.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { JobsSheet } from "../src/ui/mobile/MobileChat"; +import { TraceOverlay } from "../src/ui/mobile/MobileOverlay"; +import { Inbox } from "../src/ui/mobile/MobileScreens"; +import { mobileVisibilityRoute } from "../src/ui/mobile/MobileApp"; +import type { InboxItem, Job } from "../src/ui/mobile/mobileData"; +import type { MobileCtx } from "../src/ui/mobile/mobileTypes"; + +afterEach(cleanup); + +describe("mobile shell adapters", () => { + it("renders live jobs and routes stop/retry through the existing job adapter", async () => { + const running: Job = { id: "live-running", title: "Live source check", sub: "running", cost: "", trace: "trace-live" }; + const completed: Job = { id: "live-completed", title: "Completed live run", sub: "failed", cost: "", trace: "trace-completed" }; + const jobAct = vi.fn(async () => ({ ok: true })); + const ctx = { + jobs: { running: [running], queued: [], completed: [completed] }, + jobAct, + openTrace: vi.fn(), + closeSheet: vi.fn(), + toast: vi.fn(), + } as unknown as MobileCtx; + + render(); + expect(screen.getByText("Live source check")).toBeTruthy(); + expect(screen.queryByText("Research CardioNova funding signal")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Stop job" })); + await waitFor(() => expect(jobAct).toHaveBeenCalledWith("live-running", "cancel")); + fireEvent.click(screen.getByRole("button", { name: "Retry job" })); + await waitFor(() => expect(jobAct).toHaveBeenCalledWith("live-completed", "retry")); + }); + + it("shows an honest live trace summary instead of an empty or sample overlay", () => { + const ctx = { + traceRows: [{ id: "trace-live", kind: "commit", text: "Committed live evidence at v44", time: "now" }], + closeOverlay: vi.fn(), + toast: vi.fn(), + } as unknown as MobileCtx; + + render(); + expect(screen.getByTestId("mobile-live-trace-fallback")).toBeTruthy(); + expect(screen.getByText("Committed live evidence at v44")).toBeTruthy(); + expect(screen.getByText(/No sample steps, costs, or writes are substituted/i)).toBeTruthy(); + }); + + it("routes live Inbox approve and reject through the proposal adapter", async () => { + const item: InboxItem = { + id: "proposal-live", + icon: "sparkles", + tone: "accent", + title: "Agent edit proposed", + sub: "Cell runway - approve before it lands", + status: "approve", + statusTone: "warn", + time: "now", + kind: "plan", + preview: "doc", + }; + const resolveProposalById = vi.fn(async () => ({ ok: true })); + const ctx = { + isLive: true, + inboxItems: [item], + resolved: {}, + canApprove: true, + resolveProposalById, + openInbox: vi.fn(), + toast: vi.fn(), + } as unknown as MobileCtx; + + render(); + expect(screen.queryByRole("button", { name: "Row view" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Reject" })); + await waitFor(() => expect(resolveProposalById).toHaveBeenCalledWith("proposal-live", false)); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + await waitFor(() => expect(resolveProposalById).toHaveBeenCalledWith("proposal-live", true)); + }); + + it("keeps privacy copy and delivery lane coupled", () => { + expect(mobileVisibilityRoute("Private")).toEqual({ composerMode: "agent", agentLane: "private" }); + expect(mobileVisibilityRoute("Room")).toEqual({ composerMode: "agent", agentLane: "room" }); + }); +}); diff --git a/tests/nativeNotebookProsemirror.test.ts b/tests/nativeNotebookProsemirror.test.ts index b5f49ddc..6b62cc7e 100644 --- a/tests/nativeNotebookProsemirror.test.ts +++ b/tests/nativeNotebookProsemirror.test.ts @@ -35,7 +35,14 @@ async function seedNotebookRoom() { describe("native notebook ProseMirror sync boundary", () => { it("does not expose the doc capability without active requester proof", async () => { - const { t, roomId, artifactId, proof } = await seedNotebookRoom(); + const { t, roomId, artifactId } = await seedNotebookRoom(); + const joined = await t.mutation(api.rooms.joinAnonymous, { + code: "NBPROOF", + name: "Guest", + authToken: GUEST_TOKEN, + }); + if (!joined || "error" in joined) throw new Error("guest join failed"); + const guestProof = { actor: { kind: "user" as const, id: String(joined.memberId), name: "Guest" }, token: GUEST_TOKEN }; await expect(t.query(api.prosemirror.getNotebookDoc as any, { roomId, artifactId })) .rejects.toThrow(); @@ -43,19 +50,19 @@ describe("native notebook ProseMirror sync boundary", () => { const ensured = await t.mutation(api.prosemirror.ensureNotebookDoc, { roomId, artifactId, - requester: proof, + requester: guestProof, }); expect(ensured.prosemirrorDocId).toMatch(/^nb:/); const doc = await t.query(api.prosemirror.getNotebookDoc, { roomId, artifactId, - requester: proof, + requester: guestProof, }); expect(doc?.prosemirrorDocId).toBe(ensured.prosemirrorDocId); - await t.mutation(api.rooms.leave, { roomId, requester: proof }); - await expect(t.query(api.prosemirror.getNotebookDoc, { roomId, artifactId, requester: proof })) + await t.mutation(api.rooms.leave, { roomId, requester: guestProof }); + await expect(t.query(api.prosemirror.getNotebookDoc, { roomId, artifactId, requester: guestProof })) .rejects.toThrow(/actor_revoked/); }, 30_000); diff --git a/tests/notebookKernel.test.ts b/tests/notebookKernel.test.ts new file mode 100644 index 00000000..0c2b0c3a --- /dev/null +++ b/tests/notebookKernel.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { Actor, Artifact, Element } from "../src/engine/types"; +import { executeNotebookKernel } from "../src/notebook/notebookKernel"; +import { buildNotebookArtifactStructure, buildNotebookKernelTables, notebookKernelOutputElementId, readNotebookKernelOutputs } from "../src/ui/workArtifacts"; + +const actor: Actor = { kind: "user", id: "u1", name: "Priya" }; +const element = (id: string, value: unknown): Element => ({ id, value, version: 1, updatedAt: 1, updatedBy: actor }); + +const sheet: Artifact = { + id: "sheet-1", + roomId: "room-1", + kind: "sheet", + title: "Diligence", + version: 1, + updatedAt: 1, + order: ["r1__company", "r1__revenue", "r2__company", "r2__revenue"], + elements: { + "r1__company": element("r1__company", "CardioNova"), + "r1__revenue": element("r1__revenue", 12), + "r2__company": element("r2__company", "Mercury"), + "r2__revenue": element("r2__revenue", 21), + }, + meta: { dataframe: { columns: [{ id: "company", label: "Company", order: 0 }, { id: "revenue", label: "Revenue", order: 1 }], rowCount: 2 } }, +}; + +describe("notebook safe kernel", () => { + it("executes arithmetic without eval and emits a deterministic receipt", () => { + const first = executeNotebookKernel({ kind: "calculation", input: "Runway = 12 + 8 * 2" }, { backend: "convex", now: 10 }); + const second = executeNotebookKernel({ kind: "calculation", input: "Runway = 12 + 8 * 2" }, { backend: "convex", now: 20 }); + + expect(first).toMatchObject({ status: "completed", outputText: "28" }); + expect(first.receipt.inputHash).toBe(second.receipt.inputHash); + expect(first.receipt.outputHash).toBe(second.receipt.outputHash); + expect(first.receipt.backend).toBe("convex"); + }); + + it("runs bounded read-only SQL over room sheet snapshots", () => { + const tables = buildNotebookKernelTables([sheet]); + const result = executeNotebookKernel({ + kind: "sql", + input: "SELECT Company, Revenue FROM diligence WHERE Revenue >= 15 ORDER BY Revenue DESC LIMIT 5", + tables, + }, { now: 1 }); + + expect(result.status).toBe("completed"); + expect(result.rows).toEqual([{ Company: "Mercury", Revenue: 21 }]); + expect(result.receipt.rowCount).toBe(1); + }); + + it("blocks mutating SQL and creates chart specs from the same capped tables", () => { + const tables = buildNotebookKernelTables([sheet]); + expect(executeNotebookKernel({ kind: "sql", input: "DELETE FROM diligence", tables }, { now: 1 })).toMatchObject({ status: "blocked", errorCode: "sql_write_blocked" }); + + const chart = executeNotebookKernel({ kind: "chart", input: "bar chart Revenue by Company from diligence", tables }, { now: 1 }); + expect(chart.status).toBe("completed"); + expect(chart.chart).toMatchObject({ type: "bar", x: "Company", y: "Revenue" }); + expect(chart.chart?.points).toHaveLength(2); + }); + + it("persists kernel outputs as hidden notebook receipt elements", () => { + const result = executeNotebookKernel({ kind: "calculation", input: "2 + 3" }, { now: 1 }); + const outputId = notebookKernelOutputElementId("block-calc"); + const notebook: Artifact = { + id: "note-1", + roomId: "room-1", + kind: "note", + title: "Analysis", + version: 2, + updatedAt: 2, + order: ["doc", outputId], + elements: { + doc: element("doc", '

Calculation: 2 + 3

'), + [outputId]: element(outputId, { blockId: "block-calc", input: "2 + 3", result }), + }, + }; + + expect(readNotebookKernelOutputs(notebook)["block-calc"].result.outputText).toBe("5"); + expect(buildNotebookArtifactStructure(notebook).blockCount).toBe(1); + }); +}); diff --git a/tests/notebookProcessingTarget.test.ts b/tests/notebookProcessingTarget.test.ts index c64580b8..95855d6e 100644 --- a/tests/notebookProcessingTarget.test.ts +++ b/tests/notebookProcessingTarget.test.ts @@ -162,8 +162,11 @@ describe("notebook target processing slice", () => { }, 30_000); it("rechecks active membership before processing a queued dirty event", async () => { - const { t, roomId, artifactId, proof } = await seedNotebookRoom(); - const ensured = await t.mutation(api.prosemirror.ensureNotebookDoc, { roomId, artifactId, requester: proof }); + const { t, code, roomId, artifactId } = await seedNotebookRoom(); + const joined = await t.mutation(api.rooms.joinAnonymous, { code, name: "Guest", authToken: GUEST_TOKEN }); + if (!joined || "error" in joined) throw new Error("guest join failed"); + const guestProof = { actor: { kind: "user" as const, id: String(joined.memberId), name: "Guest" }, token: GUEST_TOKEN }; + const ensured = await t.mutation(api.prosemirror.ensureNotebookDoc, { roomId, artifactId, requester: guestProof }); await t.mutation(api.prosemirror.submitSnapshot, { id: ensured.prosemirrorDocId, version: 2, @@ -172,12 +175,12 @@ describe("notebook target processing slice", () => { const dirty = await t.mutation(api.notebookProcessing.markNotebookDirty, { roomId, artifactId, - requester: proof, + requester: guestProof, observedSnapshotVersion: 2, quietMs: 1_000, }); - await t.mutation(api.rooms.leave, { roomId, requester: proof }); + await t.mutation(api.rooms.leave, { roomId, requester: guestProof }); await makeDirtyDue(t, dirty.dirtyEventId); const processed = await t.action(internal.notebookProcessing.processNotebookDirtyEvent, { dirtyEventId: dirty.dirtyEventId, diff --git a/tests/proofloopAgentHostEnforcement.test.ts b/tests/proofloopAgentHostEnforcement.test.ts index 5d7b7839..5f3a3d90 100644 --- a/tests/proofloopAgentHostEnforcement.test.ts +++ b/tests/proofloopAgentHostEnforcement.test.ts @@ -73,12 +73,12 @@ describe("ProofLoop native agent host enforcement", () => { hostId: "cursor", promptPath, generatedAt: "2026-07-09T00:00:00.000Z", - env: {}, + env: { PROOFLOOP_CURSOR_BINARY: join(root, "missing-cursor-cli") }, }); expect(launch.status).toBe("failed"); expect(launch.command).toContain("proofloop-cursor-launch.mjs"); - expect(readFileSync(join(root, launch.stderrPath ?? ""), "utf8")).toContain("Cursor CLI not found"); + expect(readFileSync(join(root, launch.stderrPath ?? ""), "utf8")).toMatch(/not recognized|spawn error|ENOENT/i); const verified = verifyNativeAgentEnforcement({ root, hostId: "cursor", diff --git a/tests/roomFullNoFlash.test.tsx b/tests/roomFullNoFlash.test.tsx index 4090d44e..ec68a937 100644 --- a/tests/roomFullNoFlash.test.tsx +++ b/tests/roomFullNoFlash.test.tsx @@ -20,9 +20,13 @@ const { joinMock } = vi.hoisted(() => ({ // `join` is invoked on the room_full path, so its call count == joinMock's call count. Stability // matters: a fresh fn per render would itself churn the effect deps (mirrors real Convex useMutation). vi.mock("convex/react", () => ({ + useConvexAuth: () => ({ isLoading: false, isAuthenticated: false, isRefreshing: false }), useQuery: (_ref: unknown, args: unknown) => (args === "skip" ? undefined : { roomId: "r_full" }), useMutation: () => joinMock, })); +vi.mock("@convex-dev/auth/react", () => ({ + useAuthActions: () => ({ signIn: vi.fn(), signOut: vi.fn() }), +})); // Force the live (Convex) branch and stub the providers. On the room_full path `session` stays null, // so ConvexStoreProvider/RoomShell never actually render — mocking only avoids their heavy imports. @@ -39,9 +43,12 @@ import { App } from "../src/ui/App"; describe("room_full no-flash (App.tsx join-effect latch)", () => { beforeEach(() => { joinMock.mockClear(); - // Scenario: a teammate opens a shared deep-link to a room that is now at capacity. - window.history.replaceState({}, "", "/?room=TEAMQ3&name=Guest"); window.localStorage.clear(); + window.sessionStorage.clear(); + // Scenario: a teammate confirms the explicit join preflight for a room that + // reaches capacity before the mutation lands. Identity stays out of the URL. + window.sessionStorage.setItem("noderoom:livePending:TEAMQ3", JSON.stringify({ name: "Guest" })); + window.history.replaceState({}, "", "/?room=TEAMQ3&confirmed=1"); }); it("calls joinAnonymous exactly once and settles on the honest error (no re-fire loop)", async () => { diff --git a/tests/semanticGraph.test.ts b/tests/semanticGraph.test.ts index 413f19f9..80ac6438 100644 --- a/tests/semanticGraph.test.ts +++ b/tests/semanticGraph.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest"; import type { Actor, Artifact, DataframeColumn, Element, Proposal, TraceEvent } from "../src/engine/types"; import { buildSemanticGraph } from "../src/ui/graph/semanticGraph"; import { applySemanticGraphFilters } from "../src/ui/graph/semanticGraphFilters"; +import { selectSemanticGraphCluster, summarizeSemanticGraphClusters } from "../src/ui/graph/semanticGraphClusters"; import { layoutSemanticGraph } from "../src/ui/graph/semanticGraphLayout"; +import { rankSemanticConnectionPaths } from "../src/ui/graph/semanticGraphPaths"; import { selectSemanticNeighborhood } from "../src/ui/graph/semanticGraphSelectors"; +import { buildDeckStoryboardFromRoom } from "../src/ui/workArtifacts/deckStoryboard"; const human: Actor = { kind: "user", id: "u-priya", name: "Priya" }; const agent: Actor = { kind: "agent", id: "room-agent", name: "Room NodeAgent", scope: "public" }; @@ -73,6 +76,26 @@ const notebook: Artifact = { }, }; +const richNotebook: Artifact = { + id: "art-rich-note", + roomId: "room-1", + kind: "note", + title: "Diligence notebook", + version: 2, + createdBy: human, + updatedAt: 7, + order: ["doc"], + elements: { + doc: cell("doc", ` +

CardioNova diligence notebook

+

Priya researched CardioNova and summarized the board memo.

+
+ `), + }, +}; + const trace: TraceEvent = { id: "trace-1", roomId: "room-1", @@ -157,6 +180,69 @@ describe("semantic entity graph", () => { expect(selectedLabels).toContain("CardioNova"); expect(selection.sections.some((section) => section.id === "researched-companies")).toBe(true); expect(selection.sections.some((section) => section.id === "rows-blocks")).toBe(true); + expect(selection.paths?.some((path) => path.label.includes("Priya") && path.label.includes("CardioNova"))).toBe(true); + }); + + it("ranks relevant person-to-company evidence paths for graph highlighting", () => { + const graph = buildSemanticGraph({ roomId: "room-1", artifacts: [researchSheet, notebook], traces: [trace], proposals: [proposal] }); + const person = graph.nodes.find((node) => node.kind === "person" && node.label === "Priya"); + expect(person).toBeTruthy(); + + const paths = rankSemanticConnectionPaths(graph, person!.id, { maxHops: 3, maxPaths: 8 }); + + expect(paths.length).toBeGreaterThan(0); + expect(paths.some((path) => path.label.includes("Priya") && path.label.includes("researched") && path.label.includes("CardioNova"))).toBe(true); + expect(paths.some((path) => path.label.includes("Series A source") || path.label.includes("pitchbook.example"))).toBe(true); + expect(paths[0].score).toBeGreaterThanOrEqual(paths[paths.length - 1].score); + }); + + it("derives graph nodes from structured notebook blocks and citations", () => { + const graph = buildSemanticGraph({ roomId: "room-1", artifacts: [researchSheet, richNotebook], traces: [trace] }); + + expect(graph.nodes.some((node) => node.kind === "notebook_block" && node.label.includes("CardioNova diligence notebook"))).toBe(true); + expect(graph.nodes.some((node) => node.kind === "notebook_block" && node.label.includes("Runway claim needs transcript source"))).toBe(true); + expect(graph.nodes.some((node) => node.kind === "source" && node.label === "runway.example")).toBe(true); + expect(graph.nodes.some((node) => node.kind === "open_question" && node.label.includes("Runway claim"))).toBe(true); + expect(graph.edges.some((edge) => edge.kind === "mentioned_in" && edge.label === "mentions")).toBe(true); + expect(graph.edges.some((edge) => edge.kind === "cited" && edge.label === "cites source")).toBe(true); + }); + + it("links storyboard deck claims back to source artifacts, notebook blocks, proposals, traces, and evidence", () => { + const storyboard = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, richNotebook], + traces: [trace], + proposals: [proposal], + }); + const graph = buildSemanticGraph({ + roomId: "room-1", + artifacts: [researchSheet, richNotebook], + traces: [trace], + proposals: [proposal], + decks: [storyboard], + }); + + const deck = graph.nodes.find((node) => node.kind === "deck" && node.label === "Startup diligence readout"); + const noteSlide = graph.nodes.find((node) => node.kind === "deck_slide" && node.label === "Diligence notebook"); + const verifiedClaim = graph.nodes.find((node) => node.kind === "deck_claim" && node.refs.some((ref) => ref.evidenceId === "ev-funding")); + const reviewClaim = graph.nodes.find((node) => node.kind === "deck_claim" && node.refs.some((ref) => ref.proposalId === "proposal-1") && node.status === "needs_review"); + + expect(deck).toBeTruthy(); + expect(noteSlide).toBeTruthy(); + expect(verifiedClaim).toBeTruthy(); + expect(reviewClaim).toBeTruthy(); + expect(graph.edges.some((edge) => edge.kind === "belongs_to" && edge.label === "contains slide" && edge.source === deck!.id)).toBe(true); + expect(graph.edges.some((edge) => edge.kind === "derived_from" && edge.label === "draws from artifact" && edge.refs.some((ref) => ref.artifactId === "art-rich-note"))).toBe(true); + expect(graph.edges.some((edge) => edge.kind === "supported_by" && edge.label === "supported by evidence" && edge.source === verifiedClaim!.id)).toBe(true); + expect(graph.edges.some((edge) => edge.kind === "reviewed" && edge.label === "needs proposal review" && edge.source === reviewClaim!.id)).toBe(true); + expect(graph.edges.some((edge) => edge.kind === "derived_from" && edge.label === "derived from trace" && edge.source === reviewClaim!.id)).toBe(true); + + const notePaths = rankSemanticConnectionPaths(graph, noteSlide!.id, { maxHops: 4, maxPaths: 16 }); + const reviewPaths = rankSemanticConnectionPaths(graph, reviewClaim!.id, { maxHops: 4, maxPaths: 16 }); + + expect(notePaths.some((path) => path.label.includes("contains block") && path.label.includes("CardioNova diligence notebook"))).toBe(true); + expect(reviewPaths.some((path) => path.label.includes("needs proposal review") && path.label.includes("pending set proposal"))).toBe(true); }); it("filters to source-backed evidence without static mock nodes", () => { @@ -186,6 +272,23 @@ describe("semantic entity graph", () => { expect([...first.entries()]).toEqual([...second.entries()]); }); + it("ranks clusters and isolates them with bounded neighbor expansion", () => { + const graph = buildSemanticGraph({ roomId: "room-1", artifacts: [researchSheet, richNotebook], traces: [trace], proposals: [proposal] }); + const summaries = summarizeSemanticGraphClusters(graph); + const companyCluster = summaries.find((cluster) => cluster.kind === "company" && cluster.label.includes("CardioNova")); + expect(companyCluster).toBeTruthy(); + expect(companyCluster!.nodeCount).toBeGreaterThan(1); + expect(companyCluster!.relevanceScore).toBeGreaterThan(0); + + const isolated = selectSemanticGraphCluster(graph, companyCluster!.id, { neighborDepth: 0 }); + const expanded = selectSemanticGraphCluster(graph, companyCluster!.id, { neighborDepth: 1, maxNodes: 40 }); + expect(isolated.nodes.length).toBe(companyCluster!.nodeCount); + expect(isolated.edges.every((edge) => isolated.nodes.some((node) => node.id === edge.source) && isolated.nodes.some((node) => node.id === edge.target))).toBe(true); + expect(expanded.nodes.length).toBeGreaterThanOrEqual(isolated.nodes.length); + expect(expanded.nodes.length).toBeLessThanOrEqual(40); + expect(expanded.clusters.some((cluster) => cluster.id === companyCluster!.id)).toBe(true); + }); + it("keeps a 250-plus-node fixture derivable, filterable, and layoutable", () => { const graph = buildSemanticGraph({ roomId: "room-scale", diff --git a/tests/workArtifacts.test.ts b/tests/workArtifacts.test.ts new file mode 100644 index 00000000..92a53c8f --- /dev/null +++ b/tests/workArtifacts.test.ts @@ -0,0 +1,821 @@ +import { describe, expect, it } from "vitest"; +import JSZip from "jszip"; +import type { Actor, Artifact, DataframeColumn, Element, Message, Proposal, TraceEvent } from "../src/engine/types"; +import { buildSemanticGraph } from "../src/ui/graph/semanticGraph"; +import { + buildDeckStoryboardFromRoom, + buildDeckPatchPlan, + buildDeckPreviewExport, + buildDeckPdfExport, + buildDeckPptxExport, + collaborativeDeckArtifactInput, + addCollaborativeDeckSlide, + deleteCollaborativeDeckSlide, + duplicateCollaborativeDeckSlide, + isCollaborativeDeckArtifact, + moveCollaborativeDeckSlide, + normalizeCollaborativeDeck, + readCollaborativeDeckArtifact, + buildGraphRelationshipReviewPlan, + buildLivePerformanceSummary, + buildNotebookExecutionPreview, + buildNotebookArtifactStructure, + buildNotebookPatchPreviewItems, + buildNotebookPatchDiff, + buildProofBundleReceipt, + buildProofBundleExportManifest, + buildTraceReplaySummary, + buildWorkArtifacts, + classifyNotebookTypedBlocks, + buildProposalReviewItems, + countProposalReviewItems, + deckArtifactInputFromStoryboard, + deckPatchPlanFileName, + deckPatchPlanJson, + deckPdfFileName, + deckPreviewFileName, + deckPptxFileName, + filterProposalReviewItems, + graphRelationshipReviewFileName, + graphRelationshipReviewJson, + mapDeckArtifactToWorkArtifact, + mapEngineArtifactToWorkArtifact, + mapExportToWorkArtifact, + mapProposalToWorkArtifact, + mapSemanticGraphToWorkArtifact, + mapTraceToWorkArtifact, + notebookDigestStats, + proofBundleManifestFileName, + proofBundleManifestJson, + proposalReviewFeedbackMessage, + proposalValuePreview, + summarizeNotebookTypedBlocks, + traceReplayPhaseForTrace, + traceReplayStats, +} from "../src/ui/workArtifacts"; + +const human: Actor = { kind: "user", id: "u-priya", name: "Priya" }; +const agent: Actor = { kind: "agent", id: "room-agent", name: "Room NodeAgent", scope: "public" }; + +const columns: DataframeColumn[] = [ + { id: "company", label: "Company", order: 0 }, + { id: "owner", label: "Owner", order: 1 }, + { id: "funding", label: "Funding", order: 2 }, + { id: "risk", label: "Risk", order: 3 }, +]; + +function cell(id: string, value: unknown, updatedBy: Actor = human): Element { + return { id, value, updatedBy, version: 1, updatedAt: 10 }; +} + +const researchSheet: Artifact = { + id: "art-research", + roomId: "room-1", + kind: "sheet", + title: "Company research", + version: 3, + createdBy: human, + updatedAt: 20, + order: ["r1__company", "r1__owner", "r1__funding", "r1__risk"], + elements: { + "r1__company": cell("r1__company", "CardioNova"), + "r1__owner": cell("r1__owner", "Priya"), + "r1__funding": cell("r1__funding", { + value: "$14M Series A", + status: "complete", + evidence: [{ + id: "ev-funding", + kind: "source", + label: "Funding source", + url: "https://source.example/cardionova", + }], + }, agent), + "r1__risk": cell("r1__risk", { value: "Missing HIPAA source", status: "needs_review" }, agent), + }, + meta: { dataframe: { columns, rowCount: 1 } }, +}; + +const notebook: Artifact = { + id: "art-note", + roomId: "room-1", + kind: "note", + title: "Diligence notebook", + version: 1, + createdBy: human, + updatedAt: 30, + order: ["b1"], + elements: { + b1: cell("b1", { text: "Priya researched CardioNova and cited the funding source." }), + }, +}; + +const structuredNotebook: Artifact = { + id: "art-structured-note", + roomId: "room-1", + kind: "note", + title: "Capture Notebook", + version: 2, + createdBy: human, + updatedAt: 35, + order: ["doc"], + elements: { + doc: cell("doc", ` +

CardioNova diligence

+

Human context from the diligence call.

+

Agent notes

+

+ Runway claim needs source runway memo. +

+ `), + }, +}; + +const pmNotebook: Artifact = { + id: "art-pm-note", + roomId: "room-1", + kind: "note", + title: "Synced notebook", + version: 2, + createdBy: human, + updatedAt: 36, + order: ["doc"], + elements: { + doc: cell("doc", { + type: "doc", + content: [ + { type: "heading", attrs: { level: 1, blockId: "pm-title" }, content: [{ type: "text", text: "Board memo" }] }, + { type: "paragraph", attrs: { blockId: "pm-agent", authorKind: "agent", status: "needs_review" }, content: [{ type: "text", text: "Founder quote is missing a transcript link." }] }, + ], + }), + }, +}; + +const executionNotebook: Artifact = { + id: "art-exec-note", + roomId: "room-1", + kind: "note", + title: "Execution notebook", + version: 1, + createdBy: human, + updatedAt: 37, + order: ["doc"], + elements: { + doc: cell("doc", ` +

Notebook execution preview

+

Calculation: runway score = 12 + 8 * 2

+
select company, funding from diligence
+

Chart: line revenue over month

+ `), + }, +}; + +const trace: TraceEvent = { + id: "trace-1", + roomId: "room-1", + ts: 40, + actor: agent, + type: "edit_proposed", + summary: "NodeAgent proposed a source-backed risk update", + detail: "update_notebook_block -> pending approval", + refs: { artifactId: "art-research", elementId: "r1__risk", proposalId: "proposal-1" }, +}; + +const proposal: Proposal = { + id: "proposal-1", + roomId: "room-1", + artifactId: "art-research", + op: { opId: "op-1", artifactId: "art-research", elementId: "r1__risk", kind: "set", value: "HIPAA source added", baseVersion: 1 }, + author: agent, + status: "pending", + createdAt: 50, + review: { kind: "agent_edit", reason: "Needs host approval before changing diligence risk." }, +}; + +describe("work artifact adapters", () => { + it("wraps an engine sheet as a spreadsheet artifact with evidence, review, proposal, and trace receipts", () => { + const artifact = mapEngineArtifactToWorkArtifact(researchSheet, { traces: [trace], proposals: [proposal] }); + + expect(artifact.kind).toBe("spreadsheet"); + expect(artifact.status).toBe("needs_review"); + expect(artifact.receipt.evidenceCount).toBe(1); + expect(artifact.receipt.unresolvedCount).toBe(1); + expect(artifact.receipt.traceIds).toEqual(["trace-1"]); + expect(artifact.receipt.proposalIds).toEqual(["proposal-1"]); + expect(artifact.actions.map((action) => action.id)).toEqual(expect.arrayContaining(["open", "ask_nodeagent", "propose_patch", "view_trace"])); + }); + + it("derives notebook structure from legacy HTML without mutating the editor model", () => { + const structure = buildNotebookArtifactStructure(structuredNotebook, { traces: [trace], proposals: [] }); + + expect(structure.blockCount).toBe(4); + expect(structure.sectionCount).toBe(2); + expect(structure.agentBlockCount).toBe(2); + expect(structure.needsReviewCount).toBe(1); + expect(structure.citationCount).toBe(1); + expect(structure.sourceIds).toEqual(["https://source.example/runway"]); + expect(structure.sections.map((section) => section.title)).toEqual(["CardioNova diligence", "Agent notes"]); + expect(structure.status).toBe("needs_review"); + expect(structure.summary).toContain("4 blocks"); + }); + + it("summarizes notebook digest stats for the openable workbench", () => { + const structure = buildNotebookArtifactStructure(structuredNotebook, { traces: [trace], proposals: [proposal] }); + const stats = notebookDigestStats(structure); + + expect(stats).toMatchObject({ + blocks: 4, + sections: 2, + agentBlocks: 2, + humanBlocks: 2, + reviewBlocks: 1, + sources: 1, + proposals: 0, + statusLabel: "Needs review", + }); + }); + + it("builds notebook block-level patch previews from existing proposals", () => { + const noteProposal: Proposal = { + ...proposal, + id: "proposal-note", + artifactId: "art-structured-note", + op: { + ...proposal.op, + artifactId: "art-structured-note", + elementId: "blk-agent-claim", + value: "Runway claim now cites the board-approved source.", + }, + createdAt: 90, + }; + const structure = buildNotebookArtifactStructure(structuredNotebook, { traces: [], proposals: [noteProposal] }); + const previews = buildNotebookPatchPreviewItems(structure, [noteProposal]); + + expect(previews).toHaveLength(1); + expect(previews[0]).toMatchObject({ + proposalId: "proposal-note", + status: "pending", + blockId: "blk-agent-claim", + valuePreview: "Runway claim now cites the board-approved source.", + }); + expect(previews[0].blockText).toContain("Runway claim needs source"); + expect(previews[0].diff.changed).toBe(true); + expect(previews[0].diff.removedText).toContain("needs source"); + expect(previews[0].diff.addedText).toContain("now cites"); + expect(buildNotebookPatchDiff("alpha beta", "alpha gamma beta").parts.some((part) => part.kind === "added" && part.text === "gamma")).toBe(true); + }); + + it("classifies typed notebook blocks without changing the editor model", () => { + const typedNotebook: Artifact = { + ...structuredNotebook, + id: "art-typed-note", + elements: { + doc: cell("doc", ` +

Typed analysis

+

Runway calculation: $4.1M cash / $0.45M burn = 9.1 months.

+

Evidence: cited transcript https://source.example/transcript

+

Open question: who approved the burn assumption?

+

Decision: approve the sourced revenue claim.

+ `), + }, + }; + const structure = buildNotebookArtifactStructure(typedNotebook); + const typed = classifyNotebookTypedBlocks(structure); + const byId = new Map(typed.map((block) => [block.blockId, block.type])); + const summary = summarizeNotebookTypedBlocks(structure); + + expect(byId.get("typed-calc")).toBe("calculation"); + expect(byId.get("typed-evidence")).toBe("evidence"); + expect(byId.get("typed-question")).toBe("open_question"); + expect(byId.get("typed-decision")).toBe("decision"); + expect(summary.counts.calculation).toBe(1); + expect(summary.counts.evidence).toBe(1); + expect(summary.total).toBe(5); + expect(typedNotebook.elements.doc.value).toContain(" { + const structure = buildNotebookArtifactStructure(executionNotebook); + const preview = buildNotebookExecutionPreview(structure); + const byKind = new Map(preview.items.map((item) => [item.kind, item])); + + expect(preview.previewVersion).toBe(1); + expect(preview.executableCount).toBe(3); + expect(preview.readyCount).toBe(3); + expect(preview.blockedCount).toBe(0); + expect(byKind.get("calculation")).toMatchObject({ status: "ready", input: "12 + 8 * 2", result: "28" }); + expect(byKind.get("sql")?.result).toBe("Parsed 2 columns from diligence."); + expect(byKind.get("chart")?.result).toContain("line chart intent"); + expect(executionNotebook.elements.doc.value).toContain("Calculation: runway score"); + }); + + it("derives notebook structure from ProseMirror JSON blocks", () => { + const structure = buildNotebookArtifactStructure(pmNotebook); + + expect(structure.blockCount).toBe(2); + expect(structure.agentBlockCount).toBe(1); + expect(structure.needsReviewCount).toBe(1); + expect(structure.blocks[1]).toMatchObject({ + blockId: "pm-agent", + role: "agent", + status: "needs_review", + text: "Founder quote is missing a transcript link.", + }); + }); + + it("uses notebook structure in the work-artifact receipt for notes", () => { + const workpaper = mapEngineArtifactToWorkArtifact(structuredNotebook, { traces: [trace], proposals: [] }); + + expect(workpaper.kind).toBe("notebook"); + expect(workpaper.status).toBe("needs_review"); + expect(workpaper.summary).toContain("4 blocks"); + expect(workpaper.receipt.evidenceCount).toBe(1); + expect(workpaper.receipt.sourceIds).toEqual(["https://source.example/runway"]); + expect(workpaper.meta?.blockCount).toBe(4); + expect(workpaper.meta?.agentBlockCount).toBe(2); + expect(workpaper.refs.some((ref) => ref.elementId === "blk-agent-root")).toBe(true); + }); + + it("maps pending proposals to reviewable workpapers without applying the change", () => { + const workpaper = mapProposalToWorkArtifact(proposal, researchSheet, [trace]); + + expect(workpaper.kind).toBe("proposal"); + expect(workpaper.status).toBe("pending"); + expect(workpaper.summary).toContain("Needs host approval"); + expect(workpaper.receipt.unresolvedCount).toBe(1); + expect(workpaper.actions.map((action) => action.id)).toEqual(["open", "accept", "reject", "view_trace"]); + }); + + it("builds proposal review center items from existing proposals without applying changes", () => { + const semanticProposal: Proposal = { + ...proposal, + id: "proposal-2", + op: { ...proposal.op, elementId: "r1__funding", value: { value: "Funding source needs rebase" } }, + createdAt: 80, + review: { kind: "semantic_rebase", reason: "Business-value conflict", status: "needs_review" }, + }; + + const items = buildProposalReviewItems({ proposals: [proposal, semanticProposal], artifacts: [researchSheet], traces: [trace] }); + const counts = countProposalReviewItems(items); + + expect(items.map((item) => item.proposalId)).toEqual(["proposal-2", "proposal-1"]); + expect(items[0]).toMatchObject({ + artifactTitle: "Company research", + reviewKind: "semantic_rebase", + valuePreview: "Funding source needs rebase", + }); + expect(counts).toMatchObject({ total: 2, pending: 2, agentEdit: 1, semanticRebase: 1 }); + expect(filterProposalReviewItems(items, "semantic_rebase")).toHaveLength(1); + expect(filterProposalReviewItems(items, "agent_edit")).toHaveLength(1); + }); + + it("keeps proposal review feedback honest about conflicts and host gates", () => { + expect(proposalValuePreview({ value: " ".repeat(4) + "manual claim" })).toBe("manual claim"); + expect(proposalReviewFeedbackMessage({ ok: true }, true)).toBe("Proposal approved."); + expect(proposalReviewFeedbackMessage({ ok: false, reason: "conflict" }, true)).toContain("source cell changed"); + expect(proposalReviewFeedbackMessage({ ok: false, reason: "host_required" }, false)).toContain("Only the host"); + }); + + it("maps traces as proof artifacts with review status for proposal events", () => { + const traceArtifact = mapTraceToWorkArtifact(trace); + + expect(traceArtifact.kind).toBe("trace"); + expect(traceArtifact.status).toBe("needs_review"); + expect(traceArtifact.receipt.traceIds).toEqual(["trace-1"]); + expect(traceArtifact.refs[0]).toMatchObject({ artifactId: "art-research", elementId: "r1__risk", traceId: "trace-1" }); + }); + + it("wraps the semantic proof graph and preserves linked proposal and trace receipts", () => { + const graph = buildSemanticGraph({ roomId: "room-1", artifacts: [researchSheet, notebook], proposals: [proposal], traces: [trace] }); + const graphArtifact = mapSemanticGraphToWorkArtifact("room-1", graph); + + expect(graphArtifact.kind).toBe("graph"); + expect(graphArtifact.title).toBe("Proof graph"); + expect(graphArtifact.receipt.evidenceCount).toBeGreaterThan(0); + expect(graphArtifact.receipt.traceIds).toContain("trace-1"); + expect(graphArtifact.receipt.proposalIds).toContain("proposal-1"); + expect(graphArtifact.meta?.companies).toBeGreaterThan(0); + }); + + it("builds a deterministic graph relationship review plan from source-backed and review edges", () => { + const storyboard = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, structuredNotebook], + traces: [trace], + proposals: [proposal], + }); + const graph = buildSemanticGraph({ roomId: "room-1", artifacts: [researchSheet, structuredNotebook], proposals: [proposal], traces: [trace], decks: [storyboard] }); + const plan = buildGraphRelationshipReviewPlan(graph, "room-1:semantic-graph"); + const second = buildGraphRelationshipReviewPlan(graph, "room-1:semantic-graph"); + + expect(plan.reviewVersion).toBe(1); + expect(plan.integrityHash).toBe(second.integrityHash); + expect(graphRelationshipReviewJson(plan)).toBe(graphRelationshipReviewJson(second)); + expect(plan.relationshipCount).toBe(graph.edges.length); + expect(plan.confirmedCount + plan.needsConfirmationCount).toBe(plan.relationshipCount); + expect(plan.confirmedCount).toBeGreaterThan(0); + expect(plan.needsConfirmationCount).toBeGreaterThan(0); + expect(plan.proposalIds).toContain("proposal-1"); + expect(plan.traceIds).toContain("trace-1"); + expect(plan.sourceArtifactIds).toContain("art-research"); + expect(plan.items.some((item) => item.edgeKind === "supported_by" && item.reviewStatus === "confirmed")).toBe(true); + expect(plan.items.some((item) => item.edgeKind === "reviewed" && item.reviewStatus === "needs_confirmation")).toBe(true); + expect(graphRelationshipReviewFileName("room-1:semantic-graph", plan.integrityHash)).toBe(`room-1-semantic-graph-relationship-review-${plan.integrityHash}.json`); + }); + + it("supports storyboard-first deck artifacts before a full deck editor exists", () => { + const deck = mapDeckArtifactToWorkArtifact({ + id: "deck-1", + roomId: "room-1", + title: "Board diligence readout", + storyboardStatus: "needs_review", + sections: [ + { id: "s1", title: "Thesis", evidenceCount: 2 }, + { id: "s2", title: "Risks", evidenceCount: 1, unresolvedCount: 1 }, + ], + traceIds: ["trace-1"], + proposalIds: ["proposal-1"], + }); + + expect(deck.kind).toBe("deck"); + expect(deck.status).toBe("needs_review"); + expect(deck.summary).toContain("2 storyboard sections"); + expect(deck.receipt.evidenceCount).toBe(3); + expect(deck.refs.map((ref) => ref.elementId)).toEqual(["s1", "s2"]); + expect(deck.refs.every((ref) => ref.artifactId === undefined)).toBe(true); + expect(deck.actions.map((action) => action.id)).toEqual(expect.arrayContaining(["ask_nodeagent", "propose_patch", "export"])); + }); + + it("maps export bundles with receipt sidecar metadata", () => { + const exported = mapExportToWorkArtifact({ + id: "export-1", + roomId: "room-1", + title: "Diligence proof bundle", + format: "zip", + artifactCount: 4, + evidenceCount: 7, + unresolvedCount: 0, + traceIds: ["trace-1"], + }); + + expect(exported.kind).toBe("export"); + expect(exported.summary).toContain("ZIP export"); + expect(exported.receipt.evidenceCount).toBe(7); + expect(exported.actions.map((action) => action.id)).toContain("export"); + }); + + it("builds a mixed work-artifact bundle from existing room objects", () => { + const graph = buildSemanticGraph({ roomId: "room-1", artifacts: [researchSheet, notebook], proposals: [proposal], traces: [trace] }); + const bundle = buildWorkArtifacts({ + artifacts: [researchSheet, notebook], + proposals: [proposal], + traces: [trace], + graph, + decks: [{ id: "deck-1", roomId: "room-1", title: "Board readout", sections: [] }], + exports: [{ id: "export-1", roomId: "room-1", title: "Proof bundle", format: "zip" }], + }); + + expect(bundle.map((artifact) => artifact.kind)).toEqual([ + "spreadsheet", + "notebook", + "graph", + "deck", + "proposal", + "trace", + "export", + ]); + }); + + it("builds a stable proof-bundle receipt from mixed work artifacts", () => { + const graph = buildSemanticGraph({ roomId: "room-1", artifacts: [researchSheet, structuredNotebook], proposals: [proposal], traces: [trace] }); + const bundle = buildWorkArtifacts({ + artifacts: [researchSheet, structuredNotebook], + proposals: [proposal], + traces: [trace], + graph, + decks: [{ id: "deck-1", roomId: "room-1", title: "Board readout", sections: [{ id: "s1", title: "Summary", unresolvedCount: 1 }] }], + exports: [{ id: "export-1", roomId: "room-1", title: "Proof bundle", format: "zip" }], + }); + + const receipt = buildProofBundleReceipt({ roomId: "room-1", artifacts: bundle, generatedAt: 123 }); + const second = buildProofBundleReceipt({ roomId: "room-1", artifacts: [...bundle].reverse(), generatedAt: 456 }); + + expect(receipt.receiptVersion).toBe(1); + expect(receipt.receiptId).toBe(`room-1:proof-bundle:${receipt.integrityHash}`); + expect(receipt.integrityHash).toBe(second.integrityHash); + expect(receipt.artifactCount).toBe(bundle.length); + expect(receipt.kindCounts.notebook).toBe(1); + expect(receipt.kindCounts.deck).toBe(1); + expect(receipt.statusCounts.needs_review).toBeGreaterThan(0); + expect(receipt.traceIds).toContain("trace-1"); + expect(receipt.proposalIds).toContain("proposal-1"); + expect(receipt.sourceIds).toContain("https://source.example/runway"); + expect(receipt.knownGaps.some((gap) => gap.reason === "human_review_required")).toBe(true); + }); + + it("builds a deterministic proof-bundle export manifest sidecar", () => { + const graph = buildSemanticGraph({ roomId: "room-1", artifacts: [researchSheet, structuredNotebook], proposals: [proposal], traces: [trace] }); + const bundle = buildWorkArtifacts({ + artifacts: [researchSheet, structuredNotebook], + proposals: [proposal], + traces: [trace], + graph, + decks: [{ id: "deck-1", roomId: "room-1", title: "Board readout", sections: [{ id: "s1", title: "Summary", unresolvedCount: 1 }] }], + exports: [{ id: "export-1", roomId: "room-1", title: "Proof bundle", format: "zip" }], + }); + const receipt = buildProofBundleReceipt({ roomId: "room-1", artifacts: bundle, generatedAt: 123 }); + const replay = buildTraceReplaySummary({ roomId: "room-1", traces: [trace], proposals: [proposal] }); + + const manifest = buildProofBundleExportManifest({ roomId: "room-1", artifacts: bundle, receipt, traceReplay: replay, generatedAt: 123 }); + const second = buildProofBundleExportManifest({ roomId: "room-1", artifacts: [...bundle].reverse(), receipt, traceReplay: replay, generatedAt: 456 }); + const parsed = JSON.parse(proofBundleManifestJson(manifest)); + + expect(manifest.manifestVersion).toBe(1); + expect(manifest.integrityHash).toBe(second.integrityHash); + expect(manifest.manifestId).toContain(receipt.receiptId); + expect(manifest.exportIntents[0]).toMatchObject({ format: "json", receiptId: receipt.receiptId, replayHash: replay.replayHash }); + expect(manifest.artifacts.map((artifact) => artifact.id)).toEqual([...manifest.artifacts.map((artifact) => artifact.id)].sort()); + expect(parsed.receipt.integrityHash).toBe(receipt.integrityHash); + expect(proofBundleManifestFileName("Startup diligence / Q3", manifest)).toBe(`startup-diligence-q3-proof-bundle-${manifest.integrityHash}.json`); + }); + + it("builds a deterministic trace replay summary from existing trace rows", () => { + const traces: TraceEvent[] = [ + { id: "trace-room", roomId: "room-1", ts: 1, actor: human, type: "room_created", summary: "Room created" }, + { id: "trace-chat", roomId: "room-1", ts: 2, actor: human, type: "message", summary: "Asked NodeAgent to reconcile funding" }, + { id: "trace-agent", roomId: "room-1", ts: 3, actor: agent, type: "agent_status", summary: "NodeAgent working on funding evidence" }, + { id: "trace-edit", roomId: "room-1", ts: 4, actor: agent, type: "edit_proposed", summary: "Funding update proposed", refs: { artifactId: "art-research", elementId: "r1__funding", proposalId: "proposal-1" } }, + { id: "trace-note", roomId: "room-1", ts: 5, actor: agent, type: "notebook_read_model", summary: "Notebook read model updated", refs: { artifactId: "art-note" } }, + ]; + + const replay = buildTraceReplaySummary({ roomId: "room-1", traces, proposals: [proposal] }); + const second = buildTraceReplaySummary({ roomId: "room-1", traces: [...traces].reverse(), proposals: [proposal] }); + + expect(replay.replayHash).toBe(second.replayHash); + expect(replay.eventCount).toBe(5); + expect(replay.traceIds).toEqual(["trace-room", "trace-chat", "trace-agent", "trace-edit", "trace-note"]); + expect(replay.proposalIds).toContain("proposal-1"); + expect(replay.artifactIds).toEqual(["art-note", "art-research"]); + expect(replay.phases.map((phase) => phase.id)).toEqual(["room", "chat", "agent", "edit", "notebook"]); + expect(replay.phases.find((phase) => phase.id === "edit")?.status).toBe("needs_review"); + expect(replay.criticalPath.map((phase) => phase.id)).toContain("edit"); + expect(replay.status).toBe("running"); + + const stats = traceReplayStats(replay); + expect(stats).toMatchObject({ + events: 5, + phases: 5, + criticalPhases: 2, + artifacts: 2, + proposals: 1, + statusLabel: "Running", + }); + expect(traceReplayPhaseForTrace(replay, "trace-edit")?.id).toBe("edit"); + }); + + it("summarizes public chat and NodeAgent live performance telemetry", () => { + const messages: Message[] = [ + { id: "msg-human", roomId: "room-1", channel: "public", author: human, text: "@nodeagent reconcile funding", clientMsgId: "human-1", kind: "chat", createdAt: 10 }, + { id: "msg-agent", roomId: "room-1", channel: "public", author: agent, text: "Funding reconciliation complete.", clientMsgId: "final-run-1", kind: "agent", createdAt: 20 }, + ]; + + const summary = buildLivePerformanceSummary({ + roomId: "room-1", + messages, + traces: [trace], + run: { model: "openrouter/free", steps: 4, toolCalls: 3, inputTokens: 1200, outputTokens: 320, costUsd: 0.012, ms: 1800 }, + job: { + id: "job-1", + status: "running", + attempts: 1, + maxAttempts: 3, + modelPolicy: "free_auto", + runtime: "workflow_sliced", + updatedAt: 30, + toolCallCount: 3, + modelCallCount: 1, + receiptCount: 2, + }, + attempts: [{ attempt: 1, status: "running", resolvedModel: "openrouter/free", stopReason: "in_progress", ms: 900, inputTokens: 600, outputTokens: 120, costUsd: 0.004 }], + detail: { + operations: [{ sequence: 1, kind: "mutation", name: "patch_bundle_cas", status: "completed" }], + streamEvents: [{ sequence: 1, kind: "message_done", status: "completed", createdAt: 20, text: "done" }], + streamParts: [], + reasoningFrames: [{ frameId: "frame-1", sequence: 1, frameKind: "phase", phase: "synthesis", status: "running", goal: "reconcile funding", toolAllowlist: [] }], + receipts: [{ id: "receipt-1", mutationName: "patch_bundle_cas", affectedIds: ["r1__funding"], createdAt: 22 }], + leases: [], + draftOperations: [], + latestSteps: [{ idx: 1, tool: "patch_bundle_cas", status: "done" }], + }, + }); + + expect(summary.status).toBe("running"); + expect(summary.messageCount).toBe(2); + expect(summary.humanMessageCount).toBe(1); + expect(summary.agentMessageCount).toBe(1); + expect(summary.runCount).toBe(1); + expect(summary.agentTraceCount).toBe(1); + expect(summary.latestActivityAt).toBe(40); + expect(summary.job?.modelPolicy).toBe("free_auto"); + expect(summary.detailCounts).toMatchObject({ operations: 1, streamEvents: 1, reasoningFrames: 1, receipts: 1, latestSteps: 1 }); + }); + + it("derives a stable storyboard-first deck plan from room artifacts before slide generation", () => { + const storyboard = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, notebook], + traces: [trace], + proposals: [proposal], + }); + const second = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, notebook], + traces: [trace], + proposals: [proposal], + }); + + expect(storyboard.title).toBe("Startup diligence readout"); + expect(storyboard.planHash).toBe(second.planHash); + expect(storyboard.slides.map((slide) => slide.title)).toEqual(["Company research", "Diligence notebook"]); + expect(storyboard.storyboardStatus).toBe("needs_review"); + expect(storyboard.unresolvedGaps.some((gap) => gap.includes("Missing HIPAA source"))).toBe(true); + expect(storyboard.traceIds).toContain("trace-1"); + expect(storyboard.proposalIds).toContain("proposal-1"); + expect(storyboard.slides[0].claims.some((claim) => claim.status === "verified")).toBe(true); + expect(storyboard.slides[0].claims.some((claim) => claim.status === "needs_review")).toBe(true); + }); + + it("converts a storyboard into a deck artifact input with receipt-ready sections", () => { + const storyboard = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, notebook], + traces: [trace], + proposals: [proposal], + }); + const deckInput = deckArtifactInputFromStoryboard(storyboard); + + expect(deckInput.id).toBe("room-1:storyboard"); + expect(deckInput.status).toBe("needs_review"); + expect(deckInput.sections).toHaveLength(2); + expect(deckInput.sections[0]).toMatchObject({ title: "Company research" }); + expect(deckInput.traceIds).toContain("trace-1"); + expect(deckInput.proposalIds).toContain("proposal-1"); + expect(deckInput.sourceIds).toEqual(["art-research", "art-note"]); + }); + + it("round-trips a collaborative deck through an ordinary CAS-backed note artifact", () => { + const storyboard = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, notebook], + traces: [trace], + proposals: [proposal], + }); + const input = collaborativeDeckArtifactInput(storyboard); + const deckArtifact: Artifact = { + id: "art-deck", + roomId: "room-1", + kind: "note", + title: input.title, + version: 4, + createdBy: human, + updatedAt: 50, + order: input.seed.map((item) => item.id), + elements: Object.fromEntries(input.seed.map((item) => [item.id, cell(item.id, item.value)])), + meta: input.meta, + }; + + expect(isCollaborativeDeckArtifact(deckArtifact)).toBe(true); + const snapshot = readCollaborativeDeckArtifact(deckArtifact); + const storedStoryboard = input.seed.find((item) => item.id === "deck_storyboard")?.value as ReturnType; + expect(snapshot).toMatchObject({ artifactId: "art-deck", elementVersion: 1 }); + expect(snapshot?.storyboard.planHash).toBe(storedStoryboard.planHash); + expect(snapshot?.storyboard.slides).toHaveLength(2); + }); + + it("supports deterministic add, duplicate, move, delete, and normalization for deck collaboration", () => { + const storyboard = buildDeckStoryboardFromRoom({ roomId: "room-1", artifacts: [researchSheet, notebook] }); + const added = addCollaborativeDeckSlide(storyboard, storyboard.slides[0].slideId); + const newSlide = added.slides[1]; + const duplicated = duplicateCollaborativeDeckSlide(added, newSlide.slideId); + const moved = moveCollaborativeDeckSlide(duplicated, duplicated.slides[2].slideId, -1); + const deleted = deleteCollaborativeDeckSlide(moved, newSlide.slideId); + const normalized = normalizeCollaborativeDeck({ ...deleted, objective: " Board decision " }, deleted.version + 1); + + expect(added.slides).toHaveLength(3); + expect(duplicated.slides).toHaveLength(4); + expect(new Set(duplicated.slides.map((slide) => slide.slideId)).size).toBe(4); + expect(moved.slides[1].title).toContain("copy"); + expect(deleted.slides).toHaveLength(3); + expect(normalized.objective).toBe("Board decision"); + expect(normalized.version).toBeGreaterThan(storyboard.version); + expect(normalized.planHash).not.toBe(storyboard.planHash); + expect(normalized.requiredEvidence.length).toBeGreaterThan(0); + }); + + it("builds a deterministic reviewer deck patch plan from storyboard gaps and proposals", () => { + const storyboard = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, structuredNotebook], + traces: [trace], + proposals: [proposal], + }); + + const patchPlan = buildDeckPatchPlan(storyboard); + const second = buildDeckPatchPlan(storyboard); + + expect(patchPlan.patchVersion).toBe(1); + expect(patchPlan.integrityHash).toBe(second.integrityHash); + expect(deckPatchPlanJson(patchPlan)).toBe(deckPatchPlanJson(second)); + expect(patchPlan.patchCount).toBeGreaterThan(0); + expect(patchPlan.readyForReviewCount).toBeGreaterThan(0); + expect(patchPlan.needsSourceCount).toBeGreaterThan(0); + expect(patchPlan.proposalIds).toContain("proposal-1"); + expect(patchPlan.sourceArtifactIds).toEqual(expect.arrayContaining(["art-research", "art-structured-note"])); + expect(patchPlan.items.some((item) => item.kind === "proposal_review" && item.proposalId === "proposal-1")).toBe(true); + expect(patchPlan.items.some((item) => item.kind === "gap_resolution" && item.beforeText.includes("Missing HIPAA source"))).toBe(true); + expect(patchPlan.items.every((item) => item.afterText.length > item.beforeText.length)).toBe(true); + expect(deckPatchPlanFileName(storyboard.title, patchPlan.integrityHash)).toBe(`startup-diligence-readout-deck-patch-plan-${patchPlan.integrityHash}.json`); + }); + + it("builds a deterministic HTML deck preview export from the storyboard plan", () => { + const storyboard = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, structuredNotebook], + traces: [trace], + proposals: [proposal], + }); + + const preview = buildDeckPreviewExport(storyboard, 123); + const second = buildDeckPreviewExport(storyboard, 456); + + expect(preview.exportVersion).toBe(1); + expect(preview.integrityHash).toBe(second.integrityHash); + expect(preview.slideCount).toBe(2); + expect(preview.needsReviewCount).toBeGreaterThan(0); + expect(preview.html).toContain(""); + expect(preview.html).toContain("Startup diligence readout"); + expect(preview.html).toContain("NodeRoom deck preview"); + expect(deckPreviewFileName(storyboard.title, preview.integrityHash)).toBe(`startup-diligence-readout-deck-preview-${preview.integrityHash}.html`); + }); + + it("builds a deterministic portable PPTX deck export from the storyboard plan", async () => { + const storyboard = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, structuredNotebook], + traces: [trace], + proposals: [proposal], + }); + + const pptx = await buildDeckPptxExport(storyboard, 123); + const second = await buildDeckPptxExport(storyboard, 456); + const zip = await JSZip.loadAsync(pptx.bytes); + const presentation = await zip.file("ppt/presentation.xml")!.async("string"); + const presentationRels = await zip.file("ppt/_rels/presentation.xml.rels")!.async("string"); + const core = await zip.file("docProps/core.xml")!.async("string"); + const slide1 = await zip.file("ppt/slides/slide1.xml")!.async("string"); + + expect(pptx.exportVersion).toBe(1); + expect(pptx.integrityHash).toBe(second.integrityHash); + expect(Buffer.from(pptx.bytes).equals(Buffer.from(second.bytes))).toBe(true); + expect([...pptx.bytes.slice(0, 2)].map((value) => String.fromCharCode(value)).join("")).toBe("PK"); + expect(pptx.slideCount).toBe(2); + expect(pptx.needsReviewCount).toBeGreaterThan(0); + expect(deckPptxFileName(storyboard.title, pptx.integrityHash)).toBe(`startup-diligence-readout-deck-${pptx.integrityHash}.pptx`); + expect(presentation).toContain('r:id="rId2"'); + expect(presentationRels).toContain("slides/slide1.xml"); + expect(core).toContain("NodeRoom"); + expect(slide1).toContain("Company research"); + }); + + it("builds a deterministic portable PDF deck export from the storyboard plan", () => { + const storyboard = buildDeckStoryboardFromRoom({ + roomId: "room-1", + roomTitle: "Startup diligence", + artifacts: [researchSheet, structuredNotebook], + traces: [trace], + proposals: [proposal], + }); + + const pdf = buildDeckPdfExport(storyboard, 123); + const second = buildDeckPdfExport(storyboard, 456); + const text = new TextDecoder().decode(pdf.bytes); + + expect(pdf.exportVersion).toBe(1); + expect(pdf.integrityHash).toBe(second.integrityHash); + expect(Buffer.from(pdf.bytes).equals(Buffer.from(second.bytes))).toBe(true); + expect(text.startsWith("%PDF-1.4")).toBe(true); + expect((text.match(/\/Type \/Page\b/g) ?? [])).toHaveLength(2); + expect(pdf.slideCount).toBe(2); + expect(pdf.needsReviewCount).toBeGreaterThan(0); + expect(deckPdfFileName(storyboard.title, pdf.integrityHash)).toBe(`startup-diligence-readout-deck-${pdf.integrityHash}.pdf`); + expect(text).toContain("Company research"); + expect(text).toContain("Missing HIPAA source"); + }); +}); From 55b55a0cd12bf85964dfe282b08425bd5408032f Mon Sep 17 00:00:00 2001 From: homen Date: Fri, 10 Jul 2026 15:20:19 -0700 Subject: [PATCH 2/6] Repair e2e and src product-memory launch gate --- e2e/chat.spec.ts | 1 + e2e/full-modern-ux-bar.spec.ts | 41 ++++++++++++++++----------- e2e/privacy-job-wall-proposal.spec.ts | 8 ++++-- e2e/semantic-rebase.spec.ts | 2 +- e2e/work-surface-split.spec.ts | 20 +++++++------ src/app/store.tsx | 30 ++++++++++++++++---- src/ui/Chat.tsx | 22 +++++++++++--- src/ui/primitives/primitives.css | 3 +- 8 files changed, 87 insertions(+), 40 deletions(-) diff --git a/e2e/chat.spec.ts b/e2e/chat.spec.ts index 10108f2e..f214828f 100644 --- a/e2e/chat.spec.ts +++ b/e2e/chat.spec.ts @@ -195,6 +195,7 @@ test.describe("chat — optimistic send + edit (memory mode)", () => { // Quick chips are context-aware (they vary by the active artifact — diligence/runway/enrich/ // organize/memo), so assert the durable contract, not a fixed prompt pair: @nodeagent chips are // present and the legacy /ask + /free slash chips are gone. + await chat.getByTestId("chat-composer").focus(); const agentChips = chat.locator(".r-composer-hint .r-chip").filter({ hasText: /^@nodeagent / }); await expect(agentChips.first()).toBeVisible(); await expect(chat.locator(".r-composer-hint .r-chip").filter({ hasText: /^\/(ask|free)\b/ })).toHaveCount(0); diff --git a/e2e/full-modern-ux-bar.spec.ts b/e2e/full-modern-ux-bar.spec.ts index 6b93a457..29f32a1e 100644 --- a/e2e/full-modern-ux-bar.spec.ts +++ b/e2e/full-modern-ux-bar.spec.ts @@ -190,7 +190,7 @@ async function openDesktopArtifact(page: Page, label: string): Promise { test.describe("full modern UX release bar", () => { test.setTimeout(120_000); - test("desktop public agent shows immediate progress, mutates artifacts, and keeps browser health clean", async ({ + test("desktop public agent responds promptly, mutates artifacts, and keeps browser health clean", async ({ page, }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); @@ -206,8 +206,8 @@ test.describe("full modern UX release bar", () => { }); await page.goto("/?mode=memory", { waitUntil: "domcontentloaded" }); - await expect(page.getByRole("button", { name: "NodeAgent home" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Diligence that shows its work." })).toBeVisible(); + await expect(page.getByRole("button", { name: "NodeRoom home" })).toBeVisible(); + await expect(page.getByRole("heading", { name: /Work with AI.*Review every change/i })).toBeVisible(); await expect(page.getByTestId("join-room-code")).toHaveAttribute("placeholder", "ENTER CODE"); await page.getByTestId("start-demo-room").focus(); await expect(page.getByTestId("start-demo-room")).toBeFocused(); @@ -220,6 +220,7 @@ test.describe("full modern UX release bar", () => { await expectNoHorizontalOverflow(page, "desktop room shell"); const chat = publicChat(page); + await chat.getByTestId("chat-composer").focus(); await expect(chat.getByRole("button", { name: "@nodeagent diligence CardioNova" })).toBeVisible(); await chat.getByRole("button", { name: "@nodeagent diligence CardioNova" }).click(); await expect(chat.getByTestId("chat-composer")).toHaveValue(CARDIONOVA_PROMPT); @@ -228,16 +229,20 @@ test.describe("full modern UX release bar", () => { await chat.getByTestId("chat-send").click(); await expect(chat.getByTestId("chat-message").filter({ hasText: CARDIONOVA_PROMPT })).toBeVisible(); await expect( - chat.locator(".r-msg.agent").filter({ hasText: /thinking|running/i }).last(), - "public lane should show an active agent turn before the final answer", - ).toBeVisible({ timeout: 2_500 }); - expect(Date.now() - sendStart, "active agent turn latency").toBeLessThan(2_500); - await expect(chat.getByTestId("chat-message").filter({ hasText: "Researched 1 company" })).toBeVisible({ - timeout: 20_000, - }); + chat.getByTestId("chat-message").filter({ hasText: /CardioNova is already sourced and complete/i }).last(), + ).toContainText(/No unrelated company rows were changed/i, { timeout: 20_000 }); + expect(Date.now() - sendStart, "named complete-row response latency").toBeLessThan(2_500); await openDesktopArtifact(page, "Company research"); const panel = page.getByTestId("artifact-panel"); + const columnMenu = panel.locator(".r-sheet-colmenu"); + await panel.locator(".r-sheet-bar").hover(); + await columnMenu.locator(".r-sheet-colmenu-btn").click(); + for (const column of ["summary", "funding", "source", "source2", "last researched"]) { + const checkbox = columnMenu.getByRole("checkbox", { name: column, exact: true }); + if (!(await checkbox.isChecked())) await checkbox.check(); + } + await columnMenu.locator(".r-sheet-colmenu-btn").click(); const cardioRow = panel.locator(".r-research-row", { hasText: "CardioNova" }); const statusCell = panel.locator('[data-cell-key="rc_cardionova__status"]').or(cardioRow.locator("td").nth(1)).first(); const summaryCell = panel.locator('[data-cell-key="rc_cardionova__summary"]').or(cardioRow.locator("td").nth(3)).first(); @@ -245,12 +250,14 @@ test.describe("full modern UX release bar", () => { const sourceCell = panel.locator('[data-cell-key="rc_cardionova__source"]').or(cardioRow.locator(".r-research-src")).first(); const source2Cell = panel.locator('[data-cell-key="rc_cardionova__source2"]').or(cardioRow.locator(".r-research-src")).first(); const freshCell = panel.locator('[data-cell-key="rc_cardionova__last_researched"]').or(cardioRow.locator("td").nth(6)).first(); + const atlasStatusCell = panel.locator('[data-cell-key="rc_atlasnova__status"]'); await expect(statusCell).toContainText(/complete/i); - await expect(summaryCell).toContainText(/AI triage workflow/i); - await expect(fundingCell).toContainText(/Series B profile/i); - await expect(sourceCell).toContainText(/cardionova\.example/); - await expect(source2Cell).toContainText(/cardionova\.example|wikipedia|fresh/i); - await expect(freshCell).not.toBeEmpty(); + await expect(summaryCell).toContainText(/sourced product, buyer, and deployment notes/i); + await expect(fundingCell).toContainText(/Funding profile captured/i); + await expect(sourceCell).toContainText(/cardionova\.com/i); + await expect(source2Cell).toHaveAttribute("title", /cardionova\.com\/security/i); + await expect(freshCell).toContainText("2026-07-03"); + await expect(atlasStatusCell).toContainText(/pending/i); await openDesktopArtifact(page, "Q3 variance"); await expect(panel.locator('[data-cell-key="r_gp__variance"]')).toBeVisible(); @@ -281,8 +288,8 @@ test.describe("full modern UX release bar", () => { const app = page.locator(".na-app"); await expect(app).toBeVisible({ timeout: 30_000 }); await expect(app).toHaveCSS("background-color", "rgb(251, 244, 231)"); - await expect(page.locator(".na-roomsw .nm")).toHaveText("Q3 Diligence"); - await expect(page.locator('[aria-label="Capture note"]')).toBeVisible(); + await expect(page.getByRole("button", { name: "Switch room, current room Q3 Diligence" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Capture", exact: true })).toBeVisible(); await expectNoHorizontalOverflow(page, "mobile capture"); await page.getByRole("button", { name: "Home" }).click(); await expect(page.locator(".na-kicker").filter({ hasText: "Recents" })).toBeVisible(); diff --git a/e2e/privacy-job-wall-proposal.spec.ts b/e2e/privacy-job-wall-proposal.spec.ts index 34161fe1..f416bcb0 100644 --- a/e2e/privacy-job-wall-proposal.spec.ts +++ b/e2e/privacy-job-wall-proposal.spec.ts @@ -14,18 +14,20 @@ test.describe("privacy, job, wall, and proposal browser coverage", () => { await page.getByTestId("copilot-tab-private").click(); const privateChat = page.getByTestId("private-chat-panel"); await expect(privateChat).toBeVisible(); + const privateReplies = privateChat.getByTestId("chat-message").filter({ hasText: "This stays private" }); + const initialPrivateReplyCount = await privateReplies.count(); await privateChat.getByTestId("chat-composer").fill(secret); await privateChat.getByTestId("chat-send").click(); await expect(privateChat.getByTestId("chat-message").filter({ hasText: secret })).toBeVisible(); - await expect(privateChat.getByTestId("chat-message").filter({ hasText: "Reading the room context for that" })).toBeVisible(); + await expect(privateReplies).toHaveCount(initialPrivateReplyCount + 1); await page.getByTestId("copilot-tab-public").click(); const roomChat = publicChat(page); await expect(roomChat).toBeVisible(); await expect(roomChat.getByTestId("chat-message").filter({ hasText: secret })).toHaveCount(0); - await expect(roomChat.getByTestId("chat-message").filter({ hasText: "Reading the room context for that" })).toHaveCount(0); + await expect(roomChat.getByTestId("chat-message").filter({ hasText: "This stays private" })).toHaveCount(0); }); test("wall post-its can be added, edited through blur commit, and deleted", async ({ page }) => { @@ -103,7 +105,7 @@ test.describe("privacy, job, wall, and proposal browser coverage", () => { }); test("semantic conflict proposal reject removes the CRS suggestion without overwriting the host value", async ({ page }) => { - await page.getByTestId("left-rail").getByRole("button", { name: /Q3 variance/ }).click(); + await page.getByTestId("left-rail").locator('[data-testid="binder-artifact"][data-artifact-title="Q3 variance"]').first().click(); const panel = page.getByTestId("artifact-panel"); const revenueVariance = panel.locator('[data-cell-key="r_rev__variance"]'); await expect(revenueVariance).toBeVisible(); diff --git a/e2e/semantic-rebase.spec.ts b/e2e/semantic-rebase.spec.ts index e3460de0..5a9d5ec4 100644 --- a/e2e/semantic-rebase.spec.ts +++ b/e2e/semantic-rebase.spec.ts @@ -14,7 +14,7 @@ async function openRoomTrace(page: Parameters[0]) { test("semantic rebase conflict drill is visible, reviewable, and applies only after host approval", async ({ page }) => { await enterDemoRoom(page); - await page.getByTestId("left-rail").getByRole("button", { name: /Q3 variance/ }).click(); + await page.getByTestId("left-rail").locator('[data-testid="binder-artifact"][data-artifact-title="Q3 variance"]').first().click(); const panel = page.getByTestId("artifact-panel"); await expect(panel.locator('[data-cell-key="r_rev__variance"]')).toBeVisible(); await page.evaluate(() => (window as any).__runConflictDrill()); diff --git a/e2e/work-surface-split.spec.ts b/e2e/work-surface-split.spec.ts index f3e97d38..d782364c 100644 --- a/e2e/work-surface-split.spec.ts +++ b/e2e/work-surface-split.spec.ts @@ -42,7 +42,9 @@ test.describe("center-stage split mode", () => { } // Desktop tier: control present and enabled (the seeded room has multiple artifacts). + await toggle.focus(); await expect(toggle).toBeVisible(); + await expect(toggle).toBeFocused(); await expect(toggle).toBeEnabled(); await expect(toggle).toHaveAttribute("aria-pressed", "false"); @@ -68,7 +70,7 @@ test.describe("center-stage split mode", () => { }); } - test("banker coach evidence opens its source beside the primary work surface", async ({ page }) => { + test("banker coach evidence opens its literal external source without replacing the primary work surface", async ({ page, context }) => { await page.setViewportSize({ width: 1440, height: 900 }); await enterDemoRoom(page); @@ -78,19 +80,19 @@ test.describe("center-stage split mode", () => { await page.getByTestId("copilot-tab-private").click(); await page.getByTestId("private-mode-coach").click(); const coach = page.getByTestId("banker-coach-panel"); - const sourceCard = coach.getByTestId("coach-evidence-card").filter({ hasText: "Agent wiki" }); + const sourceCard = coach.getByRole("link", { name: /Open source cardionova\.com for Primary source/ }).first(); await expect(coach).toBeVisible(); await expect(sourceCard).toBeVisible(); + await expect(sourceCard).toHaveAttribute("href", "https://cardionova.com"); + const sourcePagePromise = context.waitForEvent("page"); await sourceCard.click(); + const sourcePage = await sourcePagePromise; + await expect.poll(() => sourcePage.url()).toMatch(/^https:\/\/cardionova\.com\/?/); + await sourcePage.close(); - await expect(stage).toHaveAttribute("data-split", "true"); + await expect(stage).toHaveAttribute("data-split", "false"); await expect(primary).toBeVisible(); - await expect(secondary).toBeVisible(); - const a = await primary.boundingBox(); - const b = await secondary.boundingBox(); - expect(a).not.toBeNull(); - expect(b).not.toBeNull(); - expect(b!.x).toBeGreaterThan(a!.x); + await expect(secondary).toHaveCount(0); }); }); diff --git a/src/app/store.tsx b/src/app/store.tsx index 002b1331..e0c14e27 100644 --- a/src/app/store.tsx +++ b/src/app/store.tsx @@ -938,17 +938,37 @@ export function EngineStoreProvider({ roomId, children }: { roomId: string; me: const research = artifacts.find((a) => a.kind === "sheet" && a.title === "Company research"); if (research) { const actor: Actor = { kind: "agent", id: pub.agentId, name: pub.agentName, scope: "public" }; - const pendingRows = researchRowIds(research) + const allRows = researchRowIds(research); + const pendingRows = allRows .filter((rowId) => String(research.elements[`${rowId}__status`]?.value ?? "pending") === "pending"); - // Scope to the company named in the goal (e.g. "diligence CardioNova") so the run finishes - // fast and live; only fan out to the whole watchlist when the goal explicitly asks for it. + // A named company always wins over the generic pending-watchlist fallback. Completed named + // rows return an honest no-op instead of silently redirecting the run to unrelated rows. const g = input.goal.toLowerCase(); const wantsAll = /\b(all|every|batch|watchlist|bulk|each|companies)\b/.test(g); - const named = wantsAll ? [] : pendingRows.filter((rowId) => { + const named = wantsAll ? [] : allRows.filter((rowId) => { const name = String(research.elements[`${rowId}__company`]?.value ?? "").toLowerCase(); return name.length > 1 && g.includes(name); }); - const rows = named.length ? named : pendingRows; + const namedPending = named.filter((rowId) => pendingRows.includes(rowId)); + if (named.length > 0 && namedPending.length === 0) { + const companies = named + .map((rowId) => String(research.elements[`${rowId}__company`]?.value ?? rowId)) + .join(", "); + const statuses = [...new Set(named.map((rowId) => String(research.elements[`${rowId}__status`]?.value ?? "pending")))]; + const state = statuses.length === 1 && statuses[0] === "complete" + ? "already sourced and complete" + : `already ${statuses.map((status) => status.replace(/_/g, " ")).join("/")}`; + engine.postMessage({ + roomId, + channel: "public", + author: actor, + text: `${companies} ${named.length === 1 ? "is" : "are"} ${state}. No unrelated company rows were changed.`, + clientMsgId: crypto.randomUUID(), + kind: "agent", + }); + return; + } + const rows = namedPending.length ? namedPending : pendingRows; const pending = rows.map((rowId) => researchTargetFor(research, rowId)); if (pending.length === 0) { engine.postMessage({ roomId, channel: "public", author: actor, text: "Every company on the research sheet is already sourced and complete.", clientMsgId: crypto.randomUUID(), kind: "agent" }); diff --git a/src/ui/Chat.tsx b/src/ui/Chat.tsx index e56a7126..23315ca9 100644 --- a/src/ui/Chat.tsx +++ b/src/ui/Chat.tsx @@ -2032,25 +2032,39 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId {!isPrivate && messages.length > 0 && {messages.length}} {!isPrivate && NRoom NodeAgent} - {showLongJobChrome && longJob && (() => { const bad = ["failed", "blocked"].includes(longJob.status); return ( + {!embedded && showLongJobChrome && longJob && (() => { const bad = ["failed", "blocked"].includes(longJob.status); return ( {longJob.status} {longJob.attempts}/{longJob.maxAttempts} ); })()} - {canCancelLongJob && ( + {!embedded && canCancelLongJob && ( )} - {canRetryLongJob && ( + {!embedded && canRetryLongJob && ( )} - {jobErr && {jobErr}} + {!embedded && jobErr && {jobErr}} {isPrivate &&
Only you can read this lane in NodeRoom; requests and room context are sent to the configured model provider
} {!isPrivate && showLongJobChrome && longJob && (
+ {embedded && (() => { const bad = ["failed", "blocked"].includes(longJob.status); return ( + {longJob.status} {longJob.attempts}/{longJob.maxAttempts} + ); })()} + {embedded && canCancelLongJob && ( + + )} + {embedded && canRetryLongJob && ( + + )} + {embedded && jobErr && {jobErr}} {longJob.modelPolicy} {latestAttempt && attempt {latestAttempt.attempt}: {latestAttempt.resolvedModel} · {latestAttempt.stopReason} · {shortMs(latestAttempt.ms)}} {longJob.nextRunAt && longJob.status !== "completed" && next {clock(longJob.nextRunAt)}} diff --git a/src/ui/primitives/primitives.css b/src/ui/primitives/primitives.css index 8896d55c..83807d55 100644 --- a/src/ui/primitives/primitives.css +++ b/src/ui/primitives/primitives.css @@ -1998,7 +1998,8 @@ pointer-events: none; } - .nr-app-shell .r-panel-head:hover [data-testid="artifact-split-toggle"] { + .nr-app-shell .r-panel-head:hover [data-testid="artifact-split-toggle"], + .nr-app-shell .r-panel-head:focus-within [data-testid="artifact-split-toggle"] { width: 28px; min-width: 28px; padding: 0; From b8b75d405e085f651beac661ab7f20ffd5ac130b Mon Sep 17 00:00:00 2001 From: homen Date: Fri, 10 Jul 2026 15:26:54 -0700 Subject: [PATCH 3/6] Update docs launch readiness receipt --- .../NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md b/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md index 1705f170..08814abf 100644 --- a/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md +++ b/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md @@ -31,9 +31,10 @@ Generated benchmark receipts, videos, trace archives, and unrelated evaluation W | Application TypeScript | Pass | | Convex TypeScript | Pass | | Production Vite build | Pass; existing large-chunk advisory | -| Full release-branch Vitest | Pass, 297 files / 2,033 tests | +| Full release-branch Vitest | Pass, 297 files / 2,035 tests | | Auth/session focused tests | Pass | | First-run + story + terracotta + live Convex Playwright | Pass, 28/28 | +| Product-memory Playwright | Pass, 29/29 | | Deployed authenticated fresh-phone Playwright | Pass, 2/2 at 390x844 | | Accounting ProofLoop | Pass, 100/85 | | Notion SDR/BDR ProofLoop | Pass, 100/80 | @@ -51,13 +52,14 @@ The owning shared lane separately passed 309 Vitest files / 2,073 tests and its ## Authenticated Preview Receipt - Isolated Convex preview: `hushed-jellyfish-969`, with strict account identity enforcement enabled. -- Matching Vercel preview deployment: `dpl_2L37KLrWLmQRDnCybZKruzefWzJC`, built from release revision `7ef26a1a`. +- Matching Vercel preview: `https://noderoom-8zygh5f55-hshum2018-gmailcoms-projects.vercel.app` (`dpl_46RsPag86vzETb9MwvBRq3rEtGT1`), built from release runtime revision `55b55a0c`. - Fresh-phone Create proof: landing explanation, review-first consent, account creation, empty room, public message, reload persistence, sign-out, and fail-closed rejoin. - Fresh-phone Sample proof: explicit synthetic-data consent, account creation, live governed deck, Plan to Slides to scoped request, accepted job receipt without a fabricated patch, Evidence, PPTX download, and integrity receipt. - The deployed run found and repaired three product defects: missing governed-deck recents, review state resetting on reactive object identity, and oversized provenance requests rejected by the server. -- The final post-preview delta is confined to an honestly marked memory-mode `Create a room` entry contract, its focused test, and reviewed visual baselines; production-path behavior is unchanged. +- The final runtime repair keeps embedded job controls reachable, reveals the split control on keyboard focus, updates stale accessibility selectors, and prevents a named completed-company request from mutating unrelated pending rows. +- The named-company browser proof confirms CardioNova remains source-backed and complete while AtlasNova remains pending. -The preview is launch evidence, not a production promotion. Its deployment protection bypass is temporary and is revoked after testing. +The preview is launch evidence, not a production promotion. Its temporary deployment-protection bypass was revoked after testing; the project reports an empty `protectionBypass` map. ## Production Auth Receipt From bb1956fd3f8fe5863537cb5e0c740854d8659ed1 Mon Sep 17 00:00:00 2001 From: homen Date: Fri, 10 Jul 2026 18:07:05 -0700 Subject: [PATCH 4/6] Harden .gitignore docs src and tests launch migration --- .gitignore | 1 + ...DEROOM_MOBILE_LAUNCH_READINESS_20260710.md | 43 +++++-- ...M_PRODUCTION_MIGRATION_RUNBOOK_20260710.md | 115 ++++++++++++++++++ src/ui/workArtifacts/deckPptxExport.ts | 4 +- tests/workArtifacts.test.ts | 3 + 5 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 docs/release/NODEROOM_PRODUCTION_MIGRATION_RUNBOOK_20260710.md diff --git a/.gitignore b/.gitignore index e20697c6..60f22cab 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ playwright/.cache/ .proofloop/orchestrator/ .proofloop/prod-proxy-longrun/ .proofloop/runner/ +.proofloop/rollback/ .proofloop/workers/ .proofloop/memory/ .proofloop/agents/ diff --git a/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md b/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md index 08814abf..db22c4b1 100644 --- a/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md +++ b/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md @@ -36,6 +36,7 @@ Generated benchmark receipts, videos, trace archives, and unrelated evaluation W | First-run + story + terracotta + live Convex Playwright | Pass, 28/28 | | Product-memory Playwright | Pass, 29/29 | | Deployed authenticated fresh-phone Playwright | Pass, 2/2 at 390x844 | +| Deterministic PPTX export regression | Pass; fixed-time files with no clock-stamped folder entries | | Accounting ProofLoop | Pass, 100/85 | | Notion SDR/BDR ProofLoop | Pass, 100/80 | | Linux + Windows mobile visual baselines | Reviewed and refreshed; local gate 3/3 | @@ -61,6 +62,24 @@ The owning shared lane separately passed 309 Vitest files / 2,073 tests and its The preview is launch evidence, not a production promotion. Its temporary deployment-protection bypass was revoked after testing; the project reports an empty `protectionBypass` map. +## Rollback And Migration Rehearsal Receipt + +- Authoritative source rollback: `.proofloop/rollback/zealous-goshawk-766-20260710-153059.zip`. +- Source rollback size: `5,078,797,442` bytes; `13,378` ZIP entries, including `13,106` `_storage` entries. +- Source rollback SHA-256: `CE13AF578BD4A36D660C26BCBEF58C4D7580EE6FE8414F492522169F106A2FBD`. +- Full 7-Zip test: pass; `13,378` files and `10,186,870,607` uncompressed bytes. +- Current production rollback: `.proofloop/rollback/aromatic-bass-102-20260710-175955.zip`. +- Production rollback size: `462,953` bytes; `186` ZIP entries, including `2` `_storage` entries. +- Production rollback SHA-256: `9CFC030A0BE758DE32E2A342B65C46A4CB697E7D9A25107A650C446DA306EC83`. +- Full production 7-Zip test: pass. +- Isolated rehearsal deployment: `agreeable-civet-283`, created with a one-day expiry and no public frontend. +- Clean release functions/schema deployed before import; its function-spec hash matched the authenticated preview at `d6890534fca6cd8a58abadf6a70f4e1e4a5a64fe1e5702a666aa92676bfa0e19`. +- `--replace-all` rehearsal import: pass after `1h59m45s`; `8,312,277` documents and all `13,105` stored files imported. +- Post-import checks: `1,998` rooms, `13,105` stored files, full 4,096-row samples for artifacts/messages/elements, and valid artifact-to-room, artifact-to-element, message-to-room, and upload-to-storage references. +- The rehearsal deploy key was revoked and its temporary local environment file was deleted. The deployment expires automatically. + +The rollback artifact is now proven. It is intentionally gitignored and remains local because it contains room data and stored files. + ## Production Auth Receipt - A dedicated `NodeRoom Production` GitHub OAuth application was created with homepage `https://noderoom.live` and callback on the actual production Convex site. @@ -73,22 +92,22 @@ The preview is launch evidence, not a production promotion. Its temporary deploy 1. `noderoom.live` still serves the stale public build and points at Convex development deployment `zealous-goshawk-766`. 2. The actual Convex production deployment remains `aromatic-bass-102` with an older function/schema surface. -3. The public-facing development deployment contains at least 1,000 rooms and is the authoritative current data source. -4. A storage-inclusive development export exceeded 10 minutes and produced no archive. -5. A database-only development export also exceeded 10 minutes and produced no archive. -6. No destructive import, production backend deploy, frontend promotion, or identity enforcement is permitted without a verified rollback artifact or an approved non-destructive migration. +3. The public-facing development deployment contains `1,998` rooms and is the authoritative current data source. +4. The source snapshot contains `2,688` legacy member rows but zero `users`, `authAccounts`, or `authSessions` rows. Enabling strict account identity without an explicit legacy-room claim/disposition policy can strand existing rooms. +5. The isolated import took almost two hours. Production needs a scheduled write freeze, a fresh cutover snapshot, and post-import delta reconciliation; the verified snapshot is rollback evidence, not a zero-downtime cutover artifact. +6. No production import or identity enforcement is permitted until an owner approves the legacy anonymous-room policy and supervised maintenance window in `NODEROOM_PRODUCTION_MIGRATION_RUNBOOK_20260710.md`. 7. Vercel production still needs explicit hosted `VITE_CONVEX_URL`, `VITE_CONVEX_SITE_URL`, `VITE_NODEROOM_AUTH_REQUIRED=1`, and `VITE_NODEROOM_AUTH_PROVIDER=github` configuration. 8. The independent taste gate previously remained at 5.5/6.0 and still requires its owning review before launch promotion. ## Safe Resume Sequence -1. Obtain a verified Convex export through the dashboard/support path, including file storage, or approve a non-destructive dual-read migration. -2. Compare development and production snapshots without exposing room codes or content. -3. Preserve the passing isolated preview receipt while production rollback work proceeds. -4. Obtain the owning independent taste-gate approval. -5. Deploy the production Convex revision and verify its function spec before changing Vercel production coordinates. -6. Deploy the matching frontend, test GitHub sign-in, then enable production identity enforcement. -7. Run fresh-phone Create, invited-member Join, reload recovery, proposal accept/reject, trace, and export receipt against `https://noderoom.live`. -8. Promote only when the production journey, rollback proof, and independent taste gate all pass. +1. Decide whether legacy anonymous rooms are migrated through an account-claim flow, retained temporarily without strict identity, or explicitly retired. +2. Schedule a supervised maintenance window of at least three hours and freeze writes to `zealous-goshawk-766`. +3. Capture and validate fresh source and destination snapshots immediately before cutover. +4. Follow `NODEROOM_PRODUCTION_MIGRATION_RUNBOOK_20260710.md`; verify function-spec, table, storage, and referential parity before changing Vercel production coordinates. +5. Configure the matching production frontend and GitHub auth, then test sign-in before enabling strict identity. +6. Run fresh-phone Create, invited-member Join/claim, reload recovery, proposal accept/reject, trace, and export receipt against `https://noderoom.live`. +7. Obtain the owning independent taste-gate approval. +8. Promote only when migration, authenticated production journey, rollback drill, and independent taste gate all pass. Local clean-branch dogfood: `http://127.0.0.1:4175` diff --git a/docs/release/NODEROOM_PRODUCTION_MIGRATION_RUNBOOK_20260710.md b/docs/release/NODEROOM_PRODUCTION_MIGRATION_RUNBOOK_20260710.md new file mode 100644 index 00000000..d03312d0 --- /dev/null +++ b/docs/release/NODEROOM_PRODUCTION_MIGRATION_RUNBOOK_20260710.md @@ -0,0 +1,115 @@ +# NodeRoom Production Migration Runbook + +Captured: 2026-07-10 (America/Los_Angeles) + +Status: rehearsed on isolated Convex preview; production execution blocked on owner decisions and a supervised maintenance window + +## No-Go Conditions + +Do not start the production migration when any of these are unresolved: + +- no approved disposition or account-claim path for legacy anonymous rooms; +- no announced write freeze of at least three hours; +- no fresh, storage-inclusive source and destination snapshots with hashes and full archive tests; +- no operator available to monitor the import and perform rollback; +- release branch checks, authenticated preview proof, or independent taste approval are not green; +- production GitHub OAuth callback or Vercel build coordinates do not match the target Convex deployment. + +## Rehearsal Baseline + +- Source: `zealous-goshawk-766`. +- Destination rehearsal: `agreeable-civet-283` (one-day preview). +- Snapshot: `5,078,797,442` bytes, SHA-256 `CE13AF578BD4A36D660C26BCBEF58C4D7580EE6FE8414F492522169F106A2FBD`. +- Imported: `8,312,277` documents and `13,105` stored files. +- Duration: `1h59m45s`. +- Post-import: `1,998` rooms, `13,105` stored files, matching function-spec hash, and resolved sample references. +- Rehearsal token revoked; temporary credential file deleted. + +The rehearsal proves schema and storage compatibility. It does not prove zero-downtime cutover or legacy-account ownership migration. + +## Phase 1: Owner Decisions + +Record explicit decisions for: + +1. Legacy room policy: claim, temporary compatibility, or retirement. +2. Maximum accepted write-freeze duration and user-facing maintenance copy. +3. Roll-forward versus rollback authority. +4. Independent taste-gate approval. + +Do not infer these decisions from technical success. + +## Phase 2: Freeze And Snapshot + +1. Put `noderoom.live` into a maintenance/read-only state before the source snapshot timestamp. +2. Confirm no new room, member, message, artifact, upload, proposal, or trace writes are accepted. +3. Export `zealous-goshawk-766` with file storage to a new timestamped ZIP. +4. Export `aromatic-bass-102` with file storage to a new timestamped ZIP. +5. Run `7z t` and SHA-256 over both archives. +6. Record byte size, entry count, storage entry count, hash, start time, and completion time. + +Abort if either archive is missing, unreadable, or unverified. + +## Phase 3: Backend And Import + +1. Keep production identity enforcement disabled. +2. Deploy the release Convex functions/schema to `aromatic-bass-102` and verify the function spec before data import. +3. Import the fresh source snapshot with `--replace-all --yes` during the write freeze. +4. Monitor until Convex returns a terminal success. The rehearsal required nearly two hours. +5. Do not switch frontend coordinates during an active or ambiguous import. + +Abort and restore the destination snapshot if schema validation fails, import terminates unsuccessfully, or the terminal state cannot be established. + +## Phase 4: Data Verification + +Before frontend promotion, verify: + +- exact room and storage counts from the import receipt; +- representative counts for artifacts, elements, messages, traces, jobs, proposals, and uploads; +- artifact-to-room, artifact-to-element, message-to-room, upload-to-storage, and proposal-to-artifact references; +- storage URLs resolve for representative uploaded files; +- function-spec hash matches the authenticated release preview; +- no post-freeze source writes need replay. + +Keep maintenance mode active on any mismatch. + +## Phase 5: Auth And Frontend + +1. Set production `SITE_URL=https://noderoom.live` on the target Convex deployment. +2. Verify GitHub OAuth client ID/secret and JWT signing keys without printing them. +3. Set Vercel production build values for the target Convex cloud/site URLs, auth required, and GitHub provider. +4. Deploy the matching frontend revision. +5. Test GitHub sign-in and the approved legacy-room claim/disposition path. +6. Enable strict Convex identity only after both paths pass. + +## Phase 6: Production Proof + +Run at 390x844 with a fresh browser profile: + +- first account and empty-room creation; +- invited-member join or approved legacy-room claim; +- public message, reload persistence, sign-out, and fail-closed rejoin; +- sample consent and governed deck Plan to Slides to scoped request; +- proposal accept and reject; +- evidence and trace inspection; +- PPTX/XLSX export receipt and downloaded-file integrity; +- mobile homepage CTA, keyboard focus, touch targets, overflow, and console/network health. + +Production remains blocked until every journey passes and the independent taste gate approves promotion. + +## Rollback Triggers + +Rollback immediately when: + +- import or count parity fails; +- representative references or stored files do not resolve; +- GitHub sign-in fails or users cannot claim/join their intended rooms; +- strict identity admits token-only fallback or locks out approved users; +- error rates, room creation latency, or reload recovery breach the launch bar. + +Rollback order: + +1. Restore the verified `aromatic-bass-102` pre-cutover snapshot. +2. Restore the prior Vercel production deployment and coordinates. +3. Keep strict identity disabled. +4. Reopen the old source only after verifying its write state. +5. Record exact timestamps, hashes, deployment IDs, and the failed gate. diff --git a/src/ui/workArtifacts/deckPptxExport.ts b/src/ui/workArtifacts/deckPptxExport.ts index 5ab1c346..dcf56537 100644 --- a/src/ui/workArtifacts/deckPptxExport.ts +++ b/src/ui/workArtifacts/deckPptxExport.ts @@ -15,7 +15,7 @@ export interface DeckPptxExport { } const PPTX_MIME = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; -const ZIP_DATE = new Date(0); +const ZIP_DATE = new Date(Date.UTC(1980, 0, 1, 0, 0, 0)); function stableHash(value: unknown): string { const text = JSON.stringify(value); @@ -203,7 +203,7 @@ const THEME = ` `; function addFile(zip: JSZip, path: string, body: string): void { - zip.file(path, body, { date: ZIP_DATE }); + zip.file(path, body, { date: ZIP_DATE, createFolders: false }); } export async function buildDeckPptxExport(storyboard: DeckStoryboard, generatedAt = 0): Promise { diff --git a/tests/workArtifacts.test.ts b/tests/workArtifacts.test.ts index 92a53c8f..22b9fa7d 100644 --- a/tests/workArtifacts.test.ts +++ b/tests/workArtifacts.test.ts @@ -780,10 +780,13 @@ describe("work artifact adapters", () => { const presentationRels = await zip.file("ppt/_rels/presentation.xml.rels")!.async("string"); const core = await zip.file("docProps/core.xml")!.async("string"); const slide1 = await zip.file("ppt/slides/slide1.xml")!.async("string"); + const zipEntries = Object.values(zip.files); expect(pptx.exportVersion).toBe(1); expect(pptx.integrityHash).toBe(second.integrityHash); expect(Buffer.from(pptx.bytes).equals(Buffer.from(second.bytes))).toBe(true); + expect(zipEntries.every((entry) => !entry.dir)).toBe(true); + expect(new Set(zipEntries.map((entry) => entry.date.getTime())).size).toBe(1); expect([...pptx.bytes.slice(0, 2)].map((value) => String.fromCharCode(value)).join("")).toBe("PK"); expect(pptx.slideCount).toBe(2); expect(pptx.needsReviewCount).toBeGreaterThan(0); From 102d70728065754846a9426d82ec55807032b305 Mon Sep 17 00:00:00 2001 From: homen Date: Fri, 10 Jul 2026 18:17:15 -0700 Subject: [PATCH 5/6] Stabilize e2e artifact reference assertion --- e2e/chat.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/chat.spec.ts b/e2e/chat.spec.ts index f214828f..d8789bfb 100644 --- a/e2e/chat.spec.ts +++ b/e2e/chat.spec.ts @@ -61,9 +61,12 @@ test.describe("chat — optimistic send + edit (memory mode)", () => { }, { mime: ARTIFACT_REF_MIME, ref }); await expect(chat.locator(".r-ref-chip").filter({ hasText: "Q3 variance" })).toBeVisible(); await expect(chat.getByTestId("chat-send")).toBeEnabled(); + const messages = chat.getByTestId("chat-message"); + const messageCount = await messages.count(); await chat.getByTestId("chat-send").click(); - const bubble = chat.getByTestId("chat-message").filter({ hasText: "Q3 variance" }).last(); + await expect(messages).toHaveCount(messageCount + 1); + const bubble = messages.nth(messageCount); await expect(bubble).toBeVisible(); await expect(bubble.locator(".r-msg-ref")).toContainText("Q3 variance"); const clientMsgId = await bubble.getAttribute("data-clientmsgid"); From 77757c42a9783072efca1317ef85789557dfea3f Mon Sep 17 00:00:00 2001 From: homen Date: Fri, 10 Jul 2026 18:27:50 -0700 Subject: [PATCH 6/6] Stabilize e2e reference identity tracking --- e2e/chat.spec.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/e2e/chat.spec.ts b/e2e/chat.spec.ts index d8789bfb..8b8fd387 100644 --- a/e2e/chat.spec.ts +++ b/e2e/chat.spec.ts @@ -62,16 +62,23 @@ test.describe("chat — optimistic send + edit (memory mode)", () => { await expect(chat.locator(".r-ref-chip").filter({ hasText: "Q3 variance" })).toBeVisible(); await expect(chat.getByTestId("chat-send")).toBeEnabled(); const messages = chat.getByTestId("chat-message"); - const messageCount = await messages.count(); + const existingClientMsgIds = new Set(await messages.evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-clientmsgid")).filter((id): id is string => Boolean(id)), + )); await chat.getByTestId("chat-send").click(); - await expect(messages).toHaveCount(messageCount + 1); - const bubble = messages.nth(messageCount); + let clientMsgId: string | null = null; + await expect.poll(async () => { + const referencedIds = await chat.locator('[data-testid="chat-message"]:has(.r-msg-ref)').evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-clientmsgid")).filter((id): id is string => Boolean(id)), + ); + clientMsgId = referencedIds.find((id) => !existingClientMsgIds.has(id)) ?? null; + return clientMsgId; + }).not.toBeNull(); + const bubble = chat.locator(`[data-testid="chat-message"][data-clientmsgid="${clientMsgId}"]`); await expect(bubble).toBeVisible(); await expect(bubble.locator(".r-msg-ref")).toContainText("Q3 variance"); - const clientMsgId = await bubble.getAttribute("data-clientmsgid"); - expect(clientMsgId).toBeTruthy(); - const stableBubble = chat.locator(`[data-testid="chat-message"][data-clientmsgid="${clientMsgId}"]`); + const stableBubble = bubble; await stableBubble.hover(); await stableBubble.getByTestId("chat-edit").click();

+ Runway claim needs transcript source runway source. +