diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx
index 33e9b507..ee838d41 100644
--- a/apps/x/apps/renderer/src/components/markdown-editor.tsx
+++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx
@@ -10,6 +10,7 @@ import TaskItem from '@tiptap/extension-task-item'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block'
import { TrackBlockExtension } from '@/extensions/track-block'
+import { TrackTargetExtension } from '@/extensions/track-target'
import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block'
import { ChartBlockExtension } from '@/extensions/chart-block'
@@ -43,6 +44,22 @@ function preprocessMarkdown(markdown: string): string {
})
}
+// Convert each `...` region to a
+// placeholder HTML element that Tiptap's TrackTargetExtension parses into a
+// `trackTarget` node. Content is base64-encoded so any characters survive.
+// Without this step, tiptap-markdown strips the comment markers and leaves the
+// inner content as loose paragraphs, which then duplicates when the backend
+// re-inserts fresh markers around new content.
+function preprocessTrackTargets(md: string): string {
+ return md.replace(
+ /\n?([\s\S]*?)\n?/g,
+ (_match, id: string, content: string) => {
+ const b64 = btoa(unescape(encodeURIComponent(content)))
+ return `
`
+ },
+ )
+}
+
// Post-process to clean up any zero-width spaces in the output
function postprocessMarkdown(markdown: string): string {
// Remove lines that contain only the zero-width space marker
@@ -141,6 +158,13 @@ function blockToMarkdown(node: JsonNode): string {
return serializeList(node, 0).join('\n')
case 'taskBlock':
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
+ case 'trackBlock':
+ return '```track\n' + (node.attrs?.data as string || '') + '\n```'
+ case 'trackTarget': {
+ const id = (node.attrs?.trackId as string) ?? ''
+ const content = (node.attrs?.content as string) ?? ''
+ return `\n${content}\n`
+ }
case 'imageBlock':
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock':
@@ -640,6 +664,7 @@ export const MarkdownEditor = forwardRef s.split('\n').map(line => line.trimEnd()).join('\n').trim()
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
isInternalUpdate.current = true
- // Pre-process to preserve blank lines
- const preprocessed = preprocessMarkdown(content)
+ // Pre-process to preserve blank lines, then wrap track-target comment
+ // regions into placeholder divs so TrackTargetExtension can pick them up.
+ const preprocessed = preprocessMarkdown(preprocessTrackTargets(content))
// Treat tab-open content as baseline: do not add hydration to undo history.
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
isInternalUpdate.current = false
diff --git a/apps/x/apps/renderer/src/extensions/track-target.tsx b/apps/x/apps/renderer/src/extensions/track-target.tsx
new file mode 100644
index 00000000..98d5541a
--- /dev/null
+++ b/apps/x/apps/renderer/src/extensions/track-target.tsx
@@ -0,0 +1,117 @@
+import { mergeAttributes, Node } from '@tiptap/react'
+import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
+import { Streamdown } from 'streamdown'
+
+/**
+ * TrackTargetExtension — a Tiptap atom node that owns a
+ * ...
+ * region. Content is display-only; the backend is the sole writer.
+ *
+ * Parse path: `markdown-editor.tsx#preprocessTrackTargets` converts each
+ * comment-wrapped region into a placeholder
+ *
+ * which the `parseHTML` rule below picks up.
+ *
+ * Serialize path: round-trips back to the exact comment-wrapped form via
+ * `addStorage().markdown.serialize` AND via the custom
+ * `blockToMarkdown` switch in `markdown-editor.tsx` (both save paths must
+ * handle it identically).
+ */
+
+// Unicode-safe base64 helpers — content may contain any char, including
+// emoji and CJK. btoa/atob alone only handle Latin-1.
+function encode(s: string): string {
+ return btoa(unescape(encodeURIComponent(s)))
+}
+
+function decode(s: string): string {
+ try {
+ return decodeURIComponent(escape(atob(s)))
+ } catch {
+ return ''
+ }
+}
+
+function TrackTargetView({ node }: {
+ node: { attrs: Record }
+}) {
+ const trackId = (node.attrs.trackId as string) ?? ''
+ const content = (node.attrs.content as string) ?? ''
+
+ return (
+
+ e.stopPropagation()}>
+ {content.trim()
+ ? {content}
+ : No output yet — run the track to populate this area.}
+
+
+ )
+}
+
+export const TrackTargetExtension = Node.create({
+ name: 'trackTarget',
+ group: 'block',
+ atom: true,
+ selectable: true,
+ draggable: false,
+
+ addAttributes() {
+ return {
+ trackId: { default: '' },
+ content: { default: '' },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-type="track-target"]',
+ getAttrs(el) {
+ if (!(el instanceof HTMLElement)) return false
+ const trackId = el.getAttribute('data-track-id') ?? ''
+ const b64 = el.getAttribute('data-content') ?? ''
+ return { trackId, content: b64 ? decode(b64) : '' }
+ },
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record; node: { attrs: Record } }) {
+ return [
+ 'div',
+ mergeAttributes(HTMLAttributes, {
+ 'data-type': 'track-target',
+ 'data-track-id': (node.attrs.trackId as string) ?? '',
+ 'data-content': encode((node.attrs.content as string) ?? ''),
+ }),
+ ]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(TrackTargetView)
+ },
+
+ addStorage() {
+ return {
+ markdown: {
+ serialize(
+ state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
+ node: { attrs: { trackId: string; content: string } },
+ ) {
+ const id = node.attrs.trackId ?? ''
+ const content = node.attrs.content ?? ''
+ state.write(`\n${content}\n`)
+ state.closeBlock(node)
+ },
+ parse: {
+ // handled by parseHTML after preprocessTrackTargets runs
+ },
+ },
+ }
+ },
+})
diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css
index 57e8dd73..21f8956a 100644
--- a/apps/x/apps/renderer/src/styles/editor.css
+++ b/apps/x/apps/renderer/src/styles/editor.css
@@ -717,6 +717,42 @@
outline-offset: 2px;
}
+/* =============================================================
+ Track Target — rendered output region below a track chip.
+ Display-only; the backend is the sole writer.
+ ============================================================= */
+
+.tiptap-editor .ProseMirror .track-target-wrapper {
+ margin: 6px 0 10px 0;
+}
+
+.tiptap-editor .ProseMirror .track-target-box {
+ position: relative;
+ padding: 10px 12px 10px 16px;
+ border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
+ border-left: 3px solid color-mix(in srgb, var(--foreground) 30%, transparent);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--foreground) 3%, transparent);
+ user-select: text;
+ font-size: 13.5px;
+ line-height: 1.55;
+}
+
+.tiptap-editor .ProseMirror .track-target-wrapper.ProseMirror-selectednode .track-target-box {
+ outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
+ outline-offset: 2px;
+}
+
+.tiptap-editor .ProseMirror .track-target-markdown > *:first-child { margin-top: 0; }
+.tiptap-editor .ProseMirror .track-target-markdown > *:last-child { margin-bottom: 0; }
+
+.tiptap-editor .ProseMirror .track-target-empty {
+ display: block;
+ font-size: 12px;
+ color: color-mix(in srgb, var(--foreground) 45%, transparent);
+ font-style: italic;
+}
+
/* Shared block styles (image, embed, chart, table) */
.tiptap-editor .ProseMirror .image-block-wrapper,
.tiptap-editor .ProseMirror .embed-block-wrapper,