feat(ui): extend markdown table resizing (#3037)

This commit is contained in:
José Almeida
2026-08-25 19:03:25 +00:00
committed by GitHub
parent 1b4bfc6d27
commit 024adbd55a
17 changed files with 1231 additions and 31 deletions
+37
View File
@@ -1350,6 +1350,43 @@ html[data-rendering-engine="webkit"] [data-webkit-safe-motion="true"][data-state
outline: none;
}
.tiptap-plan-wrapper .tableWrapper {
position: relative;
max-width: 100%;
overflow-x: auto;
}
.tiptap-plan-wrapper .tableWrapper :is(th, td) {
position: relative;
}
.tiptap-plan-wrapper .column-resize-handle {
position: absolute;
z-index: 20;
top: 0;
right: -0.3125rem;
bottom: -1px;
width: 0.625rem;
pointer-events: none;
touch-action: none;
}
.tiptap-plan-wrapper .column-resize-handle::after {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
content: "";
transform: translateX(-50%);
background: var(--primary);
}
.tiptap-plan-wrapper .resize-cursor,
.tiptap-plan-wrapper .resize-cursor * {
cursor: col-resize !important;
}
/* Task lists (TipTap-specific data attribute) */
.tiptap-plan-wrapper .tiptap ul[data-type="taskList"] {
list-style: none !important;
@@ -0,0 +1,174 @@
import { act, cleanup, render, waitFor } from "@testing-library/react";
import type { Editor } from "@tiptap/core";
import { columnResizingPluginKey } from "@tiptap/pm/tables";
import { afterEach, describe, expect, it, vi } from "vitest";
const breakpoint = { isFinePointer: true, isMobile: false };
vi.mock("@/components/theme/app-theme", () => ({
useTheme: () => ({ resolvedTheme: "light" }),
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => breakpoint,
}));
vi.mock("@/components/shared/mermaid-error-toast", () => ({
useMermaidErrorToast: () => undefined,
}));
vi.mock("./plan-bubble-menu", () => ({ PlanBubbleMenu: () => null }));
vi.mock("./plan-drag-handle", () => ({ PlanDragHandle: () => null }));
vi.mock("./plan-slash-menu", () => ({ PlanSlashMenu: () => null }));
import { TipTapPlanEditor } from "./tiptap-plan-editor";
const TABLE_MARKDOWN = ["| Left | Right |", "| --- | --- |", "| One | Two |"].join("\n");
function hasPluginKeyPrefix(editor: Editor, prefix: string): boolean {
return editor.state.plugins.some((plugin) => {
const { key } = plugin as unknown as { key?: unknown };
return typeof key === "string" && key.startsWith(prefix);
});
}
function getMarkdown(editor: Editor): string {
const { markdown } = editor.storage as unknown as {
markdown: { getMarkdown: () => string };
};
return markdown.getMarkdown();
}
async function renderReadyEditor(onChange: (value: string) => void): Promise<Editor> {
let readyEditor: Editor | null = null;
render(
<TipTapPlanEditor
taskId="task-1"
value={TABLE_MARKDOWN}
onChange={onChange}
onEditorReady={(editor) => {
readyEditor = editor;
}}
/>,
);
await waitFor(() => expect(readyEditor).not.toBeNull());
return readyEditor!;
}
function getFirstTableHeader(editor: Editor) {
let headerPosition: number | null = null;
editor.state.doc.descendants((node, position) => {
if (node.type.name !== "tableHeader" || headerPosition !== null) return true;
headerPosition = position;
return false;
});
if (headerPosition === null) throw new Error("table header was not parsed");
const header = editor.state.doc.nodeAt(headerPosition);
if (!header) throw new Error("table header node was not available");
return { header, headerPosition };
}
afterEach(() => {
cleanup();
breakpoint.isFinePointer = true;
breakpoint.isMobile = false;
});
describe("TipTapPlanEditor table resizing", () => {
it("recreates the resizable table view when pointer capability changes", async () => {
const readyEditors: Editor[] = [];
let latestMarkdown = TABLE_MARKDOWN;
const handleReady = (editor: Editor) => readyEditors.push(editor);
const handleChange = (value: string) => {
latestMarkdown = value;
};
const view = render(
<TipTapPlanEditor
taskId="task-1"
value={TABLE_MARKDOWN}
onChange={handleChange}
onEditorReady={handleReady}
/>,
);
await waitFor(() => {
expect(readyEditors.length).toBe(1);
});
const desktopEditor = readyEditors[0];
expect(hasPluginKeyPrefix(desktopEditor, "tableColumnResizing$")).toBe(true);
expect(
view.container.querySelector("table")?.parentElement?.classList.contains("tableWrapper"),
).toBe(true);
act(() => {
desktopEditor.commands.setContent(`${TABLE_MARKDOWN}\n\nDraft survives`);
});
await waitFor(() => expect(latestMarkdown).toContain("Draft survives"));
breakpoint.isFinePointer = false;
view.rerender(
<TipTapPlanEditor
taskId="task-1"
value={latestMarkdown}
onChange={handleChange}
onEditorReady={handleReady}
/>,
);
await waitFor(() => {
expect(readyEditors.length).toBe(2);
});
const coarsePointerEditor = readyEditors[1];
expect(coarsePointerEditor).not.toBe(desktopEditor);
expect(hasPluginKeyPrefix(coarsePointerEditor, "tableColumnResizing$")).toBe(false);
expect(getMarkdown(coarsePointerEditor)).toContain("Draft survives");
expect(
view.container.querySelector("table")?.parentElement?.classList.contains("tableWrapper"),
).toBe(true);
});
it("keeps transient column widths out of Markdown updates", async () => {
const onChange = vi.fn();
const editor = await renderReadyEditor(onChange);
const baseline = getMarkdown(editor);
const { header, headerPosition } = getFirstTableHeader(editor);
act(() => {
editor.view.dispatch(
editor.state.tr.setNodeMarkup(headerPosition, undefined, {
...header.attrs,
colwidth: [160],
}),
);
});
expect(onChange).not.toHaveBeenCalled();
expect(getMarkdown(editor)).toBe(baseline);
expect(baseline).not.toContain("160");
});
it("does not emit Markdown for a native column-width transaction", async () => {
const onChange = vi.fn();
const editor = await renderReadyEditor(onChange);
const { header, headerPosition } = getFirstTableHeader(editor);
act(() => {
editor.view.dispatch(
editor.state.tr.setMeta(columnResizingPluginKey, {
setDragging: { startX: 0, startWidth: 100 },
}),
);
});
onChange.mockClear();
act(() => {
editor.view.dispatch(
editor.state.tr.setNodeMarkup(headerPosition, undefined, {
...header.attrs,
colwidth: [160],
}),
);
});
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -33,6 +33,8 @@ import type { MenuState } from "@/components/task/chat/tiptap-suggestion";
import { DOMParser as PmDOMParser } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/core";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { MIN_MARKDOWN_COLUMN_WIDTH } from "@/lib/markdown/table-resize";
export type { CommentForEditor };
@@ -57,6 +59,13 @@ type TipTapPlanEditorProps = {
const lowlight = createLowlight(common);
function serializePlanMarkdown(editor: Editor): string {
const { markdown } = editor.storage as unknown as {
markdown?: { getMarkdown?: () => string };
};
return markdown?.getMarkdown?.() ?? editor.getText();
}
/** Regex matching common markdown syntax signals. */
const MD_SIGNALS = /^#{1,6}\s|^\s*[-*+]\s|^\s*\d+\.\s|```|\*\*|__|\[.+\]\(/m;
@@ -102,6 +111,7 @@ function buildEditorExtensions(
slashExtension: ReturnType<typeof createPlanSlashExtension>,
onOrphanedComments: (ids: string[]) => void,
taskId: string | null,
tableResizeEnabled: boolean,
) {
return [
StarterKit.configure({ codeBlock: false }),
@@ -113,7 +123,13 @@ function buildEditorExtensions(
Underline,
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: false }),
Table.configure({
resizable: tableResizeEnabled,
renderWrapper: true,
handleWidth: 10,
cellMinWidth: MIN_MARKDOWN_COLUMN_WIDTH,
lastColumnResizable: false,
}),
TableRow,
TableCell,
TableHeader,
@@ -295,6 +311,8 @@ type PlanEditorState = {
/** Hook encapsulating TipTap editor setup, extensions, and lifecycle effects. */
function usePlanEditor(props: TipTapPlanEditorProps): PlanEditorState {
const { t } = useTranslation();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const tableResizeEnabled = isFinePointer && !isMobile;
const {
value,
onChange,
@@ -312,6 +330,7 @@ function usePlanEditor(props: TipTapPlanEditorProps): PlanEditorState {
const onCommentClickRef = useRef(onCommentClick);
const onCommentDeletedRef = useRef(onCommentDeleted);
const onEditorReadyRef = useRef(onEditorReady);
const serializedMarkdownRef = useRef<string | null>(null);
const [isReady, setIsReady] = useState(false);
const slash = useSlashMenu();
@@ -341,29 +360,49 @@ function usePlanEditor(props: TipTapPlanEditorProps): PlanEditorState {
/* eslint-disable react-hooks/refs -- stableOrphanHandler reads ref for deferred access, not during render */
const extensions = useMemo(
() => buildEditorExtensions(placeholder, slash.extension, stableOrphanHandler, props.taskId),
[placeholder, props.taskId, slash.extension, stableOrphanHandler],
() =>
buildEditorExtensions(
placeholder,
slash.extension,
stableOrphanHandler,
props.taskId,
tableResizeEnabled,
),
[placeholder, props.taskId, slash.extension, stableOrphanHandler, tableResizeEnabled],
);
/* eslint-enable react-hooks/refs */
const editor = useEditor({
immediatelyRender: false,
extensions,
content: value,
editorProps: {
attributes: { class: "tiptap-plan-editor", spellcheck: "false" },
handlePaste: pasteHandler,
const editor = useEditor(
{
immediatelyRender: false,
extensions,
content: value,
editorProps: {
attributes: { class: "tiptap-plan-editor", spellcheck: "false" },
handlePaste: pasteHandler,
},
onUpdate: ({ editor: ed }) => {
const markdown = serializePlanMarkdown(ed);
if (serializedMarkdownRef.current === null) {
serializedMarkdownRef.current = markdown;
return;
}
if (markdown === serializedMarkdownRef.current) return;
serializedMarkdownRef.current = markdown;
onChangeRef.current(markdown);
},
onBeforeCreate: () => {
serializedMarkdownRef.current = null;
setIsReady(false);
},
onCreate: ({ editor: ed }) => {
serializedMarkdownRef.current = serializePlanMarkdown(ed);
setIsReady(true);
onEditorReadyRef.current?.(ed);
},
},
onUpdate: ({ editor: ed }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (ed.storage as any).markdown?.getMarkdown?.() as string | undefined;
onChangeRef.current(md ?? ed.getText());
},
onCreate: ({ editor: ed }) => {
setIsReady(true);
onEditorReadyRef.current?.(ed);
},
});
[tableResizeEnabled],
);
useEffect(() => {
editorRef.current = editor;
@@ -1,20 +1,29 @@
"use client";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
import type { ReactNode, Ref } from "react";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { canResizeColumnBoundary, MIN_MARKDOWN_COLUMN_WIDTH } from "@/lib/markdown/table-resize";
import { useMarkdownTableResize } from "./use-markdown-table-resize";
export function ResizableMarkdownTable({ children }: { children?: ReactNode }) {
type ResizableMarkdownTableProps = {
children?: ReactNode;
renderWrapper?: (props: {
children: ReactNode;
className: string;
elementRef: Ref<HTMLDivElement>;
}) => ReactNode;
};
export function ResizableMarkdownTable({ children, renderWrapper }: ResizableMarkdownTableProps) {
const { t } = useTranslation();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const resizeEnabled = isFinePointer && !isMobile;
const resize = useMarkdownTableResize(resizeEnabled);
const displayedWidths = resize.columnWidths ?? resize.geometry?.columnWidths;
return (
<div ref={resize.wrapperRef} className="markdown-table-scroll overflow-x-auto">
const tableContent = (
<>
<table
ref={resize.tableRef}
style={
@@ -74,6 +83,20 @@ export function ResizableMarkdownTable({ children }: { children?: ReactNode }) {
/>
);
})}
</>
);
if (renderWrapper) {
return renderWrapper({
children: tableContent,
className: "markdown-table-scroll overflow-x-auto",
elementRef: resize.wrapperRef,
});
}
return (
<div ref={resize.wrapperRef} className="markdown-table-scroll overflow-x-auto">
{tableContent}
</div>
);
}
@@ -59,4 +59,24 @@ describe("MarkdownPreviewContent external file action", () => {
size: "sm",
});
});
it("keeps source metadata on the shared table scroll root", () => {
render(
<TooltipProvider>
<MarkdownPreviewContent
path="table.md"
content={["| Left | Right |", "| --- | --- |", "| One | Two |"].join("\n")}
onTogglePreview={vi.fn()}
/>
</TooltipProvider>,
);
const table = screen.getByRole("table");
const wrapper = table.parentElement;
expect(wrapper?.classList.contains("markdown-table-scroll")).toBe(true);
expect(wrapper?.classList.contains("overflow-x-auto")).toBe(true);
expect(wrapper?.getAttribute("data-md-source-start")).toBe("1");
expect(wrapper?.getAttribute("data-md-source-end")).toBe("3");
expect(wrapper?.querySelectorAll(".overflow-x-auto")).toHaveLength(0);
});
});
@@ -11,6 +11,7 @@ import {
useState,
type HTMLAttributes,
type ReactNode,
type Ref,
} from "react";
import { createPortal } from "react-dom";
import ReactMarkdown, { type ExtraProps, type Components } from "react-markdown";
@@ -24,6 +25,7 @@ import {
remarkPlugins,
markdownComponents,
} from "@/components/shared/markdown-components";
import { ResizableMarkdownTable } from "@/components/shared/resizable-markdown-table";
import { cn, toRelativePath } from "@/lib/utils";
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
import { EditorCommentPopover } from "@/components/task/editor-comment-popover";
@@ -140,6 +142,7 @@ type PositionedNode = {
type SourceBlockProps = HTMLAttributes<HTMLElement> &
ExtraProps & {
children?: ReactNode;
elementRef?: Ref<HTMLElement>;
node?: PositionedNode;
tag: keyof HTMLElementTagNameMap;
};
@@ -193,7 +196,15 @@ function CommentBadge({
);
}
function SourceBlock({ tag, node, children, className, onClick, ...rest }: SourceBlockProps) {
function SourceBlock({
tag,
node,
children,
className,
elementRef,
onClick,
...rest
}: SourceBlockProps) {
const commentContext = useContext(PreviewCommentContext);
const range = sourceRangeFromNode(node);
const comments = commentContext?.comments ?? [];
@@ -227,6 +238,7 @@ function SourceBlock({ tag, node, children, className, onClick, ...rest }: Sourc
className,
),
onClick: handleClick,
ref: elementRef,
},
children,
hasCommentBadge && tag !== "pre" ? <CommentBadge onClick={handleBadgeClick} /> : null,
@@ -243,9 +255,15 @@ function SourceBlock({ tag, node, children, className, onClick, ...rest }: Sourc
function MarkdownPreviewTable({ node, children }: MarkdownSourceBlockProps) {
return (
<SourceBlock tag="div" node={node} className="overflow-x-auto">
<table>{children}</table>
</SourceBlock>
<ResizableMarkdownTable
renderWrapper={({ children: tableContent, className, elementRef }) => (
<SourceBlock tag="div" node={node} className={className} elementRef={elementRef}>
{tableContent}
</SourceBlock>
)}
>
{children}
</ResizableMarkdownTable>
);
}
@@ -198,6 +198,80 @@ test.describe("Markdown preview", () => {
expect(await testPage.evaluate(() => "markdownXss" in window)).toBe(false);
});
test("resizes adjacent columns in a rendered Markdown file preview", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// Covers AC-UI-RESIZABLE-MARKDOWN-TABLES-001.1, .3, and .9.
const fileName = "resizable-table.md";
const marker = "Preview table resize marker";
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
fs.writeFileSync(
path.join(repoDir, fileName),
[
"| Setting | Effect | Notes |",
"| --- | --- | --- |",
`| strictDepBuilds | Blocks unapproved scripts | ${marker} |`,
"| allowBuilds | Allows approved scripts | Ephemeral width |",
].join("\n"),
);
const { session } = await seedTaskWithSession(
testPage,
apiClient,
seedData,
"Markdown Preview Table Resize Test",
);
await openFileInPreview(testPage, session, fileName);
const preview = testPage.getByTestId("markdown-preview");
const table = preview.locator("table", { hasText: marker });
const wrapper = table.locator("xpath=..");
const cells = table.locator("tbody tr").first().locator("td");
const separator = preview.getByTestId("markdown-table-resizer-0");
await expect(table).toBeVisible();
await expect(separator).toBeVisible();
await expect(separator).toHaveAccessibleName("Resize table columns 1 and 2");
await expect(wrapper).toHaveAttribute("data-md-source-start", "1");
await expect(wrapper).toHaveClass(/markdown-table-scroll/);
const initialWidths = await cells.evaluateAll((elements) =>
elements.map((element) => element.getBoundingClientRect().width),
);
const initialTableWidth = await table.evaluate(
(element) => element.getBoundingClientRect().width,
);
const [separatorBox, cellBox] = await Promise.all([
separator.boundingBox(),
cells.first().boundingBox(),
]);
expect(separatorBox).not.toBeNull();
expect(cellBox).not.toBeNull();
const boundaryX = separatorBox!.x + separatorBox!.width / 2;
const rowY = cellBox!.y + cellBox!.height / 2;
await testPage.mouse.move(boundaryX, rowY);
await testPage.mouse.down();
await testPage.mouse.move(boundaryX + 40, rowY);
await testPage.mouse.up();
const resizedWidths = await cells.evaluateAll((elements) =>
elements.map((element) => element.getBoundingClientRect().width),
);
expect(resizedWidths[0] - initialWidths[0]).toBeCloseTo(40, 0);
expect(initialWidths[1] - resizedWidths[1]).toBeCloseTo(40, 0);
expect(resizedWidths[2]).toBeCloseTo(initialWidths[2], 0);
expect(await table.evaluate((element) => element.getBoundingClientRect().width)).toBeCloseTo(
initialTableWidth,
0,
);
expect(
await preview.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
).toBe(true);
});
test("markdown preview persists across page refresh", async ({
testPage,
apiClient,
@@ -312,6 +312,58 @@ test.describe("Mobile file viewer panel", () => {
});
});
test("wide Markdown preview tables scroll locally without resize controls", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// Covers AC-UI-RESIZABLE-MARKDOWN-TABLES-001.12.
test.setTimeout(90_000);
const marker = "Mobile file preview table marker";
const { filePath } = await setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle: "Mobile FV Markdown Table",
options: {
extension: "md",
content: [
"| Status | Owner | Project | Branch | Review | Build | Deploy | Notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
`| Ready | Team Alpha | Kandev | main | Pending | Passing | Staged | ${marker} |`,
].join("\n"),
},
});
await testPage.getByRole("button", { name: "Files" }).tap();
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 5_000 });
await viewer.getByTestId("markdown-preview-toggle").tap();
const preview = viewer.getByTestId("markdown-preview");
const table = preview.locator("table", { hasText: marker });
const wrapper = table.locator("xpath=..");
await expect(table).toBeVisible();
await expect(preview.getByTestId(/^markdown-table-resizer-/)).toHaveCount(0);
expect(await wrapper.evaluate((element) => element.scrollWidth > element.clientWidth + 1)).toBe(
true,
);
expect(
await preview.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
).toBe(true);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1,
),
).toBe(true);
});
test("viewer header shows full path for files inside subdirectories", async ({
testPage,
apiClient,
@@ -0,0 +1,61 @@
// Filename starts with "mobile-" so this runs under the mobile-chrome project.
import { test, expect } from "../../fixtures/test-base";
import { planScript } from "../../helpers/seed-session-messages";
import { SessionPage } from "../../pages/session-page";
const PLAN_MARKER = "Mobile Plan table marker";
const WIDE_PLAN_MARKDOWN = [
"## Mobile table",
"",
"| Status | Owner | Project | Branch | Review | Build | Deploy | Notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
`| Ready | Team Alpha | Kandev | main | Pending | Passing | Staged | ${PLAN_MARKER} |`,
].join("\n");
test.describe("mobile: Plan table column resizing", () => {
test("keeps wide tables locally scrollable without resize controls", async ({
testPage,
apiClient,
seedData,
}) => {
// Covers AC-UI-RESIZABLE-MARKDOWN-TABLES-001.12.
test.setTimeout(120_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Plan table",
seedData.agentProfileId,
{
description: planScript(WIDE_PLAN_MARKDOWN),
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect.poll(() => apiClient.getTaskPlan(task.id), { timeout: 30_000 }).not.toBeNull();
await session.waitForChatIdle({ timeout: 45_000 });
await session.composerReady();
await session.togglePlanMode();
await testPage.getByRole("button", { name: "Plan", exact: true }).tap();
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
const editor = session.planEditor();
const table = editor.locator("table", { hasText: PLAN_MARKER });
const wrapper = table.locator("xpath=..");
await expect(table).toBeVisible({ timeout: 15_000 });
await expect(editor.locator(".column-resize-handle")).toHaveCount(0);
await expect(editor.locator(".resize-cursor")).toHaveCount(0);
await expect(wrapper).toHaveClass(/tableWrapper/);
expect(await wrapper.evaluate((element) => element.scrollWidth > element.clientWidth + 1)).toBe(
true,
);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1,
),
).toBe(true);
});
});
@@ -0,0 +1,127 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { planScript } from "../../helpers/seed-session-messages";
import { dwell } from "../../helpers/causal-waits";
import { SessionPage } from "../../pages/session-page";
const PLAN_MARKER = "Plan table resize marker";
const PLAN_MARKDOWN = [
"## Resizable table",
"",
"| Setting | Effect | Notes |",
"| --- | --- | --- |",
`| strictDepBuilds | Blocks unapproved scripts | ${PLAN_MARKER} |`,
"| allowBuilds | Allows approved scripts | Ephemeral width |",
].join("\n");
async function openPlanPanel(session: SessionPage) {
if (await session.planPanel.isVisible()) return;
await session.togglePlanMode();
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
}
async function seedPlanTable(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Resizable Plan table",
seedData.agentProfileId,
{
description: planScript(PLAN_MARKDOWN),
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect.poll(() => apiClient.getTaskPlan(task.id), { timeout: 30_000 }).not.toBeNull();
const initialPlan = await apiClient.getTaskPlan(task.id);
await session.waitForChatIdle({ timeout: 45_000 });
await session.composerReady();
await openPlanPanel(session);
await expect(session.planPanel).toContainText(PLAN_MARKER, { timeout: 15_000 });
if (!initialPlan) throw new Error("seeded plan was not available");
return { initialPlan: initialPlan.content, session, taskId: task.id };
}
test.describe("Plan table column resizing", () => {
test("resizes an internal boundary without persisting the width", async ({
testPage,
apiClient,
seedData,
}) => {
// Covers AC-UI-RESIZABLE-MARKDOWN-TABLES-001.10 and .11.
test.setTimeout(120_000);
await testPage.setViewportSize({ width: 1100, height: 800 });
const { initialPlan, session, taskId } = await seedPlanTable(testPage, apiClient, seedData);
const editor = session.planEditor();
const table = editor.locator("table", { hasText: PLAN_MARKER });
const wrapper = table.locator("xpath=..");
const firstHeader = table.locator("th").first();
await expect(table).toBeVisible();
const initialHeaderBox = await firstHeader.boundingBox();
expect(initialHeaderBox).not.toBeNull();
const boundaryY = initialHeaderBox!.y + initialHeaderBox!.height / 2;
await testPage.mouse.move(initialHeaderBox!.x + initialHeaderBox!.width - 2, boundaryY);
const handle = editor.locator(".column-resize-handle").first();
await expect(handle).toBeVisible();
const initialWidth = initialHeaderBox!.width;
const boundaryX = initialHeaderBox!.x + initialHeaderBox!.width - 2;
await testPage.mouse.down();
await testPage.mouse.move(boundaryX + 60, boundaryY);
await testPage.mouse.up();
const grownWidth = await firstHeader.evaluate(
(element) => element.getBoundingClientRect().width,
);
expect(grownWidth - initialWidth).toBeCloseTo(60, 0);
const grownHeaderBox = await firstHeader.boundingBox();
expect(grownHeaderBox).not.toBeNull();
const grownBoundaryX = grownHeaderBox!.x + grownHeaderBox!.width - 2;
const grownBoundaryY = grownHeaderBox!.y + grownHeaderBox!.height / 2;
await testPage.mouse.move(grownBoundaryX, grownBoundaryY);
await testPage.mouse.down();
await testPage.mouse.move(grownBoundaryX - 1000, grownBoundaryY);
await testPage.mouse.up();
expect(
await firstHeader.evaluate((element) => element.getBoundingClientRect().width),
).toBeCloseTo(64, 0);
expect(
await wrapper.evaluate((element) =>
["auto", "scroll"].includes(getComputedStyle(element).overflowX),
),
).toBe(true);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1,
),
).toBe(true);
// Stay beyond the Plan panel's 1.5-second autosave window. A resize-only
// transaction must not enqueue a later canonicalized Markdown save.
await dwell(
testPage,
2_500,
"negative-assertion",
"a resize-only transaction must never trigger the 1.5-second Plan autosave, so there is no save event to await",
);
expect((await apiClient.getTaskPlan(taskId))?.content).toBe(initialPlan);
await testPage.reload();
await session.waitForLoad();
await openPlanPanel(session);
const reloadedFirstHeader = session.planEditor().locator("table th").first();
await expect(reloadedFirstHeader).toBeVisible({ timeout: 15_000 });
expect(
await reloadedFirstHeader.evaluate((element) => element.getBoundingClientRect().width),
).toBeGreaterThan(64);
expect((await apiClient.getTaskPlan(taskId))?.content).toBe(initialPlan);
});
});
@@ -0,0 +1,134 @@
---
created: 2026-08-24
status: implemented
requirements:
- REQ-UI-RESIZABLE-MARKDOWN-TABLES-001
system_design:
- ../../specs/ui/system-design/resizable-markdown-tables.md
legacy_specs: []
---
# Implementation Plan: Markdown Table Resize Surface Parity
## Overview
Extend the shipped chat table-column resize behavior to rendered Markdown file
previews, then enable the editor-native equivalent in the live Plan view. The
file-preview slice comes first because it reuses Kandev's existing shared
renderer and establishes source-comment and scroll-owner compatibility before
the Plan-specific TipTap integration.
## Scope
### In scope
- Rendered Markdown file previews reuse accessible shared table separators on
desktop fine-pointer layouts.
- File-preview source ranges, comments, links, and one local horizontal scroll
owner survive the shared renderer composition.
- Editable Plan tables use TipTap column dragging with the same responsive gate,
64-pixel minimum, internal-boundary scope, and ephemeral source-preserving
state.
- Desktop and mobile Playwright evidence covers both target surfaces.
### Out of scope
- Persisting widths or encoding them in Markdown.
- Touch resizing or a mobile width editor.
- Read-only plan revision and plan comparison renderers.
- Table row resizing, column reorder/hide, and non-Markdown data grids.
- Refactoring unrelated Markdown consumers or Plan editing behavior.
## Technical approach
### Rendered Markdown file previews
Update `MarkdownPreviewTable` in
`apps/web/components/task/markdown-preview-content.tsx` to compose
`ResizableMarkdownTable` instead of replacing it with a plain table. Extend the
shared component's wrapper composition only as far as needed to keep
`SourceBlock` source data, comment classes, and click handling on the table
surface.
Keep exactly one `.markdown-table-scroll` / `overflow-x-auto` owner. Do not put
the shared resizer inside the preview's current scrolling table wrapper. Resize
buttons must remain interactive targets under
`isInteractiveSourceClickTarget`, so dragging does not open comment UI.
Preserve the existing `MarkdownPreviewRenderer` raw-HTML safety chain and all
non-table component mappings.
### Editable Plan view
In `apps/web/components/editors/tiptap/tiptap-plan-editor.tsx`, derive resize
capability from `useResponsiveBreakpoint()` and pass it into
`buildEditorExtensions`. Configure TipTap `Table` with
`cellMinWidth: MIN_MARKDOWN_COLUMN_WIDTH`, `lastColumnResizable: false`, and a
fine-pointer handle width.
Make resize capability a `useEditor` creation dependency. A phone, pointer-mode,
or viewport capability change recreates the editor from parent-owned draft
Markdown, removes the resize plugin and width-only ProseMirror attributes, and
does not lose typed source text.
Add scoped styles in `apps/web/app/globals.css` for
`.tiptap-plan-wrapper .tableWrapper`, `.column-resize-handle`, and
`.resize-cursor`. The Plan table wrapper owns horizontal scrolling. Do not add
dependency-global selectors or import ProseMirror's global table stylesheet.
## Tests
- `AC-UI-RESIZABLE-MARKDOWN-TABLES-001.1`, `.3`, `.7`, `.8`, `.9`, `.11`,
and `.13`:
extend
`apps/web/components/task/markdown-preview-content.external-link.test.tsx`
and retain the existing geometry/hook tests under
`apps/web/components/shared/` and `apps/web/lib/markdown/`.
- `AC-UI-RESIZABLE-MARKDOWN-TABLES-001.10` and `.11`: use a rendered Plan table to
prove in `apps/web/components/editors/tiptap/tiptap-plan-editor.test.tsx` and
Playwright that responsive TipTap configuration changes geometry without
changing serialized Markdown.
- `AC-UI-RESIZABLE-MARKDOWN-TABLES-001.12`: cover file-preview and Plan mobile paths
in the configured Mobile Chrome project.
## E2E tests
- Extend `apps/web/e2e/tests/chat/markdown-preview.spec.ts` for the desktop Files
preview path. Drag an internal boundary and assert adjacent rendered widths,
table width, source attributes, and document containment.
- Extend `apps/web/e2e/tests/task/mobile-file-viewer.spec.ts` for the phone Files
preview path. Assert no separator, readable table content, local horizontal
containment, and zero document overflow.
- Add `apps/web/e2e/tests/task/plan-table-resize.spec.ts` for desktop. Open the
Plan panel, drag an internal table boundary, assert the 64-pixel clamp and
changed geometry, then verify the backend plan Markdown is unchanged and a
reload clears ephemeral widths.
- Add `apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts` for Mobile
Chrome. Open Plan from the phone bottom navigation and assert no resize
affordance, readable table content, and zero document overflow.
## Work orders
- [x] [Task 01: Extend resize controls to Markdown previews](task-01-extend-markdown-previews.md)
- [x] [Task 02: Enable resizing in editable Plan view](task-02-enable-plan-resizing.md)
## Verification results
Both work orders passed focused unit, typecheck, lint, desktop Playwright,
Mobile Chrome Playwright, and diff checks. Rendered Markdown file previews now
reuse the shared accessible resizer, while editable Plan tables use TipTap's
responsive resize plugin without persisting width state.
## Risks
- Preview source/comment markup currently owns the scroll wrapper. Naive nesting
would create two horizontal scroll owners and misalign separators.
- TipTap's native plugin stores temporary `colwidth` attributes in its mounted
ProseMirror document. Tests must prove the Markdown serializer and backend plan
remain unchanged.
- Changing resize capability requires editor recreation. Parent draft state must
be current so a viewport or pointer-mode transition cannot lose unsaved text.
- ProseMirror generates resize handles and wrapper class names. CSS and E2E
selectors must stay narrowly scoped to the pinned TipTap table contract.
- Desktop and mobile tests run against production assets; every source change
needs a rebuilt bundle through the managed E2E runner.
@@ -0,0 +1,121 @@
---
id: "01-extend-markdown-previews"
title: "Extend resize controls to Markdown previews"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-RESIZABLE-MARKDOWN-TABLES-001
acceptance_criteria:
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.1
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.3
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.7
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.8
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.9
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.11
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.12
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.13
system_design:
- ../../specs/ui/system-design/resizable-markdown-tables.md
---
# Task 01: Extend Resize Controls to Markdown Previews
## Summary
Make rendered Markdown file previews use the existing accessible table-resize
renderer. Preserve preview source/comment behavior and a single table-local
horizontal scroll owner across desktop and phone layouts.
## In scope
- Compose the shared resizable table into `MarkdownPreviewRenderer`.
- Preserve source-line attributes, comment highlighting and badges, links, and
interactive-target delegation.
- Keep one horizontal scroll owner and aligned separators.
- Add focused component, desktop Files-preview, and mobile Files-preview tests.
## Out of scope
- Changing shared resize geometry or chat behavior except for a proven defect.
- Changing raw-HTML sanitization.
- Adding touch resize controls.
- Changing review or share preview behavior outside inherited renderer effects.
## Acceptance
- A desktop rendered file table exposes the existing accessible separators and
preserves adjacent-only resize, reset, minimum-width, and cleanup behavior.
- Source-range comments and normal interactive content still work, and the table
has exactly one local horizontal scroll owner.
- A phone file preview exposes no separator, remains readable, and causes no
document horizontal overflow.
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps && pnpm --filter @kandev/web exec vitest run \
components/task/markdown-preview-content.test.ts \
components/task/markdown-preview-content.external-link.test.tsx \
components/shared/use-markdown-table-resize.test.ts \
lib/markdown/table-resize.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps && pnpm --filter @kandev/web exec eslint \
components/shared/resizable-markdown-table.tsx \
components/task/markdown-preview-content.tsx \
components/task/markdown-preview-content.test.ts \
e2e/tests/chat/markdown-preview.spec.ts \
e2e/tests/task/mobile-file-viewer.spec.ts)
(cd apps/web && pnpm e2e:run tests/chat/markdown-preview.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-file-viewer.spec.ts)
git diff --check
```
## Files likely touched
- `apps/web/components/shared/resizable-markdown-table.tsx`
- `apps/web/components/task/markdown-preview-content.tsx`
- `apps/web/components/task/markdown-preview-content.external-link.test.tsx`
- `apps/web/e2e/tests/chat/markdown-preview.spec.ts`
- `apps/web/e2e/tests/task/mobile-file-viewer.spec.ts`
## Dependencies
None.
## Risks
- Source/comment wrappers can accidentally become a second scroll owner.
- Preview comment delegation can treat a resize press as a table-comment click
unless the separator remains an interactive element.
- Wide-table separator geometry must use the shared scroll root's coordinate
system.
## Parallelism
`sequential`
## Inputs
- `docs/specs/ui/requirements/resizable-markdown-tables.md`
- `docs/specs/ui/system-design/resizable-markdown-tables.md`
- Existing chat renderer in
`apps/web/components/shared/resizable-markdown-table.tsx`
- Existing preview source/comment wrapper in
`apps/web/components/task/markdown-preview-content.tsx`
## Results
Implemented shared wrapper composition so rendered Markdown file previews use
the existing accessible table resizer while `SourceBlock` remains the single
scroll root and retains source/comment behavior.
Validation completed 2026-08-24:
- Focused Vitest suite: 18 tests passed.
- Web typecheck and scoped ESLint: passed.
- Desktop Markdown preview Playwright suite: 8 tests passed.
- Mobile Chrome file-viewer Playwright suite: 9 tests passed.
- `git diff --check`: passed.
@@ -0,0 +1,119 @@
---
id: "02-enable-plan-resizing"
title: "Enable resizing in editable Plan view"
status: done
wave: 2
depends_on:
- "01-extend-markdown-previews"
plan: "plan.md"
requirements:
- REQ-UI-RESIZABLE-MARKDOWN-TABLES-001
acceptance_criteria:
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.10
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.11
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.12
- AC-UI-RESIZABLE-MARKDOWN-TABLES-001.13
system_design:
- ../../specs/ui/system-design/resizable-markdown-tables.md
---
# Task 02: Enable Resizing in Editable Plan View
## Summary
Enable TipTap's native table-column drag behavior in the live Plan panel on
desktop fine-pointer layouts. Keep widths ephemeral and source-preserving, and
remove the resize plugin when the viewport or pointer capability becomes
ineligible.
## In scope
- Configure live Plan `Table` extensions with responsive resizing, a 64-pixel
minimum, and internal boundaries only.
- Recreate the editor from parent draft Markdown when resize capability changes.
- Add Plan-scoped wrapper, handle, cursor, and overflow CSS.
- Add focused desktop and Mobile Chrome Plan table E2E coverage.
## Out of scope
- Changing read-only revision previews or comparison dialogs.
- Persisting ProseMirror `colwidth` attributes.
- Replacing TipTap's table plugin with a custom resize engine.
- Adding Plan table authoring, row, or column management commands.
## Acceptance
- A desktop Plan table supports internal-boundary drag, enforces the 64-pixel
minimum, and remains contained by its local table wrapper.
- Width-only editor state never changes persisted plan Markdown and clears after
reload or capability loss without losing current draft text.
- Mobile and coarse-pointer Plan layouts expose no resize affordance, keep table
content readable, and cause no document horizontal overflow.
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps && pnpm --filter @kandev/web exec vitest run \
lib/markdown/table-resize.test.ts \
components/editors/tiptap/tiptap-plan-editor.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps && pnpm --filter @kandev/web exec eslint \
components/editors/tiptap/tiptap-plan-editor.tsx \
e2e/tests/task/plan-table-resize.spec.ts \
e2e/tests/task/mobile-plan-table-resize.spec.ts)
(cd apps/web && pnpm e2e:run tests/task/plan-table-resize.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-plan-table-resize.spec.ts)
git diff --check
```
## Files likely touched
- `apps/web/components/editors/tiptap/tiptap-plan-editor.tsx`
- `apps/web/components/editors/tiptap/tiptap-plan-editor.test.tsx`
- `apps/web/app/globals.css`
- `apps/web/e2e/tests/task/plan-table-resize.spec.ts`
- `apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts`
## Dependencies
- Task 01 establishes the target cross-surface responsive contract and completes
shared rendered-preview integration before the editor-specific variant.
## Risks
- `useEditor` recreation must use current parent draft content or it can discard
unsaved text on a responsive capability transition.
- TipTap's mounted `colwidth` attributes must stay absent from serialized and
persisted Markdown.
- ProseMirror handle classes are generated by the pinned table dependency, so
styles and selectors must remain Plan-scoped.
## Parallelism
`sequential`
## Inputs
- `docs/specs/ui/requirements/resizable-markdown-tables.md`
- `docs/specs/ui/system-design/resizable-markdown-tables.md`
- TipTap 3.19 `Table` and ProseMirror tables 1.8.5 resize contracts installed by
the workspace lockfile
- Existing mobile Plan entry in
`apps/web/components/task/mobile/session-mobile-layout.tsx`
## Results
Enabled TipTap's table resize plugin only on desktop fine-pointer layouts, with
the shared 64-pixel minimum, internal-boundary behavior, Plan-scoped overflow
and handle styles, and editor recreation when resize capability changes. Draft
Markdown survives that recreation, while transient `colwidth` state remains
absent from serialized and persisted Markdown.
Validation completed 2026-08-24:
- Focused Vitest suite: 10 tests passed.
- Web typecheck and scoped ESLint: passed.
- Desktop Plan table Playwright suite: 1 test passed, including the autosave window.
- Mobile Chrome Plan table Playwright suite: 1 test passed.
- `git diff --check`: passed.
+1 -1
View File
@@ -19,7 +19,7 @@ This catalog is the entry point for the system-oriented specification layout. Ea
| Release | [README](release/README.md) | complete | 5 requirements, 0 designs |
| System page | [README](system-page/README.md) | complete | 3 requirements, 5 designs |
| Tasks | [README](tasks/README.md) | complete | 69 requirements, 12 designs |
| UI | [README](ui/README.md) | complete | 96 requirements, 11 designs |
| UI | [README](ui/README.md) | complete | 96 requirements, 12 designs |
| Workspaces | [README](workspaces/README.md) | complete | 10 requirements, 2 designs |
## Migration record
+1
View File
@@ -152,6 +152,7 @@ contract that other capabilities can reuse.
- [Persistent status motion](system-design/persistent-status-motion.md)
- [Prompt History Panel](system-design/prompt-history-panel.md)
- [Quick Chat and Terminal Tabs](system-design/quick-terminal.md)
- [Resizable Markdown Table Columns](system-design/resizable-markdown-tables.md)
- [Task Layout Profiles](system-design/task-layout-profiles.md)
- [Task Transcript History Visibility](system-design/task-prompt-transcript-visibility.md)
@@ -9,13 +9,18 @@ owners:
## Overview
Model-generated Markdown tables often choose column proportions that are valid but inconvenient for the current chat width. Automatic wrapping must remain the safe default, but desktop users need a quick way to rebalance a table without editing the source Markdown or opening another view.
Model-generated Markdown tables often choose column proportions that are valid
but inconvenient for the current chat, rendered file preview, or editable Plan
view. Automatic wrapping must remain the safe default, but fine-pointer users
need a quick way to rebalance a table without editing its Markdown source.
## Requirements
### REQ-UI-RESIZABLE-MARKDOWN-TABLES-001: Resizable Markdown Table Columns
**Intent:** Model-generated Markdown tables often choose column proportions that are valid but inconvenient for the current chat width. Automatic wrapping must remain the safe default, but desktop users need a quick way to rebalance a table without editing the source Markdown or opening another view.
**Intent:** Provide consistent, source-preserving Markdown table resizing across
task-focused reading and planning surfaces without weakening responsive
readability.
#### Acceptance criteria
@@ -27,6 +32,24 @@ Model-generated Markdown tables often choose column proportions that are valid b
- **AC-UI-RESIZABLE-MARKDOWN-TABLES-001.6:** Double-clicking any separator clears every custom width on that table and returns it to the current automatic layout. It does not restore a stale pixel snapshot.
- **AC-UI-RESIZABLE-MARKDOWN-TABLES-001.7:** A focused separator supports `ArrowLeft` and `ArrowRight` in 8-pixel steps. `Enter` resets the whole table. Each control has `role="separator"`, an accessible name identifying its adjacent one-based column numbers, `aria-orientation="vertical"`, `aria-valuemin="64"`, `aria-valuenow` equal to the rounded current left-column width, and `aria-valuemax` equal to the adjacent pair total minus the 64-pixel right-column minimum.
- **AC-UI-RESIZABLE-MARKDOWN-TABLES-001.8:** Hover, keyboard focus, and active dragging make the complete boundary line visible. Normal links, selection, and scrolling remain interactive outside the separator's narrow hit area.
- **AC-UI-RESIZABLE-MARKDOWN-TABLES-001.9:** A rendered Markdown file preview
uses the shared resize separators while preserving source-line identity,
comment selection and badges, links, and one table-local horizontal scroll
owner.
- **AC-UI-RESIZABLE-MARKDOWN-TABLES-001.10:** An editable Plan table exposes
internal-boundary dragging on an eligible fine-pointer layout, enforces the
same 64 CSS-pixel minimum, remains contained by its local wrapper, and leaves
the Plan Markdown unchanged.
- **AC-UI-RESIZABLE-MARKDOWN-TABLES-001.11:** Resize state remains ephemeral
across unmounts, reloads, and render locations and is never written to
Markdown, task-plan data, browser storage, or backend state.
- **AC-UI-RESIZABLE-MARKDOWN-TABLES-001.12:** Phone and coarse-pointer layouts
expose no resize affordance and preserve content access through automatic
wrapping or one table-local horizontal scroll region without document-level
horizontal overflow.
- **AC-UI-RESIZABLE-MARKDOWN-TABLES-001.13:** If resize capability disappears,
the active interaction ends, cursor and selection overrides clear, and the
table returns to a readable responsive layout without losing Plan draft text.
## Migrated source detail
@@ -0,0 +1,177 @@
---
status: current
system: ui
requirements:
- REQ-UI-RESIZABLE-MARKDOWN-TABLES-001
---
# Resizable Markdown Tables System Design
## Purpose and boundaries
This design extends the existing chat table-resize behavior to rendered
Markdown file previews and the editable Plan view. It keeps resize state inside
the mounted renderer and introduces no API, persistence, Markdown syntax, or
permission change.
Rendered Markdown and Plan tables use different document engines. ReactMarkdown
surfaces reuse Kandev's existing accessible adjacent-column overlay. The Plan
editor uses TipTap's table-resize integration because ProseMirror owns its table
DOM and editing transactions. Both engines share capability gating, the
64-pixel minimum, ephemeral state, and table-local overflow policy.
## Requirement mapping
| Requirement | Design section |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REQ-UI-RESIZABLE-MARKDOWN-TABLES-001` | [Surface contracts](#surface-contracts), [Rendered Markdown flow](#rendered-markdown-flow), [Plan editor flow](#plan-editor-flow), [Responsive and mobile contract](#responsive-and-mobile-contract) |
## Surface contracts
| Surface | Renderer | Desktop fine-pointer behavior | Phone or coarse-pointer behavior |
| ------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------- |
| Chat Markdown | ReactMarkdown plus `ResizableMarkdownTable` | Existing accessible adjacent-column separators | Existing wrapping and local scrolling; no separators |
| Rendered Markdown file preview | `MarkdownPreviewRenderer` plus source-aware table wrapper | Same shared separators while retaining preview comments and source ranges | Existing mobile file-viewer preview; no separators |
| Editable Plan view | TipTap `Table` extension | Native internal-boundary drag with a 64-pixel minimum | Resizing disabled; existing Plan panel remains readable |
Read-only plan revision and comparison renderers remain unchanged. Other
ReactMarkdown consumers that already use `markdownComponents` continue to
inherit the shared renderer contract.
## Components and responsibilities
- `apps/web/components/shared/resizable-markdown-table.tsx` owns semantic table
markup, separator rendering, responsive capability checks, and the single
table-local scroll root for ReactMarkdown surfaces.
- `apps/web/components/shared/use-markdown-table-resize.ts` owns geometry,
pointer capture, keyboard resizing, reset behavior, streamed-content
remeasurement, and cleanup for rendered Markdown tables.
- `apps/web/components/task/markdown-preview-content.tsx` attaches source-line
and comment behavior to the shared resizable table without introducing a
nested horizontal scroll owner.
- `apps/web/components/editors/tiptap/tiptap-plan-editor.tsx` configures TipTap's
editable table extension for eligible layouts. TipTap remains the authority
for ProseMirror transactions and editor DOM.
- `apps/web/hooks/use-responsive-breakpoint.ts` supplies the phone and pointer
capability used by both renderer paths.
- `apps/web/app/globals.css` owns narrowly scoped preview and Plan table resize
geometry, cursor feedback, and overflow containment.
## Rendered Markdown flow
`markdownComponents.table` continues to resolve to `ResizableMarkdownTable`.
The file-preview component currently replaces that mapping with a plain
source-aware wrapper. The replacement will instead compose source-range and
comment metadata onto the shared resizable table root.
The composition must retain exactly one `overflow-x-auto` owner around each
table. The same element, or a non-scrolling parent plus the shared scroll root,
retains `data-md-source-start`, `data-md-source-end`, comment-state classes, and
click handling. Resize buttons remain interactive targets, so preview comment
click delegation does not open a comment while a user resizes.
The existing resize hook keeps its current contract:
1. Measure the browser's automatic first-row geometry.
2. Activate a fixed `<colgroup>` only after a non-zero adjustment.
3. Resize the adjacent pair through `resizeAdjacentColumns`, preserving pair and
table totals and enforcing `MIN_MARKDOWN_COLUMN_WIDTH`.
4. Remeasure after controlled width changes, wrapper changes, and streamed DOM
mutations.
5. Reset on Enter, double-click, column-count change, capability loss, or
unmount; restore the drag-start snapshot on pointer cancellation.
## Plan editor flow
The live Plan panel already uses TipTap's `Table`, `TableRow`, `TableCell`, and
`TableHeader` extensions. Its `Table` configuration changes from
`resizable: false` to capability-aware resizing with:
- `cellMinWidth` set to `MIN_MARKDOWN_COLUMN_WIDTH`;
- `lastColumnResizable: false`, so only internal boundaries act as resize
boundaries;
- a handle width large enough for reliable fine-pointer targeting without
covering normal cell interaction.
`TipTapPlanEditor` reads `useResponsiveBreakpoint()` and enables resizing only
when `isFinePointer && !isMobile`. The resize capability is a `useEditor`
creation dependency. Crossing that capability boundary recreates the editor
from the parent-owned `draftContent`, which removes ProseMirror resize plugins
and ephemeral `colwidth` attributes without losing typed Markdown.
During a drag, ProseMirror updates cell `colwidth` attributes and TipTap's table
node view updates its `<colgroup>`. The Markdown serializer does not encode
those attributes. Therefore a resize can reflow the mounted editor but cannot
change the plan source or schedule a distinct width-only backend value.
The Plan table wrapper is the local horizontal scroll owner. Scoped CSS styles
TipTap's `tableWrapper`, `column-resize-handle`, and `resize-cursor` classes
inside `.tiptap-plan-wrapper`; dependency-global selectors are not added.
## Responsive and mobile contract
- **Desktop outcome and entry point:** File-preview and Plan tables expose inline
boundary targets where the table already renders. No menu or extra step is
introduced.
- **Mobile entry points:** The existing mobile Files panel opens
`MobileFileViewerPanel` and its Markdown preview. The existing Plan bottom-nav
action opens `TaskPlanPanel` as the focused panel.
- **Nearest shipped exemplars:** Chat tables in
`mobile-markdown-wrap.spec.ts` provide the wrapping and table-local scrolling
contract. `MobileFileViewerPanel` provides the focused file-preview surface,
and `session-mobile-layout.tsx` provides the focused Plan surface.
- **Hierarchy and primary action:** Reading the file or editing the plan remains
primary. Optional precision resizing does not add controls that obscure
narrow cells.
- **Presentation:** Existing full-height file and Plan surfaces remain. No new
drawer, route, toolbar, or navigation branch is needed.
- **Scroll ownership:** Each surface keeps its current vertical owner. A wide
table owns only its local horizontal scroll; page and document horizontal
overflow stay zero.
- **Shared versus specialized behavior:** Markdown content, editor state,
minimum width, and responsive capability are shared. ReactMarkdown and TipTap
retain engine-specific resize implementations.
Mobile Playwright coverage opens both target surfaces, confirms table content is
readable, confirms no resize affordance appears, and checks document overflow.
## Failure and recovery
- Missing or invalid rendered-table geometry leaves automatic layout active and
exposes no separator.
- Preview source-line metadata remains authoritative even when a table cannot be
resized.
- Pointer cancellation or capability loss clears global cursor and selection
overrides in the shared renderer.
- TipTap owns mouse listener teardown when the editor or resize plugin is
destroyed. Recreating the editor on capability loss restores current parent
Markdown and discards width-only attributes.
- A table narrower than its content scrolls inside its local wrapper instead of
widening the file preview, Plan panel, or document.
## Persistence and compatibility
No schema, API, local-storage, session-storage, or Markdown-format change is
introduced. Existing stored chat messages, files, plans, and revisions remain
compatible. Widths exist only in mounted React state or the mounted ProseMirror
document and disappear on remount or reload.
## Security
The change does not broaden Markdown HTML handling. File previews retain the
existing `rehype-raw` followed immediately by `rehype-sanitize` boundary.
Resize interactions do not add HTML input, URL handling, or backend calls.
## Verification strategy
- Focused Vitest coverage keeps rendered Markdown geometry, responsive reset,
source-wrapper composition, and Markdown serialization behavior explicit.
- Desktop Playwright resizes a table in the Files preview and a table in the
Plan panel, checking column geometry, source preservation, and local overflow.
- Mobile Chrome opens both surfaces, verifies no resize affordance, and checks
readable table content plus zero document overflow.
## Related decisions
No new architecture decision is required. The design extends the existing
renderer and editor boundaries without changing ownership or persistence.