From 024adbd55abe14e307395ca2a246957ae71691b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:03:25 +0000 Subject: [PATCH] feat(ui): extend markdown table resizing (#3037) --- apps/web/app/globals.css | 37 ++++ .../tiptap/tiptap-plan-editor.test.tsx | 174 +++++++++++++++++ .../editors/tiptap/tiptap-plan-editor.tsx | 79 ++++++-- .../shared/resizable-markdown-table.tsx | 31 ++- ...own-preview-content.external-link.test.tsx | 20 ++ .../task/markdown-preview-content.tsx | 26 ++- .../e2e/tests/chat/markdown-preview.spec.ts | 74 ++++++++ .../e2e/tests/task/mobile-file-viewer.spec.ts | 52 +++++ .../task/mobile-plan-table-resize.spec.ts | 61 ++++++ .../e2e/tests/task/plan-table-resize.spec.ts | 127 +++++++++++++ .../plan.md | 134 +++++++++++++ .../task-01-extend-markdown-previews.md | 121 ++++++++++++ .../task-02-enable-plan-resizing.md | 119 ++++++++++++ docs/specs/INDEX.md | 2 +- docs/specs/ui/README.md | 1 + .../requirements/resizable-markdown-tables.md | 27 ++- .../resizable-markdown-tables.md | 177 ++++++++++++++++++ 17 files changed, 1231 insertions(+), 31 deletions(-) create mode 100644 apps/web/components/editors/tiptap/tiptap-plan-editor.test.tsx create mode 100644 apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts create mode 100644 apps/web/e2e/tests/task/plan-table-resize.spec.ts create mode 100644 docs/plans/markdown-table-resize-surface-parity/plan.md create mode 100644 docs/plans/markdown-table-resize-surface-parity/task-01-extend-markdown-previews.md create mode 100644 docs/plans/markdown-table-resize-surface-parity/task-02-enable-plan-resizing.md create mode 100644 docs/specs/ui/system-design/resizable-markdown-tables.md diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index af8db92f29..1f15cb73c7 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.test.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.test.tsx new file mode 100644 index 0000000000..b6b58dd853 --- /dev/null +++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.test.tsx @@ -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 { + let readyEditor: Editor | null = null; + render( + { + 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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx index cc7bb3db16..ed11d72f24 100644 --- a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx +++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx @@ -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, 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(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; diff --git a/apps/web/components/shared/resizable-markdown-table.tsx b/apps/web/components/shared/resizable-markdown-table.tsx index 18f80a30aa..4a45d2491b 100644 --- a/apps/web/components/shared/resizable-markdown-table.tsx +++ b/apps/web/components/shared/resizable-markdown-table.tsx @@ -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; + }) => 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 ( -
+ const tableContent = ( + <> ); })} + + ); + + if (renderWrapper) { + return renderWrapper({ + children: tableContent, + className: "markdown-table-scroll overflow-x-auto", + elementRef: resize.wrapperRef, + }); + } + + return ( +
+ {tableContent}
); } diff --git a/apps/web/components/task/markdown-preview-content.external-link.test.tsx b/apps/web/components/task/markdown-preview-content.external-link.test.tsx index 3cbcfc2696..876ecfd468 100644 --- a/apps/web/components/task/markdown-preview-content.external-link.test.tsx +++ b/apps/web/components/task/markdown-preview-content.external-link.test.tsx @@ -59,4 +59,24 @@ describe("MarkdownPreviewContent external file action", () => { size: "sm", }); }); + + it("keeps source metadata on the shared table scroll root", () => { + render( + + + , + ); + + 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); + }); }); diff --git a/apps/web/components/task/markdown-preview-content.tsx b/apps/web/components/task/markdown-preview-content.tsx index f7b007174e..489ee52765 100644 --- a/apps/web/components/task/markdown-preview-content.tsx +++ b/apps/web/components/task/markdown-preview-content.tsx @@ -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 & ExtraProps & { children?: ReactNode; + elementRef?: Ref; 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" ? : null, @@ -243,9 +255,15 @@ function SourceBlock({ tag, node, children, className, onClick, ...rest }: Sourc function MarkdownPreviewTable({ node, children }: MarkdownSourceBlockProps) { return ( - -
{children}
- + ( + + {tableContent} + + )} + > + {children} + ); } diff --git a/apps/web/e2e/tests/chat/markdown-preview.spec.ts b/apps/web/e2e/tests/chat/markdown-preview.spec.ts index 2c55686bef..98c3ae6f63 100644 --- a/apps/web/e2e/tests/chat/markdown-preview.spec.ts +++ b/apps/web/e2e/tests/chat/markdown-preview.spec.ts @@ -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, diff --git a/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts index 081065e89d..c5d89d909a 100644 --- a/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts +++ b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts @@ -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, diff --git a/apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts b/apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts new file mode 100644 index 0000000000..5e33675ac6 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-plan-table-resize.spec.ts @@ -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); + }); +}); diff --git a/apps/web/e2e/tests/task/plan-table-resize.spec.ts b/apps/web/e2e/tests/task/plan-table-resize.spec.ts new file mode 100644 index 0000000000..41b79aef0e --- /dev/null +++ b/apps/web/e2e/tests/task/plan-table-resize.spec.ts @@ -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); + }); +}); diff --git a/docs/plans/markdown-table-resize-surface-parity/plan.md b/docs/plans/markdown-table-resize-surface-parity/plan.md new file mode 100644 index 0000000000..794eea270e --- /dev/null +++ b/docs/plans/markdown-table-resize-surface-parity/plan.md @@ -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. diff --git a/docs/plans/markdown-table-resize-surface-parity/task-01-extend-markdown-previews.md b/docs/plans/markdown-table-resize-surface-parity/task-01-extend-markdown-previews.md new file mode 100644 index 0000000000..d37cbea39d --- /dev/null +++ b/docs/plans/markdown-table-resize-surface-parity/task-01-extend-markdown-previews.md @@ -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. diff --git a/docs/plans/markdown-table-resize-surface-parity/task-02-enable-plan-resizing.md b/docs/plans/markdown-table-resize-surface-parity/task-02-enable-plan-resizing.md new file mode 100644 index 0000000000..bdf145fb5e --- /dev/null +++ b/docs/plans/markdown-table-resize-surface-parity/task-02-enable-plan-resizing.md @@ -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. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 5207432ec5..addd0ba0a4 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index d2c0653522..69ba6c96f1 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -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) diff --git a/docs/specs/ui/requirements/resizable-markdown-tables.md b/docs/specs/ui/requirements/resizable-markdown-tables.md index 270911317c..edf2590a65 100644 --- a/docs/specs/ui/requirements/resizable-markdown-tables.md +++ b/docs/specs/ui/requirements/resizable-markdown-tables.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 diff --git a/docs/specs/ui/system-design/resizable-markdown-tables.md b/docs/specs/ui/system-design/resizable-markdown-tables.md new file mode 100644 index 0000000000..8514c2a617 --- /dev/null +++ b/docs/specs/ui/system-design/resizable-markdown-tables.md @@ -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 `` 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 ``. 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.