feat(platform): introduce artifacts (canvas-bound documents) with streaming patches#1655
Conversation
Introduces a first-class artifact entity (HTML/SVG/markdown/mermaid/code) that lives outside the message stream so the LLM can patch a single logical document across many turns instead of re-emitting its full content on every revision. - New artifacts + artifactRevisions tables with streamingContent shadow field for crash-safe live writes. - artifact_create / artifact_edit tools wired through @convex-dev/agent createTool's onInputStart/onInputDelta/execute hooks for live streaming via parsePartialJson. - applyPatches pure function (search/replace blocks, exact-once match required) used by both authoritative and optimistic apply. - 5-min janitor cron clears stale streamingContent on rows whose writing tool call went silent past the threshold. - updateMessageContent mutation removed; the Canvas pane no longer writes back into message text.
Loads every artifact in the current thread into the system prompt as an XML <artifact> block (id / type / title / revision / content) so the LLM can see existing artifacts on every turn and patch them via the artifact_edit tool instead of re-emitting full content. Caps total injected size at 80k chars; per-artifact body capped at 30k with a truncation marker. Token budget protection. Wired into both the initial buildStructuredContext call and the continue-generation retry path so the agent never loses sight of artifacts mid-step.
The Canvas pane now subscribes to an artifact row by id (via Convex useQuery) instead of reading content out of message text. While a tool call is writing, the pane shows the streamingContent shadow field with a "AI is writing/editing" status badge; once settled, it falls back to the row's content. A new ArtifactBar above the chat lists every artifact in the thread and auto-opens the most recent one in the Canvas pane the first time it appears (ChatGPT-Canvas-style discovery). The deprecated "Open in Canvas" button on raw codeblocks is removed along with its supporting plumbing — replaceCodeBlock helper, MessageContentContext, and the stale tests. User edits via the textarea now call api.artifacts.mutations.userEdit, which records a new revision (editKind: 'user') the LLM picks up on the next turn.
- canvas.streamingWriting / canvas.streamingPatch — status badge rendered while a tool call writes into the artifact. - artifacts.barLabel / barTitle / openCard — strings for the new ArtifactBar discovery strip. - canvas.openInCanvas removed (the codeblock-canvas integration is gone, so the key has no remaining caller).
Adds artifact_create and artifact_edit to the example chat agent's toolNames, and rewrites Rule 7 of the system prompt in all three locales (en/de/fr) to direct the model to call artifact_create with type: "html" instead of emitting raw triple-backtick html code blocks. Raw html code blocks no longer render as a Canvas preview after the codeblock-canvas integration was stripped, so the prompt has to steer the model toward the tool path explicitly.
Updates the Canvas docs and the chat-effectively tutorial in all three locales (en/de/fr) to describe the new artifact-backed flow: artifacts auto-open from the AI's artifact_create tool call, the AI revises them in place via artifact_edit, the ArtifactBar lists current artifacts, and user textarea edits commit a new revision instead of writing back into message text. Removes references to the deprecated "Open in Canvas" codeblock button and the message-text round-trip.
A half-emitted HTML document renders as a blank or broken iframe — not useful while the AI is still typing. Switch the pane to a syntax- highlighted source view (CanvasCodeRenderer) for the duration of any liveStreamMode === 'create' | 'rewrite'; the iframe preview takes over once the tool's execute settles and clears the streaming flag. Patch mode keeps the existing preview because the previously-settled content stays valid in the iframe — patches apply atomically when execute returns, not incrementally during streaming.
Passing Loader2 as a Badge child put the spinner inside the children <span>, where it stacked above the label and forced the badge tall. Use the Badge's built-in `icon` slot via a small SpinnerIcon wrapper so the spinner sits left of the text and the badge stays one line.
Previously the auto-open effect was gated on `!isCanvasOpen`, which meant a second artifact_create call in the same turn left the canvas stuck on the first artifact. Drop the gate so each newly-created artifact pulls focus once (still keyed by _id so it doesn't repeat). Switch the "newest" comparison from updatedAt to createdAt so an artifact_edit revision doesn't re-trigger the open.
parsePartialJson returns title:"" the moment the parser sees the \`"title":\` key, before any characters of the actual title arrive. The previous logic inserted the placeholder row at that moment and never patched the title afterwards, so every streamed artifact landed with an empty title and the artifact bar showed nothing but the type icon and version badge. - Defer the placeholder insert until title is non-empty. - Patch title (and language) on subsequent deltas as they grow, guarded by lastFlushedTitle to avoid redundant mutations. - finalizeStreamedCreate now writes the canonical args.title / args.language so the settled row matches what the LLM actually sent.
Two rounds of review (30 sliced + 10 verification sub-agents) found two critical and eleven major issues on the artifacts feature. This commit aligns the cross-cutting semantics that were inconsistent — thread access predicate, byte vs character size, newest-first vs oldest-first context truncation, fail-soft vs fail-hard, and stream timestamp meaning — and ships the targeted fixes the reviews called out. Authorization (C1, M11, M13) - Add canAccessThread / assertThreadAccess helper that mirrors the thread privacy rule (owner OR shared+org-member). Apply it in every artifact public query and userEdit so a same-org peer can no longer read or write someone else's private-thread artifacts via guessable artifact / thread ids. Refactor the inline duplicates in threads/queries.ts to share the helper. - internal_queries.getById takes optional expectedOrganizationId / expectedThreadId and returns null on mismatch. artifact_edit_tool always passes ctx.organizationId and ctx.threadId so a hallucinated cross-thread artifact id stops at the query layer. - Stop swallowing non-Unauthorized errors silently in queries.ts; the thread predicate already returns null on deny so try/catches were no longer needed. Byte-length validation (C2) - Replace .length (UTF-16 code units) with TextEncoder byte length and lower the cap to 800k bytes, well clear of Convex's 1 MiB doc limit. Apply through createArtifact / finalizeStreamedCreate / applyToolPatches result / rewriteArtifact / updateStreamingContent / userEdit, so a 1M-codepoint CJK or emoji string returns a clean too_large ConvexError instead of crashing the mutation. Context build (M1, M2, M3) - Iterate artifacts newest-first and reverse the emitted blocks so oldest artifacts collapse into omitted="true" stubs when over budget (the model needs the latest revisions to patch). Fix the misleading comment that said the opposite. - Wrap buildArtifactsContext body in try/catch + console.warn and return undefined on failure, matching query_web_context / query_rag_context fail-soft pattern. A transient artifact query no longer aborts the user's turn. - Defuse delimiter injection: sanitize </artifact> and </details> in artifact bodies so a planted payload cannot break out of the wrapper and forge a system block in shared threads. Stream lifecycle (M4, M5, M9) - updateStreamingContent refreshes liveStreamStartedAt on every flush so long streams (>60s) are not reaped by cleanupStaleStreams; cron cadence and threshold unchanged. - userEdit refuses with code: 'streaming' when liveStreamMode is set, and finalizeStreamedCreate now reads the artifact, asserts liveStreamMode === 'create', and writes the row's actual revision rather than a hard-coded 1. - Add sparse by_liveStreamMode index; cleanupStaleStreams uses it so the cron only walks active streams instead of full-scanning the artifacts table. Drop the redundant by_thread index. Patch correctness (M7, M8) - apply_patches probes the second match at firstIndex + 1 instead of + search.length so self-overlapping snippets (e.g. "aa" inside "aaa") are correctly flagged as ambiguous rather than silently applied. Add tests for overlap, CRLF mismatch, empty replace, start/end boundary, and replace-creating-new-match. - applyToolPatches and rewriteArtifact now accept expectedRevision and return a discriminated stale result on mismatch; artifact_edit_tool passes the revision it just read so OCC retries do not silently land patches on different occurrences in mutated content. Public query bandwidth (F1) - listByThread orders desc, takes at most 50 (paginationOpts optional), and reverses to chronological order. The Canvas bar and message pills no longer ship the full artifact set on every reactive tick. Frontend (M6, M12) - canvas-pane: cancel-during-edit always available, even while a stream lands on top of the editor — toast informs the user once that their draft is preserved. Disabled toggle only blocks entering edit during a stream. - message-bubble: new MessageArtifactPills below the assistant bubble surfaces artifact_create / artifact_edit results (matched via createdByMessageId / lastEditedByMessageId) so the chip-to-canvas affordance is visible inline, not just in the bar at the top. - i18n: add chat.canvas.cancel, chat.canvas.streamingDuringEdit, chat.artifacts.touchedByMessage in en/de/fr; remove orphan chat.canvas.preview key. Verification: bun run check (211 test files / 2681 tests / 0 lint). 17 files modified, 1 new helper, _generated/api.d.ts regenerated.
…treamed
parsePartialJson hands back every prefix of the streaming artifactId
("k", "ks", "ks7", ...) — onInputDelta was running getById against
each one, and Convex's v.id("artifacts") validator rejects the prefix
as a NonRetryableError that aborts the whole agent run.
- Defer the preflight lookup until the `mode` field has also been
parsed; its presence in the parsed object is a structural signal
that the LLM closed the artifactId string.
- Wrap both the onInputDelta preflight and the execute lookup in
try/catch so any future malformed id from the model degrades to a
tool-result error instead of crashing the run.
Previous canvas-pane logic showed source view during any AI stream mode (including patch). That was conceptually wrong: patch never writes streamingContent during the stream — only at execute, all at once — so source view during a patch stream just freezes the *old* source. The user stares at unchanging code with no signal of what is about to change. Worse, patch streams interrupted whatever view the user had open (preview vs source) for no informational gain. Restore the original split: - create / rewrite = continuous (content grows on the artifact) → source view lets the user watch it appear, and an iframe of half-emitted HTML would render broken anyway. - patch = instant (content unchanged until execute, then atomic) → keep the user's current preview view, since the source they would see is just stale content. To compensate for "instant" not having a visible moment, pulse the content area for ~1.2s when liveStreamMode transitions from defined to undefined. Single source of feedback that works across every renderer (code/markdown source view, html/svg/mermaid preview view) without per-renderer overlay support. Range-level diff highlight (which patch ranges actually changed) is deferred — it would require new revision queries and renderer-level range overlays, and is a separate iteration if the pane-level pulse proves insufficient.
Last commit kept patch-mode streams in preview view, reasoning that
patch is "instant" at the data layer (streamingContent never updates
during the stream window, only at execute). That argument is correct
in isolation but loses to a more important signal at the UX layer:
the "AI is editing…" badge is on screen for the whole stream window,
and a static preview underneath it makes the editor feel broken —
the badge promises activity while the canvas shows none.
Switch to source view for every stream mode including patch. Even
though patch's source is also static during the stream, source view
matches the editing semantic ("we're working on this file"), and the
settle pulse + automatic return to preview when liveStreamMode clears
handles the closing transition.
Range-level highlight of the actual changed regions remains a
follow-up; this commit only fixes the wrong cross-mode default.
Source view during an AI stream had no real visual feedback beyond the "AI is editing…" badge: new tokens appended at the bottom and the user either saw nothing (because the viewport was scrolled to the top) or watched text disappear off the bottom edge. No insertion indicator, no scroll follow, no signal of where the writer is. Two additions to canvas-code-renderer: - Stick-to-bottom auto-scroll, ported verbatim from the inline message-bubble code-block (STICK_TO_BOTTOM_THRESHOLD_PX=24, scroll listener flips the stickiness flag, code/html change triggers a scrollTop=scrollHeight). Users who scroll up to read earlier output are not yanked back; users at the bottom follow the trailing edge. - Trailing blinking caret while the stream is producing tokens, so the user sees where the next character lands. Gated on a new `isStreaming` prop that canvas-pane only sets true for create/rewrite — patch streams leave streamingContent untouched and a blinking caret on unchanging source would be misleading. Range-level highlight of newly-arrived lines is still deferred; this covers the basic "I can see the AI typing" affordance which auto-scroll + caret were enough to deliver in ChatGPT/Cursor and is the larger return on a small change.
Patch streams left the canvas visually flat: source view stayed static (streamingContent is intentionally untouched until execute), so the "AI is editing…" badge ran for 5–30 seconds with nothing else moving. Users staring at the pane could not tell what was about to change or even confirm work was happening. Surface the partial `search` snippets the model has emitted so the canvas can outline the regions it is targeting. The flow: - Schema gains `streamingPatchTargets: optional<string[]>` on artifacts. Mirrors `streamingContent` semantically — only set during a live stream, cleared on every settle path (finalizeStreamedCreate, applyToolPatches, rewriteArtifact, abortStream, cleanupStaleStreams). - `beginEditStream` initialises the field to `[]` (patch) or `undefined` (rewrite) so a stale list from a prior tool call cannot leak into the next stream. - `updateStreamingContent` accepts the new optional arg. - `stream_state` adds `patchTargetsKey` (JSON.stringify-based signature for cheap dedupe), `shouldFlushPatchTargets`, and `markFlushedPatchTargets` — same 250 ms throttle as content flushes. - `artifact_edit_tool.onInputDelta` parses the streaming patches array, collects only complete `search` strings (skips entries the model is mid-token on, where the partial substring would mark a wrong range), and pushes through the throttle. - `canvas-pane` forwards `streamingPatchTargets` to `CanvasCodeRenderer` only while `liveStreamMode === 'patch'` — create/rewrite continue to rely on caret + auto-scroll over `streamingContent`. - `CanvasCodeRenderer` adds a `highlightTargets` prop. When non-empty it renders plain-text content with `<mark>` overlays on each first non-overlapping match, in lieu of shiki highlighting (the shiki HTML cannot host overlay marks without re-tokenising). Trade-off: lose syntax colour for the ~5–30s patch window in exchange for an explicit "AI is touching these lines" signal. Type-guards used to avoid `as` casts on the streamed patches array per AGENTS.md (`isRecord` + `getString` from lib/utils/type-guards).
Two scroll issues were hiding the patch highlights:
1. The trailing-edge auto-scroll fired on every code/html change,
including the very first mount where stickToBottomRef defaults to
true. For patch streams the source doesn't grow at all, so the only
thing that moved was the initial scroll-to-bottom — sending the user
straight to </body></html> with the marks several screens above.
Gate auto-scroll on `isStreaming` (only the create/rewrite token
flow needs trailing-edge follow); patch source stays where it is.
2. Even with auto-scroll gated, a long source plus a mark in the
middle still leaves the highlight off-screen. Add a one-shot
scrollIntoView({ block: 'center' }) the first time
`highlightTargets` becomes non-empty, so the moment the AI emits
its first patch the canvas seeks to that region. Reset the latch
when targets clear so a second patch tool call in the same artifact
triggers it again.
Mark-only highlighting answered "where" but not "what". Once a search
region scrolled into view the user could see "this is the area" but
still had to wait for the patch to settle to learn what would replace
it. For complex multi-patch edits that meant several seconds of
"something is going to happen here, I just don't know what".
Upgrade the patch streaming visualisation to a true inline diff:
- Schema: rename `streamingPatchTargets: string[]` to
`streamingPatches: Array<{search, replace}>`. Both schema and the
`updateStreamingContent` validator carry full pairs, so the renderer
has everything it needs to draw a before/after preview.
- All clear paths (finalizeStreamedCreate, applyToolPatches,
rewriteArtifact, abortStream, cleanupStaleStreams) zero the new
field. `beginEditStream` initialises it to `[]` for patch mode.
- artifact_edit_tool.onInputDelta emits {search, replace} pairs as
soon as `search` is complete; `replace` may still be empty (model
mid-typing the replacement) and the renderer downgrades to a
strikethrough-only preview until the replacement arrives.
- stream_state's throttle helpers renamed accordingly
(shouldFlushStreamingPatches / markFlushedStreamingPatches);
JSON-stringify signature already covers the richer payload.
- canvas-code-renderer's `highlightTargets` prop becomes
`highlightPatches` and the renderWithHighlights helper becomes
renderWithDiff, emitting `<del>{search}</del><ins>{replace}</ins>`
per matched range. The scrollIntoView latch now seeks the first
`<del>` so the diff lands centred on first paint.
Trade-off as before: shiki syntax colour is suspended for the patch
window because the highlight overlay needs raw text. Few seconds of
plain-coloured source in exchange for a visible diff is the right
balance for this surface.
Note: `streamingPatchTargets` is a removed schema field. Demo / dev
deployments with no in-flight artifact_edit at the moment of redeploy
absorb the change cleanly; if a stream is in flight, restart the
agent action after redeploy.
A fast patch (sub-second emit) flicked through the entire visual flow before the user could read anything: source view appeared with the diff, the badge disappeared, and the canvas yanked back to preview — all in under a second. The diff was correct but invisible. Add a 10s minimum dwell on source view, anchored at stream start. A slow stream that already exceeds 10s releases the lock the moment it settles (no extra wait). A fast stream gets padded out to 10s of total visibility so the user has time to read. Mechanism: - New `keepSourceLock` state and `MIN_SOURCE_VIEW_MS = 10_000`. `showStreamingSource = !isEditing && (isStreaming || keepSourceLock)` so the source/preview switch waits for the lock to clear in addition to the stream itself. - `streamStartedAtRef` records when the lock engaged. On settle, the release `setTimeout` fires `MIN_SOURCE_VIEW_MS - elapsed` later, clamped to >= 0 so already-long streams release immediately. - `lastPatchSnapshotRef` captures `(artifact.content, streamingPatches)` on every render where a patch stream is live. Once the server clears `streamingPatches` at execute time the live data evaporates, but the renderer falls back to the snapshot so the diff stays visible for the rest of the dwell window. Snapshot cleared on lock release. - The renderer reads `sourceCode` and `sourcePatches` (snapshot when locked-and-settled, live data otherwise), so the diff anchors against the *pre*-patch content as long as the lock is up. If the user clicks Edit during the lock window, `isEditing` wins as before — the lock only governs the stream/preview view choice.
Audit of the 10s source-view lock surfaced two real edge cases: 1. Switching artifacts mid-stream fired a spurious settle pulse on the new artifact. `prevLiveStreamModeRef` was global to the component, so when the open artifact changed from "A streaming" to "B static" the transition logic read prev=patch, next=undefined and treated it as A having just settled — pulsing B's content area and running the release timer against a stale start anchor. Track the artifact id alongside the mode and treat any id change as a clean reset (clear the snapshot, lock, pulse, and re-baseline the prev tracker to the new artifact's current mode). 2. The dwell lock was firing for create / rewrite streams too, which over-corrects: those modes already stream content into source view for the duration of the stream — the user has been reading it the whole time. The asymmetric case is patch, where the diff may land in a sub-second tool call before the user can register it. Restrict the lock to `next === 'patch'` so an HTML create stream returns to the iframe preview the moment it settles, while patch keeps its 10s pad.
📝 WalkthroughWalkthroughThis PR introduces a comprehensive artifact-based system that shifts rich content (HTML, SVG, Markdown, code, Mermaid diagrams) from embedded message code blocks to first-class managed artifacts. The system adds two new agent tools ( Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Review rate limit: 4/5 reviews remaining, refill in 12 minutes. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
services/platform/convex/threads/queries.ts (1)
102-110:⚠️ Potential issue | 🟠 MajorRestore the access gate in
getThreadMessages.The helper (
getThreadMessagesinget_thread_messages.ts) does not enforce access control; it only retrieves and formats messages. Any authenticated user can read an arbitrary thread by ID, bypassing thecanAccessThreadgate thatgetThreadMessagesStreamingandgetThreadStatusenforce.Add the access check before delegating to the helper:
Proposed fix
handler: async (ctx, args) => { const authUser = await getAuthUserIdentity(ctx); if (!authUser) { return { messages: [] }; } + const metadata = await canAccessThread(ctx, args.threadId, authUser); + if (!metadata) { + return { messages: [] }; + } return await getThreadMessagesHelper(ctx, args.threadId); },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@services/platform/convex/threads/queries.ts` around lines 102 - 110, The getThreadMessages query currently calls getThreadMessagesHelper without enforcing access control; add the same access gate used by getThreadMessagesStreaming/getThreadStatus by calling canAccessThread with the authenticated user and args.threadId after getAuthUserIdentity returns a user, and only call getThreadMessagesHelper if canAccessThread permits access; otherwise return an empty messages response (or the same denial behavior used in the other queries). Reference getThreadMessages (handler), getThreadMessagesHelper, and canAccessThread when making the change.services/platform/convex/lib/agent_response/generate_response.ts (1)
1697-1707:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winCarry artifact context into timeout recovery.
The timeout-recovery rebuild currently omits
artifactsContext, so a retry after a timeout can return a response that no longer sees the same artifacts as the main flow. Pass the same artifacts context here as well.Suggested shape
const recoveryContext = await buildStructuredContext({ ctx, threadId, additionalContext, parentThreadId, maxHistoryTokens: effectiveMaxHistoryTokens, ragContext: hookData?.ragContext, + artifactsContext: organizationId + ? await buildArtifactsContext(ctx, organizationId, threadId) + : undefined, promptMessageId, });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@services/platform/convex/lib/agent_response/generate_response.ts` around lines 1697 - 1707, The timeout-recovery call to buildStructuredContext currently omits artifactsContext so the recoveryContext can miss artifact state; update the arguments passed to buildStructuredContext (the call that constructs recoveryContext) to include the same artifactsContext variable used in the main flow (pass artifactsContext: artifactsContext or equivalent), ensuring recoveryContext receives the artifact context alongside ctx, threadId, additionalContext, parentThreadId, maxHistoryTokens/effectiveMaxHistoryTokens, ragContext, and promptMessageId.services/platform/app/features/chat/components/message-bubble.tsx (1)
409-435:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRender artifact pills even when assistant text is empty.
MessageArtifactPillsis currently gated by thedisplayContentbranch, so assistant messages that only contain tool activity won’t show pills despite touching artifacts.Suggested fix
- ) : displayContent ? ( + ) : displayContent ? ( <div className="text-sm leading-5"> <div ref={isUser ? contentRef : undefined} className={cn( isUser && !isExpanded && 'max-h-96 overflow-hidden', )} > {isUser ? ( <p className="break-words whitespace-pre-wrap"> {displayContent} </p> ) : ( <CitationsContext.Provider value={citationsContextValue}> <StructuredMessage text={assistantContent} isStreaming={!!isAssistantStreaming} onSendFollowUp={onSendFollowUp} /> - {organizationId && message.threadId && ( - <MessageArtifactPills - organizationId={organizationId} - threadId={message.threadId} - messageId={message.id} - /> - )} </CitationsContext.Provider> )} </div> + {!isUser && organizationId && message.threadId && ( + <MessageArtifactPills + organizationId={organizationId} + threadId={message.threadId} + messageId={message.id} + /> + )}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@services/platform/app/features/chat/components/message-bubble.tsx` around lines 409 - 435, The artifact pills are only rendered inside the assistant displayContent branch, so assistant messages with no text (only tool activity) miss MessageArtifactPills; update the rendering in message-bubble.tsx so for non-user messages (isUser === false) you always render MessageArtifactPills when organizationId && message.threadId are present regardless of displayContent — move the <MessageArtifactPills ... /> out of the displayContent-only branch (or duplicate the check) and ensure it remains inside the CitationsContext.Provider/assistant rendering path alongside StructuredMessage/assistantContent so message.id, message.threadId and organizationId are passed unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/platform/chat/basics.md`:
- Line 64: Update the sentence fragment that currently reads "a markdown
document" so the format name is properly capitalized as "a Markdown document";
locate the sentence starting "When the AI generates a runnable HTML page, an
SVG, a Mermaid diagram, a markdown document, or a code snippet..." and replace
"markdown" with "Markdown" to reflect the proper noun.
In `@docs/platform/workspace/canvas.md`:
- Line 6: Update the capitalization of the word "markdown" to "Markdown" in the
Canvas description and the other occurrence noted (line 25 equivalent); edit the
sentence in the docs text that lists artifact types so "markdown documents"
becomes "Markdown documents" to ensure consistent proper-noun usage across the
file (look for the phrase "runnable HTML, SVG illustrations, Mermaid diagrams,
markdown documents, or code snippets" and the other matching phrase to correct).
- Line 6: Replace the current one-line opening about Canvas with a 2–4 sentence
concept paragraph that succinctly defines what Canvas is, who it’s for, and why
it exists; keep the existing keywords like "Canvas" and "artifacts" and mention
the main use cases (viewing/editing AI-generated runnable HTML, SVG, Mermaid,
markdown, code) and the benefit of stable artifact identities so readers
understand incremental revision and collaboration value. Ensure the new
paragraph stays neutral and high-level (no how-to), 2–4 sentences total, and
leads naturally into the existing detailed description that follows.
In
`@services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx`:
- Around line 52-97: renderWithDiff currently finds every patch against the
original code which breaks sequential semantics; change it to apply patches
sequentially against a mutable current string and record ranges mapped back to
the original so later patches can match text introduced by earlier ones.
Concretely: in renderWithDiff iterate patches in order, maintain a working
string (e.g., current = code) plus an indexMap array mapping current indices to
original indices, for each patch find patch.search in current, use
indexMap[start] and indexMap[end-1] to compute the original start/end for the
DiffRange, push the DiffRange with replace text, then mutate current by
replacing the matched range and update indexMap to reflect the new characters
(mapping inserted characters to the original start or to null if you prefer)
before continuing; then the existing ranges sort/slice/render logic (ranges,
parts, del/ins rendering) can remain the same.
- Around line 157-174: The effect that autoscrolls the first <del> only depends
on patchesCount so it misses content changes; update the useEffect (the block
using previouslyHighlightedRef, preRef, firstDel and patchesCount) to also
re-run when the patch content changes (e.g., include highlightPatches or a
stable stringified/ID representation of its text in the dependency array) so
streamed/updated patches retrigger the scroll, and before calling
firstDel.scrollIntoView gate the options on the user's reduced-motion preference
(use window.matchMedia('(prefers-reduced-motion: reduce)') to avoid using
behavior: 'smooth' when reduced motion is requested).
In `@services/platform/app/features/chat/components/canvas/canvas-pane.tsx`:
- Around line 276-295: The editor is being initialized from the loading fallback
(settledContent === ''), causing editBuffer to be seeded with an empty string
and allowing saves that wipe content; change the logic so you either block
entering edit mode until artifact is loaded (artifact !== undefined &&
artifact.content !== undefined) or lazily seed editBuffer only the first time
real content arrives (only set editBuffer from settledContent when editBuffer is
null/undefined and artifact.content is non-empty). Specifically update the code
paths that read settledContent, previewContent, editorContent, displayedContent
and isEditing (and the second similar occurrence of editBuffer initialization
noted in the review) so that edit mode activation or editBuffer assignment
depends on a loaded artifact rather than the '' fallback.
- Around line 223-234: The preserved editBuffer path can overwrite AI-settled
revisions because handleApply calls userEdit without an expectedRevision; change
this by adding revision-based OCC or buffer rebasing: when submitting via
handleApply (and in the analogous code at the other occurrence), include the
artifact.revision (or expectedRevision) in the userEdit mutation and
reject/return an error if the server reports a revision mismatch, then detect
that case in the UI and either prompt the user to reconcile or automatically
rebase by merging the AI-settled changes into editBuffer (clear or merge the
buffer) before retrying; ensure you reference editBuffer, handleApply, userEdit,
and artifact.liveStreamMode when locating and updating the logic.
In `@services/platform/convex/artifacts/queries.ts`:
- Around line 57-76: The listRevisions query currently streams every revision
and can return an unbounded payload; add a hard cap by introducing a
MAX_REVISIONS constant (e.g. 100) and apply it to the DB query (the
ctx.db.query('artifactRevisions').withIndex('by_artifact', ...).order('asc')
call) using the query limiter (e.g. .limit or .take depending on your DB API) so
at most MAX_REVISIONS rows are loaded and returned from listRevisions; keep the
existing auth checks (getAuthUserIdentity, canAccessThread) and return the
limited rows array, and document the cap via the constant name to make it easy
to adjust.
In `@services/platform/convex/lib/agent_response/generate_response.ts`:
- Around line 829-831: The call to buildArtifactsContext currently runs on the
critical path and will abort response generation if it rejects; change both the
initial call (where artifactsContext is set from buildArtifactsContext(ctx,
organizationId, threadId)) and the continue/retry branch (the same call around
lines ~1464-1466) to be fail-open: wrap each buildArtifactsContext(...)
invocation in a try/catch, on error log a warning (use the module's existing
logger/ctx.logger) including the caught error and context (organizationId,
threadId), and set artifactsContext to undefined so generation proceeds instead
of failing.
In `@services/platform/convex/lib/context_management/build_artifacts_context.ts`:
- Around line 54-79: The loop in build_artifacts_context currently adds an
omitted stub for every remaining artifact once MAX_TOTAL_BYTES is exceeded, so
the budget isn't enforced; update the for-loop that iterates over ordered (and
the logic that pushes `<artifact ... omitted="true" />` into blocks) to account
for the byte-size of each stub before pushing it (i.e., charge the size of the
omitted stub against totalBytes) and stop iterating when the budget would be
exceeded, or instead push a single collapsed summary stub (e.g., `<artifact
omitted_summary count="N" />`) and break; ensure this change touches the
MAX_TOTAL_BYTES check and the code that builds omitted stubs so the helper
always enforces the hard cap.
In `@services/platform/messages/fr.json`:
- Line 2241: Update the French toast value for the "streamingDuringEdit" key to
use informal "tu/ton" form: replace "votre" with "ton" and the formal imperative
"Cliquez" with the informal "Clique" (e.g., "L'agent met à jour cet artéfact —
ton brouillon est conservé. Clique sur Annuler pour le rejeter."). Ensure only
the string value for streamingDuringEdit in services/platform/messages/fr.json
is changed.
---
Outside diff comments:
In `@services/platform/app/features/chat/components/message-bubble.tsx`:
- Around line 409-435: The artifact pills are only rendered inside the assistant
displayContent branch, so assistant messages with no text (only tool activity)
miss MessageArtifactPills; update the rendering in message-bubble.tsx so for
non-user messages (isUser === false) you always render MessageArtifactPills when
organizationId && message.threadId are present regardless of displayContent —
move the <MessageArtifactPills ... /> out of the displayContent-only branch (or
duplicate the check) and ensure it remains inside the
CitationsContext.Provider/assistant rendering path alongside
StructuredMessage/assistantContent so message.id, message.threadId and
organizationId are passed unchanged.
In `@services/platform/convex/lib/agent_response/generate_response.ts`:
- Around line 1697-1707: The timeout-recovery call to buildStructuredContext
currently omits artifactsContext so the recoveryContext can miss artifact state;
update the arguments passed to buildStructuredContext (the call that constructs
recoveryContext) to include the same artifactsContext variable used in the main
flow (pass artifactsContext: artifactsContext or equivalent), ensuring
recoveryContext receives the artifact context alongside ctx, threadId,
additionalContext, parentThreadId, maxHistoryTokens/effectiveMaxHistoryTokens,
ragContext, and promptMessageId.
In `@services/platform/convex/threads/queries.ts`:
- Around line 102-110: The getThreadMessages query currently calls
getThreadMessagesHelper without enforcing access control; add the same access
gate used by getThreadMessagesStreaming/getThreadStatus by calling
canAccessThread with the authenticated user and args.threadId after
getAuthUserIdentity returns a user, and only call getThreadMessagesHelper if
canAccessThread permits access; otherwise return an empty messages response (or
the same denial behavior used in the other queries). Reference getThreadMessages
(handler), getThreadMessagesHelper, and canAccessThread when making the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: a32b3407-788e-494c-86d0-0c650c622c53
⛔ Files ignored due to path filters (1)
services/platform/convex/_generated/api.d.tsis excluded by!**/_generated/**
📒 Files selected for processing (47)
docs/de/platform/chat/basics.mddocs/de/platform/workspace/canvas.mddocs/de/tutorials/member/chat-effectively.mddocs/fr/platform/chat/basics.mddocs/fr/platform/workspace/canvas.mddocs/fr/tutorials/member/chat-effectively.mddocs/platform/chat/basics.mddocs/platform/workspace/canvas.mddocs/tutorials/member/chat-effectively.mdexamples/agents/chat-agent.jsonservices/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsxservices/platform/app/features/chat/components/canvas/artifact-bar.tsxservices/platform/app/features/chat/components/canvas/canvas-code-renderer.tsxservices/platform/app/features/chat/components/canvas/canvas-context.tsxservices/platform/app/features/chat/components/canvas/canvas-pane.tsxservices/platform/app/features/chat/components/message-bubble.tsxservices/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsxservices/platform/app/features/chat/components/message-bubble/code-block.tsxservices/platform/app/features/chat/components/message-bubble/message-content-context.tsxservices/platform/app/features/chat/utils/replace-code-block.tsservices/platform/app/routes/dashboard/$id/chat.tsxservices/platform/convex/agent_tools/artifacts/__tests__/apply_patches.test.tsservices/platform/convex/agent_tools/artifacts/apply_patches.tsservices/platform/convex/agent_tools/artifacts/artifact_create_tool.tsservices/platform/convex/agent_tools/artifacts/artifact_edit_tool.tsservices/platform/convex/agent_tools/artifacts/shared.tsservices/platform/convex/agent_tools/artifacts/stream_state.tsservices/platform/convex/agent_tools/tool_names.tsservices/platform/convex/agent_tools/tool_registry.tsservices/platform/convex/artifacts/internal_mutations.tsservices/platform/convex/artifacts/internal_queries.tsservices/platform/convex/artifacts/mutations.tsservices/platform/convex/artifacts/queries.tsservices/platform/convex/artifacts/schema.tsservices/platform/convex/crons.tsservices/platform/convex/lib/agent_response/__tests__/generate_response_error_handling.test.tsservices/platform/convex/lib/agent_response/generate_response.tsservices/platform/convex/lib/context_management/build_artifacts_context.tsservices/platform/convex/lib/context_management/message_formatter.tsservices/platform/convex/lib/context_management/structured_context_builder.tsservices/platform/convex/lib/rls/auth/can_access_thread.tsservices/platform/convex/schema.tsservices/platform/convex/threads/mutations.tsservices/platform/convex/threads/queries.tsservices/platform/messages/de.jsonservices/platform/messages/en.jsonservices/platform/messages/fr.json
💤 Files with no reviewable changes (3)
- services/platform/app/features/chat/components/message-bubble/message-content-context.tsx
- services/platform/convex/threads/mutations.ts
- services/platform/app/features/chat/utils/replace-code-block.ts
| ## Canvas | ||
|
|
||
| When the AI generates a code block, HTML snippet, Mermaid diagram, or markdown, click **Open in Canvas** to view it in a dedicated side pane. Canvas provides syntax highlighting, live preview, editing, and export. You can edit content and apply changes back to the conversation. | ||
| When the AI generates a runnable HTML page, an SVG, a Mermaid diagram, a markdown document, or a code snippet, it creates an **artifact** that appears as a card in the Artifacts bar above the chat and auto-opens in the Canvas pane. Canvas provides live preview, source editing, and export. The AI can revise the artifact in place across turns — small fixes don't require re-generating the whole document. |
There was a problem hiding this comment.
Capitalize “Markdown”.
This is the proper noun for the format, so the lower-case spelling reads inconsistently in user-facing docs.
🧰 Tools
🪛 LanguageTool
[uncategorized] ~64-~64: Did you mean the formatting language “Markdown” (= proper noun)?
Context: ...HTML page, an SVG, a Mermaid diagram, a markdown document, or a code snippet, it creates...
(MARKDOWN_NNP)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/platform/chat/basics.md` at line 64, Update the sentence fragment that
currently reads "a markdown document" so the format name is properly capitalized
as "a Markdown document"; locate the sentence starting "When the AI generates a
runnable HTML page, an SVG, a Mermaid diagram, a markdown document, or a code
snippet..." and replace "markdown" with "Markdown" to reflect the proper noun.
| --- | ||
|
|
||
| Canvas is a side pane that opens next to the chat, giving you a focused workspace for viewing and editing AI-generated content. Instead of scrolling through code blocks in the conversation, you can open them in Canvas for syntax highlighting, live preview, editing, and export. | ||
| Canvas is a side pane that opens next to the chat for viewing and editing AI-generated **artifacts** — runnable HTML, SVG illustrations, Mermaid diagrams, markdown documents, or code snippets. Each artifact lives outside the message stream and has a stable identity across the whole conversation, so the AI can revise it incrementally instead of re-emitting the whole document on every fix. |
There was a problem hiding this comment.
Capitalize “Markdown” as a proper noun.
Use “Markdown” (not “markdown”) in prose/table labels for consistency and terminology quality.
Also applies to: 25-25
🧰 Tools
🪛 LanguageTool
[uncategorized] ~6-~6: Did you mean the formatting language “Markdown” (= proper noun)?
Context: ...L, SVG illustrations, Mermaid diagrams, markdown documents, or code snippets. Each artif...
(MARKDOWN_NNP)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/platform/workspace/canvas.md` at line 6, Update the capitalization of
the word "markdown" to "Markdown" in the Canvas description and the other
occurrence noted (line 25 equivalent); edit the sentence in the docs text that
lists artifact types so "markdown documents" becomes "Markdown documents" to
ensure consistent proper-noun usage across the file (look for the phrase
"runnable HTML, SVG illustrations, Mermaid diagrams, markdown documents, or code
snippets" and the other matching phrase to correct).
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Expand the opening into a 2–4 sentence concept paragraph.
The page now opens with a single sentence; repo docs standards require a short 2–4 sentence concept intro that explains what Canvas is, who it’s for, and why it exists.
As per coding guidelines: “Every documentation page must open with a 2–4 sentence concept paragraph explaining what the feature is, who it is for, and why it exists.”
🧰 Tools
🪛 LanguageTool
[uncategorized] ~6-~6: Did you mean the formatting language “Markdown” (= proper noun)?
Context: ...L, SVG illustrations, Mermaid diagrams, markdown documents, or code snippets. Each artif...
(MARKDOWN_NNP)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/platform/workspace/canvas.md` at line 6, Replace the current one-line
opening about Canvas with a 2–4 sentence concept paragraph that succinctly
defines what Canvas is, who it’s for, and why it exists; keep the existing
keywords like "Canvas" and "artifacts" and mention the main use cases
(viewing/editing AI-generated runnable HTML, SVG, Mermaid, markdown, code) and
the benefit of stable artifact identities so readers understand incremental
revision and collaboration value. Ensure the new paragraph stays neutral and
high-level (no how-to), 2–4 sentences total, and leads naturally into the
existing detailed description that follows.
| function renderWithDiff( | ||
| code: string, | ||
| patches: readonly { search: string; replace: string }[], | ||
| ): ReactNode[] { | ||
| const ranges: DiffRange[] = []; | ||
| for (const patch of patches) { | ||
| if (!patch.search) continue; | ||
| const idx = code.indexOf(patch.search); | ||
| if (idx === -1) continue; | ||
| const start = idx; | ||
| const end = idx + patch.search.length; | ||
| // Skip overlap with an already-claimed range. First-write-wins keeps | ||
| // the visualisation deterministic when search snippets happen to nest. | ||
| const overlaps = ranges.some((r) => !(end <= r.start || start >= r.end)); | ||
| if (!overlaps) ranges.push({ start, end, replace: patch.replace }); | ||
| } | ||
| if (ranges.length === 0) return [code]; | ||
| ranges.sort((a, b) => a.start - b.start); | ||
| const parts: ReactNode[] = []; | ||
| let cursor = 0; | ||
| for (let i = 0; i < ranges.length; i += 1) { | ||
| const r = ranges[i]; | ||
| if (cursor < r.start) parts.push(code.slice(cursor, r.start)); | ||
| parts.push( | ||
| <del | ||
| key={`del-${r.start}`} | ||
| className="bg-destructive/15 text-destructive/90 decoration-destructive/60 rounded-sm decoration-2" | ||
| > | ||
| {code.slice(r.start, r.end)} | ||
| </del>, | ||
| ); | ||
| if (r.replace.length > 0) { | ||
| parts.push( | ||
| <ins | ||
| key={`ins-${r.start}`} | ||
| className="bg-success/15 text-success-foreground rounded-sm px-0.5 no-underline" | ||
| > | ||
| {r.replace} | ||
| </ins>, | ||
| ); | ||
| } | ||
| cursor = r.end; | ||
| } | ||
| if (cursor < code.length) parts.push(code.slice(cursor)); | ||
| return parts; | ||
| } |
There was a problem hiding this comment.
Mirror the backend's sequential patch semantics.
renderWithDiff looks up every search against the original code, so later patches that depend on text introduced by earlier patches never render correctly. That makes the patch preview diverge from applyPatches.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx`
around lines 52 - 97, renderWithDiff currently finds every patch against the
original code which breaks sequential semantics; change it to apply patches
sequentially against a mutable current string and record ranges mapped back to
the original so later patches can match text introduced by earlier ones.
Concretely: in renderWithDiff iterate patches in order, maintain a working
string (e.g., current = code) plus an indexMap array mapping current indices to
original indices, for each patch find patch.search in current, use
indexMap[start] and indexMap[end-1] to compute the original start/end for the
DiffRange, push the DiffRange with replace text, then mutate current by
replacing the matched range and update indexMap to reflect the new characters
(mapping inserted characters to the original start or to null if you prefer)
before continuing; then the existing ranges sort/slice/render logic (ranges,
parts, del/ins rendering) can remain the same.
| // When the first patch target appears, scroll the matched region into view | ||
| // so the user actually sees the diff — patch streams don't trigger the | ||
| // auto-follow above (no content growth) and the source might be long. | ||
| const patchesCount = highlightPatches?.length ?? 0; | ||
| const previouslyHighlightedRef = useRef(false); | ||
| useEffect(() => { | ||
| if (patchesCount === 0) { | ||
| previouslyHighlightedRef.current = false; | ||
| return; | ||
| } | ||
| if (previouslyHighlightedRef.current) return; | ||
| previouslyHighlightedRef.current = true; | ||
| const pre = preRef.current; | ||
| const firstDel = pre?.querySelector('del'); | ||
| if (firstDel instanceof HTMLElement) { | ||
| firstDel.scrollIntoView({ behavior: 'smooth', block: 'center' }); | ||
| } | ||
| }, [patchesCount]); |
There was a problem hiding this comment.
Retry the first diff scroll when patch text changes, and avoid smooth scrolling for reduced-motion users.
The auto-scroll effect only watches patchesCount, so once a patch list exists it can miss the moment when a streamed search finally becomes matchable and the <del> node appears. Re-run this effect off the patch content itself, and gate the smooth scrollIntoView call on reduced motion.
As per coding guidelines, Respect prefers-reduced-motion on every animation.
Suggested fix
- }, [patchesCount]);
+ }, [highlightPatches]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx`
around lines 157 - 174, The effect that autoscrolls the first <del> only depends
on patchesCount so it misses content changes; update the useEffect (the block
using previouslyHighlightedRef, preRef, firstDel and patchesCount) to also
re-run when the patch content changes (e.g., include highlightPatches or a
stable stringified/ID representation of its text in the dependency array) so
streamed/updated patches retrigger the scroll, and before calling
firstDel.scrollIntoView gate the options on the user's reduced-motion preference
(use window.matchMedia('(prefers-reduced-motion: reduce)') to avoid using
behavior: 'smooth' when reduced motion is requested).
| // Notify the user once when a stream starts on top of an open edit. The | ||
| // edit buffer is preserved; they can keep typing or hit Cancel to discard. | ||
| const streamingDuringEditNotifiedRef = useRef(false); | ||
| useEffect(() => { | ||
| const liveDuringEdit = isEditing && artifact?.liveStreamMode !== undefined; | ||
| if (liveDuringEdit && !streamingDuringEditNotifiedRef.current) { | ||
| streamingDuringEditNotifiedRef.current = true; | ||
| toast({ title: t('canvas.streamingDuringEdit') }); | ||
| } else if (!liveDuringEdit) { | ||
| streamingDuringEditNotifiedRef.current = false; | ||
| } | ||
| }, [isEditing, artifact?.liveStreamMode, toast, t]); |
There was a problem hiding this comment.
Preserved edits can clobber AI-settled revisions.
This path intentionally keeps the user's old editBuffer when an AI stream starts, then handleApply later submits that full buffer through userEdit. Because the save mutation does not carry an expectedRevision, applying after the stream settles overwrites the AI's newer content instead of surfacing a conflict. Please add revision-based OCC for user saves, or clear/rebase the buffer when a live stream lands.
Also applies to: 381-395
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/app/features/chat/components/canvas/canvas-pane.tsx` around
lines 223 - 234, The preserved editBuffer path can overwrite AI-settled
revisions because handleApply calls userEdit without an expectedRevision; change
this by adding revision-based OCC or buffer rebasing: when submitting via
handleApply (and in the analogous code at the other occurrence), include the
artifact.revision (or expectedRevision) in the userEdit mutation and
reject/return an error if the server reports a revision mismatch, then detect
that case in the UI and either prompt the user to reconcile or automatically
rebase by merging the AI-settled changes into editBuffer (clear or merge the
buffer) before retrying; ensure you reference editBuffer, handleApply, userEdit,
and artifact.liveStreamMode when locating and updating the logic.
| args: { | ||
| artifactId: v.id('artifacts'), | ||
| content: v.string(), | ||
| }, |
There was a problem hiding this comment.
Add expected revision checks to prevent stale user overwrites.
userEdit currently writes based on whatever revision is loaded in this call, without validating the caller’s edit base. That allows stale editor buffers to overwrite newer content without a conflict signal.
Suggested fix
export const userEdit = mutation({
args: {
artifactId: v.id('artifacts'),
content: v.string(),
+ expectedRevision: v.number(),
},
returns: v.object({ revision: v.number() }),
handler: async (ctx, args) => {
@@
const artifact = await ctx.db.get(args.artifactId);
@@
+ if (artifact.revision !== args.expectedRevision) {
+ throw new ConvexError({
+ code: 'stale_revision',
+ message: `Artifact changed to revision ${artifact.revision}. Reload and retry.`,
+ });
+ }
+
const nextRevision = artifact.revision + 1;Also applies to: 51-67
| export const listRevisions = query({ | ||
| args: { artifactId: v.id('artifacts') }, | ||
| handler: async (ctx, { artifactId }): Promise<Doc<'artifactRevisions'>[]> => { | ||
| const authUser = await getAuthUserIdentity(ctx); | ||
| if (!authUser) return []; | ||
| const artifact = await ctx.db.get(artifactId); | ||
| if (!artifact) return []; | ||
| const metadata = await canAccessThread(ctx, artifact.threadId, authUser); | ||
| if (!metadata || metadata.organizationId !== artifact.organizationId) { | ||
| return []; | ||
| } | ||
| const rows: Doc<'artifactRevisions'>[] = []; | ||
| for await (const row of ctx.db | ||
| .query('artifactRevisions') | ||
| .withIndex('by_artifact', (q) => q.eq('artifactId', artifactId)) | ||
| .order('asc')) { | ||
| rows.push(row); | ||
| } | ||
| return rows; | ||
| }, |
There was a problem hiding this comment.
listRevisions needs a hard bound/pagination to prevent unbounded payloads.
This currently loads and returns every revision doc (including full content) for an artifact. For frequently edited artifacts, this can balloon memory and response size.
💡 Suggested direction
export const listRevisions = query({
- args: { artifactId: v.id('artifacts') },
+ args: {
+ artifactId: v.id('artifacts'),
+ paginationOpts: v.optional(paginationOptsValidator),
+ },
handler: async (ctx, { artifactId
+ , paginationOpts
}): Promise<Doc<'artifactRevisions'>[]> => {
@@
- const rows: Doc<'artifactRevisions'>[] = [];
+ const MAX_LIST_REVISIONS = 100;
+ const limit = Math.min(
+ paginationOpts?.numItems ?? MAX_LIST_REVISIONS,
+ MAX_LIST_REVISIONS,
+ );
+ const rows: Doc<'artifactRevisions'>[] = [];
for await (const row of ctx.db
.query('artifactRevisions')
.withIndex('by_artifact', (q) => q.eq('artifactId', artifactId))
- .order('asc')) {
+ .order('desc')) {
rows.push(row);
+ if (rows.length >= limit) break;
}
- return rows;
+ return rows.toReversed();
},
});As per coding guidelines: “Listing queries should not accept limit/cursor unless a page has unbounded rows.”
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/artifacts/queries.ts` around lines 57 - 76, The
listRevisions query currently streams every revision and can return an unbounded
payload; add a hard cap by introducing a MAX_REVISIONS constant (e.g. 100) and
apply it to the DB query (the
ctx.db.query('artifactRevisions').withIndex('by_artifact', ...).order('asc')
call) using the query limiter (e.g. .limit or .take depending on your DB API) so
at most MAX_REVISIONS rows are loaded and returned from listRevisions; keep the
existing auth checks (getAuthUserIdentity, canAccessThread) and return the
limited rows array, and document the cap via the constant name to make it easy
to adjust.
| const artifactsContext = organizationId | ||
| ? await buildArtifactsContext(ctx, organizationId, threadId) | ||
| : undefined; |
There was a problem hiding this comment.
Treat artifact-context lookup as best-effort.
buildArtifactsContext is now on the critical path for both the initial generation and the continue/retry branch. If it rejects, the whole response fails even though artifact context is additive. Please catch the error, log a warning, and fall back to undefined instead of aborting generation.
Suggested shape
- const artifactsContext = organizationId
- ? await buildArtifactsContext(ctx, organizationId, threadId)
- : undefined;
+ let artifactsContext;
+ if (organizationId) {
+ try {
+ artifactsContext = await buildArtifactsContext(ctx, organizationId, threadId);
+ } catch (err) {
+ console.warn('[generateAgentResponse] artifacts context lookup failed:', err);
+ }
+ }Apply the same fail-open handling in the continue/retry branch as well.
Also applies to: 1464-1466
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/lib/agent_response/generate_response.ts` around
lines 829 - 831, The call to buildArtifactsContext currently runs on the
critical path and will abort response generation if it rejects; change both the
initial call (where artifactsContext is set from buildArtifactsContext(ctx,
organizationId, threadId)) and the continue/retry branch (the same call around
lines ~1464-1466) to be fail-open: wrap each buildArtifactsContext(...)
invocation in a try/catch, on error log a warning (use the module's existing
logger/ctx.logger) including the caught error and context (organizationId,
threadId), and set artifactsContext to undefined so generation proceeds instead
of failing.
| let totalBytes = 0; | ||
| const blocks: string[] = []; | ||
| for (const artifact of ordered) { | ||
| const body = sanitizeArtifactBody(truncateArtifactBody(artifact.content)); | ||
| const bytes = body.length; | ||
| if (totalBytes + bytes > MAX_TOTAL_BYTES) { | ||
| blocks.push( | ||
| `<artifact id="${artifact._id}" type="${artifact.type}" title=${JSON.stringify(artifact.title)} revision="${artifact.revision}" omitted="true" />`, | ||
| ); | ||
| continue; | ||
| } | ||
| totalBytes += bytes; | ||
| const langAttr = artifact.language | ||
| ? ` language=${JSON.stringify(artifact.language)}` | ||
| : ''; | ||
| blocks.push( | ||
| `<artifact id="${artifact._id}" type="${artifact.type}"${langAttr} title=${JSON.stringify(artifact.title)} revision="${artifact.revision}">\n${body}\n</artifact>`, | ||
| ); | ||
| } | ||
| blocks.reverse(); | ||
|
|
||
| return [ | ||
| blocks.join('\n\n'), | ||
| '', | ||
| 'You may modify any of these via the `artifact_edit` tool — prefer `mode: "patch"` for small changes. Do NOT re-emit an artifact via `artifact_create`; that creates a duplicate. Snippets in <artifact> bodies appear verbatim and can be used as `search` blocks for patches.', | ||
| ].join('\n'); |
There was a problem hiding this comment.
MAX_TOTAL_BYTES is still unbounded once omission starts.
After the first artifact that does not fit, this loop keeps appending <artifact ... omitted="true" /> blocks for every remaining row without charging those bytes against the budget. A thread with many artifacts can therefore still generate a very large prompt section, which defeats the hard cap this helper is supposed to provide. Count the stub size too, or collapse the remainder into a single summary stub and stop iterating.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/lib/context_management/build_artifacts_context.ts`
around lines 54 - 79, The loop in build_artifacts_context currently adds an
omitted stub for every remaining artifact once MAX_TOTAL_BYTES is exceeded, so
the budget isn't enforced; update the for-loop that iterates over ordered (and
the logic that pushes `<artifact ... omitted="true" />` into blocks) to account
for the byte-size of each stub before pushing it (i.e., charge the size of the
omitted stub against totalBytes) and stop iterating when the budget would be
exceeded, or instead push a single collapsed summary stub (e.g., `<artifact
omitted_summary count="N" />`) and break; ensure this change touches the
MAX_TOTAL_BYTES check and the code that builds omitted stubs so the helper
always enforces the hard cap.
| "streamingWriting": "L'IA écrit…", | ||
| "streamingPatch": "L'IA modifie…", | ||
| "cancel": "Annuler la modification", | ||
| "streamingDuringEdit": "L'agent met à jour cet artéfact — votre brouillon est conservé. Cliquez sur Annuler pour le rejeter." |
There was a problem hiding this comment.
Use informal French here.
votre and Cliquez break the repo's tu-only rule for French UI copy. Please switch this toast to informal form.
As per coding guidelines, Use informal form across all languages — du in German, tu in French. Never Sie or vous.
Suggested fix
- "streamingDuringEdit": "L'agent met à jour cet artéfact — votre brouillon est conservé. Cliquez sur Annuler pour le rejeter."
+ "streamingDuringEdit": "L'agent met à jour cet artéfact — ton brouillon est conservé. Clique sur Annuler pour le rejeter."📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "streamingDuringEdit": "L'agent met à jour cet artéfact — votre brouillon est conservé. Cliquez sur Annuler pour le rejeter." | |
| "streamingDuringEdit": "L'agent met à jour cet artéfact — ton brouillon est conservé. Clique sur Annuler pour le rejeter." |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/messages/fr.json` at line 2241, Update the French toast
value for the "streamingDuringEdit" key to use informal "tu/ton" form: replace
"votre" with "ton" and the formal imperative "Cliquez" with the informal
"Clique" (e.g., "L'agent met à jour cet artéfact — ton brouillon est conservé.
Clique sur Annuler pour le rejeter."). Ensure only the string value for
streamingDuringEdit in services/platform/messages/fr.json is changed.
Throttle parsePartialJson once the row is initialised and scale flush intervals with content size on the server; defer code/diff renders and skip shiki during streams on the client so fast streams stop bursting.
Bundled fixes from the multi-agent perf review on this branch:
- Hunk-only patch view replaces full-source renderWithDiff — the
client walks only the changed regions plus ±3 context lines per
Convex push. Adds build-hunks.ts (15 vitest cases, 1MB×5-patch
perf guard) and patch-hunk-view.tsx rendering a unified-diff flow
in a single <pre> with thin line-number gutters between regions.
- Shiki size guard at 64KB plus removal of redundant DOMPurify on
Shiki output — the dwell-end transition no longer freezes the
main thread on large artifacts.
- listByThread projects to metadata-only ArtifactListItem — push
payload drops from up to 40MB to a few KB.
- liveStreamStartedAt heartbeat throttled to 5s (cron threshold is
60s) so subscriptions stop invalidating on every chunk.
- editBuffer lifted from CanvasContext to local state in
CanvasPaneComponent — keystrokes no longer fan out to ArtifactBar,
MessageArtifactPills, and PlanPane.
- Code-editor textarea switched from controlled to uncontrolled
(defaultValue) — eliminates per-keystroke value-attribute diff
for large content.
- Auto-follow pre.scrollTop write rAF-coalesced with delta-zero
guard so create/rewrite streams don't force layout per chunk.
- paginationOpts validator on listByThread replaced with explicit
optional limit; silent truncation past 50 is now a documented cap
rather than a misleading API surface.
- backdrop-blur dropped from fullscreen header during streaming.
Drops the now-unused renderWithDiff, previouslyHighlightedRef and
its artifact-switch-leak scrollIntoView effect. Empty catch {} in
lib/utils/shiki.ts replaced with console.warn per CLAUDE.md.
- Stream artifact content client-side via the agent SDK's tool-input-delta rows: artifacts now carry the producing toolCallId, the canvas decodes partial JSON content from streamDeltas, and the create/edit tools no longer round-trip per-chunk content through streamingContent. Adaptive parse-gate and coalesced flushes keep 100 KB+ artifacts smooth instead of bursty. - Vendor reveal.js, Chart.js, D3, @tailwindcss/browser, and GSAP under public/canvas-libs/ so LLM-generated HTML can reach them same-origin. artifact_create's tool description points the model at these paths and system font stacks — preview iframe stays air-gappable and avoids third-party transfer of end-user IP/UA/Referer. Operators who need external CDNs can opt in via CANVAS_PREVIEW_CSP_EXTRA_ORIGINS. - When forking a thread, snapshot each in-scope artifact at its latest revision before the edited message into the new branch (new 'branch' edit kind). Post-fork edits in the parent no longer bleed into the branch.
Drag handler now writes width directly to the DOM under rAF and uses a fullscreen overlay to capture mousemove, so the divider no longer freezes when the cursor crosses the preview iframe and large artifacts no longer re-render on every pixel.
Shiki ships no `text` / `plaintext` grammar file — `text` is a built-in no-highlight pass — so trying to dynamic-import `shiki/langs/text.mjs` fell into the catch path and logged a warning on every plaintext render. Alias `plaintext` / `txt` to `text` and short-circuit the load attempt.
The canvas iframe is sandboxed with `connect-src 'self'`, so runtime `fetch` / WebSocket / XHR to any host fails silently. The existing tool description already warned about external static resources (CDN, fonts) but said nothing about runtime APIs, and the model kept generating "translate the user's input" / "score this answer" pages that look real but never actually call anything. Spell out the constraint, and tell the model to either keep such features in chat or redesign without runtime intelligence — and explicitly forbid faking it with hardcoded lookup tables, since that produces hollow demo-shaped pages.
Use vite's re-exported Connect namespace instead of importing from the transitive `connect` package, and drop the unused export on MAX_SHIKI_BYTES.
The artifact iframe is sandboxed allow-scripts without allow-same-origin,
giving it an opaque ("null") origin. AI-generated HTML that touches
localStorage / sessionStorage hits a synchronous SecurityError at the
property-access level, which a try/catch around getItem can't recover.
Inject a Proxy-backed in-memory Storage shim into the wrapper HEAD so
both method calls (getItem/setItem) and bracket notation route through
the same Map, capped at 5 MiB with a real QuotaExceededError. Install
via Object.defineProperty with a direct-assignment fallback; both paths
log if they fail rather than silently regress.
Update the artifact_create tool prompt so the LLM treats storage as
volatile working memory and stops emitting "saved" / "记忆已保存" UI
copy that would mislead users about persistence — closes the GDPR Art
5(1)(a) transparency gap that the volatile-storage model would
otherwise create.
Tests: vm-isolated unit suite covers the API contract (round-trip,
bracket notation, coercion, quota, store independence, byte-budget
release on overwrite). New vitest browser-e2e project exercises the
shim in a real Chromium opaque-origin iframe with postMessage as the
cross-origin channel — the only check that proves defineProperty
actually shadows the throwing platform getter.
Clicking New chat left the previous thread's artifact open in the canvas pane. Add resetCanvas() and call it from the existing thread→new effect so all entry points (header button, sidebar button, shortcut, not-found fallback) clear the stale artifact in one place.
Summary
artifact_createandartifact_edit— with full streaming support, including search/replace patches that highlight, scroll to, and inline-diff the affected regions in the canvas as they stream.artifactstable + schema, RLS-aware queries/mutations, internal mutations for streaming title/content/patches, patch helper with apply-patches tests, artifact context injection into the agent prompt, and per-thread artifact GC cron.Test plan
bun run checkpassesapi.d.tscommitted)Summary by CodeRabbit
Release Notes
New Features
Documentation