feat(ui): extend markdown table resizing (#3037)
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user