From 9b55d4662adeec97019c6cf800b86c05f1cf6a51 Mon Sep 17 00:00:00 2001 From: hshum Date: Thu, 16 Jul 2026 14:42:51 -0700 Subject: [PATCH 1/2] fix(redesign): continue reproducible answers in chat --- AGENT_COORDINATION.md | 13 ++ CHANGELOG/integrations/pipeline-runtime.md | 7 ++ CHANGELOG/pages/redesign-chat.md | 31 +++++ convex/domains/redesign/chatRuns.ts | 68 ++++++++++- convex/schema.ts | 8 ++ src/features/redesign/RedesignShell.tsx | 11 +- src/features/redesign/agent-workspace.css | 53 +++++++++ .../redesign/hooks/useRedesignChatRun.ts | 23 +++- src/features/redesign/lib/chatContinuation.ts | 63 ++++++++++ .../redesign/pages/ReproducibleChatPage.tsx | 37 ++++-- .../surfaces/ChatContinuation.test.ts | 54 +++++++++ .../redesign/surfaces/ChatSurface.tsx | 111 ++++++++++++++++-- vite.config.ts | 9 +- 13 files changed, 457 insertions(+), 31 deletions(-) create mode 100644 CHANGELOG/pages/redesign-chat.md create mode 100644 src/features/redesign/lib/chatContinuation.ts create mode 100644 src/features/redesign/surfaces/ChatContinuation.test.ts diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 087da931..24d6c6a4 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -57,6 +57,12 @@ Server Error) for an unknown slug. ## Active claims (who is editing what RIGHT NOW) +- **2026-07-16 · Codex `/root` →** `convex/schema.ts#redesignChatRuns`, + `convex/domains/redesign/chatRuns.ts#continuation-context`, + `src/features/redesign/{pages/ReproducibleChatPage,surfaces/ChatSurface,RedesignShell}` · + add durable, privacy-redacted receipt-to-live-chat continuation · + branch `codex/receipt-continue-chat`. CI deploy only; no out-of-band Convex deploy. + - **2026-06-03 · Claude →** `convex/*#handoff-token`, `src/.../ScratchnodePrivateBridge`, `src/App.tsx#events-private-route`, `public/proto/home-v5.html#private-handoff` · shipping the cross-domain private-notes token bridge (opaque stateful token, PR #496) · @@ -68,6 +74,13 @@ Server Error) for an unknown slug. ## Hand-offs (built + ready for the other agent to call) +- **2026-07-16 - Codex `/root` ->** Receipt continuation contract is additive and ready + on `codex/receipt-continue-chat`: `startChat` accepts optional + `conversationContext: Array<{role: "user" | "assistant"; text: string; sourceUrls?: string[]}>` + plus `parentRunHash?: string`. Context is bounded and sanitized before persistence and + grounding. Public `getByHash` receipts deliberately redact both fields so a shared hash + never exposes a private follow-up transcript. + - **2026-07-16 - Codex `/root` -> frontend/runtime agents** - additive optional `redesignChatRuns.clientRequestId/provider/runtimeReceiptId/cancelRequestedAt/cancelledAt` fields; `startChat({ ..., clientRequestId? })` dedupes by owner + request key; diff --git a/CHANGELOG/integrations/pipeline-runtime.md b/CHANGELOG/integrations/pipeline-runtime.md index 1298288e..acfca9aa 100644 --- a/CHANGELOG/integrations/pipeline-runtime.md +++ b/CHANGELOG/integrations/pipeline-runtime.md @@ -3,6 +3,13 @@ Append-only lane for pipeline launch, activity, streaming, evaluation, schedule, and secret-gated MCP bridge ownership contracts. Newest entries first. +## 2026-07-16 - Ground chat follow-ups in bounded receipt context + +The pending candidate extends redesign chat runs with optional, sanitized conversation +turns and a parent receipt hash. The live action includes this transcript as context while +requiring factual claims to be re-grounded, and public hash receipts redact the private +continuation fields. See [`../pages/redesign-chat.md`](../pages/redesign-chat.md). + ## 2026-07-15 — Derive pipeline ownership on the server The pending candidate removes browser-selected `ownerKey` authority from public pipeline launch, history, detail, bundle, stream, scorecard, and schedule APIs. Cost-bearing single and composed launches and all schedule controls now require an authenticated server identity. Guest history, detail, bundle, stream, and evaluation reads require an anonymous-session possession credential; schedule changes verify row ownership, public responses omit owner keys, and cron or secret-gated MCP work uses explicit internal service contracts. Legacy anonymous schedules do not execute. diff --git a/CHANGELOG/pages/redesign-chat.md b/CHANGELOG/pages/redesign-chat.md new file mode 100644 index 00000000..a30bd956 --- /dev/null +++ b/CHANGELOG/pages/redesign-chat.md @@ -0,0 +1,31 @@ +# Redesign Chat + +Append-only lane for the public redesign chat, reproducible answer receipts, and their +transition into an authenticated live conversation. Newest entries first. + +## 2026-07-16 - Continue an immutable answer in live chat + +Reproducible answer receipts now separate two intents that were previously collapsed: +`Re-run prompt` starts a fresh reproducible run, while `Continue in chat` opens the real +chat surface with the receipt's prompt, answer, and source lineage already visible. The +composer remains available beneath that context, so the receipt is no longer a dead end. + +Follow-up runs persist a bounded, role-aware conversation context and parent receipt hash. +The backend sanitizes this context before grounding the next answer and strips both fields +from public hash lookups, preventing a shared receipt URL from leaking private follow-up +conversation history. Reload recovery reconstructs the same transcript from stored run +context instead of inventing UI-only state. + +The release also excludes the route-lazy markdown editor chunk from Workbox precaching; +it remains network loaded and runtime cached, avoiding an unrelated 2 MiB precache ceiling +from blocking production builds as dependency versions move. + +**PR / canonical main commit**: pending CI-gated candidate. + +**Evidence state**: +- Root TypeScript and 14 focused continuation/chat contract tests passed locally. +- Production build reached the full client bundle; final release proof is recorded on the PR. +- Responsive receipt-to-chat browser evidence remains outside git under `.qa/evidence/`. + +**Author**: Homen Shum + Codex. +**Touches**: [`../integrations/pipeline-runtime.md`](../integrations/pipeline-runtime.md). diff --git a/convex/domains/redesign/chatRuns.ts b/convex/domains/redesign/chatRuns.ts index 62452659..3976b7c1 100644 --- a/convex/domains/redesign/chatRuns.ts +++ b/convex/domains/redesign/chatRuns.ts @@ -69,6 +69,12 @@ type TraceRow = { durationMs: number; }; +export type ConversationContextTurn = { + role: "user" | "assistant"; + text: string; + sourceUrls?: string[]; +}; + interface AnswerPacket { shortAnswer: string; whyItMatters: string; @@ -143,6 +149,9 @@ type RuntimeContextPacket = { // ───────── Constants ───────── const MAX_PROMPT_CHARS = 4_000; +const MAX_CONVERSATION_TURNS = 10; +const MAX_CONVERSATION_TURN_CHARS = 2_000; +const MAX_CONVERSATION_SOURCE_URLS = 5; const TIMEOUT_MS = 45_000; const FALLBACK_SOURCE_TIMEOUT_MS = 12_000; const FALLBACK_SOURCE_LIMIT = 5; @@ -176,11 +185,34 @@ async function assertRunReadable(ctx: any, runId: string): Promise { return row; } -function redactSharedRun(row: any): any { +export function sanitizeConversationContext( + rows: ConversationContextTurn[] | undefined, +): ConversationContextTurn[] | undefined { + if (!rows?.length) return undefined; + const normalized = rows + .slice(-MAX_CONVERSATION_TURNS) + .map((row) => ({ + role: row.role, + text: row.text.trim().slice(0, MAX_CONVERSATION_TURN_CHARS), + ...(row.sourceUrls?.length + ? { + sourceUrls: row.sourceUrls + .filter((url) => /^https?:\/\//i.test(url)) + .slice(0, MAX_CONVERSATION_SOURCE_URLS), + } + : {}), + })) + .filter((row) => row.text.length > 0); + return normalized.length ? normalized : undefined; +} + +export function redactSharedRun(row: any): any { if (!row) return null; const { userId: _userId, clientRequestId: _clientRequestId, + conversationContext: _conversationContext, + parentRunHash: _parentRunHash, cancelRequestedAt: _cancelRequestedAt, ...safeRow } = row; @@ -1030,6 +1062,13 @@ export const startChat = mutation({ text: v.string(), source: v.optional(v.string()), }))), + /** Bounded prior transcript. Persisted privately for reload recovery. */ + conversationContext: v.optional(v.array(v.object({ + role: v.union(v.literal("user"), v.literal("assistant")), + text: v.string(), + sourceUrls: v.optional(v.array(v.string())), + }))), + parentRunHash: v.optional(v.string()), /** Phase 5 — counterfactual probe. When set, the run is a probe * re-evaluation of an earlier run with the cited source masked. */ probeOriginRunId: v.optional(v.string()), @@ -1053,6 +1092,8 @@ export const startChat = mutation({ if (existing) return existing.runId; } const normalizedTier = normalizeChatTier(args.tier); + const conversationContext = sanitizeConversationContext(args.conversationContext); + const parentRunHash = args.parentRunHash?.trim().slice(0, 80); const model = modelForTier(normalizedTier); const runId = generateRunId(); await ctx.db.insert("redesignChatRuns", { @@ -1064,6 +1105,8 @@ export const startChat = mutation({ model, provider: "google-gemini", runtimeReceiptId: `redesign-chat:${runId}`, + ...(conversationContext ? { conversationContext } : {}), + ...(parentRunHash ? { parentRunHash } : {}), status: "pending", createdAt: Date.now(), }); @@ -1074,6 +1117,8 @@ export const startChat = mutation({ contextRef: args.contextRef, model, pinnedClaims: args.pinnedClaims, + conversationContext, + parentRunHash, probeOriginRunId: args.probeOriginRunId, probeMaskedSourceUrl: args.probeMaskedSourceUrl, probeMaskedSourceIdx: args.probeMaskedSourceIdx, @@ -1337,6 +1382,12 @@ export const runStreamingChat = internalAction({ text: v.string(), source: v.optional(v.string()), }))), + conversationContext: v.optional(v.array(v.object({ + role: v.union(v.literal("user"), v.literal("assistant")), + text: v.string(), + sourceUrls: v.optional(v.array(v.string())), + }))), + parentRunHash: v.optional(v.string()), /** Phase 5 — counterfactual probe origin */ probeOriginRunId: v.optional(v.string()), probeMaskedSourceUrl: v.optional(v.string()), @@ -1469,6 +1520,12 @@ export const runStreamingChat = internalAction({ const pinnedSection = args.pinnedClaims && args.pinnedClaims.length > 0 ? `\n\nPinned claims (carry forward as established context — do not contradict without explicit re-grounding):\n${args.pinnedClaims.map((p, i) => ` ${i + 1}. ${p.text}${p.source ? ` (source: ${p.source})` : ""}`).join("\n")}` : ""; + const conversationSection = args.conversationContext && args.conversationContext.length > 0 + ? `\n\nPrior conversation (context only; preserve the user's intent, but re-ground factual claims when needed):\n${args.conversationContext.map((turn, i) => { + const sources = turn.sourceUrls?.length ? `\n sources: ${turn.sourceUrls.join(", ")}` : ""; + return ` ${i + 1}. ${turn.role.toUpperCase()}: ${turn.text}${sources}`; + }).join("\n")}` + : ""; // Phase 5 — counterfactual probe instruction const probeSection = args.probeMaskedSourceUrl ? `\n\nIMPORTANT — counterfactual probe: The source previously at <${args.probeMaskedSourceUrl}> (originally cited as [${args.probeMaskedSourceIdx ?? "?"}] in run ${args.probeOriginRunId ?? "?"}) is being treated as UNRELIABLE for this answer. DO NOT cite it. DO NOT use it as the basis for any claim. Re-answer the same prompt and explicitly note in "Risks / unknowns" how the conclusion changes (or holds) if that source is excluded. Prefer alternative grounded sources.` @@ -1509,7 +1566,7 @@ ${citationInstruction} ${liveGrounding.useLiveGrounding ? "Keep claims grounded ${calculationInstruction} -Context: ${JSON.stringify(contextBundle)}${pinnedSection}${probeSection}${verifiedCalculationSection}`; +Context: ${JSON.stringify(contextBundle)}${conversationSection}${pinnedSection}${probeSection}${verifiedCalculationSection}`; // Emit a stage event so the UI can show "Probing without [N]" / "Carrying forward N pins" if (probeSection) { await append("stage", { stage: "probe", maskedUrl: args.probeMaskedSourceUrl, maskedIdx: args.probeMaskedSourceIdx, originRunId: args.probeOriginRunId }); @@ -1517,6 +1574,13 @@ Context: ${JSON.stringify(contextBundle)}${pinnedSection}${probeSection}${verifi if (pinnedSection) { await append("stage", { stage: "pinned", count: args.pinnedClaims!.length }); } + if (conversationSection) { + await append("stage", { + stage: "conversation_context", + count: args.conversationContext!.length, + parentRunHash: args.parentRunHash, + }); + } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); diff --git a/convex/schema.ts b/convex/schema.ts index 9d36ae0f..f3be0a80 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -16026,6 +16026,14 @@ export default defineSchema({ /** Honest runtime receipt metadata; no provider response id is inferred. */ provider: v.optional(v.string()), runtimeReceiptId: v.optional(v.string()), + /** Private, bounded transcript supplied to a continuation run. Never returned by getByHash. */ + conversationContext: v.optional(v.array(v.object({ + role: v.union(v.literal("user"), v.literal("assistant")), + text: v.string(), + sourceUrls: v.optional(v.array(v.string())), + }))), + /** Reproducible receipt this run continued from. Optional for existing rows. */ + parentRunHash: v.optional(v.string()), cancelRequestedAt: v.optional(v.number()), cancelledAt: v.optional(v.number()), createdAt: v.number(), diff --git a/src/features/redesign/RedesignShell.tsx b/src/features/redesign/RedesignShell.tsx index 855684f9..d7294d32 100644 --- a/src/features/redesign/RedesignShell.tsx +++ b/src/features/redesign/RedesignShell.tsx @@ -47,6 +47,7 @@ import { WorkspaceSurface } from "./surfaces/WorkspaceSurface"; import { ReproducibleChatPage } from "./pages/ReproducibleChatPage"; import { useLiveArtifacts, type LiveArtifactDetail } from "./hooks/useLiveArtifacts"; import type { ReportCardData, SurfaceId } from "./fixtures"; +import { parseChatLaunchParams } from "./lib/chatContinuation"; const PATH_TO_SURFACE: Record = { "": "home", @@ -75,11 +76,6 @@ function pathToChatHash(pathname: string): string | null { return match?.[1] ?? null; } -function queryPrompt(search: string): string { - const params = new URLSearchParams(search); - return params.get("q")?.trim() ?? ""; -} - function normalizeEntityKey(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); } @@ -117,7 +113,7 @@ export default function RedesignShell() { const reportId = useMemo(() => pathToReportId(location.pathname), [location.pathname]); const chatHash = useMemo(() => pathToChatHash(location.pathname), [location.pathname]); const workspace = useMemo(() => workspaceParams(location.search), [location.search]); - const initialChatPrompt = useMemo(() => queryPrompt(location.search), [location.search]); + const chatLaunch = useMemo(() => parseChatLaunchParams(location.search), [location.search]); const focusHomeComposer = useMemo( () => new URLSearchParams(location.search).get("focus") === "home-composer", [location.search], @@ -360,7 +356,8 @@ export default function RedesignShell() { )} {!isPrototypeKit && surface === "chat" && ( diff --git a/src/features/redesign/agent-workspace.css b/src/features/redesign/agent-workspace.css index 01fdf106..1fe2290d 100644 --- a/src/features/redesign/agent-workspace.css +++ b/src/features/redesign/agent-workspace.css @@ -37,13 +37,52 @@ [data-redesign] .rd-chat-workspace { display: grid; grid-template-rows: minmax(0, 1fr) auto auto auto; + width: 100%; + min-width: 0; height: 100%; min-height: 0; overflow: hidden; background: var(--rd-paper); } +[data-redesign] .rd-chat-workspace > * { + min-width: 0; +} + +[data-redesign] .rd-chat-workspace[data-continuation="true"] { + grid-template-rows: auto minmax(0, 1fr) auto auto auto; +} + +[data-redesign] .rd-continuation-context { + width: min(100% - 32px, 840px); + box-sizing: border-box; + margin: 12px auto 0; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid var(--rd-accent-ring); + border-radius: var(--rd-r-md); + background: var(--rd-accent-tint); + color: var(--rd-ink); +} + +[data-redesign] .rd-continuation-context p { + margin: 3px 0 0; + color: var(--rd-ink-mute); + font-size: 12px; + line-height: 1.4; +} + +[data-redesign] .rd-continuation-context[data-state="unavailable"] { + border-color: var(--rd-amber-ring, var(--rd-line)); + background: var(--rd-amber-tint, var(--rd-panel)); +} + [data-redesign] .rd-chat-transcript { + width: 100%; + min-width: 0; min-height: 0; height: 100%; overflow: hidden; @@ -51,6 +90,8 @@ [data-redesign] .rd-chat-transcript__content { width: min(100%, 840px); + min-width: 0; + box-sizing: border-box; min-height: 100%; margin: 0 auto; padding: clamp(20px, 4vw, 44px) clamp(18px, 4vw, 40px) 48px; @@ -71,6 +112,9 @@ [data-redesign] .rd-composer-dock { position: relative; + width: 100%; + min-width: 0; + box-sizing: border-box; bottom: auto; z-index: 5; padding: 10px clamp(14px, 4vw, 40px) max(14px, env(safe-area-inset-bottom)); @@ -81,6 +125,7 @@ [data-redesign] .rd-composer-shell { width: min(100%, 840px); + min-width: 0; margin: 0 auto; } @@ -183,6 +228,14 @@ } @media (max-width: 760px) { + [data-redesign] .rd-continuation-context { + width: calc(100% - 24px); + margin-top: 8px; + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + [data-redesign] .rd-run-scope > summary span:nth-of-type(2), [data-redesign] .rd-run-scope > summary span:nth-of-type(3) { display: none; diff --git a/src/features/redesign/hooks/useRedesignChatRun.ts b/src/features/redesign/hooks/useRedesignChatRun.ts index 5bbdb26d..29724ed8 100644 --- a/src/features/redesign/hooks/useRedesignChatRun.ts +++ b/src/features/redesign/hooks/useRedesignChatRun.ts @@ -23,6 +23,12 @@ import type { LiveGroundingDecision } from "shared/redesign/contextRuntimePolicy export type ChatRunTier = "free" | "fast" | "auto" | "deep"; +export interface ConversationContextTurn { + role: "user" | "assistant"; + text: string; + sourceUrls?: string[]; +} + export function createChatClientRequestId(): string { if (typeof globalThis.crypto?.randomUUID === "function") { return globalThis.crypto.randomUUID(); @@ -61,6 +67,8 @@ export interface RuntimeMetrics { model?: string; provider?: string; runtimeReceiptId?: string; + conversationContext?: ConversationContextTurn[]; + parentRunHash?: string; createdAt?: number; completedAt?: number; totalLatencyMs?: number; @@ -389,6 +397,8 @@ export function useRedesignChatRun() { model: runRow?.model, provider: runRow?.provider, runtimeReceiptId: runRow?.runtimeReceiptId, + conversationContext: runRow?.conversationContext as ConversationContextTurn[] | undefined, + parentRunHash: runRow?.parentRunHash, createdAt: runRow?.createdAt, completedAt: runRow?.completedAt, totalLatencyMs: runRow?.totalLatencyMs ?? metrics?.totalLatencyMs, @@ -447,6 +457,8 @@ export function useRedesignChatRun() { tier: RouterTier, contextRef?: string, pinnedClaims?: Array<{ text: string; source?: string }>, + conversationContext?: ConversationContextTurn[], + parentRunHash?: string, ): Promise => { if (authLoading) { setError("Auth state is still loading. Try again in a moment."); @@ -462,7 +474,14 @@ export function useRedesignChatRun() { } setError(null); const normalizedTier = normalizeRouterTierForChatRun(tier); - const fingerprint = JSON.stringify({ prompt: prompt.trim(), tier: normalizedTier, contextRef, pinnedClaims }); + const fingerprint = JSON.stringify({ + prompt: prompt.trim(), + tier: normalizedTier, + contextRef, + pinnedClaims, + conversationContext, + parentRunHash, + }); const existingRequest = pendingSubmissionRef.current; const clientRequestId = existingRequest?.fingerprint === fingerprint ? existingRequest.requestId @@ -484,6 +503,8 @@ export function useRedesignChatRun() { tier: normalizedTier, contextRef, pinnedClaims, + conversationContext, + parentRunHash, clientRequestId, }); setActiveRunId(runId); diff --git a/src/features/redesign/lib/chatContinuation.ts b/src/features/redesign/lib/chatContinuation.ts new file mode 100644 index 00000000..e0c391fc --- /dev/null +++ b/src/features/redesign/lib/chatContinuation.ts @@ -0,0 +1,63 @@ +import type { ChatAnswer } from "../fixtures"; +import type { ConversationContextTurn } from "../hooks/useRedesignChatRun"; + +const MAX_CONVERSATION_CONTEXT_TURNS = 10; +const MAX_CONVERSATION_CONTEXT_CHARS = 2_000; + +interface ConversationTurnInput { + role: "user" | "assistant"; + text?: string; + markdown?: string; + packet?: ChatAnswer; +} + +function sourceUrl(source: string): string | null { + const match = source.match(/https?:\/\/[^\s)>\]]+/i); + return match?.[0].replace(/[.,;:]+$/, "") ?? null; +} + +function assistantContextText(turn: ConversationTurnInput): string { + if (turn.packet) { + return [ + turn.packet.shortAnswer, + turn.packet.whyItMatters, + turn.packet.risks.length ? `Risks: ${turn.packet.risks.join("; ")}` : "", + turn.packet.nextAction ? `Next action: ${turn.packet.nextAction}` : "", + ].filter(Boolean).join("\n"); + } + return turn.markdown ?? ""; +} + +/** Parse only the two supported chat launches; reject path-like continuation values. */ +export function parseChatLaunchParams(search: string): { prompt: string; continuationHash?: string } { + const params = new URLSearchParams(search); + const continuationHash = params.get("continue")?.trim(); + return { + prompt: params.get("q")?.trim() ?? "", + ...(continuationHash && /^[A-Za-z0-9_-]+$/.test(continuationHash) + ? { continuationHash } + : {}), + }; +} + +/** Bounded role-aware transcript sent to the next run and persisted privately. */ +export function buildConversationContext( + turns: ConversationTurnInput[], +): ConversationContextTurn[] | undefined { + const context = turns.flatMap((turn): ConversationContextTurn[] => { + const text = (turn.role === "user" ? turn.text : assistantContextText(turn))?.trim(); + if (!text) return []; + const sourceUrls = turn.role === "assistant" + ? turn.packet?.evidence + .map((row) => sourceUrl(row.source)) + .filter((url): url is string => Boolean(url)) + .slice(0, 5) + : undefined; + return [{ + role: turn.role, + text: text.slice(0, MAX_CONVERSATION_CONTEXT_CHARS), + ...(sourceUrls?.length ? { sourceUrls } : {}), + }]; + }).slice(-MAX_CONVERSATION_CONTEXT_TURNS); + return context.length ? context : undefined; +} diff --git a/src/features/redesign/pages/ReproducibleChatPage.tsx b/src/features/redesign/pages/ReproducibleChatPage.tsx index a07ecd3a..3ee40094 100644 --- a/src/features/redesign/pages/ReproducibleChatPage.tsx +++ b/src/features/redesign/pages/ReproducibleChatPage.tsx @@ -9,7 +9,7 @@ * Behavior: * - Loading: skeleton card * - Not found: helpful message + back-to-chat CTA - * - Complete: full AnswerPacket render with hash banner + re-run CTA + * - Complete: full AnswerPacket render with continuation + re-run CTAs * * Accessibility: doc title set to "{shortAnswer} · NodeBench" so the * tab name reflects the cached answer. @@ -174,17 +174,30 @@ export function ReproducibleChatPage({ hash }: ReproducibleChatPageProps) { {latencyStr && ` · ${latencyStr}`} {costStr && ` · ${costStr}`} - +
+ + +
{/* User prompt */} diff --git a/src/features/redesign/surfaces/ChatContinuation.test.ts b/src/features/redesign/surfaces/ChatContinuation.test.ts new file mode 100644 index 00000000..0030c219 --- /dev/null +++ b/src/features/redesign/surfaces/ChatContinuation.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { buildConversationContext, parseChatLaunchParams } from "../lib/chatContinuation"; + +describe("reproducible answer continuation", () => { + it("distinguishes continuation from an explicit re-run", () => { + expect(parseChatLaunchParams("?continue=1oicws5io3h2")).toEqual({ + prompt: "", + continuationHash: "1oicws5io3h2", + }); + expect(parseChatLaunchParams("?q=verify%20this")).toEqual({ prompt: "verify this" }); + expect(parseChatLaunchParams("?continue=../../private")).toEqual({ prompt: "" }); + }); + + it("carries a bounded, role-aware transcript with source lineage", () => { + const context = buildConversationContext([ + { role: "user", text: "What is the production status?" }, + { + role: "assistant", + packet: { + shortAnswer: "It is generally available.", + whyItMatters: "Production workloads are supported.", + evidence: [ + { idx: 1, quote: "GA", source: "Docs https://example.com/model" }, + { idx: 2, quote: "Cached", source: "internal-memory" }, + ], + risks: ["Confirm regional availability"], + nextAction: "Check quotas.", + sourceCount: 2, + trace: [], + }, + }, + ]); + + expect(context).toEqual([ + { role: "user", text: "What is the production status?" }, + { + role: "assistant", + text: "It is generally available.\nProduction workloads are supported.\nRisks: Confirm regional availability\nNext action: Check quotas.", + sourceUrls: ["https://example.com/model"], + }, + ]); + }); + + it("keeps private continuation history out of public hash reads", () => { + const backend = readFileSync("convex/domains/redesign/chatRuns.ts", "utf8"); + const receipt = readFileSync("src/features/redesign/pages/ReproducibleChatPage.tsx", "utf8"); + expect(backend).toContain("conversationContext: _conversationContext"); + expect(backend).toContain("parentRunHash: _parentRunHash"); + expect(receipt).toContain("data-testid=\"continue-reproducible-chat\""); + expect(receipt).toContain("/redesign/chat?q="); + expect(receipt).toContain("/redesign/chat?continue="); + }); +}); diff --git a/src/features/redesign/surfaces/ChatSurface.tsx b/src/features/redesign/surfaces/ChatSurface.tsx index aaaad7cf..0e6a3b1f 100644 --- a/src/features/redesign/surfaces/ChatSurface.tsx +++ b/src/features/redesign/surfaces/ChatSurface.tsx @@ -21,8 +21,16 @@ import { ChatToolCall, type ToolCall } from "../components/ChatToolCall"; import { ChatEmptyState } from "../components/ChatEmptyState"; import { showToast } from "../components/Toast"; -import { normalizeRouterTierForChatRun, useRedesignChatRun, type ChatRunState, type RealChatRun } from "../hooks/useRedesignChatRun"; +import { + normalizeRouterTierForChatRun, + useRedesignChatByHash, + useRedesignChatRun, + type ChatRunState, + type ConversationContextTurn, + type RealChatRun, +} from "../hooks/useRedesignChatRun"; import { buildGraphContextBridgePacket } from "../lib/graphContextBridge"; +import { buildConversationContext } from "../lib/chatContinuation"; import { useMutation, useQuery, useConvexAuth } from "convex/react"; import { api } from "../../../../convex/_generated/api"; import { LiveResearchChecklist, type ResearchStage, type ResearchStageId } from "../../research/LiveResearchChecklist"; @@ -105,6 +113,7 @@ interface ChatSurfaceProps { contextLabel?: string; workspaceDetail?: LiveArtifactDetail; initialPrompt?: string; + continuationHash?: string; onActiveContextChange?: (detail: LiveArtifactDetail | null) => void; onAgentRailChange?: (snapshot: AgentRailSnapshot | null) => void; } @@ -133,6 +142,15 @@ interface Turn { liveScratchpad?: string; } +function restoredTurns(context: ConversationContextTurn[] | undefined, createdAt: number): Turn[] { + return (context ?? []).map((turn, index) => ({ + id: `history-${createdAt}-${index}`, + role: turn.role, + ...(turn.role === "user" ? { text: turn.text } : { markdown: turn.text }), + createdAt: createdAt - ((context?.length ?? 0) - index) * 2, + })); +} + function agentStatusFromPlanner(status?: string): AgentRailStatus { if (!status) return "queued"; if (status === "complete" || status === "selected") return "done"; @@ -747,6 +765,7 @@ export function ChatSurface({ contextLabel = "Asking about: current context", workspaceDetail, initialPrompt, + continuationHash, onActiveContextChange, onAgentRailChange, }: ChatSurfaceProps) { @@ -763,6 +782,7 @@ export function ChatSurface({ : liveArtifacts.details[0]; // Phase 1 — real LLM chat behind the composer for authenticated users. const chatRun = useRedesignChatRun(); + const continuedRun = useRedesignChatByHash(continuationHash); const { isAuthenticated, isLoading: authLoading } = useConvexAuth(); // Phase 4 — real reactions for inline correction + 👍/👎 toolbar. const recordReaction = useMutation(api.domains.redesign.agentRunFeedback.recordReaction); @@ -787,9 +807,11 @@ export function ChatSurface({ }, [liveDetail]); const [turns, setTurns] = useState([]); const recoveredRunIdRef = useRef(null); + const consumedContinuationRef = useRef(null); const [hasUserInteracted, setHasUserInteracted] = useState(false); const consumedInitialPromptRef = useRef(null); const hasInitialPrompt = Boolean(initialPrompt?.trim()); + const hasLaunchContext = hasInitialPrompt || Boolean(continuationHash); const turnIdCounterRef = useRef(0); const nextTurnId = (prefix: "u" | "a") => { turnIdCounterRef.current += 1; @@ -803,7 +825,7 @@ export function ChatSurface({ // Rebuild the newest owned conversation pair from the durable run after reload. useEffect(() => { const real = chatRun.state.run; - if (!real?.prompt || turns.length > 0 || recoveredRunIdRef.current === real.runId) return; + if (continuationHash || !real?.prompt || turns.length > 0 || recoveredRunIdRef.current === real.runId) return; recoveredRunIdRef.current = real.runId; const createdAt = real.createdAt ?? Date.now(); const terminalMarkdown = real.status === "cancelled" @@ -812,6 +834,7 @@ export function ChatSurface({ ? liveChatUnavailableMarkdown(real.errorMessage ?? "The recovered run failed before producing a final packet.", liveDetail) : undefined; setTurns([ + ...restoredTurns(real.conversationContext, createdAt), { id: nextTurnId("u"), role: "user", text: real.prompt, createdAt }, { id: nextTurnId("a"), @@ -825,7 +848,35 @@ export function ChatSurface({ createdAt: createdAt + 1, }, ]); - }, [chatRun.state.run, liveDetail, turns.length]); + }, [chatRun.state.run, continuationHash, liveDetail, turns.length]); + + // Keep the receipt immutable while restoring it as the visible start of a + // new local conversation. Future runs persist this transcript privately. + useEffect(() => { + const packet = continuedRun?.packet as ChatAnswer | undefined; + if ( + !continuationHash || + !packet?.shortAnswer || + consumedContinuationRef.current === continuationHash || + turns.length > 0 + ) return; + consumedContinuationRef.current = continuationHash; + const createdAt = continuedRun.createdAt ?? Date.now(); + setHasUserInteracted(true); + setCtx(`Continued from reproducible answer /r/${continuationHash}`); + setTier((continuedRun.tier ?? "auto") as RouterTier); + setTurns([ + { id: `receipt-user-${continuationHash}`, role: "user", text: continuedRun.prompt, createdAt }, + { + id: `receipt-answer-${continuationHash}`, + role: "assistant", + packet, + runHash: continuationHash, + tier: (continuedRun.tier ?? "auto") as RouterTier, + createdAt: createdAt + 1, + }, + ]); + }, [continuedRun, continuationHash, turns.length]); // Sprint 4 P1.6 — pinned items carried into the next turn's context. const [pinned, setPinned] = useState>([]); @@ -946,9 +997,9 @@ export function ChatSurface({ const compareTurn = compareTurnId ? turns.find((t) => t.id === compareTurnId) : undefined; useEffect(() => { - if (hasUserInteracted || hasInitialPrompt) return; + if (hasUserInteracted || hasLaunchContext) return; setCtx(liveDetail ? `Asking about: ${liveDetail.title}` : contextLabel); - }, [contextLabel, hasInitialPrompt, hasUserInteracted, liveDetail]); + }, [contextLabel, hasLaunchContext, hasUserInteracted, liveDetail]); // Phase 2 — when the streamed chat run completes, commit the final packet // onto whichever turn carries the matching chatRunId. While in flight, @@ -1033,6 +1084,8 @@ export function ChatSurface({ const now = Date.now(); const userId = nextTurnId("u"); const assistantId = nextTurnId("a"); + const conversationContext = buildConversationContext(turns); + const parentRunHash = [...turns].reverse().find((turn) => turn.runHash)?.runHash; const canRunLiveChat = chatRun.state.available && !_skipLiveSeed; const unavailableReason = _skipLiveSeed ? "The `fresh=1` diagnostic flag disables live chat for this route." @@ -1080,7 +1133,14 @@ export function ChatSurface({ const contextRef = liveDetail ? buildLiveContextRef(liveDetail, requestedArtifactKey) : undefined; - void chatRun.submit(text, submittedTier, contextRef, pinnedClaims).then((runId) => { + void chatRun.submit( + text, + submittedTier, + contextRef, + pinnedClaims, + conversationContext, + parentRunHash, + ).then((runId) => { if (runId) { setTurns((prev) => prev.map((t) => t.id === assistantId @@ -1126,6 +1186,9 @@ export function ChatSurface({ const regenerate = (turnId: string, _tierOverride?: "free" | "fast" | "deep") => { const targetIndex = turns.findIndex((t) => t.id === turnId); + const priorTurns = targetIndex > 0 ? turns.slice(0, targetIndex) : []; + const conversationContext = buildConversationContext(priorTurns); + const parentRunHash = [...priorTurns].reverse().find((turn) => turn.runHash)?.runHash; const prompt = (() => { for (let i = targetIndex - 1; i >= 0; i--) { if (turns[i].role === "user" && turns[i].text?.trim()) return turns[i].text.trim(); @@ -1145,7 +1208,14 @@ export function ChatSurface({ const contextRef = liveDetail ? buildLiveContextRef(liveDetail, requestedArtifactKey) : undefined; - void chatRun.submit(prompt, requestedTier, contextRef, pinnedClaims).then((runId) => { + void chatRun.submit( + prompt, + requestedTier, + contextRef, + pinnedClaims, + conversationContext, + parentRunHash, + ).then((runId) => { setTurns((prev) => prev.map((t) => t.id === turnId ? runId @@ -1194,7 +1264,30 @@ export function ChatSurface({ className="rd-chat-workspace" data-agent-runtime-surface="redesign-chat" data-empty={turns.length === 0 ? "true" : undefined} + data-continuation={continuationHash ? "true" : undefined} > + {continuationHash && ( + + )} setLiveBatch(null)}> @@ -1202,7 +1295,9 @@ export function ChatSurface({ {batch && } - {turns.length === 0 ? ( + {turns.length === 0 && continuationHash && continuedRun === undefined ? ( +
Restoring conversation context…
+ ) : turns.length === 0 ? ( sendMessage(prompt, tier)} diff --git a/vite.config.ts b/vite.config.ts index 48fee2ec..972906ff 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -234,6 +234,11 @@ export default defineConfig(({ mode }) => { // Shiki grammar/theme chunks — lazy-loaded on demand, not precached. // See chunkFileNames routing above. '**/assets/shiki/**', + // The markdown note editor is a route-lazy workspace surface and can + // exceed Workbox's 2 MiB precache ceiling as dependency versions move. + // Keep it network-loaded and runtime-cached instead of blocking every + // production build on an optional editor chunk. + '**/assets/EntityNoteMarkdownEditor-*.js', ], navigateFallback: '/index.html', navigateFallbackDenylist: [/^\/api\//, /^\/voice\//, /^\/install\.sh/], @@ -374,7 +379,9 @@ window.addEventListener('message', async (message) => { { find: /^@\//, replacement: `${path.resolve(__dirname, "./src").replace(/\\/g, "/")}/` }, ], // Prevent duplicate React instances (common cause of "Invalid hook call" in Vite/monorepo setups). - dedupe: ["react", "react-dom"], + // Streamdown and the app both consume Shiki. Resolve one root copy so a + // no-lock install cannot leave Rolldown chasing a nested peer at build time. + dedupe: ["react", "react-dom", "shiki"], }, optimizeDeps: { include: ["react", "react-dom", "rehype-raw", "rehype-sanitize", "rehype-parse", "hast-util-raw"], From 9ca3e627cd2e904eaea1a126f0e32afc8d546b77 Mon Sep 17 00:00:00 2001 From: hshum Date: Thu, 16 Jul 2026 14:44:33 -0700 Subject: [PATCH 2/2] docs: align receipt continuation release ledger --- AGENT_COORDINATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 24d6c6a4..49c58506 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -61,7 +61,7 @@ Server Error) for an unknown slug. `convex/domains/redesign/chatRuns.ts#continuation-context`, `src/features/redesign/{pages/ReproducibleChatPage,surfaces/ChatSurface,RedesignShell}` · add durable, privacy-redacted receipt-to-live-chat continuation · - branch `codex/receipt-continue-chat`. CI deploy only; no out-of-band Convex deploy. + branch `fix/receipt-continue-chat`, PR #557. CI deploy only; no out-of-band Convex deploy. - **2026-06-03 · Claude →** `convex/*#handoff-token`, `src/.../ScratchnodePrivateBridge`, `src/App.tsx#events-private-route`, `public/proto/home-v5.html#private-handoff` · @@ -75,7 +75,7 @@ Server Error) for an unknown slug. ## Hand-offs (built + ready for the other agent to call) - **2026-07-16 - Codex `/root` ->** Receipt continuation contract is additive and ready - on `codex/receipt-continue-chat`: `startChat` accepts optional + on `fix/receipt-continue-chat` (PR #557): `startChat` accepts optional `conversationContext: Array<{role: "user" | "assistant"; text: string; sourceUrls?: string[]}>` plus `parentRunHash?: string`. Context is bounded and sanitized before persistence and grounding. Public `getByHash` receipts deliberately redact both fields so a shared hash