Skip to content

feat(platform): add syntax-highlighted editing to canvas#1422

Closed
yannickmonney wants to merge 4 commits into
mainfrom
feat/canvas-editing-iter1
Closed

feat(platform): add syntax-highlighted editing to canvas#1422
yannickmonney wants to merge 4 commits into
mainfrom
feat/canvas-editing-iter1

Conversation

@yannickmonney
Copy link
Copy Markdown
Contributor

@yannickmonney yannickmonney commented Apr 11, 2026

Summary

  • Extract shared extractShikiCodeContent to lib/utils/shiki.ts (deduplicate from canvas-code-renderer and code-block)
  • Add line numbers to canvas code preview mode via .code-line-numbers CSS class
  • Replace plain textarea with Shiki overlay editor for syntax-highlighted editing in canvas
  • Add .code-editor-surface CSS class for pixel-perfect font alignment between textarea and highlight layers
  • Add "Apply" button to send edited canvas code back to chat as a new user message
  • Wire canvas onApply callback through ChatInterface to sendMessage for seamless AI interaction
  • Add canvas context dirty state tracking (originalContent vs current content)
  • Add codeEditor, apply, applyTooltip translation keys (en + de)
  • Add app/features/** glob to Storybook config for feature story discovery
  • Add CanvasCodeRenderer unit tests (11 cases) and Storybook stories (5 variants)
  • Update canvas-pane and code-block test mocks for shared extractShikiCodeContent import

Test plan

  • Open canvas with code block — verify line numbers in preview
  • Toggle edit mode — verify syntax highlighting persists
  • Type code — verify highlight updates after 150ms debounce
  • Press Tab — verify 2-space indent insertion
  • Scroll long code — verify highlight and textarea stay aligned
  • Switch to preview — verify updated content renders correctly
  • Click Apply — verify edited code appears as new user message in chat
  • Verify Apply button only shows when content is dirty
  • Test in both light and dark themes
  • Run bun run --filter @tale/platform test — all 4786 tests pass
  • Run bun run --filter @tale/platform lint — clean

Closes #1413

Summary by CodeRabbit

Release Notes

  • New Features

    • Added inline code editor to canvas with syntax highlighting and tab indentation support.
    • Added "Apply changes" button to send edited code back to chat conversations.
    • Improved code editing experience with synchronized scrolling and proper text rendering.
  • Documentation

    • Added story documentation for canvas code renderer component.

- Extract shared extractShikiCodeContent to lib/utils/shiki.ts
- Add line numbers to canvas code preview via .code-line-numbers class
- Replace plain textarea with Shiki overlay editor for edit mode
- Add .code-editor-surface CSS class for pixel-perfect font alignment
- Add Apply button to send edited canvas code back to chat
- Wire canvas onApply callback through ChatInterface to sendMessage
- Add canvas context dirty state tracking (originalContent vs current)
- Add codeEditor, apply, applyTooltip translation keys (en + de)
- Add app/features/** glob to Storybook config for feature stories
- Add CanvasCodeRenderer tests (11 cases) and Storybook stories (5)
- Update canvas-pane and code-block test mocks for shared import
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 11, 2026

📝 Walkthrough

Walkthrough

The PR adds editing capabilities to the Canvas component, enabling users to modify generated code directly within the preview pane. Key changes include: extending canvas context with dirty-state tracking and apply lifecycle callbacks; refactoring CanvasCodeRenderer to support edit mode with a transparent textarea overlaid on a non-interactive syntax-highlighted code display (with scroll synchronization); implementing Tab-key indentation insertion; adding an "Apply changes" button to CanvasPane that conditionally appears when content is dirty; registering an onApply callback in ChatInterface to send edited code back as messages; extracting a shared extractShikiCodeContent utility function; providing localized UI strings in English and German; and adding comprehensive test coverage and Storybook stories.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

The changes span interconnected canvas and chat components with dense rendering logic (scroll synchronization, textarea overlay, requestAnimationFrame scheduling, Tab indentation handling), new state management patterns for dirty tracking and apply callbacks, and multiple file modifications requiring understanding of the complete edit-to-chat flow.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: adding syntax-highlighted editing capability to the canvas component, which is the primary objective of this changeset.
Linked Issues check ✅ Passed All coding requirements from issue #1413 are met: editing capability added via textarea overlay, real-time preview updates, Apply button for explicit changes, and UI controls for edit mode integration.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the stated objective of adding syntax-highlighted editing to canvas; no unrelated modifications were introduced.

✏️ 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/canvas-editing-iter1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@services/platform/app/features/chat/components/canvas/canvas-code-renderer.stories.tsx`:
- Around line 54-60: The EditJavaScript story is read-only because
args.onContentChange is a no-op; make the story controlled by replacing that
no-op with a stateful handler (e.g., use a local useState or Storybook action
that updates the args.code) so CanvasCodeRenderer receives updated code on
edits; update the EditJavaScript story definition to provide a mutable
onContentChange that sets the new content (and optionally keeps args.code in
sync) so typing, Tab indentation, and scroll-sync can be demonstrated.

In
`@services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx`:
- Around line 30-44: The Shiki highlight debounce can leave the textarea
transparent showing stale or empty highlighted HTML; modify the effect that
calls highlightCode so it tracks which code snapshot produced the current html
(e.g., add a htmlForCode or lastHighlightedCode ref/state) and only replace the
rendered highlighted HTML when the result matches the latest code; while
highlighting is pending, fall back to rendering the raw code text (or keep
textarea opaque) so edits are visible immediately. Concretely: in the useEffect
wrapping highlightCode (and the similar block at 74-103), capture the current
code value into a local token/ref, pass that through the async result check,
update lastHighlightedCode when setting
setHtml(extractShikiCodeContent(result)), and conditionally render the fallback
raw text whenever lastHighlightedCode !== current code or a pending flag is set.

In `@services/platform/app/features/chat/components/canvas/canvas-pane.tsx`:
- Around line 224-236: The Apply button is shown when canEdit && isDirty even if
no apply handler exists, causing applyCanvasContent to clear dirty without
sending anything; add a canApply check based on the canvas context's onApplyRef
(e.g. const canApply = onApplyRef.current !== null in canvas-context.tsx) and
update the render condition to require canApply (use canEdit && isDirty &&
canApply) and/or guard applyCanvasContent to no-op if onApplyRef.current is null
so the button is hidden and clicks do nothing when there is no registered
onApply handler.

In `@services/platform/app/features/chat/components/chat-interface.tsx`:
- Around line 485-486: The composed message uses a fixed triple-backtick fence
which will break if content contains ``` sequences; update the logic around
lang/language and message to compute a safe fence by scanning content for the
longest consecutive backtick run and using a fence of one more backtick (e.g.,
fence = '`'.repeat(maxRun + 1)), then build the message as `I've edited the
${lang}...${fence}${language || ''}\n${content}\n${fence}` so the wrapper never
collides with backticks inside content; apply this change where message is
constructed and reference the variables language, lang, content, and message.
- Around line 482-490: The effect registers a persistent callback via
canvasContext.registerOnApply which isn't removed on cleanup, risking stale
closures (sendMessage/threadId) firing; update the useEffect in
chat-interface.tsx to store the registration result or the handler reference and
unregister it in the cleanup function (either call the unsubscribe function
returned by registerOnApply or call canvasContext.unregisterOnApply(handler) if
that API exists), ensuring the same handler uses scrollingToBottomBehaviorRef,
threadId and sendMessage and is removed when canvasContext, readOnly,
sendMessage or threadId 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: df88d4b1-d5f7-4794-8cca-b379452c82cf

📥 Commits

Reviewing files that changed from the base of the PR and between d7bd810 and 3e83f20.

📒 Files selected for processing (14)
  • services/platform/.storybook/main.ts
  • services/platform/app/features/chat/components/canvas/__tests__/canvas-code-renderer.test.tsx
  • services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx
  • services/platform/app/features/chat/components/canvas/canvas-code-renderer.stories.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/chat-interface.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/globals.css
  • services/platform/lib/utils/shiki.ts
  • services/platform/messages/de.json
  • services/platform/messages/en.json

Comment on lines +54 to +60
export const EditJavaScript: Story = {
args: {
code: SAMPLE_JS,
language: 'javascript',
isEditing: true,
onContentChange: () => {},
},
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

Make the edit story actually editable.

CanvasCodeRenderer is controlled, so the no-op onContentChange keeps this story read-only. That means the Storybook variant can't demonstrate typing, Tab indentation, or scroll-sync behavior.

💡 Suggested fix
+import { useState } from 'react';
 import type { Meta, StoryObj } from '@storybook/react';
 
 import { CanvasCodeRenderer } from './canvas-code-renderer';
@@
 export const EditJavaScript: Story = {
+  render: (args) => {
+    const [code, setCode] = useState(args.code);
+
+    return (
+      <CanvasCodeRenderer
+        {...args}
+        code={code}
+        onContentChange={setCode}
+      />
+    );
+  },
   args: {
     code: SAMPLE_JS,
     language: 'javascript',
     isEditing: true,
-    onContentChange: () => {},
   },
 };
📝 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
export const EditJavaScript: Story = {
args: {
code: SAMPLE_JS,
language: 'javascript',
isEditing: true,
onContentChange: () => {},
},
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { CanvasCodeRenderer } from './canvas-code-renderer';
export const EditJavaScript: Story = {
render: (args) => {
const [code, setCode] = useState(args.code);
return (
<CanvasCodeRenderer
{...args}
code={code}
onContentChange={setCode}
/>
);
},
args: {
code: SAMPLE_JS,
language: 'javascript',
isEditing: true,
},
};
🤖 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.stories.tsx`
around lines 54 - 60, The EditJavaScript story is read-only because
args.onContentChange is a no-op; make the story controlled by replacing that
no-op with a stateful handler (e.g., use a local useState or Storybook action
that updates the args.code) so CanvasCodeRenderer receives updated code on
edits; update the EditJavaScript story definition to provide a mutable
onContentChange that sets the new content (and optionally keeps args.code in
sync) so typing, Tab indentation, and scroll-sync can be demonstrated.

Comment on lines 30 to 44
useEffect(() => {
if (isEditing) return undefined;
let cancelled = false;
void highlightCode(code, language, shikiTheme).then((result) => {
if (!cancelled && result) {
setHtml(extractShikiCodeContent(result));
}
});
const delay = isEditing ? 150 : 0;
const timeout = setTimeout(() => {
void highlightCode(code, language, shikiTheme).then((result) => {
if (!cancelled && result) {
setHtml(extractShikiCodeContent(result));
}
});
}, delay);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [code, language, shikiTheme, isEditing]);
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

Don't hide the current text while the next highlight is still pending.

In edit mode the textarea is always transparent, but the Shiki layer only updates after the debounce completes. That means fresh keystrokes can render as stale content, or nothing at all on first focus, for ~150ms. For a code editor this feels broken. Track which code snapshot produced html and fall back to visible raw text until the highlight catches up.

Also applies to: 74-103

🤖 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 30 - 44, The Shiki highlight debounce can leave the textarea
transparent showing stale or empty highlighted HTML; modify the effect that
calls highlightCode so it tracks which code snapshot produced the current html
(e.g., add a htmlForCode or lastHighlightedCode ref/state) and only replace the
rendered highlighted HTML when the result matches the latest code; while
highlighting is pending, fall back to rendering the raw code text (or keep
textarea opaque) so edits are visible immediately. Concretely: in the useEffect
wrapping highlightCode (and the similar block at 74-103), capture the current
code value into a local token/ref, pass that through the async result check,
update lastHighlightedCode when setting
setHtml(extractShikiCodeContent(result)), and conditionally render the fallback
raw text whenever lastHighlightedCode !== current code or a pending flag is set.

Comment on lines +39 to +43
registerOnApply: (
callback: (content: string, language?: string) => void,
) => void;
applyCanvasContent: () => void;
resetDirtyState: () => void;
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

Only clear isDirty after Apply actually succeeds.

applyCanvasContent() marks the canvas clean even when no apply handler is registered, and it also ignores async failures from the handler. In this PR the handler is used to send a new chat message, so a send error would still hide the Apply button and make the edit look applied. Make the callback awaitable and only copy canvasContent into originalContent after a successful apply.

Also applies to: 114-122

Comment on lines +224 to +236
{canEdit && isDirty && (
<Tooltip content={t('canvas.applyTooltip')} side="bottom">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={applyCanvasContent}
aria-label={t('canvas.apply')}
>
<Check className="text-success size-3.5" />
</Button>
</Tooltip>
)}
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

Hide Apply when there is no apply handler.

This button is currently gated only by canEdit && isDirty. In read-only/shared chats, applyCanvasContent() still clears the dirty state even though no callback is registered, so the UI tells the user their change was applied when nothing was sent anywhere.

Suggested direction
-    isDirty,
+    isDirty,
+    canApply,
     closeCanvas,
     updateCanvasContent,
     applyCanvasContent,
   } = useCanvas();
-          {canEdit && isDirty && (
+          {canEdit && canApply && isDirty && (
             <Tooltip content={t('canvas.applyTooltip')} side="bottom">
// canvas-context.tsx
const canApply = onApplyRef.current !== null;
🤖 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 224 - 236, The Apply button is shown when canEdit && isDirty even if no
apply handler exists, causing applyCanvasContent to clear dirty without sending
anything; add a canApply check based on the canvas context's onApplyRef (e.g.
const canApply = onApplyRef.current !== null in canvas-context.tsx) and update
the render condition to require canApply (use canEdit && isDirty && canApply)
and/or guard applyCanvasContent to no-op if onApplyRef.current is null so the
button is hidden and clicks do nothing when there is no registered onApply
handler.

Comment on lines +482 to +490
useEffect(() => {
if (!canvasContext || readOnly) return;
canvasContext.registerOnApply((content, language) => {
const lang = language || 'code';
const message = `I've edited the ${lang}. Here is the updated version:\n\n\`\`\`${language || ''}\n${content}\n\`\`\``;
scrollingToBottomBehaviorRef.current = threadId ? 'smooth' : 'instant';
void sendMessage(message);
});
}, [canvasContext, readOnly, sendMessage, threadId]);
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

Clear the canvas apply callback on cleanup.

This effect registers a callback, but it never unregisters it. If the canvas provider survives a thread change or a switch into readOnly, Apply can keep sending edits through a stale sendMessage closure to the wrong chat thread.

Suggested fix
  useEffect(() => {
-    if (!canvasContext || readOnly) return;
+    if (!canvasContext) return;
+
+    const clearOnApply = () => {
+      canvasContext.registerOnApply(() => {});
+    };
+
+    if (readOnly) {
+      clearOnApply();
+      return clearOnApply;
+    }
+
     canvasContext.registerOnApply((content, language) => {
       const lang = language || 'code';
       const message = `I've edited the ${lang}. Here is the updated version:\n\n\`\`\`${language || ''}\n${content}\n\`\`\``;
       scrollingToBottomBehaviorRef.current = threadId ? 'smooth' : 'instant';
       void sendMessage(message);
     });
+    return clearOnApply;
   }, [canvasContext, readOnly, sendMessage, threadId]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/chat/components/chat-interface.tsx` around
lines 482 - 490, The effect registers a persistent callback via
canvasContext.registerOnApply which isn't removed on cleanup, risking stale
closures (sendMessage/threadId) firing; update the useEffect in
chat-interface.tsx to store the registration result or the handler reference and
unregister it in the cleanup function (either call the unsubscribe function
returned by registerOnApply or call canvasContext.unregisterOnApply(handler) if
that API exists), ensuring the same handler uses scrollingToBottomBehaviorRef,
threadId and sendMessage and is removed when canvasContext, readOnly,
sendMessage or threadId change.

Comment on lines +485 to +486
const lang = language || 'code';
const message = `I've edited the ${lang}. Here is the updated version:\n\n\`\`\`${language || ''}\n${content}\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

Fence edited content safely before sending it back to chat.

The new message always uses triple backticks. Any edited markdown/code containing ``` will break the wrapper and send malformed content back to the model.

Suggested fix
     canvasContext.registerOnApply((content, language) => {
       const lang = language || 'code';
-      const message = `I've edited the ${lang}. Here is the updated version:\n\n\`\`\`${language || ''}\n${content}\n\`\`\``;
+      const longestBacktickRun = Math.max(
+        2,
+        ...Array.from(content.matchAll(/`+/g), ([run]) => run.length),
+      );
+      const fence = '`'.repeat(longestBacktickRun + 1);
+      const message = `I've edited the ${lang}. Here is the updated version:\n\n${fence}${language ?? ''}\n${content}\n${fence}`;
       scrollingToBottomBehaviorRef.current = threadId ? 'smooth' : 'instant';
       void sendMessage(message);
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/chat/components/chat-interface.tsx` around
lines 485 - 486, The composed message uses a fixed triple-backtick fence which
will break if content contains ``` sequences; update the logic around
lang/language and message to compute a safe fence by scanning content for the
longest consecutive backtick run and using a fence of one more backtick (e.g.,
fence = '`'.repeat(maxRun + 1)), then build the message as `I've edited the
${lang}...${fence}${language || ''}\n${content}\n${fence}` so the wrapper never
collides with backticks inside content; apply this change where message is
constructed and reference the variables language, lang, content, and message.

…ighting

- Add render/code view toggle for HTML, Markdown, and SVG canvas types
- Add line numbers to chat code blocks via .code-line-numbers class
- Add line hover highlighting CSS (.code-line-hover) with reduced-motion
- Add code-line-hover class to canvas code renderer preview mode
- Allow editing HTML/SVG content in code view mode
- Add viewCode/viewRender translation keys (en + de)
- Rewrite stories with JavaScript, HTML, Bash, Markdown examples
- Make stories interactive with useState wrapper
- Update canvas-pane tests with new translation keys
- Fix canvas edit mode: show plain textarea when Shiki hasn't loaded,
  switch to overlay editor once highlighting is available
- Fix line numbers popping in: render .line spans in plain-text fallback
  for both canvas code renderer and chat code blocks
- Fix line hover: extend highlight to full line width with negative margin
- Refactor render/code toggle to tab switch for HTML & Markdown only
- Remove SVG from view mode toggle (not needed)
- Shorten viewCode/viewRender translation labels
- Add viewModeLabel translation key for tablist aria-label
- Add optional language prop to UI CodeBlock for Shiki highlighting
- Render .line spans in plain-text fallback for instant line numbers
- Apply code-line-numbers and code-line-hover classes when language is set
- Update stories with JavaScript, HTML, Bash, JSON highlighted examples
- Update tests with syntax highlighting and accessibility coverage
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.

Canvas preview does not support editing of generated content

1 participant