From e1271ff384f839fdf1e50904375d5552733b8598 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 31 Aug 2026 18:25:03 +0000
Subject: [PATCH 1/4] Initial plan
From 1e8a4e19807fa936e40fb621193b0e2625091974 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 31 Aug 2026 18:32:09 +0000
Subject: [PATCH 2/4] feat: add markdown tabs support in rendered view
Adds a `TabGroupExtension` TipTap extension that renders ` ```tabs ``` `
fenced code blocks as interactive tabbed panels.
Syntax:
```tabs
## Tab 1
Content for the first tab.
## Tab 2
Content for the second tab.
```
- packages/editor/src/tabs-extension.tsx: new extension + parse/serialize helpers
- packages/editor/src/tabs-extension.test.ts: unit tests (9 passing)
- packages/editor/src/rendered-editor.tsx: wire TabGroupExtension into editor
- packages/editor/src/index.ts: re-export new module
- apps/web/src/styles.css: tab-group UI styles
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/web/src/styles.css | 57 +++++
package-lock.json | 32 +--
packages/editor/src/index.ts | 1 +
packages/editor/src/rendered-editor.tsx | 2 +
packages/editor/src/tabs-extension.test.ts | 62 +++++
packages/editor/src/tabs-extension.tsx | 252 +++++++++++++++++++++
6 files changed, 376 insertions(+), 30 deletions(-)
create mode 100644 packages/editor/src/tabs-extension.test.ts
create mode 100644 packages/editor/src/tabs-extension.tsx
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index c62c557..3e8270a 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -1610,6 +1610,63 @@ button {
padding: 8px;
}
+/* Tab group (```tabs fenced block) */
+.tab-group {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ overflow: hidden;
+ margin: 12px 0;
+}
+
+.tab-group__bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0;
+ background: var(--bg-subtle, var(--bg-2));
+ border-bottom: 1px solid var(--border);
+}
+
+.tab-group__tab {
+ padding: 6px 16px;
+ border: none;
+ background: transparent;
+ color: var(--fg-muted);
+ cursor: pointer;
+ font-size: 13px;
+ border-bottom: 2px solid transparent;
+ transition: color 0.15s, border-color 0.15s;
+ user-select: none;
+}
+
+.tab-group__tab:hover {
+ color: var(--fg);
+}
+
+.tab-group__tab--active {
+ color: var(--accent, var(--fg));
+ border-bottom-color: var(--accent, var(--fg));
+}
+
+.tab-group__panel {
+ padding: 12px 16px;
+ background: var(--bg);
+}
+
+.tab-group__panel > *:first-child {
+ margin-top: 0;
+}
+
+.tab-group__panel > *:last-child {
+ margin-bottom: 0;
+}
+
+.tab-group__empty {
+ padding: 12px 16px;
+ color: var(--fg-muted);
+ font-style: italic;
+ font-size: 13px;
+}
+
.image-note {
display: flex;
flex-direction: column;
diff --git a/package-lock.json b/package-lock.json
index 12cd6a4..6effa22 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2472,9 +2472,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2492,9 +2489,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2512,9 +2506,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2532,9 +2523,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2552,9 +2540,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2572,9 +2557,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4713,6 +4695,7 @@
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
+ "hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
@@ -9383,9 +9366,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9407,9 +9387,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9431,9 +9408,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9455,9 +9429,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -12436,6 +12407,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 6aa08dd..c636680 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -9,6 +9,7 @@ export * from "./note-toolbar";
export * from "./suggestion-popup";
export * from "./wikilink-decorator";
export * from "./embed-extension";
+export * from "./tabs-extension";
export * from "./image-node";
export * from "./prompt-dialog";
export * from "./native-source-editor";
diff --git a/packages/editor/src/rendered-editor.tsx b/packages/editor/src/rendered-editor.tsx
index a1cbb76..e756aff 100644
--- a/packages/editor/src/rendered-editor.tsx
+++ b/packages/editor/src/rendered-editor.tsx
@@ -21,6 +21,7 @@ import {
} from "react";
import { Markdown } from "tiptap-markdown";
import { Embed } from "./embed-extension";
+import { TabGroupExtension } from "./tabs-extension";
import { ImageNode } from "./image-node";
import { StyledTextMark } from "./styled-text-mark";
import { SuggestionPopup } from "./suggestion-popup";
@@ -184,6 +185,7 @@ export function RenderedEditor({
StyledTextMark,
Markdown.configure({ html: true, tightLists: true, transformPastedText: true }),
WikilinkDecorator,
+ TabGroupExtension,
...(callbacks?.renderEmbed
? [
Embed.configure({
diff --git a/packages/editor/src/tabs-extension.test.ts b/packages/editor/src/tabs-extension.test.ts
new file mode 100644
index 0000000..1a29700
--- /dev/null
+++ b/packages/editor/src/tabs-extension.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { parseTabsContent, serializeTabsContent } from "./tabs-extension";
+
+describe("parseTabsContent", () => {
+ it("parses two tabs separated by ## headings", () => {
+ const input = "## Tab 1\nHello from tab one.\n\n## Tab 2\nHello from tab two.";
+ const result = parseTabsContent(input);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toEqual({ label: "Tab 1", content: "Hello from tab one." });
+ expect(result[1]).toEqual({ label: "Tab 2", content: "Hello from tab two." });
+ });
+
+ it("trims surrounding whitespace from each tab's content", () => {
+ const input = "## My Tab\n\n content here \n\n";
+ const result = parseTabsContent(input);
+ expect(result[0].content).toBe("content here");
+ });
+
+ it("returns empty array when no ## headings are present", () => {
+ const result = parseTabsContent("just some text with no headings");
+ expect(result).toHaveLength(0);
+ });
+
+ it("discards text before the first ## heading", () => {
+ const input = "preamble\n\n## First\ncontent";
+ const result = parseTabsContent(input);
+ expect(result).toHaveLength(1);
+ expect(result[0].label).toBe("First");
+ });
+
+ it("preserves multi-line content within a tab", () => {
+ const input = "## Tab A\nLine 1\nLine 2\n\n## Tab B\nOnly line";
+ const result = parseTabsContent(input);
+ expect(result[0].content).toBe("Line 1\nLine 2");
+ expect(result[1].content).toBe("Only line");
+ });
+
+ it("handles a single tab", () => {
+ const result = parseTabsContent("## Solo\nSingle tab content.");
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({ label: "Solo", content: "Single tab content." });
+ });
+});
+
+describe("serializeTabsContent", () => {
+ it("round-trips through parse → serialize", () => {
+ const original = "## Tab 1\nContent one\n\n## Tab 2\nContent two";
+ const tabs = parseTabsContent(original);
+ const serialized = serializeTabsContent(tabs);
+ const reparsed = parseTabsContent(serialized);
+ expect(reparsed).toEqual(tabs);
+ });
+
+ it("returns empty string for empty tab array", () => {
+ expect(serializeTabsContent([])).toBe("");
+ });
+
+ it("produces the expected format for a single tab", () => {
+ const tabs = [{ label: "Foo", content: "bar" }];
+ expect(serializeTabsContent(tabs)).toBe("## Foo\nbar");
+ });
+});
diff --git a/packages/editor/src/tabs-extension.tsx b/packages/editor/src/tabs-extension.tsx
new file mode 100644
index 0000000..662f9d5
--- /dev/null
+++ b/packages/editor/src/tabs-extension.tsx
@@ -0,0 +1,252 @@
+import { Node, mergeAttributes } from "@tiptap/core";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from "@tiptap/react";
+import { useState } from "react";
+
+export interface TabEntry {
+ label: string;
+ content: string;
+}
+
+/**
+ * Parses tab-fenced content (`\`\`\`tabs ... \`\`\``) into an array of
+ * `{ label, content }` pairs. Tabs are delimited by lines starting with
+ * `## ` (level-2 heading); everything before the first heading is discarded.
+ *
+ * Example:
+ * ```
+ * ## Tab 1
+ * Body of tab 1.
+ *
+ * ## Tab 2
+ * Body of tab 2.
+ * ```
+ */
+export function parseTabsContent(raw: string): TabEntry[] {
+ const lines = raw.split("\n");
+ const tabs: TabEntry[] = [];
+ let currentLabel = "";
+ let currentLines: string[] = [];
+
+ for (const line of lines) {
+ if (line.startsWith("## ") || line.startsWith("##\t")) {
+ const label = line.slice(2).trim();
+ if (currentLabel) {
+ tabs.push({ label: currentLabel, content: currentLines.join("\n").trim() });
+ }
+ currentLabel = label;
+ currentLines = [];
+ } else if (currentLabel) {
+ currentLines.push(line);
+ }
+ }
+ if (currentLabel) {
+ tabs.push({ label: currentLabel, content: currentLines.join("\n").trim() });
+ }
+ return tabs;
+}
+
+/**
+ * Serialises an array of tab entries back to the fenced-block body.
+ */
+export function serializeTabsContent(tabs: TabEntry[]): string {
+ return tabs.map((tab) => `## ${tab.label}\n${tab.content}`).join("\n\n");
+}
+
+function TabGroupNodeView(props: NodeViewProps) {
+ const raw = String(props.node.attrs.content ?? "");
+ const tabs = parseTabsContent(raw);
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ if (tabs.length === 0) {
+ return (
+
${fence[1]}`;
+ }
+ // Unordered list
+ if (/^[*\-] /m.test(trimmed)) {
+ const items = trimmed
+ .split("\n")
+ .filter((l) => /^[*\-] /.test(l))
+ .map((l) => `${inner}`; + } + // Paragraph (single line-breaks within become
${inlineMarkdown(trimmed.replace(/\n/g, "
"))}
$1")
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(/\*([^*]+)\*/g, "$1")
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
+}
+
+/**
+ * TipTap extension that renders ` ```tabs ... ``` ` fenced code blocks as an
+ * interactive tabbed-panel component in the WYSIWYG (rendered) editor.
+ *
+ * Markdown syntax:
+ * ```
+ * \`\`\`tabs
+ * ## Tab 1
+ * Content for the first tab.
+ *
+ * ## Tab 2
+ * Content for the second tab.
+ * \`\`\`
+ * ```
+ */
+export const TabGroupExtension = Node.create({
+ name: "tabGroup",
+ group: "block",
+ atom: true,
+ selectable: true,
+ draggable: false,
+
+ addAttributes() {
+ return { content: { default: "" } };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: "div[data-tab-group]",
+ getAttrs: (element) => ({
+ content: (element as HTMLElement).getAttribute("data-tab-group") ?? "",
+ }),
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ["div", mergeAttributes({ "data-tab-group": HTMLAttributes.content as string })];
+ },
+
+ addStorage() {
+ return {
+ markdown: {
+ serialize(
+ state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
+ node: { attrs: { content: string } },
+ ) {
+ state.write("```tabs\n" + node.attrs.content + "\n```");
+ state.closeBlock(node);
+ },
+ parse: {},
+ },
+ };
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(TabGroupNodeView);
+ },
+
+ addProseMirrorPlugins() {
+ const type = this.type;
+ return [
+ new Plugin({
+ key: new PluginKey("tabGroupNormalize"),
+ appendTransaction(_transactions, _oldState, newState) {
+ const matches: { from: number; to: number; content: string }[] = [];
+ newState.doc.descendants((node, pos) => {
+ if (node.type.name === "codeBlock" && node.attrs.language === "tabs") {
+ matches.push({ from: pos, to: pos + node.nodeSize, content: node.textContent });
+ return false;
+ }
+ return true;
+ });
+ if (matches.length === 0) {
+ return null;
+ }
+ const tr = newState.tr;
+ for (const match of matches.reverse()) {
+ tr.replaceWith(match.from, match.to, type.create({ content: match.content }));
+ }
+ return tr;
+ },
+ }),
+ ];
+ },
+});
From 5b3a7980d748c48fa3c9ceef75ab350424cfae5a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 31 Aug 2026 18:33:16 +0000
Subject: [PATCH 3/4] Apply remaining changes
---
apps/web/src/styles.css | 4 +++-
packages/editor/src/tabs-extension.tsx | 5 +----
2 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 3e8270a..8e2fd8f 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -1634,7 +1634,9 @@ button {
cursor: pointer;
font-size: 13px;
border-bottom: 2px solid transparent;
- transition: color 0.15s, border-color 0.15s;
+ transition:
+ color 0.15s,
+ border-color 0.15s;
user-select: none;
}
diff --git a/packages/editor/src/tabs-extension.tsx b/packages/editor/src/tabs-extension.tsx
index 662f9d5..9f965b1 100644
--- a/packages/editor/src/tabs-extension.tsx
+++ b/packages/editor/src/tabs-extension.tsx
@@ -99,10 +99,7 @@ function TabGroupNodeView(props: NodeViewProps) {
* without pulling in a full parser.
*/
function markdownToHtml(md: string): string {
- const escaped = md
- .replace(/&/g, "&")
- .replace(//g, ">");
+ const escaped = md.replace(/&/g, "&").replace(//g, ">");
const paragraphs = escaped
.split(/\n{2,}/)
From c273491c45233277fa03e8d96a1308daf8aa9c86 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 31 Aug 2026 19:20:41 +0000
Subject: [PATCH 4/4] fix: remove unnecessary escape characters flagged by
eslint
Replace /^[*\-] / with /^[*-] / in the tabs-extension markdownToHtml
helper to fix the no-useless-escape lint errors.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
packages/editor/src/tabs-extension.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/editor/src/tabs-extension.tsx b/packages/editor/src/tabs-extension.tsx
index 9f965b1..68d7322 100644
--- a/packages/editor/src/tabs-extension.tsx
+++ b/packages/editor/src/tabs-extension.tsx
@@ -112,11 +112,11 @@ function markdownToHtml(md: string): string {
return `${fence[1]}`;
}
// Unordered list
- if (/^[*\-] /m.test(trimmed)) {
+ if (/^[*-] /m.test(trimmed)) {
const items = trimmed
.split("\n")
- .filter((l) => /^[*\-] /.test(l))
- .map((l) => `