From 7c4c61bbe4a7d449e664d25e6c09312437e23e45 Mon Sep 17 00:00:00 2001 From: eleven Date: Thu, 20 Aug 2026 07:45:24 -0700 Subject: [PATCH 01/17] feat(agent): persist segmented streaming output --- apps/site/convex/agent.ts | 191 +++++++++++------ apps/site/convex/messages.ts | 109 +++++++++- apps/site/convex/schema.ts | 9 + apps/site/src/agent/charter.ts | 19 +- .../src/__tests__/executor.test.ts | 54 +++++ packages/aisdk-executor/src/executor.ts | 196 +++++++++++------- 6 files changed, 423 insertions(+), 155 deletions(-) diff --git a/apps/site/convex/agent.ts b/apps/site/convex/agent.ts index 1b65d27..c7e0092 100644 --- a/apps/site/convex/agent.ts +++ b/apps/site/convex/agent.ts @@ -18,7 +18,6 @@ import { internal } from "./_generated/api"; import { action, internalAction, type ActionCtx } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import { - COMMENTARY_ACTION_NAME, GET_SURFACE_SOURCE_ACTION_NAME, READ_SESSION_ARTIFACTS_ACTION_NAME, READ_SESSION_MESSAGES_ACTION_NAME, @@ -29,6 +28,7 @@ import { import { escapeConvexJson, restoreConvexJson } from "./convexJson"; const MODEL_ID = process.env.OPENAI_MODEL ?? "gpt-5.6-sol"; +const STREAM_WRITE_INTERVAL_MS = 250; export const sendMessage = action({ args: { @@ -68,7 +68,7 @@ async function runUserMessage( const trimmed = text.trim(); if (!trimmed) throw new Error("Message requires text"); - const { machine, referenceFrameId, streamWrites } = await loadAgentMachine(ctx, sessionId); + const { machine, referenceFrameId, streamWriter } = await loadAgentMachine(ctx, sessionId); // The user row is written before the run so it sorts ahead of the // assistant's streaming rows (createdAt is the thread order). The frame @@ -90,7 +90,7 @@ async function runUserMessage( sessionId, machine, referenceFrameId, - streamWrites, + streamWriter, }); } @@ -103,12 +103,12 @@ export const continueAfterCommand = internalAction({ returns: v.null(), handler: async (ctx, { sessionId }) => { try { - const { machine, referenceFrameId, streamWrites } = await loadAgentMachine(ctx, sessionId); + const { machine, referenceFrameId, streamWriter } = await loadAgentMachine(ctx, sessionId); await runAndPersistAgent(ctx, { sessionId, machine, referenceFrameId, - streamWrites, + streamWriter, }); } finally { // The frame-persist mutation clears workStartedAt transactionally; this @@ -135,15 +135,15 @@ async function loadAgentMachine(ctx: ActionCtx, sessionId: Id<"sessions">) { const contextFrames = restoreConvexJson( await ctx.runQuery(internal.sessions.listMachineContextFrames, { sessionId }), ) as Frame[]; - const streamWrites: Promise[] = []; + const streamWriter = createStreamWriter(ctx, sessionId); const machine = createMachine({ id: sessionId, instance, charter: siteCharter, - executor: createExecutor(ctx, sessionId, streamWrites), + executor: createExecutor(ctx, sessionId, streamWriter), frames: contextFrames, }); - return { machine, referenceFrameId: session.frameId, streamWrites }; + return { machine, referenceFrameId: session.frameId, streamWriter }; } async function runAndPersistAgent( @@ -152,20 +152,24 @@ async function runAndPersistAgent( sessionId, machine, referenceFrameId, - streamWrites, + streamWriter, }: { sessionId: Id<"sessions">; machine: Machine; referenceFrameId: Id<"frames">; - streamWrites: Promise[]; + streamWriter: StreamWriter; }, ): Promise { const producedFrames: Frame[] = []; - for await (const frame of runMachine(machine)) { - producedFrames.push(frame); + try { + for await (const frame of runMachine(machine)) { + producedFrames.push(frame); + } + } finally { + // The executor reports stream failures out-of-band too. Always drain its + // final delta or terminal marker, even when model/tool execution throws. + await streamWriter.flush(); } - await Promise.resolve(); - await Promise.allSettled(streamWrites); if (producedFrames.length === 0) return; @@ -205,39 +209,14 @@ async function runAndPersistAgent( function createExecutor( ctx: ActionCtx, sessionId: Id<"sessions">, - streamWrites: Promise[], + streamWriter: StreamWriter, ) { - // Stream writes chain sequentially: concurrent patches to the same message - // row just make the mutation retry loop (OCC conflicts) — the streamSeq - // guard keeps them safe, but serial is both safe and quiet. - let lastWrite: Promise = Promise.resolve(); return new AiSdkExecutor({ model: openai(MODEL_ID), maxOutputTokens: 4096, stream: true, - runAction: async ({ action, input, context, aiSdkContext }) => { - if (action.name === COMMENTARY_ACTION_NAME) { - const message = readStringField(input, "message")?.trim(); - if (!message) throw new Error("sendCommentary requires a message"); - const toolCallId = readStringField(aiSdkContext, "toolCallId") ?? crypto.randomUUID(); - await ctx.runMutation(internal.messages.add, { - sessionId, - role: "assistant", - content: message, - idempotencyKey: assistantStreamKey(toolCallId), - }); - return actionResult({ - value: "commentary sent", - messages: [ - { - type: "assistant", - text: message, - audience: "broadcast", - messageId: toolCallId, - } as FrameMessage, - ], - }); - } + providerOptions: { openai: { parallelToolCalls: true } }, + runAction: async ({ action, input, context }) => { // The surface's TSX lives in the artifacts table, not machine state, so // retrieval is an executor concern: read the latest artifact here. if (action.name === GET_SURFACE_SOURCE_ACTION_NAME) { @@ -274,40 +253,120 @@ function createExecutor( return await action.run?.(input as never, context as never); }, onStreamUpdate: (update) => { - lastWrite = lastWrite - .catch(() => {}) - .then(() => persistStreamUpdate(ctx, sessionId, update)); - streamWrites.push(lastWrite); - return lastWrite; + streamWriter.push(update); }, }); } +type StreamWriter = { + push: (update: AiSdkStreamUpdate) => void; + flush: () => Promise; +}; + +function createStreamWriter( + ctx: ActionCtx, + sessionId: Id<"sessions">, +): StreamWriter { + // Keep only the latest pending snapshot for each assistant block and cap + // durable updates at a still-smooth 4 fps. Each successful write appends + // only the text since the prior write; coalesced provider chunks therefore + // become one small immutable delta rather than a cumulative message rewrite. + const pending = new Map(); + const persistedText = new Map(); + const terminals = new Map(); + const failed = new Set(); + let draining: Promise | undefined; + let lastWriteAt = 0; + + const drain = async (): Promise => { + while (pending.size > 0 || terminals.size > 0) { + const next = pending.entries().next().value as + | [string, AiSdkStreamUpdate] + | undefined; + if (next) { + const [messageId, update] = next; + pending.delete(messageId); + if (!failed.has(messageId)) { + const previous = persistedText.get(messageId) ?? ""; + const delta = update.text.startsWith(previous) + ? update.text.slice(previous.length) + : update.text; + if (delta) { + const waitMs = STREAM_WRITE_INTERVAL_MS - (Date.now() - lastWriteAt); + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + try { + await ctx.runMutation(internal.messages.appendStreamDelta, { + sessionId, + messageKey: assistantStreamKey(messageId), + text: delta, + streamSeq: update.streamSeq, + }); + persistedText.set(messageId, update.text); + lastWriteAt = Date.now(); + } catch { + failed.add(messageId); + terminals.set(messageId, "error"); + } + } + } + continue; + } + + const terminal = terminals.entries().next().value as + | [string, "cancelled" | "error"] + | undefined; + if (!terminal) break; + const [messageId, state] = terminal; + terminals.delete(messageId); + await ctx.runMutation(internal.messages.markStreamFailed, { + sessionId, + messageKey: assistantStreamKey(messageId), + state, + }).catch(() => {}); + } + }; + + const ensureDrain = (): Promise => { + if (!draining) { + draining = drain().finally(() => { + draining = undefined; + if (pending.size > 0 || terminals.size > 0) void ensureDrain(); + }); + } + return draining; + }; + + return { + push(update) { + if (update.streamState === "streaming" && update.text.length > 0) { + pending.set(update.messageId, update); + } + if (update.streamState === "cancelled" || update.streamState === "error") { + terminals.set(update.messageId, update.streamState); + } + if (pending.size === 0 && terminals.size === 0) return; + void ensureDrain(); + }, + async flush() { + // Stream callbacks are deliberately out-of-band in the executor. Let + // the final callback enter this writer, then drain until it stays empty. + await Promise.resolve(); + while (draining || pending.size > 0 || terminals.size > 0) { + await ensureDrain(); + await Promise.resolve(); + } + }, + }; +} + function requireSessionId(input: unknown): Id<"sessions"> { const sessionId = readStringField(input, "sessionId")?.trim(); if (!sessionId) throw new Error("sessionId is required"); return sessionId as Id<"sessions">; } -async function persistStreamUpdate( - ctx: ActionCtx, - sessionId: Id<"sessions">, - update: AiSdkStreamUpdate, -): Promise { - // The executor announces a stream before it has text. Do not create the - // final-response row yet: commentary tools may run first, and message - // creation time is the transcript order. - if (update.streamState !== "streaming" || update.text.length === 0) return; - await ctx.runMutation(internal.messages.add, { - sessionId, - role: "assistant", - content: update.text, - idempotencyKey: assistantStreamKey(update.messageId), - streamState: "streaming", - streamSeq: update.streamSeq, - }); -} - function findLastAssistantMessage( frames: Frame[], ): { frameIndex: number; messageIndex: number } | null { diff --git a/apps/site/convex/messages.ts b/apps/site/convex/messages.ts index e9b98d4..2174864 100644 --- a/apps/site/convex/messages.ts +++ b/apps/site/convex/messages.ts @@ -20,6 +20,8 @@ type DbCtx = MutationCtx | QueryCtx; type MessageDoc = Doc<"messages">; const MAX_SESSION_MESSAGES = 2000; +// At the 250ms writer cadence this covers the full ten-minute action limit. +const MAX_STREAM_DELTAS = 4096; const agentMessageValidator = v.object({ id: v.id("messages"), @@ -70,6 +72,85 @@ export const add = internalMutation({ handler: (ctx, args) => addMessageInternal(ctx, args), }); +export const appendStreamDelta = internalMutation({ + args: { + sessionId: v.id("sessions"), + messageKey: v.string(), + text: v.string(), + streamSeq: v.number(), + }, + returns: v.id("messages"), + handler: async (ctx, { sessionId, messageKey, text, streamSeq }) => { + const existingIndex = await ctx.db + .query("messageIndex") + .withIndex("by_session_idempotency_key", (q) => + q.eq("sessionId", sessionId).eq("idempotencyKey", messageKey), + ) + .first(); + const existing = existingIndex ? await ctx.db.get(existingIndex.messageId) : null; + if (existing && existing.streamState !== "streaming") return existing._id; + const messageId = existing + ? existing._id + : await addMessageInternal(ctx, { + sessionId, + role: "assistant", + content: "", + idempotencyKey: messageKey, + streamState: "streaming", + // The durable message's sequence guard only needs the initial value; + // delta ordering is guarded by the append-only delta index below. + streamSeq: 0, + }); + if (!text) return messageId; + + const latest = await ctx.db + .query("messageStreamDeltas") + .withIndex("by_message_and_stream_seq", (q) => q.eq("messageId", messageId)) + .order("desc") + .first(); + if (latest && latest.streamSeq >= streamSeq) return messageId; + + await ctx.db.insert("messageStreamDeltas", { + messageId, + streamSeq, + text, + }); + return messageId; + }, +}); + +export const markStreamFailed = internalMutation({ + args: { + sessionId: v.id("sessions"), + messageKey: v.string(), + state: v.union(v.literal("cancelled"), v.literal("error")), + }, + returns: v.null(), + handler: async (ctx, { sessionId, messageKey, state }) => { + const index = await ctx.db + .query("messageIndex") + .withIndex("by_session_idempotency_key", (q) => + q.eq("sessionId", sessionId).eq("idempotencyKey", messageKey), + ) + .first(); + if (!index) return null; + const message = await ctx.db.get(index.messageId); + if (message?.streamState === "streaming") { + const deltas = await ctx.db + .query("messageStreamDeltas") + .withIndex("by_message_and_stream_seq", (q) => q.eq("messageId", message._id)) + .order("asc") + .take(MAX_STREAM_DELTAS); + await ctx.db.patch(message._id, { + content: message.content + deltas.map((delta) => delta.text).join(""), + streamState: state, + }); + await Promise.all(deltas.map((delta) => ctx.db.delete(delta._id))); + } + return null; + }, +}); + // Shared by the agent action (via the mutation) and sendCommand (directly): // idempotent, stream-seq-guarded message upsert. export async function addMessageInternal( @@ -109,7 +190,16 @@ export async function addMessageInternal( if (args.streamState !== undefined) patch.streamState = args.streamState; if (args.streamSeq !== undefined) patch.streamSeq = args.streamSeq; if (args.updatedSurface !== undefined) patch.updatedSurface = args.updatedSurface; + if (args.widget !== undefined) patch.widget = args.widget; + if (args.card !== undefined) patch.card = args.card; await ctx.db.patch(existing._id, patch); + if (args.streamState === "complete") { + const deltas = await ctx.db + .query("messageStreamDeltas") + .withIndex("by_message_and_stream_seq", (q) => q.eq("messageId", existing._id)) + .take(MAX_STREAM_DELTAS); + await Promise.all(deltas.map((delta) => ctx.db.delete(delta._id))); + } await setDefaultSessionTitle(ctx, session, args.role, args.content); return existing._id; } @@ -166,6 +256,23 @@ export const list = query({ ), handler: async (ctx, { sessionId }) => { const messages = await listMessagesForSession(ctx, sessionId); + const liveContent = new Map( + await Promise.all( + messages + .filter((message) => message.streamState === "streaming") + .map(async (message) => { + const deltas = await ctx.db + .query("messageStreamDeltas") + .withIndex("by_message_and_stream_seq", (q) => q.eq("messageId", message._id)) + .order("asc") + .take(MAX_STREAM_DELTAS); + return [ + message._id, + message.content + deltas.map((delta) => delta.text).join(""), + ] as const; + }), + ), + ); const frameIds = [ ...new Set( messages.flatMap((message) => (message.frameId ? [message.frameId] : [])), @@ -184,7 +291,7 @@ export const list = query({ return { id: message._id, role: message.role, - content: message.content, + content: liveContent.get(message._id) ?? message.content, createdAt: message.createdAt, ...(activationId !== undefined ? { activationId } : {}), ...(message.streamState !== undefined ? { streamState: message.streamState } : {}), diff --git a/apps/site/convex/schema.ts b/apps/site/convex/schema.ts index 5c7beb8..35b3473 100644 --- a/apps/site/convex/schema.ts +++ b/apps/site/convex/schema.ts @@ -101,4 +101,13 @@ export default defineSchema({ .index("by_session", ["sessionId"]) .index("by_session_message", ["sessionId", "messageId"]) .index("by_session_idempotency_key", ["sessionId", "idempotencyKey"]), + + // Ephemeral, append-only chunks for live assistant output. The messages row + // is the stable UI identity and eventual durable value; these deltas exist + // only while that row is streaming and are removed when its frame settles. + messageStreamDeltas: defineTable({ + messageId: v.id("messages"), + streamSeq: v.number(), + text: v.string(), + }).index("by_message_and_stream_seq", ["messageId", "streamSeq"]), }); diff --git a/apps/site/src/agent/charter.ts b/apps/site/src/agent/charter.ts index a9ef60b..b1fc3cd 100644 --- a/apps/site/src/agent/charter.ts +++ b/apps/site/src/agent/charter.ts @@ -30,7 +30,6 @@ import { import { z } from "zod"; export const SITE_SOURCE_INSTANCE_ID = "guide"; -export const COMMENTARY_ACTION_NAME = "sendCommentary"; const siteParamsSchema = z.object({ sessionId: z.string(), @@ -63,17 +62,6 @@ const noteAudience = createAction({ }, }); -const sendCommentary = createAction({ - state: null, - name: COMMENTARY_ACTION_NAME, - description: - "Send a brief user-visible progress update before work that may take a while. The update appears immediately as a normal assistant message and remains in the conversation. Keep it specific to what you are doing, not generic filler; do not reveal private chain-of-thought.", - inputSchema: z.object({ - message: z.string().trim().min(1).max(280), - }), - run: () => "commentary sent", -}); - // The shell's chrome as machine state: which side panes are open. One action, // caller "any", so it is simultaneously the agent's tool and the client's // command — the minimize buttons and the model manipulate the same durable @@ -488,7 +476,7 @@ const guideNode = createNode({ name: "projector guide", params: siteParamsSchema, states: [audienceState], - tools: [noteAudience, sendCommentary, readSessionMessages, readSessionArtifacts], + tools: [noteAudience, readSessionMessages, readSessionArtifacts], parts: [action(spawnChild, "any"), action(cedeChild, "any"), action(postCard, "any")], instructions: `You are projector's introduction agent — and you are yourself a projector machine. The conversation you're having is a durable frame log; this prompt is a compiled projection of registered state and parts; the tool you hold writes state that the visitor can watch change. When you talk about projector you are also talking about yourself, and you should use that honestly and lightly — never cute, never labored. @@ -502,7 +490,8 @@ Who you're talking to: visitors arrive from the marketing page. Some think at th How to behave: - Be quietly competent. Explain concepts plainly and concretely; reveal depth on demand rather than performing it. -- Before work that will take more than a quick direct answer, call sendCommentary with a short, specific progress update. It is a durable message the visitor will keep seeing, so write it conversationally and do not narrate private reasoning. Skip it for near-instant answers. +- Before the first tool call in a turn, write one brief user-visible progress update explaining what you are about to do. It is a durable assistant message, so keep it conversational and specific; do not narrate private reasoning. Skip it for near-instant answers that need no tools. Add another short update only after meaningful progress or when a long task changes phase. +- Batch independent tool calls in the same step so they can run in parallel. Keep dependent calls sequential, and do not repeat a successful call merely to check it. - Ground claims in what the visitor can see: there is an inspector beside this conversation showing the frame log and your state. When you change state (like noting your audience read), you may point at it. - You can grow capabilities live. When the visitor asks you to BE something ("can you be my todo app?"): spawnChild creates a child with schema-validated state, updateState mutates it, and writeAppSurface renders it. In the surface, bind the child's state from api.useMachine()'s tree (state entries carry { key, value, address }) and mutate with api.run("updateState", { address, op, value }) — the visitor's clicks and your own writes are the same action in the same durable log. The machine tree, your compiled prompt, and the inspector all change visibly when you spawn; point at it. - Surfaces may wake you after a meaningful interaction with api.run("appPanePing", { message, data? }). Wire this only when a response is useful (a request for judgment, a completed flow, a consequential choice); ordinary toggles and edits should update state without making you speak. If an interaction both changes state and pings, await updateState first. @@ -518,7 +507,7 @@ export const siteCharter = createCharter({ version: "0.0.1", params: siteParamsSchema, nodes: [guideNode, uiNode], - tools: [noteAudience, sendCommentary, readSessionMessages, readSessionArtifacts], + tools: [noteAudience, readSessionMessages, readSessionArtifacts], actions: [setPanes, writeAppSurface, getSurfaceSource, spawnChild, cedeChild, updateStateAction, postCard], commands: [reportSurfaceError, appPanePing], // appSurface carries projection code (render/note), so registration is diff --git a/packages/aisdk-executor/src/__tests__/executor.test.ts b/packages/aisdk-executor/src/__tests__/executor.test.ts index 6d69794..cb711b5 100644 --- a/packages/aisdk-executor/src/__tests__/executor.test.ts +++ b/packages/aisdk-executor/src/__tests__/executor.test.ts @@ -427,6 +427,43 @@ describe("AiSdkExecutor", () => { expect(frames).toEqual([]); }); + it("preserves a native preamble as a distinct frame before tool work", async () => { + const frames: FrameDraft[] = []; + const streamUpdates: any[] = []; + const stream = vi.fn(() => fullStreamResult([ + { type: "text-start", id: "preamble" }, + { type: "text-delta", id: "preamble", text: "I’ll inspect the current state." }, + { type: "text-end", id: "preamble" }, + { type: "tool-call", toolCallId: "call-1", toolName: "lookup", input: {} }, + { type: "text-start", id: "answer" }, + { type: "text-delta", id: "answer", text: "Here’s what I found." }, + { type: "text-end", id: "answer" }, + ])); + const executor = new AiSdkExecutor({ + model: fakeModel(), + streamText: stream as never, + stream: true, + onStreamUpdate: (update) => streamUpdates.push(update), + }); + + const output = await executor.run(request({ enqueueFrame: enqueueTo(frames) })); + await flushPromises(); + + expect(frames).toHaveLength(2); + expect(frames[0]?.messages[0]).toMatchObject({ + type: "assistant", + text: "I’ll inspect the current state.", + streamState: "complete", + }); + expect(frames[1]?.messages[0]).toMatchObject({ + type: "assistant", + text: "Here’s what I found.", + streamState: "complete", + }); + expect(output.frames).toBeUndefined(); + expect(new Set(streamUpdates.map((update) => update.messageId)).size).toBe(2); + }); + it("does not enqueue a frame when generated text is empty", async () => { const frames: FrameDraft[] = []; const executor = new AiSdkExecutor({ @@ -1047,3 +1084,20 @@ function streamResult(chunks: string[]) { })(), }; } + +function fullStreamResult(parts: Array>) { + return { + text: Promise.resolve( + parts + .filter((part) => part.type === "text-delta") + .map((part) => part.text) + .join(""), + ), + steps: Promise.resolve([{ stepType: "initial" }]), + usage: Promise.resolve({ inputTokens: 1, outputTokens: 2, totalTokens: 3 }), + finishReason: Promise.resolve("stop"), + fullStream: (async function* () { + for (const part of parts) yield part; + })(), + }; +} diff --git a/packages/aisdk-executor/src/executor.ts b/packages/aisdk-executor/src/executor.ts index d7d055b..a4cee95 100644 --- a/packages/aisdk-executor/src/executor.ts +++ b/packages/aisdk-executor/src/executor.ts @@ -127,80 +127,137 @@ export class AiSdkExecutor< runState: RunState, ): Promise> { const startedAt = Date.now(); - const messageId = crypto.randomUUID(); - let seq = 0; - let text = ""; - - emitStreamUpdate(this.config, { - request, - messageId, - text, - streamState: "streaming", - streamSeq: seq, - }); + type TextSegment = { + partId: string; + messageId: string; + text: string; + seq: number; + enqueued: boolean; + }; + const segments: TextSegment[] = []; + const activeSegments = new Map(); + + const startSegment = (partId: string): TextSegment => { + const segment: TextSegment = { + partId, + messageId: crypto.randomUUID(), + text: "", + seq: 0, + enqueued: false, + }; + segments.push(segment); + activeSegments.set(partId, segment); + emitStreamUpdate(this.config, { + request, + messageId: segment.messageId, + text: "", + streamState: "streaming", + streamSeq: 0, + }); + return segment; + }; + + const appendSegment = (partId: string, delta: string): void => { + if (!delta) return; + const segment = activeSegments.get(partId) ?? startSegment(partId); + segment.text += delta; + segment.seq += 1; + emitStreamUpdate(this.config, { + request, + messageId: segment.messageId, + text: segment.text, + delta, + streamState: "streaming", + streamSeq: segment.seq, + }); + }; + + const closeSegment = (partId: string, enqueue = false): void => { + const segment = activeSegments.get(partId); + if (!segment) return; + if (!enqueue || segment.enqueued || !segment.text.trim()) return; + // Full-stream text boundaries are durable assistant-message boundaries. + // Enqueue at text-end, before a following tool call can finish, so frame + // replay has exactly the order the user observed during streaming. + segment.enqueued = true; + request.enqueueFrame({ + generatorId: request.generatorId, + activationId: request.activationId, + messages: [ + outputMessageFromText(segment.text, request.output, { + messageId: segment.messageId, + streamState: "complete", + streamSeq: segment.seq + 1, + }), + ], + }); + }; + + const remainingFrames = (interrupted = false) => { + return segments + .filter((segment) => !segment.enqueued && segment.text.trim()) + .map((segment) => ({ + ...(interrupted ? { metadata: { interrupted: true } } : {}), + messages: [ + outputMessageFromText(segment.text, request.output, { + messageId: segment.messageId, + streamState: interrupted ? "cancelled" : "complete", + streamSeq: segment.seq + 1, + }), + ], + })); + }; // A barge-in abort must not surface the partial text as a completed // assistant turn, but the partial is still valid work: it goes into the // frame log marked interrupted so the next turn's history sees what was // said, while hosts skip message persistence for cancelled activations. const cancelledResult = (): ExecutorRunResult => { - seq += 1; - emitStreamUpdate(this.config, { - request, - messageId, - text, - streamState: "cancelled", - streamSeq: seq, - }); + for (const segment of segments.filter((item) => !item.enqueued)) { + segment.seq += 1; + emitStreamUpdate(this.config, { + request, + messageId: segment.messageId, + text: segment.text, + streamState: "cancelled", + streamSeq: segment.seq, + }); + } + const frames = remainingFrames(true); return { completionReason: "cancelled", - ...(text.trim() - ? { - frames: [ - { - metadata: { interrupted: true }, - messages: [ - outputMessageFromText(text, request.output, { - messageId, - streamState: "cancelled", - streamSeq: seq, - }), - ], - }, - ], - } - : {}), + ...(frames.length ? { frames } : {}), }; }; const result = stream(input); try { - for await (const delta of result.textStream) { - if (!delta) continue; - text += delta; - seq += 1; - emitStreamUpdate(this.config, { - request, - messageId, - text, - delta, - streamState: "streaming", - streamSeq: seq, - }); + if (result.fullStream) { + for await (const part of result.fullStream) { + if (part.type === "text-start") startSegment(part.id); + if (part.type === "text-delta") appendSegment(part.id, part.text); + if (part.type === "text-end") closeSegment(part.id, true); + } + } else { + const segment = startSegment("text"); + for await (const delta of result.textStream) appendSegment(segment.partId, delta); + closeSegment(segment.partId); } } catch (error) { if (isAbortError(error) || request.signal?.aborted) { return cancelledResult(); } - seq += 1; - emitStreamUpdate(this.config, { - request, - messageId, - text, - streamState: "error", - streamSeq: seq, - error: error instanceof Error ? error.message : String(error), - }); + for (const segment of segments.filter((item) => !item.enqueued)) { + segment.seq += 1; + emitStreamUpdate(this.config, { + request, + messageId: segment.messageId, + text: segment.text, + streamState: "error", + streamSeq: segment.seq, + error: error instanceof Error ? error.message : String(error), + }); + } throw error; } @@ -210,28 +267,21 @@ export class AiSdkExecutor< return cancelledResult(); } - const finalText = text || await result.text; - const finalSeq = seq + 1; + if (segments.length === 0) { + const finalText = await result.text; + if (finalText) { + const segment = startSegment("text"); + appendSegment(segment.partId, finalText); + closeSegment(segment.partId, true); + } + } const finishReason = await result.finishReason; const usage = await Promise.resolve(result.usage).catch(() => undefined); + const frames = remainingFrames(); return { completionReason: completionReasonForFinish(runState, finishReason, this.config), - ...(finalText.trim() - ? { - frames: [ - { - messages: [ - outputMessageFromText(finalText, request.output, { - messageId, - streamState: "complete", - streamSeq: finalSeq, - }), - ], - }, - ], - } - : {}), + ...(frames.length ? { frames } : {}), execution: executionReport(this.config, startedAt, usage), }; } From a785364fb681a6e4ea232057a001f56a2c68f912 Mon Sep 17 00:00:00 2001 From: eleven Date: Thu, 20 Aug 2026 09:26:06 -0700 Subject: [PATCH 02/17] site: optimistic updateState for agent surfaces, with per-call opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.run("updateState", …) now applies optimistically by default: the client runs the machine's own state-update fold (applyStateUpdate, moved to state.ts and shared) over the projection, and the existing effigy machinery reconciles — overlay retires when the durable frame's residue lands, reverts if the server rejects. The agent opts out per call with { optimistic: false } for contended state (multiplayer counters, turn-taking), and the surface contract documents both the default and the opt-out. Co-Authored-By: Claude Fable 5 --- apps/site/src/agent/charter.ts | 1 + apps/site/src/app/App.tsx | 128 +++++++++++++++++- apps/site/src/app/surface/api.ts | 68 +++++++++- apps/site/src/site.css | 16 ++- .../src/client/__tests__/client.test.ts | 46 +++++++ packages/projector/src/client/index.ts | 23 +++- packages/projector/src/machine.ts | 68 +--------- packages/projector/src/state.ts | 74 ++++++++++ 8 files changed, 340 insertions(+), 84 deletions(-) diff --git a/apps/site/src/agent/charter.ts b/apps/site/src/agent/charter.ts index b1fc3cd..91fa2bd 100644 --- a/apps/site/src/agent/charter.ts +++ b/apps/site/src/agent/charter.ts @@ -205,6 +205,7 @@ ${DESIGN_BRIEF} - api.machine() returns the projected client instance tree (states and commands); api.useMachine() is the subscribed hook form; api.run(commandName, input) executes a machine command. Bind UI to machine state — never hold app data in component state. - Child data binding: walk api.useMachine() for the child instance (by node key); its states entries carry { key, value, address }. Render from value; mutate with api.run("updateState", { address, op: "replace"|"patch"|"append", value }). +- updateState applies optimistically by default: the projection reflects the write instantly and reconciles when the durable frame lands (or reverts if the server rejects it), so never add local loading/busy state to mask write latency. Opt out per call with api.run("updateState", input, { optimistic: false }) when instant last-write-wins prediction would mislead — state several actors contend on (a shared counter, turn-taking) or values the server arbitrates. - To have the agent respond to an interaction, call api.run("appPanePing", { message, data? }). Await any updateState call first so the agent sees the resulting state. Use this only when a conversational reaction adds value; routine controls should stay silent. - Keep it focused and under 32KB. diff --git a/apps/site/src/app/App.tsx b/apps/site/src/app/App.tsx index d7a1f01..f9f3c7c 100644 --- a/apps/site/src/app/App.tsx +++ b/apps/site/src/app/App.tsx @@ -40,6 +40,7 @@ type Panes = { const DEFAULT_PANES: Panes = { app: false, inspector: false, appWidth: 22, inspectorWidth: 26 }; const PANE_MIN_REM = 14; const PANE_MAX_REM = 44; +const TURN_TOP_INSET = 12; type StateEntry = { value: unknown; address: unknown }; @@ -834,7 +835,16 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses return () => clearTimeout(timeout); }, [paneAgentNotice?.id]); - const streaming = server.some((m) => m.streamState === "streaming"); + // Stream state belongs to rows, but the blinking cursor belongs to the + // transcript: only the newest live assistant row should own it. Multiple + // rows can briefly remain marked streaming across commentary/final handoff. + let pendingAssistantId: (typeof server)[number]["id"] | undefined; + for (const message of server) { + if (message.role === "assistant" && message.streamState === "streaming") { + pendingAssistantId = message.id; + } + } + const streaming = pendingAssistantId !== undefined; // A poke (appPanePing) marks the session doc the moment its mutation // commits, so the thinking indicator starts as soon as the agent wake is @@ -896,7 +906,7 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses widget: m.widget, card: m.card, updatedSurface: m.updatedSurface === true, - pending: m.streamState === "streaming", + pending: m.role === "assistant" && m.id === pendingAssistantId, })), ...visibleOptimistic.map((m) => ({ key: m.key, @@ -928,9 +938,94 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses }); const scrollRef = useRef(null); + const threadRef = useRef(null); + const composerRef = useRef(null); + const bottomSpacerRef = useRef(null); + const atBottomRef = useRef(true); + const followingBottomRef = useRef(false); const syncedOnce = useRef(false); + + // Track actual bottom position, while treating wheel/touch/drag input as an + // explicit request to stop following a multi-message assistant turn. useEffect(() => { + const container = scrollRef.current; + if (!container) return; + const readBottom = () => { + const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight <= 2; + if (!followingBottomRef.current || atBottom) atBottomRef.current = atBottom; + }; + const releaseFollow = () => { + followingBottomRef.current = false; + readBottom(); + }; + readBottom(); + container.addEventListener("scroll", readBottom, { passive: true }); + container.addEventListener("wheel", releaseFollow, { passive: true }); + container.addEventListener("touchstart", releaseFollow, { passive: true }); + container.addEventListener("pointerdown", releaseFollow, { passive: true }); + return () => { + container.removeEventListener("scroll", readBottom); + container.removeEventListener("wheel", releaseFollow); + container.removeEventListener("touchstart", releaseFollow); + container.removeEventListener("pointerdown", releaseFollow); + }; + }, []); + + // The newest turn needs enough runway to page exactly to the top while it + // is short. As it grows, trade that runway for message content until only + // the composer's clearance remains. Measuring the rendered turn instead + // of guessing at an average response height keeps both ends exact. + useLayoutEffect(() => { + const container = scrollRef.current; + const thread = threadRef.current; + const composer = composerRef.current; + const spacer = bottomSpacerRef.current; + if (!container || !thread || !composer || !spacer) return; + + let frame = 0; + const resize = () => { + frame = 0; + const starts = thread.querySelectorAll("[data-turn-start]"); + const target = starts[starts.length - 1]; + if (!target) { + spacer.style.height = "0px"; + return; + } + + const composerClearance = composer.getBoundingClientRect().height; + spacer.style.height = `${composerClearance}px`; + + const activeRunHeight = + thread.getBoundingClientRect().bottom - target.getBoundingClientRect().top; + const missingRunway = Math.max( + 0, + container.clientHeight - activeRunHeight - TURN_TOP_INSET, + ); + spacer.style.height = `${composerClearance + missingRunway}px`; + if (followingBottomRef.current) { + container.scrollTop = container.scrollHeight; + atBottomRef.current = true; + } + }; + const scheduleResize = () => { + if (frame) cancelAnimationFrame(frame); + frame = requestAnimationFrame(resize); + }; + + resize(); + const observer = new ResizeObserver(scheduleResize); + observer.observe(container); + observer.observe(composer); + thread.querySelectorAll(".msg").forEach((message) => observer.observe(message)); + return () => { + if (frame) cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [turnCount, items.length, items.map((item) => String(item.key)).join("\n")]); + + useLayoutEffect(() => { if (turnCount === 0) return; + followingBottomRef.current = false; const container = scrollRef.current; if (!container) return; const starts = container.querySelectorAll("[data-turn-start]"); @@ -940,7 +1035,7 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses target.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop - - 24; // breathing room above the page top + TURN_TOP_INSET; const still = matchMedia("(prefers-reduced-motion: reduce)").matches; container.scrollTo({ top: Math.max(0, top), @@ -950,6 +1045,23 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses syncedOnce.current = true; }, [turnCount]); + const previousTranscriptRef = useRef({ initialized: false, itemCount: 0, turnCount: 0 }); + useLayoutEffect(() => { + const previous = previousTranscriptRef.current; + const appendedWithinTurn = + previous.initialized && items.length > previous.itemCount && turnCount === previous.turnCount; + previousTranscriptRef.current = { initialized: true, itemCount: items.length, turnCount }; + if (!appendedWithinTurn || !atBottomRef.current) return; + + const container = scrollRef.current; + if (!container) return; + followingBottomRef.current = true; + container.scrollTo({ + top: container.scrollHeight, + behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth", + }); + }, [items.length, turnCount]); + const submit = (e: React.FormEvent) => { e.preventDefault(); const draft = input.trim(); @@ -1051,7 +1163,7 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses )}
-
+
{items.map((m) => ( {sendError}

)} +
-
+ {authPrompt && !isAuthenticated ? (
diff --git a/apps/site/src/app/surface/api.ts b/apps/site/src/app/surface/api.ts index 47db291..62b61a2 100644 --- a/apps/site/src/app/surface/api.ts +++ b/apps/site/src/app/surface/api.ts @@ -5,7 +5,17 @@ // window on the world. import { useSyncExternalStore } from "react"; -import type { OptimisticEffigy } from "@projectors/core/client"; +import type { OptimisticEffigy, StateAddress, StateUpdate } from "@projectors/core/client"; + +export type SurfaceRunOptions = { + /** + * updateState applies optimistically by default — the projection reflects + * the write instantly and reconciles when the durable frame lands (or the + * server rejects it). Pass false when last-write-wins prediction is wrong: + * shared/multiplayer state, server-arbitrated values. + */ + optimistic?: boolean; +}; export type SurfaceApi = { /** Current projected client instance tree (states, commands, children). */ @@ -13,7 +23,7 @@ export type SurfaceApi = { /** Hook form: re-renders the surface when the projection changes. */ useMachine(): unknown; /** Execute a machine command (external caller), e.g. api.run("setPanes", { app: false }). */ - run(name: string, input?: unknown): Promise; + run(name: string, input?: unknown, options?: SurfaceRunOptions): Promise; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -43,9 +53,59 @@ export function createSurfaceApi(effigy: OptimisticEffigy): SurfaceApi { // Called from surface component render bodies; the hook comes from the // host's React, which is the only React in the page. useSyncExternalStore(subscribe, read), - run: async (name, input) => { - const command = effigy.getCommand(name as never); + run: async (name, input, options) => { + // updateState's input IS its effect, so the client can run the exact + // same fold the machine will — prediction and durable truth can't + // diverge for a valid write. Other commands hide their effect + // server-side and stay non-optimistic. + const update = name === "updateState" && options?.optimistic !== false + ? readStateUpdateInput(input) + : null; + const command = effigy.getCommand(name as never, update + ? { optimistic: (ctx) => ctx.updateAt(update.address, update.update) } + : undefined); return await command.run(input as never); }, }; } + +// Mirrors the charter's updateState action: {address, op, value, values, path} +// → the machine StateUpdate its run() enqueues. +function readStateUpdateInput( + input: unknown, +): { address: StateAddress; update: StateUpdate } | null { + if (!input || typeof input !== "object") return null; + const record = input as { + address?: StateAddress; + op?: string; + value?: unknown; + values?: unknown[]; + path?: Array; + }; + const address = record.address; + if (!address) return null; + if (record.op === "replace") { + return { address, update: { op: "replace", value: record.value } }; + } + if (record.op === "patch") { + return { + address, + update: { + op: "patch", + value: (record.value ?? {}) as Record, + ...(record.path ? { path: record.path } : {}), + }, + }; + } + if (record.op === "append") { + return { + address, + update: { + op: "append", + values: record.values ?? [record.value], + ...(record.path ? { path: record.path } : {}), + }, + }; + } + return null; +} diff --git a/apps/site/src/site.css b/apps/site/src/site.css index 0f452ae..985db5b 100644 --- a/apps/site/src/site.css +++ b/apps/site/src/site.css @@ -1653,15 +1653,18 @@ main { max-width: var(--wrap); margin: 0 auto; padding: 0 2.25rem; } .app-thread { max-width: 44rem; margin: 0 auto; - /* The tall bottom padding is the paging runway: the newest turn must be - able to scroll all the way to the top of the viewport even when nothing - follows it, so there is always ~a screen of blank paper below. */ - padding: 2rem clamp(1rem, 4vw, 2rem) max(2rem, calc(var(--app-viewport-height, 100dvh) - 12rem)); + /* JS sizes the final spacer from the newest turn's rendered height. */ + padding: 2rem clamp(1rem, 4vw, 2rem) 0; display: flex; flex-direction: column; - gap: 1.5rem; + gap: 1rem; +} +.app-thread-spacer { + flex: 0 0 auto; + height: 7rem; + pointer-events: none; } -.msg { display: flex; flex-direction: column; gap: 0.375rem; } +.msg { display: flex; flex-direction: column; gap: 0.25rem; } .msg-role { font-family: var(--mono); font-size: 0.6875rem; @@ -1671,6 +1674,7 @@ main { max-width: var(--wrap); margin: 0 auto; padding: 0 2.25rem; } } .msg-user .msg-role { color: var(--accent); } .msg-body { + margin: 0; font-size: 0.9375rem; line-height: 1.65; white-space: pre-wrap; diff --git a/packages/projector/src/client/__tests__/client.test.ts b/packages/projector/src/client/__tests__/client.test.ts index 09faf66..9c69bad 100644 --- a/packages/projector/src/client/__tests__/client.test.ts +++ b/packages/projector/src/client/__tests__/client.test.ts @@ -243,6 +243,52 @@ describe("optimistic effigy", () => { }); }); + it("updateAt predicts with the machine's own state-update fold", async () => { + type Instances = Array<{ + states: Array<{ address: StateAddress; value: { items: string[]; meta: { open: boolean } } }>; + commands: Array<{ name: "updateState" }>; + members: []; + children: []; + contributor: unknown; + }>; + + const address = { instanceId: "child-1", stateKey: "todos" }; + const effigy = createMachineEffigy(() => {}); + effigy.setInstances([ + { + states: [{ address, value: { items: ["a"], meta: { open: false } } }], + commands: [{ name: "updateState" }], + members: [], + children: [], + contributor: {}, + }, + ] as Instances); + const optimistic = createOptimisticEffigy(effigy); + + await optimistic + .getCommand("updateState", { + optimistic: (ctx) => + ctx.updateAt(address, { op: "append", values: ["b"], path: ["items"] }), + }) + .run({} as never); + await optimistic + .getCommand("updateState", { + optimistic: (ctx) => ctx.updateAt(address, { op: "patch", value: { open: true }, path: ["meta"] }), + }) + .run({} as never); + // An update the fold rejects (append to a non-array) must leave truth alone. + await optimistic + .getCommand("updateState", { + optimistic: (ctx) => ctx.updateAt(address, { op: "append", values: [1], path: ["meta"] }), + }) + .run({} as never); + + expect(optimistic.getInstances()?.[0]?.states[0]?.value).toEqual({ + items: ["a", "b"], + meta: { open: true }, + }); + }); + it("evicts an acked optimistic overlay and trusts fresh server state", async () => { const sent: Array<{ callId: string }> = []; const effigy = createMachineEffigy((message) => { diff --git a/packages/projector/src/client/index.ts b/packages/projector/src/client/index.ts index 2884486..88cd5e6 100644 --- a/packages/projector/src/client/index.ts +++ b/packages/projector/src/client/index.ts @@ -16,7 +16,7 @@ import { implicitDefaultLayout, layoutRegionForSlot } from "../layouts.ts"; import { encodeProjectionAddress } from "../projection-address.ts"; import { callerAllows, resolveContributorActions, type ResolvedNodeAction } from "../scoped-actions.ts"; import { slotPlacement } from "../slots.ts"; -import { groupStatesByContributor, resolveStates, type ResolvedState } from "../state.ts"; +import { applyStateUpdate, groupStatesByContributor, resolveStates, type ResolvedState } from "../state.ts"; import type { Action, ActionRequestMessage, @@ -28,6 +28,7 @@ import type { NormalizedRuntime, ProjectionAddress, StateAddress, + StateUpdate, TextPart, Exposure, } from "../types.ts"; @@ -35,7 +36,7 @@ import type { export type JSONSchema = unknown; export type ClientMachineMessage = ActionRequestMessage & { action: "command" }; -export type { ActionRequestMessage, ExecuteActionResult }; +export type { ActionRequestMessage, ExecuteActionResult, StateAddress, StateUpdate }; export type MachineClientSnapshot = { instance: TInstance; @@ -234,6 +235,13 @@ export type OptimisticContext = { patch(patch: Record): void; patchAt(address: StateAddress, patch: Record): void; replaceAt(address: StateAddress, value: unknown): void; + /** + * Applies a machine StateUpdate ({op, value, path…}) through the same fold + * the machine uses server-side — the exact prediction of what the durable + * log will do with this update. Invalid updates (bad path, append to + * non-array) leave the overlay untouched; the server is the arbiter. + */ + updateAt(address: StateAddress, update: StateUpdate): void; getInstances(): TInstances | undefined; }; @@ -748,6 +756,17 @@ function createOptimisticContext( } state.value = value; }, + updateAt: (address, update) => { + const state = findStateView(instances, address); + if (!state) { + return; + } + try { + state.value = applyStateUpdate(state.value, update); + } catch { + // The fold rejected the update; the server will too. Show truth. + } + }, getInstances: () => instances, }; } diff --git a/packages/projector/src/machine.ts b/packages/projector/src/machine.ts index 6138744..2add579 100644 --- a/packages/projector/src/machine.ts +++ b/packages/projector/src/machine.ts @@ -29,7 +29,7 @@ import { decodeContributorId, encodeProjectionAddress } from "./projection-addre import { callerAllows, collectAllNodeActions, resolveContributorActions } from "./scoped-actions.ts"; import { collectScopeDuplicates } from "./scopes.ts"; import { hydrateInstance, hydrateNode, serializeInstance, serializeNode } from "./serialization.ts"; -import { realizeResolvedState, resolveStates, type ResolveStatesOptions, type StateReset } from "./state.ts"; +import { applyStateUpdate, realizeResolvedState, resolveStates, type ResolveStatesOptions, type StateReset } from "./state.ts"; import { actorMessageVisibleToGenerator, isActorMessage, @@ -1610,37 +1610,6 @@ function stateAddressForContributor( }; } -function patchObject(value: unknown, patch: Record): unknown { - return { - ...(value && typeof value === "object" && !Array.isArray(value) ? value : {}), - ...patch, - }; -} - -function applyStateUpdate(value: unknown, update: StateUpdate): unknown { - if (update.op === "replace") { - return update.value; - } - - if (update.op === "patch") { - return updateAtPath(value, update.path ?? [], (target) => - patchObject(target, update.value as Record), - ); - } - - if (update.op === "append") { - return updateAtPath(value, update.path ?? [], (target) => { - if (!Array.isArray(target)) { - throw new Error("Cannot append to non-array state value"); - } - return [...target, ...update.values]; - }); - } - - const unreachable: never = update; - return unreachable; -} - function resolveStateUpdate( state: S, update: StateUpdateInput, @@ -1648,41 +1617,6 @@ function resolveStateUpdate( return typeof update === "function" ? update(state) : update; } -function updateAtPath( - value: unknown, - path: StatePath, - updater: (target: unknown) => unknown, -): unknown { - if (path.length === 0) { - return updater(value); - } - - const [segment, ...rest] = path; - if (Array.isArray(value)) { - if (typeof segment !== "number") { - throw new Error("Array state paths must use numeric segments"); - } - if (segment < 0 || segment >= value.length) { - throw new Error(`Array state path segment ${segment} is out of bounds`); - } - const next = [...value]; - next[segment] = updateAtPath(next[segment], rest, updater); - return next; - } - - if (!value || typeof value !== "object") { - throw new Error("Cannot update nested path on non-object state value"); - } - - if (typeof segment !== "string") { - throw new Error("Object state paths must use string segments"); - } - return { - ...(value as Record), - [segment]: updateAtPath((value as Record)[segment], rest, updater), - }; -} - function findInstance(root: Instance, instanceId: string): Instance | undefined { return findInstanceInTree(root, instanceId); } diff --git a/packages/projector/src/state.ts b/packages/projector/src/state.ts index fb75f7a..eac6bdb 100644 --- a/packages/projector/src/state.ts +++ b/packages/projector/src/state.ts @@ -7,8 +7,82 @@ import type { StateAddress, StateContainer, StateKey, + StatePath, + StateUpdate, } from "./types.ts"; +/** + * The one state-update fold. The machine applies it when a state.update + * message lands in the log; clients apply the SAME function for optimistic + * overlays, so a prediction and its eventual durable fold can never differ + * for a valid update. + */ +export function applyStateUpdate(value: unknown, update: StateUpdate): unknown { + if (update.op === "replace") { + return update.value; + } + + if (update.op === "patch") { + return updateAtPath(value, update.path ?? [], (target) => + patchObject(target, update.value as Record), + ); + } + + if (update.op === "append") { + return updateAtPath(value, update.path ?? [], (target) => { + if (!Array.isArray(target)) { + throw new Error("Cannot append to non-array state value"); + } + return [...target, ...update.values]; + }); + } + + const unreachable: never = update; + return unreachable; +} + +function patchObject(value: unknown, patch: Record): unknown { + return { + ...(value && typeof value === "object" && !Array.isArray(value) ? value : {}), + ...patch, + }; +} + +function updateAtPath( + value: unknown, + path: StatePath, + updater: (target: unknown) => unknown, +): unknown { + if (path.length === 0) { + return updater(value); + } + + const [segment, ...rest] = path; + if (Array.isArray(value)) { + if (typeof segment !== "number") { + throw new Error("Array state paths must use numeric segments"); + } + if (segment < 0 || segment >= value.length) { + throw new Error(`Array state path segment ${segment} is out of bounds`); + } + const next = [...value]; + next[segment] = updateAtPath(next[segment], rest, updater); + return next; + } + + if (!value || typeof value !== "object") { + throw new Error("Cannot update nested path on non-object state value"); + } + + if (typeof segment !== "string") { + throw new Error("Object state paths must use string segments"); + } + return { + ...(value as Record), + [segment]: updateAtPath((value as Record)[segment], rest, updater), + }; +} + export type ResolvedState = { address: StateAddress; targetInstance: Instance; From 916f9ded4e23df42b02bbb6b74ba9023594fdcb1 Mon Sep 17 00:00:00 2001 From: eleven Date: Thu, 20 Aug 2026 09:58:45 -0700 Subject: [PATCH 03/17] Add multiplayer chat identities --- apps/site/convex/_generated/api.d.ts | 2 + apps/site/convex/access.ts | 32 ++++--- apps/site/convex/actors.ts | 44 ++++++++++ apps/site/convex/agent.ts | 74 +++++++++++++++-- apps/site/convex/auth.ts | 14 +++- apps/site/convex/http.ts | 16 +++- apps/site/convex/messages.ts | 119 +++++++++++++++++++-------- apps/site/convex/schema.ts | 24 ++++++ apps/site/convex/topics.ts | 32 +++++-- apps/site/src/agent/charter.ts | 23 +++++- apps/site/src/app/App.tsx | 93 +++++++++++++++++---- apps/site/src/guest-access.ts | 8 +- apps/site/src/site.css | 3 + packages/projector/src/types.ts | 9 ++ 14 files changed, 411 insertions(+), 82 deletions(-) create mode 100644 apps/site/convex/actors.ts diff --git a/apps/site/convex/_generated/api.d.ts b/apps/site/convex/_generated/api.d.ts index a4aec20..dbd4bd0 100644 --- a/apps/site/convex/_generated/api.d.ts +++ b/apps/site/convex/_generated/api.d.ts @@ -9,6 +9,7 @@ */ import type * as access from "../access.js"; +import type * as actors from "../actors.js"; import type * as agent from "../agent.js"; import type * as artifacts from "../artifacts.js"; import type * as auth from "../auth.js"; @@ -27,6 +28,7 @@ import type { declare const fullApi: ApiFromModules<{ access: typeof access; + actors: typeof actors; agent: typeof agent; artifacts: typeof artifacts; auth: typeof auth; diff --git a/apps/site/convex/access.ts b/apps/site/convex/access.ts index 2319b5a..8e8ebe6 100644 --- a/apps/site/convex/access.ts +++ b/apps/site/convex/access.ts @@ -39,28 +39,36 @@ export async function authorizeSessionWrite( ): Promise<"authenticated" | "guest"> { const userId = await getAuthUserId(ctx); - if (session.ownerUserId !== undefined) { - if (userId !== session.ownerUserId) { - throw new ConvexError(ACCESS_ERROR.forbidden); + // Public demo sessions are collaborative: authentication is sufficient to + // write, regardless of who created the session. Preserve ownerUserId only + // as creator metadata for a guest who later signs in. + if (userId) { + if (session.ownerUserId === undefined && guestSecret !== undefined) { + const guestMatches = + isValidGuestSecret(guestSecret) && + session.guestSecretHash !== undefined && + (await hashGuestSecret(guestSecret)) === session.guestSecretHash; + if (guestMatches) { + await ctx.db.patch(session._id, { + ownerUserId: userId, + guestSecretHash: undefined, + }); + } } return "authenticated"; } + if (session.ownerUserId !== undefined) { + throw new ConvexError(ACCESS_ERROR.authRequired); + } + const guestMatches = guestSecret !== undefined && isValidGuestSecret(guestSecret) && session.guestSecretHash !== undefined && (await hashGuestSecret(guestSecret)) === session.guestSecretHash; if (!guestMatches) { - throw new ConvexError(userId ? ACCESS_ERROR.forbidden : ACCESS_ERROR.authRequired); - } - - if (userId) { - await ctx.db.patch(session._id, { - ownerUserId: userId, - guestSecretHash: undefined, - }); - return "authenticated"; + throw new ConvexError(ACCESS_ERROR.authRequired); } return "guest"; diff --git a/apps/site/convex/actors.ts b/apps/site/convex/actors.ts new file mode 100644 index 0000000..b55a3b4 --- /dev/null +++ b/apps/site/convex/actors.ts @@ -0,0 +1,44 @@ +import { getAuthUserId } from "@convex-dev/auth/server"; +import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; +import { internalQuery } from "./_generated/server"; + +export type MessageActor = { + id: string; + kind: "anonymous" | "github"; + label: string; +}; + +export const messageActorValidator = v.object({ + id: v.string(), + kind: v.union(v.literal("anonymous"), v.literal("github")), + label: v.string(), +}); + +export function anonymousActor(sessionId: Id<"sessions">): MessageActor { + return { + id: `anonymous:${sessionId}`, + kind: "anonymous", + label: "ANON", + }; +} + +export function githubActor(user: Doc<"users">): MessageActor { + const fallback = user.name?.trim() || "user"; + return { + id: `github:${user._id}`, + kind: "github", + label: `@github/${user.githubHandle?.trim() || fallback}`, + }; +} + +export const current = internalQuery({ + args: {}, + returns: v.union(v.null(), messageActorValidator), + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (!userId) return null; + const user = await ctx.db.get(userId); + return user ? githubActor(user) : null; + }, +}); diff --git a/apps/site/convex/agent.ts b/apps/site/convex/agent.ts index c7e0092..e70a53c 100644 --- a/apps/site/convex/agent.ts +++ b/apps/site/convex/agent.ts @@ -25,6 +25,7 @@ import { readCardData, siteCharter, } from "../src/agent/charter"; +import { anonymousActor, type MessageActor } from "./actors"; import { escapeConvexJson, restoreConvexJson } from "./convexJson"; const MODEL_ID = process.env.OPENAI_MODEL ?? "gpt-5.6-sol"; @@ -34,16 +35,22 @@ export const sendMessage = action({ args: { sessionId: v.id("sessions"), text: v.string(), + clientMessageId: v.string(), guestSecret: v.optional(v.string()), }, returns: v.object({ success: v.literal(true) }), - handler: async (ctx, { sessionId, text, guestSecret }): Promise<{ success: true }> => { + handler: async ( + ctx, + { sessionId, text, clientMessageId, guestSecret }, + ): Promise<{ success: true }> => { if (!(await getAuthUserId(ctx))) throw new Error("AUTH_REQUIRED"); await ctx.runMutation(internal.sessions.authorizeAuthenticatedTurn, { sessionId, guestSecret, }); - await runUserMessage(ctx, sessionId, text); + const actor = await ctx.runQuery(internal.actors.current, {}); + if (!actor) throw new Error("AUTH_REQUIRED"); + await runUserMessage(ctx, sessionId, text, clientMessageId, actor); return { success: true }; }, }); @@ -52,10 +59,14 @@ export const sendAnonymousMessage = internalAction({ args: { sessionId: v.id("sessions"), text: v.string(), + clientMessageId: v.string(), }, returns: v.object({ success: v.literal(true) }), - handler: async (ctx, { sessionId, text }): Promise<{ success: true }> => { - await runUserMessage(ctx, sessionId, text); + handler: async ( + ctx, + { sessionId, text, clientMessageId }, + ): Promise<{ success: true }> => { + await runUserMessage(ctx, sessionId, text, clientMessageId, anonymousActor(sessionId)); return { success: true }; }, }); @@ -64,9 +75,12 @@ async function runUserMessage( ctx: ActionCtx, sessionId: Id<"sessions">, text: string, + clientMessageId: string, + actor: MessageActor, ): Promise { const trimmed = text.trim(); if (!trimmed) throw new Error("Message requires text"); + const normalizedClientMessageId = requireClientMessageId(clientMessageId); const { machine, referenceFrameId, streamWriter } = await loadAgentMachine(ctx, sessionId); @@ -74,16 +88,26 @@ async function runUserMessage( // assistant's streaming rows (createdAt is the thread order). The frame // persistence pass later finds it by the same idempotency key — derived // from this messageId — and patches on the real frameId. - const userMessageId = crypto.randomUUID(); + const userMessageId = `${actor.id}:${normalizedClientMessageId}`; await ctx.runMutation(internal.messages.add, { sessionId, role: "user", content: trimmed, + actor, + clientMessageId: normalizedClientMessageId, idempotencyKey: `user:${userMessageId}`, }); machine.enqueueFrame({ - messages: [{ type: "user", text: trimmed, messageId: userMessageId }], + messages: [ + { + type: "user", + text: trimmed, + actor, + clientMessageId: normalizedClientMessageId, + messageId: userMessageId, + }, + ], }); await runAndPersistAgent(ctx, { @@ -216,6 +240,20 @@ function createExecutor( maxOutputTokens: 4096, stream: true, providerOptions: { openai: { parallelToolCalls: true } }, + messageToModelMessage: (message) => { + if (message.type !== "user" || !message.actor || message.text === undefined) { + return undefined; + } + const attribution = JSON.stringify({ + id: message.actor.id, + label: message.actor.label, + kind: message.actor.kind, + }); + return { + role: "user", + content: `${attribution}\n${message.text}`, + }; + }, runAction: async ({ action, input, context }) => { // The surface's TSX lives in the artifacts table, not machine state, so // retrieval is an executor concern: read the latest artifact here. @@ -399,11 +437,15 @@ async function persistFrameMessages( for (const [messageIndex, message] of frame.messages.entries()) { const text = typeof message.text === "string" ? message.text : ""; if (message.type === "user" && text.trim()) { + const actor = readMessageActor(message); + const clientMessageId = readStringField(message, "clientMessageId"); await ctx.runMutation(internal.messages.add, { sessionId, role: "user", content: text, frameId, + ...(actor ? { actor } : {}), + ...(clientMessageId ? { clientMessageId } : {}), idempotencyKey: frameMessageKey("user", frame, message, messageIndex), }); } @@ -468,3 +510,23 @@ function readNumberField(value: unknown, key: string): number | undefined { const field = (value as Record)[key]; return typeof field === "number" && Number.isFinite(field) ? field : undefined; } + +function requireClientMessageId(value: string): string { + const id = value.trim(); + if (!id || id.length > 100) throw new Error("Invalid client message id"); + return id; +} + +function readMessageActor(message: FrameMessage): MessageActor | undefined { + const actor = message.actor; + if (!actor || typeof actor !== "object") return undefined; + const { id, kind, label } = actor as Record; + if ( + typeof id !== "string" || + (kind !== "anonymous" && kind !== "github") || + typeof label !== "string" + ) { + return undefined; + } + return { id, kind, label }; +} diff --git a/apps/site/convex/auth.ts b/apps/site/convex/auth.ts index 796d290..3de5421 100644 --- a/apps/site/convex/auth.ts +++ b/apps/site/convex/auth.ts @@ -2,5 +2,17 @@ import GitHub from "@auth/core/providers/github"; import { convexAuth } from "@convex-dev/auth/server"; export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ - providers: [GitHub], + providers: [ + GitHub({ + profile(profile) { + return { + id: profile.id.toString(), + name: profile.name ?? profile.login, + email: profile.email ?? undefined, + image: profile.avatar_url, + githubHandle: profile.login, + }; + }, + }), + ], }); diff --git a/apps/site/convex/http.ts b/apps/site/convex/http.ts index 0151314..30a14d2 100644 --- a/apps/site/convex/http.ts +++ b/apps/site/convex/http.ts @@ -71,6 +71,7 @@ export default http; type AnonymousMessageBody = { sessionId: Id<"sessions">; text: string; + clientMessageId: string; }; async function readMessageBody(request: Request): Promise { @@ -82,10 +83,19 @@ async function readMessageBody(request: Request): Promise; - if (typeof value.sessionId !== "string" || typeof value.text !== "string") return null; + if ( + typeof value.sessionId !== "string" || + typeof value.text !== "string" || + typeof value.clientMessageId !== "string" + ) return null; const text = value.text.trim(); - if (!text) return null; - return { sessionId: value.sessionId as AnonymousMessageBody["sessionId"], text }; + const clientMessageId = value.clientMessageId.trim(); + if (!text || !clientMessageId || clientMessageId.length > 100) return null; + return { + sessionId: value.sessionId as AnonymousMessageBody["sessionId"], + text, + clientMessageId, + }; } function corsHeaders(request: Request): Headers | null { diff --git a/apps/site/convex/messages.ts b/apps/site/convex/messages.ts index 2174864..0d2c0ee 100644 --- a/apps/site/convex/messages.ts +++ b/apps/site/convex/messages.ts @@ -3,6 +3,7 @@ import { paginationOptsValidator, paginationResultValidator, } from "convex/server"; +import { getAuthUserId } from "@convex-dev/auth/server"; import { internalMutation, internalQuery, @@ -15,6 +16,12 @@ import { getFrameIndexForSession, getLatestSessionFrameDoc, } from "./frameHistory"; +import { + githubActor, + messageActorValidator, + type MessageActor, +} from "./actors"; +import { hashGuestSecret, isValidGuestSecret } from "./access"; type DbCtx = MutationCtx | QueryCtx; type MessageDoc = Doc<"messages">; @@ -27,6 +34,7 @@ const agentMessageValidator = v.object({ id: v.id("messages"), role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), + actor: v.optional(messageActorValidator), createdAt: v.number(), }); @@ -46,6 +54,8 @@ export type AddMessageArgs = { sessionId: Id<"sessions">; role: "user" | "assistant"; content: string; + actor?: MessageActor; + clientMessageId?: string; frameId?: Id<"frames">; idempotencyKey?: string; streamState?: string; @@ -60,6 +70,8 @@ export const add = internalMutation({ sessionId: v.id("sessions"), role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), + actor: v.optional(messageActorValidator), + clientMessageId: v.optional(v.string()), frameId: v.optional(v.id("frames")), idempotencyKey: v.optional(v.string()), streamState: v.optional(v.string()), @@ -192,6 +204,8 @@ export async function addMessageInternal( if (args.updatedSurface !== undefined) patch.updatedSurface = args.updatedSurface; if (args.widget !== undefined) patch.widget = args.widget; if (args.card !== undefined) patch.card = args.card; + if (args.actor !== undefined) patch.actor = args.actor; + if (args.clientMessageId !== undefined) patch.clientMessageId = args.clientMessageId; await ctx.db.patch(existing._id, patch); if (args.streamState === "complete") { const deltas = await ctx.db @@ -209,6 +223,10 @@ export async function addMessageInternal( role: args.role, content: args.content, frameId, + ...(args.actor !== undefined ? { actor: args.actor } : {}), + ...(args.clientMessageId !== undefined + ? { clientMessageId: args.clientMessageId } + : {}), ...(args.widget !== undefined ? { widget: args.widget } : {}), ...(args.card !== undefined ? { card: args.card } : {}), ...(args.updatedSurface !== undefined ? { updatedSurface: args.updatedSurface } : {}), @@ -240,22 +258,44 @@ async function setDefaultSessionTitle( } export const list = query({ - args: { sessionId: v.id("sessions") }, - returns: v.array( - v.object({ - id: v.id("messages"), - role: v.union(v.literal("user"), v.literal("assistant")), - content: v.string(), - createdAt: v.number(), - activationId: v.optional(v.string()), - streamState: v.optional(v.string()), - widget: v.optional(v.string()), - card: v.optional(v.object({ title: v.string(), source: v.string() })), - updatedSurface: v.optional(v.boolean()), - }), - ), - handler: async (ctx, { sessionId }) => { + args: { + sessionId: v.id("sessions"), + guestSecret: v.optional(v.string()), + }, + returns: v.object({ + viewerActorIds: v.array(v.string()), + messages: v.array( + v.object({ + id: v.id("messages"), + role: v.union(v.literal("user"), v.literal("assistant")), + content: v.string(), + actor: v.optional(messageActorValidator), + clientMessageId: v.optional(v.string()), + createdAt: v.number(), + activationId: v.optional(v.string()), + streamState: v.optional(v.string()), + widget: v.optional(v.string()), + card: v.optional(v.object({ title: v.string(), source: v.string() })), + updatedSurface: v.optional(v.boolean()), + }), + ), + }), + handler: async (ctx, { sessionId, guestSecret }) => { const messages = await listMessagesForSession(ctx, sessionId); + const session = await ctx.db.get(sessionId); + const userId = await getAuthUserId(ctx); + const user = userId ? await ctx.db.get(userId) : null; + const viewerActorIds = user ? [githubActor(user).id] : []; + const ownsAnonymousActor = + session !== null && + ((userId !== null && session.ownerUserId === userId) || + (guestSecret !== undefined && + isValidGuestSecret(guestSecret) && + session.guestSecretHash !== undefined && + (await hashGuestSecret(guestSecret)) === session.guestSecretHash)); + if (ownsAnonymousActor) { + viewerActorIds.push(`anonymous:${sessionId}`); + } const liveContent = new Map( await Promise.all( messages @@ -284,22 +324,31 @@ export const list = query({ .filter((frame) => frame !== null) .map((frame) => [frame._id, frame.activationId] as const), ); - return messages.map((message) => { - const activationId = message.frameId - ? activationByFrameId.get(message.frameId) - : undefined; - return { - id: message._id, - role: message.role, - content: liveContent.get(message._id) ?? message.content, - createdAt: message.createdAt, - ...(activationId !== undefined ? { activationId } : {}), - ...(message.streamState !== undefined ? { streamState: message.streamState } : {}), - ...(message.widget !== undefined ? { widget: message.widget } : {}), - ...(message.card !== undefined ? { card: message.card } : {}), - ...(message.updatedSurface !== undefined ? { updatedSurface: message.updatedSurface } : {}), - }; - }); + return { + viewerActorIds, + messages: messages.map((message) => { + const activationId = message.frameId + ? activationByFrameId.get(message.frameId) + : undefined; + return { + id: message._id, + role: message.role, + content: liveContent.get(message._id) ?? message.content, + ...(message.actor !== undefined ? { actor: message.actor } : {}), + ...(message.clientMessageId !== undefined + ? { clientMessageId: message.clientMessageId } + : {}), + createdAt: message.createdAt, + ...(activationId !== undefined ? { activationId } : {}), + ...(message.streamState !== undefined ? { streamState: message.streamState } : {}), + ...(message.widget !== undefined ? { widget: message.widget } : {}), + ...(message.card !== undefined ? { card: message.card } : {}), + ...(message.updatedSurface !== undefined + ? { updatedSurface: message.updatedSurface } + : {}), + }; + }), + }; }, }); @@ -329,6 +378,7 @@ export const pageForAgent = internalQuery({ id: message._id, role: message.role, content: message.content, + ...(message.actor !== undefined ? { actor: message.actor } : {}), createdAt: message.createdAt, })); @@ -353,7 +403,8 @@ export async function listMessagesForSession( .order("desc") .take(MAX_SESSION_MESSAGES); const messages = await Promise.all(rows.map((row) => ctx.db.get(row.messageId))); - return messages - .filter((message): message is MessageDoc => message !== null) - .sort((a, b) => a.createdAt - b.createdAt || a._id.localeCompare(b._id)); + // The index query provides the authoritative insertion order. Reverse the + // newest-first bounded page instead of re-sorting by millisecond timestamps, + // which can collide when multiple clients send together. + return messages.reverse().filter((message): message is MessageDoc => message !== null); } diff --git a/apps/site/convex/schema.ts b/apps/site/convex/schema.ts index 35b3473..6d14447 100644 --- a/apps/site/convex/schema.ts +++ b/apps/site/convex/schema.ts @@ -2,9 +2,31 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; import { authTables } from "@convex-dev/auth/server"; +const messageActor = v.object({ + id: v.string(), + kind: v.union(v.literal("anonymous"), v.literal("github")), + label: v.string(), +}); + export default defineSchema({ ...authTables, + // Convex Auth's standard user shape plus the public GitHub login used for + // attributed multiplayer messages. Optional keeps existing users valid; + // their next GitHub sign-in fills it. + users: defineTable({ + name: v.optional(v.string()), + image: v.optional(v.string()), + email: v.optional(v.string()), + emailVerificationTime: v.optional(v.number()), + phone: v.optional(v.string()), + phoneVerificationTime: v.optional(v.number()), + isAnonymous: v.optional(v.boolean()), + githubHandle: v.optional(v.string()), + }) + .index("email", ["email"]) + .index("phone", ["phone"]), + sessions: defineTable({ contextEpoch: v.number(), title: v.optional(v.string()), @@ -74,6 +96,8 @@ export default defineSchema({ frameId: v.optional(v.id("frames")), role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), + actor: v.optional(messageActor), + clientMessageId: v.optional(v.string()), // Rich client rendering for this message: the id of a prebuilt explainer // widget. content stays the plain-text equivalent — it is what the LLM // sees as history and what renders if the widget id is unknown. diff --git a/apps/site/convex/topics.ts b/apps/site/convex/topics.ts index 14bcfd4..8b985e6 100644 --- a/apps/site/convex/topics.ts +++ b/apps/site/convex/topics.ts @@ -9,6 +9,8 @@ import { v } from "convex/values"; import { mutation } from "./_generated/server"; import { appendMachineFrameInternal } from "./sessions"; import { authorizeSessionWrite } from "./access"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import { anonymousActor, githubActor } from "./actors"; const TOPICS: Record = { subagents: { @@ -28,15 +30,23 @@ export const open = mutation({ // The question as the visitor's card showed it; falls back to the // topic's canonical ask so the log always has a coherent user turn. ask: v.optional(v.string()), + clientMessageId: v.string(), guestSecret: v.optional(v.string()), }, returns: v.null(), - handler: async (ctx, { sessionId, topic, ask, guestSecret }) => { + handler: async (ctx, { sessionId, topic, ask, clientMessageId, guestSecret }) => { const entry = TOPICS[topic]; if (!entry) throw new Error(`Unknown topic "${topic}"`); const session = await ctx.db.get(sessionId); if (!session) throw new Error("Session not found"); - await authorizeSessionWrite(ctx, session, guestSecret); + const access = await authorizeSessionWrite(ctx, session, guestSecret); + const userId = access === "authenticated" ? await getAuthUserId(ctx) : null; + const user = userId ? await ctx.db.get(userId) : null; + const actor = user ? githubActor(user) : anonymousActor(sessionId); + const normalizedClientMessageId = clientMessageId.trim(); + if (!normalizedClientMessageId || normalizedClientMessageId.length > 100) { + throw new Error("Invalid client message id"); + } const userText = ask?.trim() || entry.ask; const frameId = await appendMachineFrameInternal(ctx, { @@ -45,7 +55,12 @@ export const open = mutation({ frame: { metadata: { type: "topic", topic }, messages: [ - { type: "user", text: userText }, + { + type: "user", + text: userText, + actor, + clientMessageId: normalizedClientMessageId, + }, // Assistant messages default to audience "self", which is scoped to // the frame's generator — and an app-authored frame has none, so the // reply would be invisible to the model. This one was said to the @@ -67,14 +82,21 @@ export const open = mutation({ role, content, frameId, + ...(role === "user" ? { actor, clientMessageId: normalizedClientMessageId } : {}), ...(widget !== undefined ? { widget } : {}), createdAt: at++, - idempotencyKey: `topic:${frameId}:${role}`, + idempotencyKey: + role === "user" + ? `topic:${actor.id}:${normalizedClientMessageId}` + : `topic:${frameId}:${role}`, }); await ctx.db.insert("messageIndex", { sessionId, messageId, - idempotencyKey: `topic:${frameId}:${role}`, + idempotencyKey: + role === "user" + ? `topic:${actor.id}:${normalizedClientMessageId}` + : `topic:${frameId}:${role}`, }); }; await insert("user", userText); diff --git a/apps/site/src/agent/charter.ts b/apps/site/src/agent/charter.ts index 91fa2bd..3e6e66a 100644 --- a/apps/site/src/agent/charter.ts +++ b/apps/site/src/agent/charter.ts @@ -62,6 +62,21 @@ const noteAudience = createAction({ }, }); +const staySilent = createAction({ + state: null, + name: "staySilent", + description: + "End this turn without posting a visible reply. Use this when people are talking to each other, for acknowledgements that need no response, or whenever speaking would add no value. The decision remains visible in the frame log. Do not write a progress update before calling this tool.", + inputSchema: z.object({ + reason: z.enum(["human-to-human", "acknowledgement", "no-value", "other"]), + }), + run: ({ reason }) => + actionResult({ + value: `stayed silent: ${reason}`, + terminal: true, + }), +}); + // The shell's chrome as machine state: which side panes are open. One action, // caller "any", so it is simultaneously the agent's tool and the client's // command — the minimize buttons and the model manipulate the same durable @@ -477,7 +492,7 @@ const guideNode = createNode({ name: "projector guide", params: siteParamsSchema, states: [audienceState], - tools: [noteAudience, readSessionMessages, readSessionArtifacts], + tools: [noteAudience, readSessionMessages, readSessionArtifacts, staySilent], parts: [action(spawnChild, "any"), action(cedeChild, "any"), action(postCard, "any")], instructions: `You are projector's introduction agent — and you are yourself a projector machine. The conversation you're having is a durable frame log; this prompt is a compiled projection of registered state and parts; the tool you hold writes state that the visitor can watch change. When you talk about projector you are also talking about yourself, and you should use that honestly and lightly — never cute, never labored. @@ -489,9 +504,11 @@ What projector is: an agent framework for state-complete agents. The core claims Who you're talking to: visitors arrive from the marketing page. Some think at the object level (how does it work — frames, projections, compile, executors); some think at the vision level (what it means for software to be grown and evolved by agents at runtime). Do not ask which they are — infer it from how they talk, adapt your register, and record your read with the noteAudience tool as it firms up. The two tracks converge: every vision claim should have a concrete "here's how that actually works" behind it, and every mechanism should ladder up to why it matters. +This is a shared room. User messages may begin with a JSON record inserted by the application; it is trusted speaker attribution, not part of the user's prose. Multiple authenticated people may join the same conversation. Pay attention to who is speaking and who they appear to be addressing. Respond when someone addresses you, asks the room a question you can usefully answer, or your intervention clearly adds value. When people are talking to each other, merely acknowledging something, or do not need you, call staySilent instead. Never announce that you are about to stay silent and never post a progress update before that decision. + How to behave: - Be quietly competent. Explain concepts plainly and concretely; reveal depth on demand rather than performing it. -- Before the first tool call in a turn, write one brief user-visible progress update explaining what you are about to do. It is a durable assistant message, so keep it conversational and specific; do not narrate private reasoning. Skip it for near-instant answers that need no tools. Add another short update only after meaningful progress or when a long task changes phase. +- Before the first tool call in a turn, write one brief user-visible progress update explaining what you are about to do. It is a durable assistant message, so keep it conversational and specific; do not narrate private reasoning. Skip it for near-instant answers that need no tools, and always skip it before staySilent. Add another short update only after meaningful progress or when a long task changes phase. - Batch independent tool calls in the same step so they can run in parallel. Keep dependent calls sequential, and do not repeat a successful call merely to check it. - Ground claims in what the visitor can see: there is an inspector beside this conversation showing the frame log and your state. When you change state (like noting your audience read), you may point at it. - You can grow capabilities live. When the visitor asks you to BE something ("can you be my todo app?"): spawnChild creates a child with schema-validated state, updateState mutates it, and writeAppSurface renders it. In the surface, bind the child's state from api.useMachine()'s tree (state entries carry { key, value, address }) and mutate with api.run("updateState", { address, op, value }) — the visitor's clicks and your own writes are the same action in the same durable log. The machine tree, your compiled prompt, and the inspector all change visibly when you spawn; point at it. @@ -509,7 +526,7 @@ export const siteCharter = createCharter({ params: siteParamsSchema, nodes: [guideNode, uiNode], tools: [noteAudience, readSessionMessages, readSessionArtifacts], - actions: [setPanes, writeAppSurface, getSurfaceSource, spawnChild, cedeChild, updateStateAction, postCard], + actions: [setPanes, writeAppSurface, getSurfaceSource, spawnChild, cedeChild, updateStateAction, postCard, staySilent], commands: [reportSurfaceError, appPanePing], // appSurface carries projection code (render/note), so registration is // required, not just preferred. diff --git a/apps/site/src/app/App.tsx b/apps/site/src/app/App.tsx index f9f3c7c..1bac2e2 100644 --- a/apps/site/src/app/App.tsx +++ b/apps/site/src/app/App.tsx @@ -323,11 +323,18 @@ function ThemeToggle() { type LocalMessage = { key: string; + clientMessageId: string; role: "user" | "assistant"; content: string; pending?: boolean; }; +type MessageActor = { + id: string; + kind: "anonymous" | "github"; + label: string; +}; + type PaneAgentNotice = { id: string; content: string; @@ -352,7 +359,7 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses const [input, setInput] = useState(""); const [authPrompt, setAuthPrompt] = useState(false); const [signingIn, setSigningIn] = useState(false); - const inputRef = useRef(null); + const inputRef = useRef(null); const guestSecret = useMemo(getGuestSecret, []); const { isLoading: authLoading, isAuthenticated } = useConvexAuth(); const { signIn } = useAuthActions(); @@ -634,10 +641,12 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses return () => removeEventListener("keydown", onType); }, []); - const serverMessages = useQuery( + const serverMessageResult = useQuery( api.messages.list, - sessionId ? { sessionId } : "skip", + sessionId ? { sessionId, guestSecret } : "skip", ); + const serverMessages = serverMessageResult?.messages; + const viewerActorIds = serverMessageResult?.viewerActorIds ?? []; const requestAuthentication = useCallback((draft: string) => { setInput(draft); @@ -665,10 +674,15 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses history.replaceState({ app: true }, "", `/s/${id}`); } - const optimisticKey = `opt-${Date.now()}-${crypto.randomUUID()}`; + const clientMessageId = crypto.randomUUID(); setOptimistic((prev) => [ ...prev, - { key: optimisticKey, role: "user", content: trimmed }, + { + key: clientMessageId, + clientMessageId, + role: "user", + content: trimmed, + }, ]); setFinalResponseStarted(false); setWaitingSince(Date.now()); @@ -677,11 +691,27 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses // A topic turn is prebuilt server-side — a rich explainer lands as a // real frame with no model call, so the answer is effectively instant. if (topic) { - await openTopic({ sessionId: id, topic, ask: trimmed, guestSecret }); + await openTopic({ + sessionId: id, + topic, + ask: trimmed, + clientMessageId, + guestSecret, + }); } else if (isAuthenticated) { - await sendMessage({ sessionId: id, text: trimmed, guestSecret }); + await sendMessage({ + sessionId: id, + text: trimmed, + clientMessageId, + guestSecret, + }); } else if (actionsUrl) { - await sendAnonymousMessage(actionsUrl, { sessionId: id, text: trimmed, guestSecret }); + await sendAnonymousMessage(actionsUrl, { + sessionId: id, + text: trimmed, + clientMessageId, + guestSecret, + }); } else { throw new Error("Anonymous message endpoint isn't configured"); } @@ -692,7 +722,9 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses error instanceof AnonymousMessageError && (error.code === "AUTH_REQUIRED" || error.code === "IP_RATE_LIMITED") ) { - setOptimistic((prev) => prev.filter((message) => message.key !== optimisticKey)); + setOptimistic((prev) => + prev.filter((message) => message.clientMessageId !== clientMessageId), + ); requestAuthentication(trimmed); return; } @@ -763,11 +795,11 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses } }, [authLoading, createSession, guestSecret, initialMessage, initialTopic, send, sessionId]); - // Server truth replaces optimism as soon as it covers it: any optimistic - // user message whose text has landed server-side is dropped. + // Server truth replaces optimism by end-to-end client message id as soon as + // the durable row lands. Matching text is ambiguous in a shared room. const server = serverMessages ?? []; const visibleOptimistic = optimistic.filter( - (o) => !server.some((m) => m.role === o.role && m.content === o.content), + (o) => !server.some((m) => m.clientMessageId === o.clientMessageId), ); // A full-screen narrow pane hides the conversation, but it should not hide @@ -902,6 +934,9 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses key: m.id, role: m.role, content: m.content, + actor: m.actor, + clientMessageId: m.clientMessageId, + isMine: m.role === "user" && viewerActorIds.includes(m.actor?.id ?? ""), activationId: m.activationId, widget: m.widget, card: m.card, @@ -912,6 +947,9 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses key: m.key, role: m.role, content: m.content, + actor: undefined as MessageActor | undefined, + clientMessageId: m.clientMessageId, + isMine: true, activationId: undefined as string | undefined, widget: undefined as string | undefined, card: undefined as { title: string; source: string } | undefined, @@ -922,7 +960,11 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses let turnCount = 0; const items = rendered.map((m, i) => { const previous = rendered[i - 1]; - const speakerStart = i === 0 || previous.role !== m.role; + const speakerId = m.role === "assistant" ? "projector" : m.actor?.id ?? m.clientMessageId; + const previousSpeakerId = previous?.role === "assistant" + ? "projector" + : previous?.actor?.id ?? previous?.clientMessageId; + const speakerStart = i === 0 || speakerId !== previousSpeakerId; // An autonomous activation has no visible user row to create a speaker // boundary. Its changed activation value arms a one-message latch: the // first assistant row starts a page, then later rows from that same run @@ -1074,6 +1116,13 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses void send(draft); }; + useLayoutEffect(() => { + const field = inputRef.current; + if (!field) return; + field.style.height = "0px"; + field.style.height = `${field.scrollHeight}px`; + }, [input]); + const revealPaneAgentNotice = useCallback(() => { const messageId = paneAgentNotice?.id; setPaneAgentNotice(null); @@ -1170,6 +1219,8 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses messageId={String(m.key)} role={m.role} content={m.content} + actor={m.actor} + isMine={m.isMine} widget={m.widget} card={m.card} api={surfaceApi} @@ -1214,11 +1265,17 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses
) : (
- setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return; + e.preventDefault(); + e.currentTarget.form?.requestSubmit(); + }} placeholder="ask projector…" spellCheck={false} enterKeyHint="send" @@ -1336,10 +1393,12 @@ function AppPane({ width, onResizeStart, surface, api, onSurfaceError, onAsk }: ); } -function Message({ messageId, role, content, widget, card, api, pending, turnStart, onAsk, onOpenAppPane }: { +function Message({ messageId, role, content, actor, isMine, widget, card, api, pending, turnStart, onAsk, onOpenAppPane }: { messageId?: string; role: "user" | "assistant"; content: string; + actor?: MessageActor; + isMine?: boolean; widget?: string; card?: { title: string; source: string }; api?: ReturnType; @@ -1358,7 +1417,9 @@ function Message({ messageId, role, content, widget, card, api, pending, turnSta data-message-id={messageId} data-turn-start={turnStart ? "" : undefined} > - {role === "user" ? "you" : "projector"} + + {role === "assistant" ? "projector" : isMine ? "you" : actor?.label ?? "anon"} + {card && api ? : Explainer && onAsk diff --git a/apps/site/src/guest-access.ts b/apps/site/src/guest-access.ts index 52a6e78..ee7baae 100644 --- a/apps/site/src/guest-access.ts +++ b/apps/site/src/guest-access.ts @@ -14,7 +14,7 @@ export function getGuestSecret(): string { export async function sendAnonymousMessage( actionsUrl: string, - args: { sessionId: string; text: string; guestSecret: string }, + args: { sessionId: string; text: string; clientMessageId: string; guestSecret: string }, ): Promise { const response = await fetch(`${actionsUrl.replace(/\/$/, "")}/api/anonymous/message`, { method: "POST", @@ -22,7 +22,11 @@ export async function sendAnonymousMessage( "Content-Type": "application/json", "X-Projector-Guest": args.guestSecret, }, - body: JSON.stringify({ sessionId: args.sessionId, text: args.text }), + body: JSON.stringify({ + sessionId: args.sessionId, + text: args.text, + clientMessageId: args.clientMessageId, + }), }); if (response.ok) return; diff --git a/apps/site/src/site.css b/apps/site/src/site.css index 985db5b..c21c6da 100644 --- a/apps/site/src/site.css +++ b/apps/site/src/site.css @@ -1204,12 +1204,15 @@ main { max-width: var(--wrap); margin: 0 auto; padding: 0 2.25rem; } .talk-input { flex: 1; min-width: 0; + max-height: 8rem; border: 0; background: none; color: var(--ink); font: 500 0.9375rem/1.4 var(--sans); padding: 0.5rem 0; outline: none; + overflow-y: auto; + resize: none; } .talk-input::placeholder { color: var(--faint); } .talk-mic, .talk-clear, .talk-go { diff --git a/packages/projector/src/types.ts b/packages/projector/src/types.ts index bce327a..737f570 100644 --- a/packages/projector/src/types.ts +++ b/packages/projector/src/types.ts @@ -128,10 +128,18 @@ export type Audience = "self" | "broadcast" | AudienceTarget | AudienceTarget[]; export type MessageDelivery = "immediate" | "queued"; +/** Stable application-authored identity for a human or agent actor. */ +export type ActorIdentity = { + id: string; + label: string; + kind?: string; +}; + export type UserMessage = { type: "user"; text?: string; content?: ContentPart[]; + actor?: ActorIdentity; audience?: Audience; delivery?: MessageDelivery; }; @@ -140,6 +148,7 @@ export type AssistantMessage = { type: "assistant"; text?: string; content?: ContentPart[]; + actor?: ActorIdentity; audience?: Audience; delivery?: MessageDelivery; }; From 2fa07d0a63a01c40404e9d3525e4b562656be295 Mon Sep 17 00:00:00 2001 From: eleven Date: Thu, 20 Aug 2026 09:59:57 -0700 Subject: [PATCH 04/17] chore: improve agent observability and latency From 7a69434fe08be2d8dbc492c321eaf6ce50c3e4cd Mon Sep 17 00:00:00 2001 From: eleven Date: Thu, 20 Aug 2026 11:11:31 -0700 Subject: [PATCH 05/17] Simplify multiplayer identity handling --- apps/site/convex/_generated/api.d.ts | 2 + apps/site/convex/access.ts | 27 +++++++++---- apps/site/convex/actors.ts | 38 ++++++------------ apps/site/convex/agent.ts | 13 +++--- apps/site/convex/auth.ts | 4 +- apps/site/convex/messageActor.ts | 9 +++++ apps/site/convex/messages.ts | 17 +++----- apps/site/convex/schema.ts | 25 +----------- apps/site/convex/sessions.ts | 18 ++++++--- apps/site/convex/topics.ts | 59 ++++++++++------------------ apps/site/src/app/App.tsx | 2 +- 11 files changed, 92 insertions(+), 122 deletions(-) create mode 100644 apps/site/convex/messageActor.ts diff --git a/apps/site/convex/_generated/api.d.ts b/apps/site/convex/_generated/api.d.ts index dbd4bd0..e3a7829 100644 --- a/apps/site/convex/_generated/api.d.ts +++ b/apps/site/convex/_generated/api.d.ts @@ -16,6 +16,7 @@ import type * as auth from "../auth.js"; import type * as convexJson from "../convexJson.js"; import type * as frameHistory from "../frameHistory.js"; import type * as http from "../http.js"; +import type * as messageActor from "../messageActor.js"; import type * as messages from "../messages.js"; import type * as sessions from "../sessions.js"; import type * as topics from "../topics.js"; @@ -35,6 +36,7 @@ declare const fullApi: ApiFromModules<{ convexJson: typeof convexJson; frameHistory: typeof frameHistory; http: typeof http; + messageActor: typeof messageActor; messages: typeof messages; sessions: typeof sessions; topics: typeof topics; diff --git a/apps/site/convex/access.ts b/apps/site/convex/access.ts index 8e8ebe6..76c4040 100644 --- a/apps/site/convex/access.ts +++ b/apps/site/convex/access.ts @@ -1,9 +1,10 @@ import { DAY, RateLimiter } from "@convex-dev/rate-limiter"; -import { getAuthUserId } from "@convex-dev/auth/server"; import { ConvexError } from "convex/values"; import { components } from "./_generated/api"; import type { Doc } from "./_generated/dataModel"; import type { MutationCtx } from "./_generated/server"; +import { anonymousActor, authenticatedUser } from "./actors"; +import type { MessageActor } from "./messageActor"; const rateLimiter = new RateLimiter(components.rateLimiter, { // The cookie is the primary one-free-turn boundary. The IP limit is a @@ -36,13 +37,13 @@ export async function authorizeSessionWrite( ctx: MutationCtx, session: Doc<"sessions">, guestSecret?: string, -): Promise<"authenticated" | "guest"> { - const userId = await getAuthUserId(ctx); +): Promise { + const user = await authenticatedUser(ctx); // Public demo sessions are collaborative: authentication is sufficient to // write, regardless of who created the session. Preserve ownerUserId only // as creator metadata for a guest who later signs in. - if (userId) { + if (user) { if (session.ownerUserId === undefined && guestSecret !== undefined) { const guestMatches = isValidGuestSecret(guestSecret) && @@ -50,12 +51,12 @@ export async function authorizeSessionWrite( (await hashGuestSecret(guestSecret)) === session.guestSecretHash; if (guestMatches) { await ctx.db.patch(session._id, { - ownerUserId: userId, + ownerUserId: user.userId, guestSecretHash: undefined, }); } } - return "authenticated"; + return user.actor; } if (session.ownerUserId !== undefined) { @@ -71,7 +72,17 @@ export async function authorizeSessionWrite( throw new ConvexError(ACCESS_ERROR.authRequired); } - return "guest"; + return anonymousActor(session._id); +} + +export async function consumeAnonymousTurn( + ctx: MutationCtx, + session: Doc<"sessions">, +): Promise { + if (session.ownerUserId !== undefined || session.anonymousTurnUsedAt !== undefined) { + throw new ConvexError(ACCESS_ERROR.authRequired); + } + await ctx.db.patch(session._id, { anonymousTurnUsedAt: Date.now() }); } export async function reserveAnonymousTurn( @@ -95,5 +106,5 @@ export async function reserveAnonymousTurn( const ipLimit = await rateLimiter.limit(ctx, "anonymousByIp", { key: ipHash }); if (!ipLimit.ok) throw new ConvexError(ACCESS_ERROR.ipRateLimited); - await ctx.db.patch(session._id, { anonymousTurnUsedAt: Date.now() }); + await consumeAnonymousTurn(ctx, session); } diff --git a/apps/site/convex/actors.ts b/apps/site/convex/actors.ts index b55a3b4..597d174 100644 --- a/apps/site/convex/actors.ts +++ b/apps/site/convex/actors.ts @@ -1,19 +1,7 @@ import { getAuthUserId } from "@convex-dev/auth/server"; -import { v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; -import { internalQuery } from "./_generated/server"; - -export type MessageActor = { - id: string; - kind: "anonymous" | "github"; - label: string; -}; - -export const messageActorValidator = v.object({ - id: v.string(), - kind: v.union(v.literal("anonymous"), v.literal("github")), - label: v.string(), -}); +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import type { MessageActor } from "./messageActor"; export function anonymousActor(sessionId: Id<"sessions">): MessageActor { return { @@ -24,21 +12,19 @@ export function anonymousActor(sessionId: Id<"sessions">): MessageActor { } export function githubActor(user: Doc<"users">): MessageActor { - const fallback = user.name?.trim() || "user"; + const handle = user.name?.trim() || "user"; return { id: `github:${user._id}`, kind: "github", - label: `@github/${user.githubHandle?.trim() || fallback}`, + label: `@github/${handle}`, }; } -export const current = internalQuery({ - args: {}, - returns: v.union(v.null(), messageActorValidator), - handler: async (ctx) => { - const userId = await getAuthUserId(ctx); - if (!userId) return null; - const user = await ctx.db.get(userId); - return user ? githubActor(user) : null; - }, -}); +export async function authenticatedUser( + ctx: MutationCtx | QueryCtx, +): Promise<{ userId: Id<"users">; actor: MessageActor } | null> { + const userId = await getAuthUserId(ctx); + if (!userId) return null; + const user = await ctx.db.get(userId); + return user ? { userId, actor: githubActor(user) } : null; +} diff --git a/apps/site/convex/agent.ts b/apps/site/convex/agent.ts index e70a53c..d541a2a 100644 --- a/apps/site/convex/agent.ts +++ b/apps/site/convex/agent.ts @@ -1,7 +1,6 @@ "use node"; import { openai } from "@ai-sdk/openai"; -import { getAuthUserId } from "@convex-dev/auth/server"; import { AiSdkExecutor, type AiSdkStreamUpdate } from "@projectors/aisdk-executor"; import { ROOT_GENERATOR_ID, @@ -25,7 +24,8 @@ import { readCardData, siteCharter, } from "../src/agent/charter"; -import { anonymousActor, type MessageActor } from "./actors"; +import { anonymousActor } from "./actors"; +import type { MessageActor } from "./messageActor"; import { escapeConvexJson, restoreConvexJson } from "./convexJson"; const MODEL_ID = process.env.OPENAI_MODEL ?? "gpt-5.6-sol"; @@ -43,13 +43,10 @@ export const sendMessage = action({ ctx, { sessionId, text, clientMessageId, guestSecret }, ): Promise<{ success: true }> => { - if (!(await getAuthUserId(ctx))) throw new Error("AUTH_REQUIRED"); - await ctx.runMutation(internal.sessions.authorizeAuthenticatedTurn, { + const actor = await ctx.runMutation(internal.sessions.authorizeAuthenticatedTurn, { sessionId, guestSecret, }); - const actor = await ctx.runQuery(internal.actors.current, {}); - if (!actor) throw new Error("AUTH_REQUIRED"); await runUserMessage(ctx, sessionId, text, clientMessageId, actor); return { success: true }; }, @@ -88,7 +85,9 @@ async function runUserMessage( // assistant's streaming rows (createdAt is the thread order). The frame // persistence pass later finds it by the same idempotency key — derived // from this messageId — and patches on the real frameId. - const userMessageId = `${actor.id}:${normalizedClientMessageId}`; + // The client id only reconciles the optimistic UI row. Persistence gets a + // fresh server identity, so retrying a request is plainly a second turn. + const userMessageId = crypto.randomUUID(); await ctx.runMutation(internal.messages.add, { sessionId, role: "user", diff --git a/apps/site/convex/auth.ts b/apps/site/convex/auth.ts index 3de5421..f5341bf 100644 --- a/apps/site/convex/auth.ts +++ b/apps/site/convex/auth.ts @@ -7,10 +7,10 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ profile(profile) { return { id: profile.id.toString(), - name: profile.name ?? profile.login, + // The only public identity this demo needs is the GitHub handle. + name: profile.login, email: profile.email ?? undefined, image: profile.avatar_url, - githubHandle: profile.login, }; }, }), diff --git a/apps/site/convex/messageActor.ts b/apps/site/convex/messageActor.ts new file mode 100644 index 0000000..ae42251 --- /dev/null +++ b/apps/site/convex/messageActor.ts @@ -0,0 +1,9 @@ +import { type Infer, v } from "convex/values"; + +export const messageActorValidator = v.object({ + id: v.string(), + kind: v.union(v.literal("anonymous"), v.literal("github")), + label: v.string(), +}); + +export type MessageActor = Infer; diff --git a/apps/site/convex/messages.ts b/apps/site/convex/messages.ts index 0d2c0ee..9658ff2 100644 --- a/apps/site/convex/messages.ts +++ b/apps/site/convex/messages.ts @@ -3,7 +3,6 @@ import { paginationOptsValidator, paginationResultValidator, } from "convex/server"; -import { getAuthUserId } from "@convex-dev/auth/server"; import { internalMutation, internalQuery, @@ -16,11 +15,8 @@ import { getFrameIndexForSession, getLatestSessionFrameDoc, } from "./frameHistory"; -import { - githubActor, - messageActorValidator, - type MessageActor, -} from "./actors"; +import { anonymousActor, authenticatedUser } from "./actors"; +import { messageActorValidator, type MessageActor } from "./messageActor"; import { hashGuestSecret, isValidGuestSecret } from "./access"; type DbCtx = MutationCtx | QueryCtx; @@ -283,18 +279,17 @@ export const list = query({ handler: async (ctx, { sessionId, guestSecret }) => { const messages = await listMessagesForSession(ctx, sessionId); const session = await ctx.db.get(sessionId); - const userId = await getAuthUserId(ctx); - const user = userId ? await ctx.db.get(userId) : null; - const viewerActorIds = user ? [githubActor(user).id] : []; + const user = await authenticatedUser(ctx); + const viewerActorIds = user ? [user.actor.id] : []; const ownsAnonymousActor = session !== null && - ((userId !== null && session.ownerUserId === userId) || + ((user !== null && session.ownerUserId === user.userId) || (guestSecret !== undefined && isValidGuestSecret(guestSecret) && session.guestSecretHash !== undefined && (await hashGuestSecret(guestSecret)) === session.guestSecretHash)); if (ownsAnonymousActor) { - viewerActorIds.push(`anonymous:${sessionId}`); + viewerActorIds.push(anonymousActor(sessionId).id); } const liveContent = new Map( await Promise.all( diff --git a/apps/site/convex/schema.ts b/apps/site/convex/schema.ts index 6d14447..e047491 100644 --- a/apps/site/convex/schema.ts +++ b/apps/site/convex/schema.ts @@ -1,32 +1,11 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; import { authTables } from "@convex-dev/auth/server"; - -const messageActor = v.object({ - id: v.string(), - kind: v.union(v.literal("anonymous"), v.literal("github")), - label: v.string(), -}); +import { messageActorValidator } from "./messageActor"; export default defineSchema({ ...authTables, - // Convex Auth's standard user shape plus the public GitHub login used for - // attributed multiplayer messages. Optional keeps existing users valid; - // their next GitHub sign-in fills it. - users: defineTable({ - name: v.optional(v.string()), - image: v.optional(v.string()), - email: v.optional(v.string()), - emailVerificationTime: v.optional(v.number()), - phone: v.optional(v.string()), - phoneVerificationTime: v.optional(v.number()), - isAnonymous: v.optional(v.boolean()), - githubHandle: v.optional(v.string()), - }) - .index("email", ["email"]) - .index("phone", ["phone"]), - sessions: defineTable({ contextEpoch: v.number(), title: v.optional(v.string()), @@ -96,7 +75,7 @@ export default defineSchema({ frameId: v.optional(v.id("frames")), role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), - actor: v.optional(messageActor), + actor: v.optional(messageActorValidator), clientMessageId: v.optional(v.string()), // Rich client rendering for this message: the id of a prebuilt explainer // widget. content stays the plain-text equivalent — it is what the LLM diff --git a/apps/site/convex/sessions.ts b/apps/site/convex/sessions.ts index c09b62a..f9552b1 100644 --- a/apps/site/convex/sessions.ts +++ b/apps/site/convex/sessions.ts @@ -48,6 +48,7 @@ import { isValidGuestSecret, reserveAnonymousTurn as reserveAnonymousTurnForSession, } from "./access"; +import { messageActorValidator } from "./messageActor"; import { escapeConvexJson, restoreConvexJson, stripClientSchemas } from "./convexJson"; import { getFrameIndexForSession, @@ -283,7 +284,7 @@ export const sendCommand = mutation({ returns: v.null(), handler: async (ctx, { sessionId, message, guestSecret }) => { const session = await getSessionOrThrow(ctx, sessionId); - const access = await authorizeSessionWrite(ctx, session, guestSecret); + const actor = await authorizeSessionWrite(ctx, session, guestSecret); const latestFrame = await getLatestSessionFrameDoc(ctx, sessionId); if (!latestFrame) throw new Error("Session has no frames"); const serialized = await getLatestSerializedSource(ctx, sessionId); @@ -299,9 +300,14 @@ export const sendCommand = mutation({ const produced: Frame[] = []; for await (const frame of runMachine(machine, { scheduleWork: false })) { + for (const producedMessage of frame.messages) { + if (producedMessage.type === "user" && !producedMessage.actor) { + producedMessage.actor = actor; + } + } produced.push(frame); } - if (access === "guest" && containsWorkActivation(produced)) { + if (actor.kind === "anonymous" && containsWorkActivation(produced)) { throw new Error(ACCESS_ERROR.authRequired); } if (produced.length > 0) { @@ -367,12 +373,12 @@ export const authorizeAuthenticatedTurn = internalMutation({ sessionId: v.id("sessions"), guestSecret: v.optional(v.string()), }, - returns: v.null(), + returns: messageActorValidator, handler: async (ctx, { sessionId, guestSecret }) => { const session = await getSessionOrThrow(ctx, sessionId); - const access = await authorizeSessionWrite(ctx, session, guestSecret); - if (access !== "authenticated") throw new Error(ACCESS_ERROR.authRequired); - return null; + const actor = await authorizeSessionWrite(ctx, session, guestSecret); + if (actor.kind !== "github") throw new Error(ACCESS_ERROR.authRequired); + return actor; }, }); diff --git a/apps/site/convex/topics.ts b/apps/site/convex/topics.ts index 8b985e6..56b53c5 100644 --- a/apps/site/convex/topics.ts +++ b/apps/site/convex/topics.ts @@ -8,9 +8,8 @@ import { v } from "convex/values"; import { mutation } from "./_generated/server"; import { appendMachineFrameInternal } from "./sessions"; -import { authorizeSessionWrite } from "./access"; -import { getAuthUserId } from "@convex-dev/auth/server"; -import { anonymousActor, githubActor } from "./actors"; +import { authorizeSessionWrite, consumeAnonymousTurn } from "./access"; +import { addMessageInternal } from "./messages"; const TOPICS: Record = { subagents: { @@ -39,10 +38,8 @@ export const open = mutation({ if (!entry) throw new Error(`Unknown topic "${topic}"`); const session = await ctx.db.get(sessionId); if (!session) throw new Error("Session not found"); - const access = await authorizeSessionWrite(ctx, session, guestSecret); - const userId = access === "authenticated" ? await getAuthUserId(ctx) : null; - const user = userId ? await ctx.db.get(userId) : null; - const actor = user ? githubActor(user) : anonymousActor(sessionId); + const actor = await authorizeSessionWrite(ctx, session, guestSecret); + if (actor.kind === "anonymous") await consumeAnonymousTurn(ctx, session); const normalizedClientMessageId = clientMessageId.trim(); if (!normalizedClientMessageId || normalizedClientMessageId.length > 100) { throw new Error("Invalid client message id"); @@ -70,37 +67,23 @@ export const open = mutation({ }, }); - // createdAt is the sort key and _id is a random tiebreak, so the two rows - // must not share a timestamp or the reply can sort above the question. - let at = Date.now(); - const insert = async ( - role: "user" | "assistant", - content: string, - widget?: string, - ) => { - const messageId = await ctx.db.insert("messages", { - role, - content, - frameId, - ...(role === "user" ? { actor, clientMessageId: normalizedClientMessageId } : {}), - ...(widget !== undefined ? { widget } : {}), - createdAt: at++, - idempotencyKey: - role === "user" - ? `topic:${actor.id}:${normalizedClientMessageId}` - : `topic:${frameId}:${role}`, - }); - await ctx.db.insert("messageIndex", { - sessionId, - messageId, - idempotencyKey: - role === "user" - ? `topic:${actor.id}:${normalizedClientMessageId}` - : `topic:${frameId}:${role}`, - }); - }; - await insert("user", userText); - await insert("assistant", entry.reply, topic); + await addMessageInternal(ctx, { + sessionId, + role: "user", + content: userText, + actor, + clientMessageId: normalizedClientMessageId, + frameId, + idempotencyKey: `topic:${frameId}:user`, + }); + await addMessageInternal(ctx, { + sessionId, + role: "assistant", + content: entry.reply, + widget: topic, + frameId, + idempotencyKey: `topic:${frameId}:assistant`, + }); return null; }, diff --git a/apps/site/src/app/App.tsx b/apps/site/src/app/App.tsx index 1bac2e2..f32b673 100644 --- a/apps/site/src/app/App.tsx +++ b/apps/site/src/app/App.tsx @@ -662,7 +662,7 @@ function Conversation({ actionsUrl, initialMessage, initialTopic, sessionId: ses setInput(trimmed); return; } - if (!topic && !isAuthenticated && session?.anonymousTurnUsed) { + if (!isAuthenticated && session?.anonymousTurnUsed) { requestAuthentication(trimmed); return; } From 08a3f0efa7225374369562b6bfa4a9dd25d106e1 Mon Sep 17 00:00:00 2001 From: eleven Date: Thu, 20 Aug 2026 11:12:06 -0700 Subject: [PATCH 06/17] feat(site): add admin observability panel --- apps/site/AUTH.md | 14 + apps/site/convex/_generated/api.d.ts | 8 + apps/site/convex/_generated/server.d.ts | 13 + apps/site/convex/_generated/server.js | 5 + apps/site/convex/agent.ts | 20 +- apps/site/convex/convex.config.ts | 8 +- apps/site/convex/dev/access.ts | 59 ++++ apps/site/convex/dev/prompts.ts | 118 +++++++ apps/site/convex/dev/sessions.ts | 27 ++ apps/site/convex/executorConfig.ts | 32 ++ apps/site/src/app/App.tsx | 36 +- apps/site/src/app/dev/DevPanel.tsx | 433 ++++++++++++++++++++++++ apps/site/src/site.css | 240 +++++++++++++ 13 files changed, 992 insertions(+), 21 deletions(-) create mode 100644 apps/site/convex/dev/access.ts create mode 100644 apps/site/convex/dev/prompts.ts create mode 100644 apps/site/convex/dev/sessions.ts create mode 100644 apps/site/convex/executorConfig.ts create mode 100644 apps/site/src/app/dev/DevPanel.tsx diff --git a/apps/site/AUTH.md b/apps/site/AUTH.md index bb5eab7..38c3b66 100644 --- a/apps/site/AUTH.md +++ b/apps/site/AUTH.md @@ -39,6 +39,20 @@ These controls deter casual resets; they do not identify a person reliably. npx convex env set ANONYMOUS_IP_SALT ``` +5. Grant developer-panel access with a comma- or whitespace-separated list of + stable numeric GitHub account IDs. You can find an account's ID from the + GitHub API (`https://api.github.com/users/` → `id`): + + ```sh + npx convex env set ADMIN_GITHUB_IDS "583231,9919" + ``` + + Convex Auth stores this value as the GitHub account's `providerAccountId`, + so authorization survives handle changes. The navigation capability probe + reveals only whether the signed-in user is an admin. Every query that + returns developer-panel data independently enforces the same server-side + admin check. + Production needs its own OAuth App/callback and its own Convex Auth keys and environment variables; dev values do not carry over. Run the corresponding commands with `--prod`. diff --git a/apps/site/convex/_generated/api.d.ts b/apps/site/convex/_generated/api.d.ts index dbd4bd0..82558f0 100644 --- a/apps/site/convex/_generated/api.d.ts +++ b/apps/site/convex/_generated/api.d.ts @@ -14,6 +14,10 @@ import type * as agent from "../agent.js"; import type * as artifacts from "../artifacts.js"; import type * as auth from "../auth.js"; import type * as convexJson from "../convexJson.js"; +import type * as dev_access from "../dev/access.js"; +import type * as dev_prompts from "../dev/prompts.js"; +import type * as dev_sessions from "../dev/sessions.js"; +import type * as executorConfig from "../executorConfig.js"; import type * as frameHistory from "../frameHistory.js"; import type * as http from "../http.js"; import type * as messages from "../messages.js"; @@ -33,6 +37,10 @@ declare const fullApi: ApiFromModules<{ artifacts: typeof artifacts; auth: typeof auth; convexJson: typeof convexJson; + "dev/access": typeof dev_access; + "dev/prompts": typeof dev_prompts; + "dev/sessions": typeof dev_sessions; + executorConfig: typeof executorConfig; frameHistory: typeof frameHistory; http: typeof http; messages: typeof messages; diff --git a/apps/site/convex/_generated/server.d.ts b/apps/site/convex/_generated/server.d.ts index bec05e6..25871de 100644 --- a/apps/site/convex/_generated/server.d.ts +++ b/apps/site/convex/_generated/server.d.ts @@ -21,6 +21,14 @@ import { } from "convex/server"; import type { DataModel } from "./dataModel.js"; +/** + * Typesafe environment variables declared in `convex.config.ts`. + */ +type Env = { + readonly ADMIN_GITHUB_IDS: string | undefined; + readonly OPENAI_MODEL: string | undefined; +}; + /** * Define a query in this Convex app's public API. * @@ -95,6 +103,11 @@ export declare const internalAction: ActionBuilder; */ export declare const httpAction: HttpActionBuilder; +/** + * Typesafe environment variables declared in `convex.config.ts`. + */ +export declare const env: Env; + /** * A set of services for use within Convex query functions. * diff --git a/apps/site/convex/_generated/server.js b/apps/site/convex/_generated/server.js index bf3d25a..e379347 100644 --- a/apps/site/convex/_generated/server.js +++ b/apps/site/convex/_generated/server.js @@ -91,3 +91,8 @@ export const internalAction = internalActionGeneric; * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ export const httpAction = httpActionGeneric; + +/** + * Typesafe environment variables declared in `convex.config.ts`. + */ +export const env = process.env; diff --git a/apps/site/convex/agent.ts b/apps/site/convex/agent.ts index e70a53c..944b139 100644 --- a/apps/site/convex/agent.ts +++ b/apps/site/convex/agent.ts @@ -27,8 +27,8 @@ import { } from "../src/agent/charter"; import { anonymousActor, type MessageActor } from "./actors"; import { escapeConvexJson, restoreConvexJson } from "./convexJson"; +import { SITE_MODEL_ID, sitePromptExecutorConfig } from "./executorConfig"; -const MODEL_ID = process.env.OPENAI_MODEL ?? "gpt-5.6-sol"; const STREAM_WRITE_INTERVAL_MS = 250; export const sendMessage = action({ @@ -236,24 +236,8 @@ function createExecutor( streamWriter: StreamWriter, ) { return new AiSdkExecutor({ - model: openai(MODEL_ID), - maxOutputTokens: 4096, + ...sitePromptExecutorConfig(openai(SITE_MODEL_ID)), stream: true, - providerOptions: { openai: { parallelToolCalls: true } }, - messageToModelMessage: (message) => { - if (message.type !== "user" || !message.actor || message.text === undefined) { - return undefined; - } - const attribution = JSON.stringify({ - id: message.actor.id, - label: message.actor.label, - kind: message.actor.kind, - }); - return { - role: "user", - content: `${attribution}\n${message.text}`, - }; - }, runAction: async ({ action, input, context }) => { // The surface's TSX lives in the artifacts table, not machine state, so // retrieval is an executor concern: read the latest artifact here. diff --git a/apps/site/convex/convex.config.ts b/apps/site/convex/convex.config.ts index 1d2b332..dc1f625 100644 --- a/apps/site/convex/convex.config.ts +++ b/apps/site/convex/convex.config.ts @@ -1,7 +1,13 @@ import { defineApp } from "convex/server"; +import { v } from "convex/values"; import rateLimiter from "@convex-dev/rate-limiter/convex.config.js"; -const app = defineApp(); +const app = defineApp({ + env: { + ADMIN_GITHUB_IDS: v.optional(v.string()), + OPENAI_MODEL: v.optional(v.string()), + }, +}); app.use(rateLimiter); export default app; diff --git a/apps/site/convex/dev/access.ts b/apps/site/convex/dev/access.ts new file mode 100644 index 0000000..7545324 --- /dev/null +++ b/apps/site/convex/dev/access.ts @@ -0,0 +1,59 @@ +import { getAuthUserId } from "@convex-dev/auth/server"; +import { ConvexError, v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; +import { env, query, type QueryCtx } from "../_generated/server"; + +const ADMIN_REQUIRED = "ADMIN_REQUIRED"; +const AUTH_REQUIRED = "AUTH_REQUIRED"; +const GITHUB_PROVIDER = "github"; + +function configuredAdminGithubIds(): Set { + return new Set( + (env.ADMIN_GITHUB_IDS ?? "") + .split(/[\s,]+/) + .map((id) => id.trim()) + .filter(Boolean), + ); +} + +async function currentGithubAccount( + ctx: QueryCtx, +): Promise<{ user: Doc<"users">; providerAccountId: string } | null> { + const userId = await getAuthUserId(ctx); + if (!userId) return null; + + const [user, account] = await Promise.all([ + ctx.db.get(userId), + ctx.db + .query("authAccounts") + .withIndex("userIdAndProvider", (q) => + q.eq("userId", userId).eq("provider", GITHUB_PROVIDER), + ) + .unique(), + ]); + return user && account + ? { user, providerAccountId: account.providerAccountId } + : null; +} + +/** + * The one deliberately non-admin dev endpoint: authenticated users may learn + * only whether their server-resolved GitHub identity has panel access. + */ +export const current = query({ + args: {}, + returns: v.object({ isAdmin: v.boolean() }), + handler: async (ctx) => { + const account = await currentGithubAccount(ctx); + if (!account) throw new ConvexError(AUTH_REQUIRED); + return { isAdmin: configuredAdminGithubIds().has(account.providerAccountId) }; + }, +}); + +export async function requireAdmin(ctx: QueryCtx): Promise> { + const account = await currentGithubAccount(ctx); + if (!account || !configuredAdminGithubIds().has(account.providerAccountId)) { + throw new ConvexError(ADMIN_REQUIRED); + } + return account.user; +} diff --git a/apps/site/convex/dev/prompts.ts b/apps/site/convex/dev/prompts.ts new file mode 100644 index 0000000..e52cc23 --- /dev/null +++ b/apps/site/convex/dev/prompts.ts @@ -0,0 +1,118 @@ +import { AiSdkExecutor } from "@projectors/aisdk-executor"; +import { + compileProjection, + inspectCompiledProjectionTree, + type CompiledContributor, + type ExecutorRealizedPrompt, + type Frame, + type SerializedInstance, +} from "@projectors/core"; +import { v } from "convex/values"; +import { hydrateSiteInstance, siteCharter } from "../../src/agent/charter"; +import { query } from "../_generated/server"; +import { restoreConvexJson, stripClientSchemas } from "../convexJson"; +import { SITE_MODEL_ID, sitePromptExecutorConfig } from "../executorConfig"; +import { listSessionContextFrameDocs, restoreFrame } from "../frameHistory"; +import { requireAdmin } from "./access"; + +type RuntimeTarget = { + generatorId: string; + kind: "generator"; + nodeKey: string; + name?: string; +}; + +export const current = query({ + args: { sessionId: v.id("sessions") }, + returns: v.union( + v.null(), + v.object({ + sessionId: v.id("sessions"), + runtimes: v.array( + v.object({ + generatorId: v.string(), + kind: v.literal("generator"), + nodeKey: v.string(), + name: v.optional(v.string()), + prompt: v.object({ provider: v.string(), input: v.any() }), + }), + ), + }), + ), + handler: async (ctx, { sessionId }) => { + await requireAdmin(ctx); + + const session = await ctx.db.get(sessionId); + if (!session) return null; + + const latest = await ctx.db + .query("projectorInstanceLog") + .withIndex("by_session", (q) => q.eq("sessionId", sessionId)) + .order("desc") + .first(); + if (!latest) return null; + + const serialized = restoreConvexJson(latest.instance) as SerializedInstance; + const root = hydrateSiteInstance(serialized, sessionId); + const frames = (await listSessionContextFrameDocs(ctx, session)).map(restoreFrame) as Frame[]; + const runtimes = collectRuntimeTargets( + inspectCompiledProjectionTree(root, { charter: siteCharter }).roots, + ); + const executor = createPromptExecutor(); + + return stripClientSchemas({ + sessionId, + runtimes: await Promise.all( + runtimes.map(async (runtime) => { + let prompt: ExecutorRealizedPrompt; + try { + const inference = compileProjection(root, { + charter: siteCharter, + targetGeneratorId: runtime.generatorId, + frameHistory: frames, + }); + prompt = await executor.realizePrompt({ + generatorId: runtime.generatorId, + activationId: "", + inference, + }); + } catch (error) { + prompt = { + provider: "error", + input: { message: error instanceof Error ? error.message : String(error) }, + }; + } + return { ...runtime, prompt }; + }), + ), + }); + }, +}); + +function collectRuntimeTargets(nodes: CompiledContributor[]): RuntimeTarget[] { + const targets: RuntimeTarget[] = []; + const visit = (node: CompiledContributor) => { + targets.push({ + generatorId: node.id, + kind: node.kind, + nodeKey: node.nodeKey, + ...(node.name ? { name: node.name } : {}), + }); + node.children.forEach(visit); + }; + nodes.forEach(visit); + return targets; +} + +// Prompt realization must mirror the production executor's provider-facing +// settings, but intentionally omits action execution and streaming: this query +// serializes the request that would be sent now and never invokes the model. +function createPromptExecutor() { + return new AiSdkExecutor(sitePromptExecutorConfig(modelRef(SITE_MODEL_ID))); +} + +function modelRef(modelId: string): never { + // `openai(modelId)` uses the Responses API provider identity. Keeping that + // identity exact also preserves provider-specific deferred-tool lowering. + return { provider: "openai.responses", modelId } as never; +} diff --git a/apps/site/convex/dev/sessions.ts b/apps/site/convex/dev/sessions.ts new file mode 100644 index 0000000..ffed202 --- /dev/null +++ b/apps/site/convex/dev/sessions.ts @@ -0,0 +1,27 @@ +import { v } from "convex/values"; +import type { SerializedInstance } from "@projectors/core"; +import { createSiteClientSnapshot } from "../../src/agent/charter"; +import { query } from "../_generated/server"; +import { restoreConvexJson, stripClientSchemas } from "../convexJson"; +import { requireAdmin } from "./access"; + +export const instanceTree = query({ + args: { sessionId: v.id("sessions") }, + returns: v.union(v.null(), v.any()), + handler: async (ctx, { sessionId }) => { + await requireAdmin(ctx); + + const session = await ctx.db.get(sessionId); + if (!session) return null; + + const latest = await ctx.db + .query("projectorInstanceLog") + .withIndex("by_session", (q) => q.eq("sessionId", sessionId)) + .order("desc") + .first(); + if (!latest) return null; + + const serialized = restoreConvexJson(latest.instance) as SerializedInstance; + return stripClientSchemas(createSiteClientSnapshot(serialized).instance); + }, +}); diff --git a/apps/site/convex/executorConfig.ts b/apps/site/convex/executorConfig.ts new file mode 100644 index 0000000..a4fc6a2 --- /dev/null +++ b/apps/site/convex/executorConfig.ts @@ -0,0 +1,32 @@ +import type { AiSdkExecutorConfig } from "@projectors/aisdk-executor"; +import type { LanguageModel } from "ai"; +import { env } from "./_generated/server"; + +export const SITE_MODEL_ID = env.OPENAI_MODEL ?? "gpt-5.6-sol"; + +/** + * Canonical provider-facing configuration for both real execution and prompt + * inspection. Execution-only concerns such as streaming and action dispatch + * are layered on by the production executor. + */ +export function sitePromptExecutorConfig(model: LanguageModel): AiSdkExecutorConfig { + return { + model, + maxOutputTokens: 4096, + providerOptions: { openai: { parallelToolCalls: true } }, + messageToModelMessage: (message) => { + if (message.type !== "user" || !message.actor || message.text === undefined) { + return undefined; + } + const attribution = JSON.stringify({ + id: message.actor.id, + label: message.actor.label, + kind: message.actor.kind, + }); + return { + role: "user", + content: `${attribution}\n${message.text}`, + }; + }, + }; +} diff --git a/apps/site/src/app/App.tsx b/apps/site/src/app/App.tsx index 1bac2e2..aa6b6c8 100644 --- a/apps/site/src/app/App.tsx +++ b/apps/site/src/app/App.tsx @@ -21,6 +21,7 @@ import { } from "../guest-access"; import { EXPLAINERS } from "./explainers"; import { Inspector, PaneIcon } from "./Inspector"; +import { DevPanel } from "./dev/DevPanel"; import { createSurfaceApi } from "./surface/api"; import { SurfaceHost } from "./surface/Surface"; @@ -231,7 +232,15 @@ export function App({ client, actionsUrl, initialMessage, initialTopic, sessionI // The marketing header, continued: same brand, same two CTA steps centered, // same Why/Docs/theme cluster on the right — the page folded into an app, so // the chrome shouldn't change vocabulary. -function AppNav({ sessionId }: { sessionId?: string }) { +function AppNav({ + sessionId, + isAdmin = false, + onOpenDev, +}: { + sessionId?: string; + isAdmin?: boolean; + onOpenDev?: () => void; +}) { const exit = (e: React.MouseEvent) => { e.preventDefault(); if (history.state?.app) history.back(); @@ -254,6 +263,11 @@ function AppNav({ sessionId }: { sessionId?: string }) {