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/.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/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..db22c4b1 --- /dev/null +++ b/docs/release/NODEROOM_MOBILE_LAUNCH_READINESS_20260710.md @@ -0,0 +1,113 @@ +# 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,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 | +| 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 | +| 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: `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 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 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. +- 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 `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. 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/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/chat.spec.ts b/e2e/chat.spec.ts index 10108f2e..8b8fd387 100644 --- a/e2e/chat.spec.ts +++ b/e2e/chat.spec.ts @@ -61,14 +61,24 @@ 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 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(); - const bubble = chat.getByTestId("chat-message").filter({ hasText: "Q3 variance" }).last(); + 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(); @@ -195,6 +205,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/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 4778a122..f9346b41 100644 Binary files a/e2e/design-baseline.spec.ts-snapshots/demo-room-mobile-chromium-linux.png and b/e2e/design-baseline.spec.ts-snapshots/demo-room-mobile-chromium-linux.png differ 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 4778a122..6bfd9877 100644 Binary files a/e2e/design-baseline.spec.ts-snapshots/demo-room-mobile-chromium-win32.png and b/e2e/design-baseline.spec.ts-snapshots/demo-room-mobile-chromium-win32.png differ 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/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/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/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/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.

+

+ Runway claim needs transcript source runway source. +

+ `), + }, +}; + 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..22b9fa7d --- /dev/null +++ b/tests/workArtifacts.test.ts @@ -0,0 +1,824 @@ +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"); + 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); + 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"); + }); +});