feat(platform): add syntax-highlighted editing to canvas#1422
feat(platform): add syntax-highlighted editing to canvas#1422yannickmonney wants to merge 4 commits into
Conversation
- 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
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
📝 WalkthroughWalkthroughThe 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 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)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (14)
services/platform/.storybook/main.tsservices/platform/app/features/chat/components/canvas/__tests__/canvas-code-renderer.test.tsxservices/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsxservices/platform/app/features/chat/components/canvas/canvas-code-renderer.stories.tsxservices/platform/app/features/chat/components/canvas/canvas-code-renderer.tsxservices/platform/app/features/chat/components/canvas/canvas-context.tsxservices/platform/app/features/chat/components/canvas/canvas-pane.tsxservices/platform/app/features/chat/components/chat-interface.tsxservices/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsxservices/platform/app/features/chat/components/message-bubble/code-block.tsxservices/platform/app/globals.cssservices/platform/lib/utils/shiki.tsservices/platform/messages/de.jsonservices/platform/messages/en.json
| export const EditJavaScript: Story = { | ||
| args: { | ||
| code: SAMPLE_JS, | ||
| language: 'javascript', | ||
| isEditing: true, | ||
| onContentChange: () => {}, | ||
| }, |
There was a problem hiding this comment.
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.
| 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.
| 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]); |
There was a problem hiding this comment.
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.
| registerOnApply: ( | ||
| callback: (content: string, language?: string) => void, | ||
| ) => void; | ||
| applyCanvasContent: () => void; | ||
| resetDirtyState: () => void; |
There was a problem hiding this comment.
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
| {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> | ||
| )} |
There was a problem hiding this comment.
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.
| 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]); |
There was a problem hiding this comment.
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.
| const lang = language || 'code'; | ||
| const message = `I've edited the ${lang}. Here is the updated version:\n\n\`\`\`${language || ''}\n${content}\n\`\`\``; |
There was a problem hiding this comment.
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
Summary
extractShikiCodeContenttolib/utils/shiki.ts(deduplicate from canvas-code-renderer and code-block).code-line-numbersCSS class.code-editor-surfaceCSS class for pixel-perfect font alignment between textarea and highlight layersonApplycallback throughChatInterfacetosendMessagefor seamless AI interactionoriginalContentvs current content)codeEditor,apply,applyTooltiptranslation keys (en + de)app/features/**glob to Storybook config for feature story discoveryCanvasCodeRendererunit tests (11 cases) and Storybook stories (5 variants)extractShikiCodeContentimportTest plan
bun run --filter @tale/platform test— all 4786 tests passbun run --filter @tale/platform lint— cleanCloses #1413
Summary by CodeRabbit
Release Notes
New Features
Documentation