Skip to content

feat(platform): introduce artifacts (canvas-bound documents) with streaming patches#1655

Merged
larryro merged 29 commits into
mainfrom
feat/artifacts
May 2, 2026
Merged

feat(platform): introduce artifacts (canvas-bound documents) with streaming patches#1655
larryro merged 29 commits into
mainfrom
feat/artifacts

Conversation

@larryro
Copy link
Copy Markdown
Collaborator

@larryro larryro commented May 1, 2026

Summary

  • Adds an artifacts primitive: thread-bound documents the agent can create and edit, surfaced in the Canvas pane.
  • Introduces two agent tools — artifact_create and artifact_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.
  • Rewires the Canvas pane around artifacts: artifact bar with switcher, source vs. preview view, ambient streaming indicators, focus pulled to the latest artifact during AI edits, and source view held briefly after streams settle so users can read what changed.
  • Backend: new artifacts table + 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.
  • Docs (en/de/fr) and i18n keys updated for the new Canvas/artifact UX; chat-agent example wired up with the new tools.

Test plan

  • bun run check passes
  • Convex codegen up to date (api.d.ts committed)
  • Create an artifact via chat — title streams, content settles, canvas focuses it
  • Edit an artifact via chat — patch regions highlight, inline diff renders, source view holds for ~10s after settle
  • Switch artifacts mid-stream — dwell lock resets, no stale highlights
  • Multiple artifacts in one thread — artifact bar switcher works, GC cron prunes orphans
  • Authz: cannot read/edit artifacts on threads you don't own

Summary by CodeRabbit

Release Notes

  • New Features

    • Artifacts now auto-create and appear in an Artifacts bar above chat for quick access
    • Added support for executable HTML and SVG outputs as artifacts
    • Live preview with real-time streaming feedback during artifact generation
    • Incremental revisions: AI can revise artifacts with targeted patches instead of full regeneration
    • Added full-screen view for artifacts
    • New revision history tracking for all artifact edits
  • Documentation

    • Updated platform guides and tutorials across all languages to reflect artifact-first workflow

larryro added 20 commits May 1, 2026 19:03
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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

📝 Walkthrough

Walkthrough

This 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 (artifact_create and artifact_edit) that create artifacts with automatic display in an artifacts bar and Canvas auto-open. The Canvas context API changes from accepting inline content to accepting artifact IDs. New Convex database tables and mutations manage artifact lifecycle including streaming writes, patch-based edits, and revision history. Frontend components are refactored to fetch and display artifacts instead of managing message-embedded content. Documentation across multiple languages is updated to reflect the artifact-first workflow. The structured context pipeline integrates artifacts context for the agent to reference existing thread artifacts during generation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature: introducing artifacts (canvas-bound documents) with streaming patches, which aligns with the substantial changes across schema, tools, UI, and documentation.
Description check ✅ Passed The description covers key changes (artifacts primitive, agent tools, Canvas pane rewiring, backend implementation, docs/i18n updates), includes comprehensive pre-merge checklist items, and provides a concrete test plan with specific verification steps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/artifacts

Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Restore the access gate in getThreadMessages.

The helper (getThreadMessages in get_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 the canAccessThread gate that getThreadMessagesStreaming and getThreadStatus enforce.

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 win

Carry 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 win

Render artifact pills even when assistant text is empty.

MessageArtifactPills is currently gated by the displayContent branch, 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

📥 Commits

Reviewing files that changed from the base of the PR and between e15d736 and 85fbaf0.

⛔ Files ignored due to path filters (1)
  • services/platform/convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (47)
  • docs/de/platform/chat/basics.md
  • docs/de/platform/workspace/canvas.md
  • docs/de/tutorials/member/chat-effectively.md
  • docs/fr/platform/chat/basics.md
  • docs/fr/platform/workspace/canvas.md
  • docs/fr/tutorials/member/chat-effectively.md
  • docs/platform/chat/basics.md
  • docs/platform/workspace/canvas.md
  • docs/tutorials/member/chat-effectively.md
  • examples/agents/chat-agent.json
  • services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx
  • services/platform/app/features/chat/components/canvas/artifact-bar.tsx
  • services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx
  • services/platform/app/features/chat/components/canvas/canvas-context.tsx
  • services/platform/app/features/chat/components/canvas/canvas-pane.tsx
  • services/platform/app/features/chat/components/message-bubble.tsx
  • services/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsx
  • services/platform/app/features/chat/components/message-bubble/code-block.tsx
  • services/platform/app/features/chat/components/message-bubble/message-content-context.tsx
  • services/platform/app/features/chat/utils/replace-code-block.ts
  • services/platform/app/routes/dashboard/$id/chat.tsx
  • services/platform/convex/agent_tools/artifacts/__tests__/apply_patches.test.ts
  • services/platform/convex/agent_tools/artifacts/apply_patches.ts
  • services/platform/convex/agent_tools/artifacts/artifact_create_tool.ts
  • services/platform/convex/agent_tools/artifacts/artifact_edit_tool.ts
  • services/platform/convex/agent_tools/artifacts/shared.ts
  • services/platform/convex/agent_tools/artifacts/stream_state.ts
  • services/platform/convex/agent_tools/tool_names.ts
  • services/platform/convex/agent_tools/tool_registry.ts
  • services/platform/convex/artifacts/internal_mutations.ts
  • services/platform/convex/artifacts/internal_queries.ts
  • services/platform/convex/artifacts/mutations.ts
  • services/platform/convex/artifacts/queries.ts
  • services/platform/convex/artifacts/schema.ts
  • services/platform/convex/crons.ts
  • services/platform/convex/lib/agent_response/__tests__/generate_response_error_handling.test.ts
  • services/platform/convex/lib/agent_response/generate_response.ts
  • services/platform/convex/lib/context_management/build_artifacts_context.ts
  • services/platform/convex/lib/context_management/message_formatter.ts
  • services/platform/convex/lib/context_management/structured_context_builder.ts
  • services/platform/convex/lib/rls/auth/can_access_thread.ts
  • services/platform/convex/schema.ts
  • services/platform/convex/threads/mutations.ts
  • services/platform/convex/threads/queries.ts
  • services/platform/messages/de.json
  • services/platform/messages/en.json
  • services/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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Comment on lines +52 to +97
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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.

Comment on lines +157 to +174
// 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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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).

Comment on lines +223 to +234
// 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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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.

Comment on lines +10 to +13
args: {
artifactId: v.id('artifacts'),
content: v.string(),
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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

Comment on lines +57 to +76
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;
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +829 to +831
const artifactsContext = organizationId
? await buildArtifactsContext(ctx, organizationId, threadId)
: undefined;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +54 to +79
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');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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."
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
"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.

larryro added 8 commits May 1, 2026 22:50
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.
@larryro larryro merged commit fd3d74e into main May 2, 2026
20 checks passed
@larryro larryro deleted the feat/artifacts branch May 2, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant