feat(web): add responsive, resizable markdown tables (#2098)

This commit is contained in:
José Almeida
2026-08-06 15:40:58 +01:00
committed by GitHub
parent a5d6649f69
commit f94af8de7b
19 changed files with 1659 additions and 54 deletions
+41 -12
View File
@@ -1043,31 +1043,60 @@ html[data-rendering-engine="webkit"] [data-webkit-safe-motion="true"][data-state
table-layout: auto;
}
.markdown-table-scroll {
position: relative;
}
.markdown-table-resizer {
position: absolute;
z-index: 10;
width: 0.625rem;
padding: 0;
transform: translateX(-50%);
cursor: col-resize;
touch-action: none;
border: 0;
outline: none;
background: transparent;
}
.markdown-table-resizer::after {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
content: "";
transform: translateX(-50%);
background: transparent;
transition: background-color 100ms ease;
}
.markdown-table-resizer:hover::after,
.markdown-table-resizer:focus-visible::after,
.markdown-table-resizer[data-active="true"]::after {
background: var(--primary);
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
font-size: 13px;
vertical-align: top;
overflow-wrap: anywhere;
white-space: normal;
}
.markdown-body td {
overflow-wrap: anywhere;
/* Keep genuinely wide Markdown tables readable in their local scroll wrapper.
Ordinary two- and three-column tables use the available width and wrap. */
.markdown-body table:has(> thead > tr > th:nth-child(4)) :is(th, td) {
min-width: 8rem;
}
.markdown-body th {
background: var(--muted);
font-weight: 600;
white-space: nowrap;
}
/* Label/value tables (common in agent output) put short keys in the first
column. Keep that column on one line and sized to its content so a long
value cell can't starve it down to a few characters and wrap "Failing"
into "Failin g". */
.markdown-body td:first-child {
white-space: nowrap;
width: 1%;
}
.markdown-body td:not(:first-child) code {
@@ -7,6 +7,7 @@ import remarkGemoji from "remark-gemoji";
import { InlineCode } from "@/components/task/chat/messages/inline-code";
import { CodeBlock } from "@/components/task/chat/messages/code-block";
import { MermaidBlock } from "@/components/shared/mermaid-block";
import { ResizableMarkdownTable } from "@/components/shared/resizable-markdown-table";
import { isMermaidContent } from "@/components/editors/tiptap/tiptap-mermaid-extension";
import { usePanelActions } from "@/hooks/use-panel-actions";
import { useAppStore } from "@/components/state-provider";
@@ -233,9 +234,5 @@ function MarkdownCode({ className, children }: MarkdownCodeProps) {
export const markdownComponents = {
code: MarkdownCode,
a: MarkdownLink,
table: ({ children }: { children?: ReactNode }) => (
<div className="overflow-x-auto">
<table>{children}</table>
</div>
),
table: ResizableMarkdownTable,
};
@@ -0,0 +1,79 @@
"use client";
import { useTranslation } from "react-i18next";
import type { ReactNode } 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 }) {
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">
<table
ref={resize.tableRef}
style={
resize.columnWidths && resize.fixedTableWidth
? { tableLayout: "fixed", width: resize.fixedTableWidth }
: undefined
}
>
{resize.columnWidths && (
<colgroup>
{resize.columnWidths.map((width, index) => (
<col key={index} style={{ width }} />
))}
</colgroup>
)}
{children}
</table>
{resizeEnabled &&
resize.geometry?.boundaries.map((left, index) => {
const leftWidth = displayedWidths?.[index];
const rightWidth = displayedWidths?.[index + 1];
if (
!displayedWidths ||
leftWidth === undefined ||
rightWidth === undefined ||
!canResizeColumnBoundary(displayedWidths, index)
) {
return null;
}
return (
<button
key={index}
type="button"
role="separator"
aria-orientation="vertical"
aria-label={t("common:resizeTableColumns", { left: index + 1, right: index + 2 })}
aria-valuemin={MIN_MARKDOWN_COLUMN_WIDTH}
aria-valuemax={Math.max(
MIN_MARKDOWN_COLUMN_WIDTH,
leftWidth + rightWidth - MIN_MARKDOWN_COLUMN_WIDTH,
)}
aria-valuenow={Math.round(leftWidth)}
data-active={resize.activeBoundary === index}
data-testid={`markdown-table-resizer-${index}`}
className="markdown-table-resizer"
style={{
height: resize.geometry?.height,
left,
top: resize.geometry?.top,
}}
onDoubleClick={resize.reset}
onKeyDown={(event) => resize.resizeWithKeyboard(index, event)}
onPointerCancel={(event) => resize.finishDrag(event, true)}
onPointerDown={(event) => resize.startResize(index, event)}
onPointerMove={resize.moveResize}
onPointerUp={(event) => resize.finishDrag(event, false)}
/>
);
})}
</div>
);
}
@@ -0,0 +1,204 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMarkdownTableResize } from "./use-markdown-table-resize";
let notifyMutation: MutationCallback;
const observeMutation = vi.fn();
class ResizeObserverStub {
disconnect = vi.fn();
observe = vi.fn();
}
class MutationObserverStub {
disconnect = vi.fn();
observe = observeMutation;
constructor(callback: MutationCallback) {
notifyMutation = callback;
}
}
function rect(left: number, top: number, width: number, height: number): DOMRect {
return {
bottom: top + height,
height,
left,
right: left + width,
top,
width,
x: left,
y: top,
toJSON: () => ({}),
};
}
function createTableGeometry(widths: number[]) {
let measuredWidths = [...widths];
const wrapper = document.createElement("div");
const table = document.createElement("table");
const row = table.insertRow();
wrapper.append(table);
document.body.append(wrapper);
Object.defineProperty(wrapper, "getBoundingClientRect", {
value: () => rect(10, 20, 300, 100),
});
Object.defineProperty(table, "getBoundingClientRect", {
value: () => rect(10, 20, 300, 100),
});
widths.forEach((_, index) => {
const cell = row.insertCell();
Object.defineProperty(cell, "getBoundingClientRect", {
value: () => {
const left = 10 + measuredWidths.slice(0, index).reduce((sum, width) => sum + width, 0);
return rect(left, 20, measuredWidths[index] ?? 0, 30);
},
});
});
return {
row,
setMeasuredWidths: (next: number[]) => {
measuredWidths = next;
},
table,
wrapper,
};
}
function renderResizeHook() {
const rendered = renderHook(({ enabled }) => useMarkdownTableResize(enabled), {
initialProps: { enabled: false },
});
const elements = createTableGeometry([120, 180]);
act(() => {
rendered.result.current.tableRef.current = elements.table;
rendered.result.current.wrapperRef.current = elements.wrapper;
});
rendered.rerender({ enabled: true });
return { ...rendered, ...elements };
}
function keyboardEvent(key: string) {
return { key, preventDefault: vi.fn() };
}
beforeEach(() => {
observeMutation.mockClear();
vi.stubGlobal("ResizeObserver", ResizeObserverStub);
vi.stubGlobal("MutationObserver", MutationObserverStub);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("useMarkdownTableResize", () => {
it("observes streamed text mutations that can rebalance automatic columns", () => {
const { table } = renderResizeHook();
expect(observeMutation).toHaveBeenCalledWith(table, {
characterData: true,
childList: true,
subtree: true,
});
});
it("resizes with arrow keys and resets with Enter", () => {
const { result } = renderResizeHook();
const right = keyboardEvent("ArrowRight");
const left = keyboardEvent("ArrowLeft");
const enter = keyboardEvent("Enter");
act(() => result.current.resizeWithKeyboard(0, right as never));
expect(right.preventDefault).toHaveBeenCalledOnce();
expect(result.current.columnWidths).toEqual([128, 172]);
expect(result.current.fixedTableWidth).toBe(300);
act(() => result.current.resizeWithKeyboard(0, left as never));
expect(left.preventDefault).toHaveBeenCalledOnce();
expect(result.current.columnWidths).toEqual([120, 180]);
act(() => result.current.resizeWithKeyboard(0, enter as never));
expect(enter.preventDefault).toHaveBeenCalledOnce();
expect(result.current.columnWidths).toBeNull();
expect(result.current.fixedTableWidth).toBeNull();
});
it("remeasures separator geometry when controlled widths change", () => {
const { result, setMeasuredWidths } = renderResizeHook();
expect(result.current.geometry?.boundaries[0]).toBe(120);
setMeasuredWidths([140, 160]);
act(() => result.current.resizeWithKeyboard(0, keyboardEvent("ArrowRight") as never));
expect(result.current.geometry?.boundaries[0]).toBe(140);
});
it("clears custom widths when resizing becomes disabled", () => {
const { result, rerender } = renderResizeHook();
act(() => result.current.resizeWithKeyboard(0, keyboardEvent("ArrowRight") as never));
expect(result.current.columnWidths).not.toBeNull();
rerender({ enabled: false });
expect(result.current.columnWidths).toBeNull();
expect(result.current.fixedTableWidth).toBeNull();
});
it("clears an active drag and document resize state when resizing becomes disabled", () => {
const { result, rerender } = renderResizeHook();
const pointer = {
button: 0,
clientX: 130,
currentTarget: { setPointerCapture: vi.fn() },
pointerId: 7,
preventDefault: vi.fn(),
};
act(() => result.current.startResize(0, pointer as never));
expect(result.current.activeBoundary).toBe(0);
expect(document.body.style.cursor).toBe("col-resize");
expect(document.body.style.userSelect).toBe("none");
rerender({ enabled: false });
expect(result.current.activeBoundary).toBeNull();
expect(document.body.style.cursor).toBe("");
expect(document.body.style.userSelect).toBe("");
});
it("keeps automatic layout when a pointer drag ends without movement", () => {
const { result } = renderResizeHook();
const target = {
hasPointerCapture: vi.fn(() => true),
releasePointerCapture: vi.fn(),
setPointerCapture: vi.fn(),
};
const pointer = {
button: 0,
clientX: 130,
currentTarget: target,
pointerId: 7,
preventDefault: vi.fn(),
};
act(() => result.current.startResize(0, pointer as never));
act(() => result.current.finishDrag(pointer as never, false));
expect(result.current.columnWidths).toBeNull();
expect(result.current.fixedTableWidth).toBeNull();
expect(target.releasePointerCapture).toHaveBeenCalledWith(7);
});
it("clears custom widths when the table column count changes", () => {
const { result, row } = renderResizeHook();
act(() => result.current.resizeWithKeyboard(0, keyboardEvent("ArrowRight") as never));
row.insertCell();
act(() => notifyMutation([], {} as MutationObserver));
expect(result.current.columnWidths).toBeNull();
expect(result.current.fixedTableWidth).toBeNull();
});
});
@@ -0,0 +1,262 @@
"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type KeyboardEvent,
type PointerEvent,
type RefObject,
type SetStateAction,
} from "react";
import { resizeAdjacentColumns } from "@/lib/markdown/table-resize";
type TableGeometry = {
boundaries: number[];
columnWidths: number[];
height: number;
tableWidth: number;
top: number;
};
type DragState = {
boundaryIndex: number;
pointerId: number;
restoreTableWidth: number | null;
restoreWidths: number[] | null;
startWidths: number[];
startX: number;
tableWidth: number;
};
function readGeometry(table: HTMLTableElement, wrapper: HTMLDivElement): TableGeometry | null {
const cells = Array.from(table.rows.item(0)?.cells ?? []);
const tableRect = table.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
if (cells.length < 2 || tableRect.width <= 0 || tableRect.height <= 0) return null;
const cellRects = cells.map((cell) => cell.getBoundingClientRect());
return {
boundaries: cellRects
.slice(0, -1)
.map((rect) => rect.right - wrapperRect.left + wrapper.scrollLeft),
columnWidths: cellRects.map((rect) => rect.width),
height: tableRect.height,
tableWidth: tableRect.width,
top: tableRect.top - wrapperRect.top + wrapper.scrollTop,
};
}
function setDocumentResizeState(active: boolean) {
document.body.style.cursor = active ? "col-resize" : "";
document.body.style.userSelect = active ? "none" : "";
}
type TableRefs = {
tableRef: RefObject<HTMLTableElement | null>;
wrapperRef: RefObject<HTMLDivElement | null>;
};
function useTableGeometry(
enabled: boolean,
refs: TableRefs,
widthsRef: RefObject<number[] | null>,
reset: () => void,
columnWidths: number[] | null,
) {
const [geometry, setGeometry] = useState<TableGeometry | null>(null);
const measure = useCallback(() => {
const table = refs.tableRef.current;
const wrapper = refs.wrapperRef.current;
if (!enabled || !table || !wrapper) {
if (!enabled && widthsRef.current) reset();
setGeometry(null);
return;
}
const columnCount = table.rows.item(0)?.cells.length ?? 0;
if (widthsRef.current && widthsRef.current.length !== columnCount) reset();
const next = readGeometry(table, wrapper);
setGeometry(next);
}, [enabled, refs, reset, widthsRef]);
useLayoutEffect(() => measure(), [columnWidths, measure]);
useLayoutEffect(() => {
const table = refs.tableRef.current;
const wrapper = refs.wrapperRef.current;
if (!enabled || !table || !wrapper) return;
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(table);
resizeObserver.observe(wrapper);
const mutationObserver = new MutationObserver(measure);
mutationObserver.observe(table, { characterData: true, childList: true, subtree: true });
window.addEventListener("resize", measure);
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
window.removeEventListener("resize", measure);
};
}, [enabled, measure, refs]);
return geometry;
}
type PointerResizeOptions = {
enabled: boolean;
fixedTableWidth: number | null;
refs: TableRefs;
setColumnWidths: (widths: number[] | null) => void;
setFixedTableWidth: Dispatch<SetStateAction<number | null>>;
widthsRef: RefObject<number[] | null>;
};
function usePointerResize({
enabled,
fixedTableWidth,
refs,
setColumnWidths,
setFixedTableWidth,
widthsRef,
}: PointerResizeOptions) {
const dragRef = useRef<DragState | null>(null);
const [activeBoundary, setActiveBoundary] = useState<number | null>(null);
const finishDrag = useCallback(
(event: PointerEvent<HTMLButtonElement>, cancelled: boolean) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
if (cancelled) {
setColumnWidths(drag.restoreWidths);
setFixedTableWidth(drag.restoreTableWidth);
}
dragRef.current = null;
setActiveBoundary(null);
setDocumentResizeState(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
},
[setColumnWidths],
);
useEffect(
() => () => {
if (!dragRef.current) return;
dragRef.current = null;
setDocumentResizeState(false);
},
[],
);
useEffect(() => {
if (enabled || !dragRef.current) return;
dragRef.current = null;
setActiveBoundary(null);
setDocumentResizeState(false);
}, [enabled]);
const startResize = useCallback(
(boundaryIndex: number, event: PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
const table = refs.tableRef.current;
const wrapper = refs.wrapperRef.current;
if (!table || !wrapper) return;
const measured = readGeometry(table, wrapper);
if (!measured) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
boundaryIndex,
pointerId: event.pointerId,
restoreTableWidth: fixedTableWidth,
restoreWidths: widthsRef.current ? [...widthsRef.current] : null,
startWidths: measured.columnWidths,
startX: event.clientX,
tableWidth: measured.tableWidth,
};
setActiveBoundary(boundaryIndex);
setDocumentResizeState(true);
},
[fixedTableWidth, refs, widthsRef],
);
const moveResize = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const delta = event.clientX - drag.startX;
if (delta === 0) return;
setFixedTableWidth(drag.tableWidth);
setColumnWidths(resizeAdjacentColumns(drag.startWidths, drag.boundaryIndex, delta));
},
[setColumnWidths, setFixedTableWidth],
);
return { activeBoundary, finishDrag, moveResize, startResize };
}
export function useMarkdownTableResize(enabled: boolean) {
const wrapperRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
const refs = useMemo(() => ({ tableRef, wrapperRef }), []);
const widthsRef = useRef<number[] | null>(null);
const [columnWidths, setColumnWidthsState] = useState<number[] | null>(null);
const [fixedTableWidth, setFixedTableWidth] = useState<number | null>(null);
const setColumnWidths = useCallback((widths: number[] | null) => {
widthsRef.current = widths;
setColumnWidthsState(widths);
}, []);
const reset = useCallback(() => {
setColumnWidths(null);
setFixedTableWidth(null);
}, [setColumnWidths]);
const geometry = useTableGeometry(enabled, refs, widthsRef, reset, columnWidths);
const pointerResize = usePointerResize({
enabled,
fixedTableWidth,
refs,
setColumnWidths,
setFixedTableWidth,
widthsRef,
});
const resizeWithKeyboard = useCallback(
(boundaryIndex: number, event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "Enter") {
event.preventDefault();
reset();
return;
}
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
const table = refs.tableRef.current;
const wrapper = refs.wrapperRef.current;
const measured = table && wrapper ? readGeometry(table, wrapper) : null;
const widths = widthsRef.current ?? measured?.columnWidths;
if (!widths || !measured) return;
event.preventDefault();
setFixedTableWidth(fixedTableWidth ?? measured.tableWidth);
setColumnWidths(
resizeAdjacentColumns(widths, boundaryIndex, event.key === "ArrowRight" ? 8 : -8),
);
},
[fixedTableWidth, refs, reset, setColumnWidths],
);
return {
...pointerResize,
columnWidths,
fixedTableWidth,
geometry,
reset,
resizeWithKeyboard,
tableRef,
wrapperRef,
};
}
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MessageRenderer } from "./message-renderer";
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
@@ -92,4 +92,28 @@ describe("MessageRenderer markdown file links", () => {
expect(onOpenFile).toHaveBeenCalledWith("apps/web/AGENTS.md");
expect(fallbackOpenFile).not.toHaveBeenCalled();
});
it("opens thinking file links with the renderer worktree context", () => {
const onOpenFile = vi.fn();
const { container } = render(
<MessageRenderer
comment={message({
type: "thinking",
content:
"I need to inspect the project guidance before proceeding with this task. Read [AGENTS](/workspace/kandev/apps/web/AGENTS.md).",
})}
isTaskDescription={false}
worktreePath="/workspace/kandev"
onOpenFile={onOpenFile}
/>,
);
const thinking = within(container);
fireEvent.click(thinking.getByText("Thinking", { exact: true }));
fireEvent.click(thinking.getByRole("link", { name: "AGENTS" }));
expect(onOpenFile).toHaveBeenCalledWith("apps/web/AGENTS.md");
expect(fallbackOpenFile).not.toHaveBeenCalled();
});
});
@@ -159,7 +159,13 @@ type MessageAdapter = {
const adapters: MessageAdapter[] = [
{
matches: (comment) => comment.type === "thinking",
render: (comment) => <ThinkingMessage comment={comment} />,
render: (comment, ctx) => (
<ThinkingMessage
comment={comment}
worktreePath={ctx.worktreePath}
onOpenFile={ctx.onOpenFile}
/>
),
},
{
matches: (comment) => comment.type === "todo",
@@ -1,11 +1,10 @@
"use client";
import { useState, memo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { IconBrain } from "@tabler/icons-react";
import type { Message } from "@/lib/types/http";
import type { RichMetadata } from "@/components/task/chat/types";
import { MemoizedMarkdown } from "@/components/shared/memoized-markdown";
import { ExpandableRow } from "./expandable-row";
import { useTranslation } from "react-i18next";
@@ -29,7 +28,15 @@ function stripMarkdown(text: string): string {
);
}
export const ThinkingMessage = memo(function ThinkingMessage({ comment }: { comment: Message }) {
export const ThinkingMessage = memo(function ThinkingMessage({
comment,
worktreePath,
onOpenFile,
}: {
comment: Message;
worktreePath?: string;
onOpenFile?: (path: string) => void;
}) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const metadata = comment.metadata as RichMetadata | undefined;
@@ -60,7 +67,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({ comment }: { comm
{!isShort && (
<div className="pl-4 border-l-2 border-border/30">
<div className="markdown-body max-w-none text-xs text-foreground/70 [&>*]:my-1 [&>p]:my-1 [&>ul]:my-1 [&>ol]:my-1 [&_strong]:text-foreground/80">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
<MemoizedMarkdown content={text} worktreePath={worktreePath} onOpenFile={onOpenFile} />
</div>
</div>
)}
+342 -31
View File
@@ -1,19 +1,20 @@
import { test, expect } from "../../fixtures/test-base";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import type { Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
const LONG_WORD = "abcdefghij".repeat(30); // 300 chars, no spaces
/** Create a task with a mock agent message containing the given text. */
async function createTaskWithMessage(
testPage: import("@playwright/test").Page,
apiClient: import("../helpers/api-client").ApiClient,
seedData: import("../fixtures/test-base").SeedData,
title: string,
messageText: string,
/** Create and open a task with one mock-agent Markdown update. */
async function openTaskWithMarkdown(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
{ title, kind, text }: { title: string; kind: "message" | "thinking"; text: string },
): Promise<SessionPage> {
await apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, {
description: `e2e:message("${messageText}")`,
description: `e2e:${kind}("${text}")`,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
@@ -33,7 +34,7 @@ async function createTaskWithMessage(
}
/** Assert no .markdown-body element overflows horizontally. */
async function expectNoMarkdownOverflow(testPage: import("@playwright/test").Page) {
async function expectNoMarkdownOverflow(testPage: Page) {
const overflows = await testPage.evaluate(() => {
const els = document.querySelectorAll(".markdown-body");
return Array.from(els).some((el) => el.scrollWidth > el.clientWidth + 1);
@@ -41,6 +42,13 @@ async function expectNoMarkdownOverflow(testPage: import("@playwright/test").Pag
expect(overflows).toBe(false);
}
async function expectNoDocumentOverflow(testPage: Page) {
const overflows = await testPage.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
);
expect(overflows).toBe(false);
}
test.describe("Markdown text wrapping", () => {
test("long plain text wraps within the chat message container", async ({
testPage,
@@ -49,13 +57,11 @@ test.describe("Markdown text wrapping", () => {
}) => {
test.setTimeout(90_000);
const session = await createTaskWithMessage(
testPage,
apiClient,
seedData,
"Wrap Plain Text",
`Here is a finding: ${LONG_WORD} - end of finding`,
);
const session = await openTaskWithMarkdown(testPage, apiClient, seedData, {
title: "Wrap Plain Text",
kind: "message",
text: `Here is a finding: ${LONG_WORD} - end of finding`,
});
await expect(session.chat.getByText("end of finding").last()).toBeVisible({
timeout: 30_000,
@@ -71,13 +77,11 @@ test.describe("Markdown text wrapping", () => {
}) => {
test.setTimeout(90_000);
const session = await createTaskWithMessage(
testPage,
apiClient,
seedData,
"Wrap Inline Code",
`Check this path: \`${LONG_WORD}\` for details`,
);
const session = await openTaskWithMarkdown(testPage, apiClient, seedData, {
title: "Wrap Inline Code",
kind: "message",
text: `Check this path: \`${LONG_WORD}\` for details`,
});
await expect(session.chat.getByText("for details").last()).toBeVisible({
timeout: 30_000,
@@ -95,13 +99,11 @@ test.describe("Markdown text wrapping", () => {
// Fenced code block with a long line — should be contained via
// horizontal scroll (shiki) or line wrapping (codemirror)
const session = await createTaskWithMessage(
testPage,
apiClient,
seedData,
"Wrap Code Block",
`Code block:\\n\`\`\`\\nconst x = "${LONG_WORD}";\\n\`\`\`\\nAfter code block`,
);
const session = await openTaskWithMarkdown(testPage, apiClient, seedData, {
title: "Wrap Code Block",
kind: "message",
text: `Code block:\\n\`\`\`\\nconst x = "${LONG_WORD}";\\n\`\`\`\\nAfter code block`,
});
await expect(session.chat.getByText("After code block").last()).toBeVisible({
timeout: 30_000,
@@ -109,4 +111,313 @@ test.describe("Markdown text wrapping", () => {
await expectNoMarkdownOverflow(testPage);
});
test("ordinary two-column tables wrap instead of scrolling at the 255px chat width", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await testPage.setViewportSize({ width: 303, height: 800 });
const marker = "dependency lifecycle scripts";
const session = await openTaskWithMarkdown(testPage, apiClient, seedData, {
title: "Wrap Pnpm Settings Table",
kind: "message",
text: [
"| pnpm setting | Effect |",
"| --- | --- |",
"| default (pnpm 10+) | dependency lifecycle scripts **off** unless approved |",
"| `strictDepBuilds: true` | install **fails** if a dep wants a build and isn't allowlisted |",
"| `allowBuilds.esbuild: true` | only then may esbuild's postinstall run |",
].join("\\n"),
});
const table = session
.activeChat()
.locator(".markdown-body", { hasText: marker })
.locator("table");
const markdown = table.locator(
"xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' markdown-body ')]",
);
const tableWrapper = table.locator("xpath=..");
const firstColumnCode = table.locator("tbody tr").nth(1).locator("td").first().locator("code");
await expect(table).toBeVisible({ timeout: 30_000 });
expect(await tableWrapper.evaluate((element) => element.clientWidth)).toBe(255);
expect(
await firstColumnCode.evaluate((code) => {
const range = document.createRange();
range.selectNodeContents(code);
return range.getClientRects().length;
}),
).toBeGreaterThan(1);
expect(
await tableWrapper.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
).toBe(true);
expect(await table.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(
true,
);
expect(
await markdown.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
).toBe(true);
expect(
await session
.activeChat()
.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
).toBe(true);
await expectNoMarkdownOverflow(testPage);
await expectNoDocumentOverflow(testPage);
});
test("desktop users resize adjacent table columns from the full-height boundary", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await testPage.setViewportSize({ width: 1100, height: 800 });
const marker = "Resizable table marker";
const session = await openTaskWithMarkdown(testPage, apiClient, seedData, {
title: "Resize Markdown Table Columns",
kind: "message",
text: [
"| Setting | Effect | Notes |",
"| --- | --- | --- |",
`| strictDepBuilds | Install fails for unapproved lifecycle scripts | ${marker} |`,
"| allowBuilds | Approved packages may run build scripts | Ephemeral adjustment |",
].join("\\n"),
});
const markdown = session.activeChat().locator(".markdown-body", { hasText: marker });
const table = markdown.locator("table");
const cells = table.locator("tbody tr").first().locator("td");
const separator = markdown.getByTestId("markdown-table-resizer-0");
await expect(table).toBeVisible({ timeout: 30_000 });
await expect(separator).toBeVisible();
const initialWidths = await cells.evaluateAll((elements) =>
elements.map((element) => element.getBoundingClientRect().width),
);
await expect(separator).toHaveAttribute("role", "separator");
await expect(separator).toHaveAccessibleName("Resize table columns 1 and 2");
await expect(separator).toHaveAttribute("aria-orientation", "vertical");
await expect(separator).toHaveAttribute("aria-valuemin", "64");
await expect(separator).toHaveAttribute("aria-valuenow", String(Math.round(initialWidths[0])));
const expectedMax = Math.max(64, initialWidths[0] + initialWidths[1] - 64);
expect(Number(await separator.getAttribute("aria-valuemax"))).toBeCloseTo(expectedMax, 5);
const initialTableWidth = await table.evaluate(
(element) => element.getBoundingClientRect().width,
);
const [separatorBox, bodyCellBox, tableBox] = await Promise.all([
separator.boundingBox(),
cells.first().boundingBox(),
table.boundingBox(),
]);
expect(separatorBox).not.toBeNull();
expect(bodyCellBox).not.toBeNull();
expect(tableBox).not.toBeNull();
expect(separatorBox!.height).toBeCloseTo(tableBox!.height, 1);
const boundaryX = separatorBox!.x + separatorBox!.width / 2;
const bodyRowY = bodyCellBox!.y + bodyCellBox!.height / 2;
await testPage.mouse.move(boundaryX, bodyRowY);
await testPage.mouse.down();
await testPage.mouse.move(boundaryX + 60, bodyRowY);
await testPage.mouse.up();
const resizedWidths = await cells.evaluateAll((elements) =>
elements.map((element) => element.getBoundingClientRect().width),
);
expect(resizedWidths[0] - initialWidths[0]).toBeCloseTo(60, 0);
expect(initialWidths[1] - resizedWidths[1]).toBeCloseTo(60, 0);
expect(resizedWidths[2]).toBeCloseTo(initialWidths[2], 0);
expect(await table.evaluate((element) => element.getBoundingClientRect().width)).toBeCloseTo(
initialTableWidth,
0,
);
const movedSeparatorBox = await separator.boundingBox();
const movedBodyCellBox = await cells.first().boundingBox();
expect(movedSeparatorBox).not.toBeNull();
expect(movedBodyCellBox).not.toBeNull();
const movedBoundaryX = movedSeparatorBox!.x + movedSeparatorBox!.width / 2;
const movedBodyRowY = movedBodyCellBox!.y + movedBodyCellBox!.height / 2;
await testPage.mouse.move(movedBoundaryX, movedBodyRowY);
await testPage.mouse.down();
await testPage.mouse.move(movedBoundaryX + 1000, movedBodyRowY);
await testPage.mouse.up();
const clampedWidths = await cells.evaluateAll((elements) =>
elements.map((element) => element.getBoundingClientRect().width),
);
expect(clampedWidths[1]).toBeCloseTo(64, 0);
expect(clampedWidths[2]).toBeCloseTo(initialWidths[2], 0);
await separator.dblclick();
const resetWidths = await cells.evaluateAll((elements) =>
elements.map((element) => element.getBoundingClientRect().width),
);
resetWidths.forEach((width, index) => expect(width).toBeCloseTo(initialWidths[index], 0));
await separator.focus();
await testPage.keyboard.press("ArrowRight");
const keyboardWidths = await cells.evaluateAll((elements) =>
elements.map((element) => element.getBoundingClientRect().width),
);
expect(keyboardWidths[0] - initialWidths[0]).toBeCloseTo(8, 0);
expect(initialWidths[1] - keyboardWidths[1]).toBeCloseTo(8, 0);
await testPage.keyboard.press("Enter");
const keyboardResetWidths = await cells.evaluateAll((elements) =>
elements.map((element) => element.getBoundingClientRect().width),
);
keyboardResetWidths.forEach((width, index) =>
expect(width).toBeCloseTo(initialWidths[index], 0),
);
await testPage.keyboard.press("ArrowRight");
await expect(table.locator("colgroup")).toHaveCount(1);
await testPage.setViewportSize({ width: 600, height: 800 });
await expect(markdown.getByTestId(/^markdown-table-resizer-/)).toHaveCount(0);
await expect(table.locator("colgroup")).toHaveCount(0);
await expectNoDocumentOverflow(testPage);
});
test("wide desktop table separators stay aligned inside local scrolling", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await testPage.setViewportSize({ width: 900, height: 800 });
const marker = "Scrollable resizer marker";
const session = await openTaskWithMarkdown(testPage, apiClient, seedData, {
title: "Scroll Resizable Markdown Table",
kind: "message",
text: [
"| Status | Owner | Project | Branch | Review | Build | Deploy | Notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
`| Failing checks | Team Alpha | Kandev Web | main | Pending | Passing | Ready | ${marker} |`,
].join("\\n"),
});
const markdown = session.activeChat().locator(".markdown-body", { hasText: marker });
const table = markdown.locator("table");
const wrapper = table.locator("xpath=..");
const separator = markdown.getByTestId("markdown-table-resizer-2");
const thirdHeader = table.locator("thead th").nth(2);
const fourthHeader = table.locator("thead th").nth(3);
await expect(separator).toBeVisible({ timeout: 30_000 });
expect(await wrapper.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(
true,
);
await wrapper.evaluate((element) => {
element.scrollLeft = 160;
});
const [separatorBox, headerBox] = await Promise.all([
separator.boundingBox(),
thirdHeader.boundingBox(),
]);
expect(separatorBox).not.toBeNull();
expect(headerBox).not.toBeNull();
expect(separatorBox!.x + separatorBox!.width / 2).toBeCloseTo(
headerBox!.x + headerBox!.width,
0,
);
const boundaryX = separatorBox!.x + separatorBox!.width / 2;
const headerY = headerBox!.y + headerBox!.height / 2;
await testPage.mouse.move(boundaryX, headerY);
await testPage.mouse.down();
await testPage.mouse.move(boundaryX + 1000, headerY);
await testPage.mouse.up();
expect(await fourthHeader.evaluate((cell) => cell.getBoundingClientRect().width)).toBeCloseTo(
64,
0,
);
await expectNoMarkdownOverflow(testPage);
await expectNoDocumentOverflow(testPage);
});
test("wide tables keep readable columns and scroll internally at 320px", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await testPage.setViewportSize({ width: 320, height: 800 });
const marker = "Wide first cell marker";
const session = await openTaskWithMarkdown(testPage, apiClient, seedData, {
title: "Wrap Wide Table First Cell",
kind: "message",
text: [
"| Status | Owner | Project | Branch | Review | Build | Deploy | Notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
`| \`${LONG_WORD}\` ${marker} | Team Alpha | Kandev Web | main | Pending | Passing | Ready | Notes |`,
].join("\\n"),
});
const markdown = session.activeChat().locator(".markdown-body", { hasText: marker });
const table = markdown.locator("table");
const tableWrapper = table.locator("xpath=..");
const firstCell = table.locator("tbody td").first();
await expect(table).toBeVisible({ timeout: 30_000 });
await expect(firstCell).toContainText(marker);
expect(
await firstCell.evaluate((cell) => cell.getBoundingClientRect().width),
).toBeGreaterThanOrEqual(96);
expect(
await firstCell.evaluate((cell) => {
const range = document.createRange();
range.selectNodeContents(cell);
return range.getClientRects().length;
}),
).toBeGreaterThan(1);
expect(await firstCell.evaluate((cell) => cell.scrollWidth <= cell.clientWidth + 1)).toBe(true);
expect(
await tableWrapper.evaluate((element) => element.scrollWidth > element.clientWidth + 1),
).toBe(true);
await expectNoMarkdownOverflow(testPage);
await expectNoDocumentOverflow(testPage);
});
test("wide thinking tables scroll locally without overflowing chat at 320px", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await testPage.setViewportSize({ width: 320, height: 800 });
const marker = "Wide thinking table marker";
const session = await openTaskWithMarkdown(testPage, apiClient, seedData, {
title: "Scroll Wide Thinking Table",
kind: "thinking",
text: [
"| Status | Owner | Project | Branch | Review | Build | Deploy | Notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
`| Failing checks | Team Alpha | Kandev Web | main | Pending | Passing | Ready | ${marker} |`,
].join("\\n"),
});
const thinkingRow = session.activeChat().getByText("Thinking", { exact: true });
await expect(thinkingRow).toBeVisible({ timeout: 30_000 });
await thinkingRow.click();
const markdown = session.activeChat().locator(".markdown-body", { hasText: marker });
const table = markdown.locator("table");
const tableWrapper = table.locator("xpath=..");
await expect(table).toBeVisible();
expect(
await tableWrapper.evaluate((element) => element.scrollWidth > element.clientWidth + 1),
).toBe(true);
await expectNoMarkdownOverflow(testPage);
await expectNoDocumentOverflow(testPage);
});
});
@@ -0,0 +1,126 @@
// Filename routes this phone-width overflow regression to mobile-chrome (Pixel 5).
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
test.describe("mobile: Markdown table wrapping", () => {
test("ordinary two-column pnpm tables wrap without overflowing the chat or document", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const marker = "dependency lifecycle scripts";
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Wrap Pnpm Settings Table",
seedData.agentProfileId,
{
description: `e2e:message("${[
"| pnpm setting | Effect |",
"| --- | --- |",
"| default (pnpm 10+) | dependency lifecycle scripts **off** unless approved |",
"| `strictDepBuilds: true` | install **fails** if a dep wants a build and isn't allowlisted |",
"| `allowBuilds.esbuild: true` | only then may esbuild's postinstall run |",
].join("\\n")}")`,
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();
const table = session
.activeChat()
.locator(".markdown-body", { hasText: marker })
.locator("table");
const markdown = table.locator(
"xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' markdown-body ')]",
);
const tableWrapper = table.locator("xpath=..");
const firstColumnCode = table.locator("tbody tr").nth(1).locator("td").first().locator("code");
await expect(table).toBeVisible({ timeout: 30_000 });
await expect(markdown.getByTestId(/^markdown-table-resizer-/)).toHaveCount(0);
expect(
await firstColumnCode.evaluate((code) => {
const range = document.createRange();
range.selectNodeContents(code);
return range.getClientRects().length;
}),
).toBeGreaterThan(1);
expect(
await tableWrapper.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
).toBe(true);
expect(await table.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(
true,
);
expect(
await markdown.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
).toBe(true);
expect(
await session
.activeChat()
.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
).toBe(true);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1,
),
).toBe(true);
});
test("wide thinking tables scroll locally without overflowing the chat or document", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const marker = "Mobile wide thinking table marker";
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Scroll Wide Thinking Table",
seedData.agentProfileId,
{
description: `e2e:thinking("${[
"| Status | Owner | Project | Branch | Review | Build | Deploy | Notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
`| Failing checks | Team Alpha | Kandev Web | main | Pending | Passing | Ready | ${marker} |`,
].join("\\n")}")`,
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();
const thinkingRow = session.activeChat().getByText("Thinking", { exact: true });
await expect(thinkingRow).toBeVisible({ timeout: 30_000 });
await thinkingRow.click();
const markdown = session.activeChat().locator(".markdown-body", { hasText: marker }).last();
const table = markdown.locator("table");
const tableWrapper = table.locator("xpath=..");
await expect(table).toBeVisible();
await expect(markdown.getByTestId(/^markdown-table-resizer-/)).toHaveCount(0);
expect(
await tableWrapper.evaluate((element) => element.scrollWidth > element.clientWidth + 1),
).toBe(true);
expect(
await markdown.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,35 @@
import { describe, expect, it } from "vitest";
import { canResizeColumnBoundary, resizeAdjacentColumns } from "./table-resize";
describe("canResizeColumnBoundary", () => {
it("allows a boundary when both adjacent columns meet the minimum", () => {
expect(canResizeColumnBoundary([64, 64, 240], 0)).toBe(true);
});
it("rejects a boundary when either adjacent column is below the minimum", () => {
expect(canResizeColumnBoundary([63, 65, 240], 0)).toBe(false);
expect(canResizeColumnBoundary([65, 63, 240], 0)).toBe(false);
});
});
describe("resizeAdjacentColumns", () => {
it("moves one boundary without changing the table width", () => {
expect(resizeAdjacentColumns([120, 180, 240], 0, 32)).toEqual([152, 148, 240]);
});
it("clamps a shrinking right column to the minimum width", () => {
expect(resizeAdjacentColumns([120, 80, 240], 0, 40)).toEqual([136, 64, 240]);
});
it("clamps a shrinking left column to the minimum width", () => {
expect(resizeAdjacentColumns([80, 120, 240], 0, -40)).toEqual([64, 136, 240]);
});
it("leaves widths unchanged when the boundary is invalid", () => {
expect(resizeAdjacentColumns([120, 180], 1, 20)).toEqual([120, 180]);
});
it("leaves a sub-128-pixel pair unchanged when both minimums cannot fit", () => {
expect(resizeAdjacentColumns([63, 64, 240], 0, 20)).toEqual([63, 64, 240]);
});
});
+34
View File
@@ -0,0 +1,34 @@
export const MIN_MARKDOWN_COLUMN_WIDTH = 64;
export function canResizeColumnBoundary(widths: readonly number[], boundaryIndex: number): boolean {
const left = widths[boundaryIndex];
const right = widths[boundaryIndex + 1];
return (
left !== undefined &&
right !== undefined &&
left >= MIN_MARKDOWN_COLUMN_WIDTH &&
right >= MIN_MARKDOWN_COLUMN_WIDTH
);
}
export function resizeAdjacentColumns(
widths: readonly number[],
boundaryIndex: number,
delta: number,
): number[] {
const resized = [...widths];
const left = resized[boundaryIndex];
const right = resized[boundaryIndex + 1];
if (left === undefined || right === undefined) return resized;
const pairWidth = left + right;
if (pairWidth < MIN_MARKDOWN_COLUMN_WIDTH * 2) return resized;
const nextLeft = Math.min(
Math.max(left + delta, MIN_MARKDOWN_COLUMN_WIDTH),
pairWidth - MIN_MARKDOWN_COLUMN_WIDTH,
);
resized[boundaryIndex] = nextLeft;
resized[boundaryIndex + 1] = pairWidth - nextLeft;
return resized;
}
+1
View File
@@ -101,6 +101,7 @@
"rename": "Rename",
"reviewSubmoduleBoundary": "Submodule: {{scope}}",
"reset": "Reset",
"resizeTableColumns": "Resize table columns {{left}} and {{right}}",
"resetWatchChecking": "Checking how many tasks would be deleted…",
"resetWatchDeleteAllTasks": "This will delete every task previously created by the watch, including archived tasks. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.",
"resetWatchDeleteTasks_one": "This will delete {{count}} task previously created by the watch, including archived tasks. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.",
+1
View File
@@ -101,6 +101,7 @@
"rename": "Ŕēńàḿē",
"reviewSubmoduleBoundary": "Śũƀḿōďũĺē: {{scope}}",
"reset": "Ŕēśēţ",
"resizeTableColumns": "Ŕēśĩźē ţàƀĺē ćōĺũḿńś {{left}} àńď {{right}}",
"resetWatchChecking": "Ćĥēćķĩńĝ ĥōŵ ḿàńŷ ţàśķś ŵōũĺď ƀē ďēĺēţēď…",
"resetWatchDeleteAllTasks": "Ţĥĩś ŵĩĺĺ ďēĺēţē ēvēŕŷ ţàśķ ƥŕēvĩōũśĺŷ ćŕēàţēď ƀŷ ţĥē ŵàţćĥ, ĩńćĺũďĩńĝ àŕćĥĩvēď ţàśķś. Ţĥē ŵàţćĥ'ś ƥōĺĺĩńĝ ćũŕśōŕ ĩś àĺśō ćĺēàŕēď śō ţĥē ńēxţ ćĥēćķ ŕē-ĩḿƥōŕţś ēvēŕŷ ćũŕŕēńţĺŷ-ḿàţćĥĩńĝ ĩţēḿ. Ţĥĩś ćàńńōţ ƀē ũńďōńē.",
"resetWatchDeleteTasks_one": "Ţĥĩś ŵĩĺĺ ďēĺēţē {{count}} ţàśķ ƥŕēvĩōũśĺŷ ćŕēàţēď ƀŷ ţĥē ŵàţćĥ, ĩńćĺũďĩńĝ àŕćĥĩvēď ţàśķś. Ţĥē ŵàţćĥ'ś ƥōĺĺĩńĝ ćũŕśōŕ ĩś àĺśō ćĺēàŕēď śō ţĥē ńēxţ ćĥēćķ ŕē-ĩḿƥōŕţś ēvēŕŷ ćũŕŕēńţĺŷ-ḿàţćĥĩńĝ ĩţēḿ. Ţĥĩś ćàńńōţ ƀē ũńďōńē.",
@@ -0,0 +1,181 @@
---
spec: docs/specs/ui/resizable-markdown-tables.md
created: 2026-08-05
status: complete
---
# Implementation Plan: Resizable Markdown Table Columns
## Overview
Add ephemeral, accessible column resizing to the shared Markdown table renderer
without weakening the wrapping repair already covered by chat browser tests.
Desktop fine-pointer users receive full-height inline separators; phone and
coarse-pointer layouts retain automatic wrapping and local scrolling with no
resize controls.
## Frontend design
### Width geometry
Add a small pure geometry module under `apps/web/lib/markdown/` that adjusts an
adjacent width pair while preserving its sum and enforcing the 64-pixel minimum.
Use the same function for pointer and keyboard input so clamping behavior has one
testable source of truth. If the pair totals less than 128 pixels, leave both
widths unchanged because the total-width and dual-minimum constraints cannot
otherwise both hold.
### Resizable table component
Extract the shared renderer's current table wrapper into a client component,
likely `apps/web/components/shared/resizable-markdown-table.tsx`.
- Render the semantic `<table>` unchanged in automatic mode.
- Measure the table and its header cells after layout. A `ResizeObserver` keeps
separator positions aligned while automatic content or container geometry
changes, and controlled column-width updates explicitly trigger another
measurement even when the table's outer box is unchanged. Observe character
data as well as child-list mutations so streamed text updates also remeasure.
- Place focusable `role="separator"` controls in an overlay within the table's
scroll content. Each control is centered on an internal boundary, spans the
full table height, and scrolls with the table. Omit a boundary while either
adjacent measured column is below the 64-pixel minimum.
- Give each separator an adjacent-column accessible name, vertical orientation,
a 64-pixel minimum, the rounded current left-column width, and a maximum equal
to the pair total minus the right-column minimum. Verify those rendered ARIA
semantics in Playwright alongside keyboard behavior.
- On first adjustment, snapshot measured widths and apply them through a
`<colgroup>` with fixed table layout. Keep the measured table width constant so
one adjacent column grows exactly as the other shrinks. Do not activate fixed
widths until pointer movement has a non-zero delta.
- Use pointer capture for dragging. Restore the drag-start snapshot on
`pointercancel`, and clean up cursor and selection overrides on every exit
path.
- Double-click or `Enter` clears the custom width state and returns the table to
CSS automatic layout. A changed column count does the same.
- Render controls only when the responsive capability is non-phone and
fine-pointer. Tables that cannot provide valid multi-column geometry stay
automatic. A capability change during a drag also clears the active drag and
document-level cursor and selection overrides.
### Styling and localization
Add narrowly scoped Markdown-table classes in `apps/web/app/globals.css` for the
relative stage, separator hit area, and hover/focus/active line. Preserve the
existing `overflow-x-auto` wrapper and the two-/three-column wrapping and
four-plus-column minimum-width policies.
Add the separator's accessible name to the shared English locale and access it
through `t()`; no visible copy or public documentation is added.
## Responsive and mobile implementation
The nearest mobile exemplar is the current task-chat Markdown table tested by
`mobile-markdown-wrap.spec.ts`. The component shares parsing, measurement-safe
markup, wrapping, and the local scroll owner at every viewport. It suppresses
the separator overlay on phones and coarse pointers because a usable touch hit
target would obstruct narrow table content. Mobile capability remains complete
for reading and scrolling; only the optional precision adjustment is desktop
specific.
## Tests
### Unit tests
- RED: adjacent-pair resizing grows and shrinks by equal amounts.
- RED: both drag directions clamp at 64 pixels without changing the pair total.
- RED: keyboard deltas use the same geometry behavior.
- Cover the resize hook's keyboard branches, capability-disable reset, and
column-count-change reset in a focused hook test.
- GREEN: implement the geometry module and table component, then wire it into
`markdown-components.tsx`.
- The repository does not add isolated React component tests for pure UI.
Playwright covers separator semantics, rendered geometry, reset behavior, and
responsive capability gating through the real shared renderer.
Likely files:
- `apps/web/lib/markdown/table-resize.ts`
- `apps/web/lib/markdown/table-resize.test.ts`
- `apps/web/components/shared/resizable-markdown-table.tsx`
- `apps/web/components/shared/use-markdown-table-resize.ts`
- `apps/web/components/shared/use-markdown-table-resize.test.ts`
- `apps/web/components/shared/markdown-components.tsx`
- `apps/web/app/globals.css`
- `apps/web/src/locales/en/common.json`
### Browser tests
Extend the existing chat Markdown wrapping suites rather than creating a second
fixture path.
- Desktop: render a three-column table, start a drag at a body-row point on the
full-height boundary, and assert adjacent widths change, the untouched column
and total width remain stable, and minimum-width clamping works.
- Desktop: assert double-click returns near the initial automatic proportions;
assert arrow keys resize and `Enter` resets.
- Desktop wide table: scroll its local wrapper and assert separators remain
aligned and no chat/document overflow appears.
- Mobile Chrome: assert no separators are exposed while the existing ordinary
wrapping and wide-table local scrolling checks still pass.
Likely files:
- `apps/web/e2e/tests/chat/markdown-wrap.spec.ts`
- `apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts`
## Implementation waves
Execution is sequential in the primary conversation. The E2E work depends on
the renderer contract, and this package does not authorize subagent use.
- [x] [Task 01: Build the ephemeral resize renderer](task-01-build-resize-renderer.md)
- [x] [Task 02: Prove desktop interaction and mobile parity](task-02-prove-resize-interaction.md)
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps && pnpm --filter @kandev/web exec vitest run \
lib/markdown/table-resize.test.ts \
components/shared/use-markdown-table-resize.test.ts \
components/shared/markdown-components.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps && pnpm --filter @kandev/web lint)
make build-web
(cd apps/web && pnpm e2e:run tests/chat/markdown-wrap.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-markdown-wrap.spec.ts)
```
## Documentation impact
The durable UI spec and plan cover the new behavior. No public CLI, config, API,
or workflow documentation changes are required.
## Risks
- Separator geometry must use the scroll content's coordinate system; viewport
coordinates would drift after horizontal scrolling.
- Streaming updates can change row height or column count after initial render.
Observation must update positions, while structure changes must discard custom
widths.
- Fixed widths must activate only after user adjustment. Applying them on first
render would regress responsive automatic wrapping.
- Pointer cancellation and unmount must restore global cursor and text-selection
state to avoid a sticky resize cursor elsewhere in the app.
- Component tests run in jsdom, so deterministic width math belongs in a pure
helper and real geometry assertions belong in Playwright.
## Open questions
None.
## Verification results
- `vitest`: 42 focused geometry/hook/shared-renderer tests passed.
- `pnpm run typecheck`: passed.
- `pnpm --filter @kandev/web lint`: passed with zero warnings.
- `make build-web`: passed.
- Desktop `markdown-wrap.spec.ts`: 8 tests passed.
- Mobile Chrome `mobile-markdown-wrap.spec.ts`: 2 tests passed.
- `git diff --check`: passed.
@@ -0,0 +1,99 @@
---
id: "01-build-resize-renderer"
title: "Build the ephemeral Markdown table resize renderer"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/resizable-markdown-tables.md"
---
# Task 01: Build the Ephemeral Resize Renderer
## Acceptance
- Shared Markdown tables render accessible full-height internal separators only
on non-phone fine-pointer layouts with valid multi-column geometry.
- Every separator exposes its adjacent-column accessible name, vertical
orientation, 64-pixel minimum, rounded current left-column width, and the
pair-derived maximum through the complete ARIA separator contract.
- Pointer and keyboard adjustment resize only adjacent columns, preserve the
table width, and enforce a 64-pixel minimum.
- Boundaries with either adjacent measured column below 64 pixels are omitted;
adjacent pairs below 128 pixels therefore remain unchanged without exposing
an invalid or immovable separator.
- Double-click and `Enter` clear all custom widths and restore CSS automatic
layout; unmount, reload, and column-count changes retain no state.
- Existing two-/three-column wrapping and four-plus-column local scrolling CSS
remains authoritative before the first user adjustment.
- Pointer cancellation restores drag-start widths and all pointer/cursor/
selection cleanup paths are covered.
## TDD sequence
1. RED: add pure tests for equal-and-opposite width changes, both clamp
directions, unchanged pair totals, and 8-pixel keyboard deltas.
2. RED: add a browser regression proving the shared renderer has no resize
separator before production behavior changes.
3. GREEN: implement the geometry helper and resizable table component. Keep
pure UI behavior in Playwright instead of adding isolated React tests.
4. GREEN: replace the shared Markdown table wrapper, add scoped styles and the
localized accessible name, then rerun the existing renderer suite.
5. REFACTOR: keep DOM measurement and pointer lifecycle local to the component;
do not leak resizing concerns into Markdown normalization or message stores.
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps && pnpm --filter @kandev/web exec vitest run \
lib/markdown/table-resize.test.ts \
components/shared/use-markdown-table-resize.test.ts \
components/shared/markdown-components.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps && pnpm --filter @kandev/web lint)
git diff --check
```
## Files likely touched
- `apps/web/lib/markdown/table-resize.ts`
- `apps/web/lib/markdown/table-resize.test.ts`
- `apps/web/components/shared/resizable-markdown-table.tsx`
- `apps/web/components/shared/use-markdown-table-resize.ts`
- `apps/web/components/shared/markdown-components.tsx`
- `apps/web/app/globals.css`
- `apps/web/src/locales/en/common.json`
## Dependencies
None.
## Parallelism
`sequential`. Measurement behavior, component semantics, and shared renderer
wiring must stay in one RED-GREEN cycle.
## Inputs
- `docs/specs/ui/resizable-markdown-tables.md`
- Existing table wrapper in `apps/web/components/shared/markdown-components.tsx`
- Existing wrapping rules in `apps/web/app/globals.css`
- Fine-pointer and phone capability in `apps/web/hooks/use-responsive-breakpoint.ts`
- Pointer-capture cleanup patterns in `apps/web/components/app-status-bar/`
## Output contract
Record the expected RED failures, focused GREEN command results, files changed,
and the rendered geometry proven in Playwright.
## Result
- RED: three geometry assertions failed against the no-op width helper; the
desktop Playwright regression failed because no separator existed.
- GREEN: 32 focused geometry/shared-renderer tests passed; TypeScript, frontend
lint, and `git diff --check` passed.
- The renderer now provides fine-pointer separators, pointer capture, 64-pixel
adjacent clamping, keyboard adjustment, ephemeral reset, structure-change
invalidation, and cleanup on cancellation/unmount.
- Real table geometry and responsive behavior are covered by Task 02.
@@ -0,0 +1,86 @@
---
id: "02-prove-resize-interaction"
title: "Prove Markdown table resizing and mobile parity"
status: done
wave: 2
depends_on: ["01-build-resize-renderer"]
plan: "plan.md"
spec: "../../specs/ui/resizable-markdown-tables.md"
---
# Task 02: Prove Resize Interaction and Mobile Parity
## Acceptance
- A desktop browser test drags a separator from its body-row hit area and proves
equal-and-opposite adjacent width changes, an unchanged third column, a stable
table width, and 64-pixel clamping.
- Desktop tests prove double-click reset, arrow-key adjustment, and `Enter` reset.
- A locally scrolling wide table keeps separators aligned after scrolling and
creates no chat- or document-level horizontal overflow.
- Mobile Chrome exposes no separator controls and preserves ordinary table
wrapping plus wide-table local scrolling.
- Existing Markdown wrapping regressions remain green.
## TDD sequence
1. RED: extend the desktop wrapping fixture with a three-column interaction case
and prove current tables have no separator.
2. GREEN: drive pointer, double-click, and keyboard behavior against the Task 01
implementation; refine only externally observable defects.
3. RED/GREEN: add mobile absence and parity assertions to the existing phone
fixture.
4. Run both complete focused files to catch wrapping or scroll-owner regressions.
## Verification
```bash
make build-web
(cd apps/web && pnpm e2e:run tests/chat/markdown-wrap.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-markdown-wrap.spec.ts)
(cd apps/web && pnpm run typecheck)
(cd apps && pnpm --filter @kandev/web lint)
git diff --check
```
## Files likely touched
- `apps/web/e2e/tests/chat/markdown-wrap.spec.ts`
- `apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts`
- Task 01 production files only if browser evidence reveals an interaction bug
## Dependencies
- `01-build-resize-renderer`
## Parallelism
`sequential`. Browser assertions depend on the completed renderer contract and
may legitimately feed a defect back into Task 01 files.
## Inputs
- Existing exact pnpm-table and wide-table fixtures in both wrapping suites
- `docs/specs/ui/resizable-markdown-tables.md`, especially desktop interaction,
local-scroll ownership, and mobile capability scenarios
## Output contract
Report desktop and mobile command results, before/after column and table widths,
the tested hit-point location, local/document overflow measurements, files
changed, and any unsupported browser behavior.
## Result
- RED: the desktop user-flow test failed because the shared renderer exposed no
resize separator.
- GREEN: the rebuilt production bundle passed all 8 desktop Markdown wrapping
tests and both Mobile Chrome Markdown tests.
- The desktop test drags from the first body row, verifies equal-and-opposite
60-pixel adjacent changes, an unchanged third column and table width, the
64-pixel clamp, double-click reset, 8-pixel keyboard adjustment, and `Enter`
reset.
- A wide desktop table keeps its separator aligned after 160 pixels of local
scroll. Chat and document overflow checks remain clean.
- Mobile Chrome exposes no separators while ordinary tables wrap and wide tables
retain local scrolling. No unsupported browser behavior remains in scope.
+1
View File
@@ -142,6 +142,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [ci-pr-automation](ui/ci-pr-automation.md) | building |
| [github-pr-review-actions](ui/github-pr-review-actions.md) | shipped |
| [comment-markdown](ui/comment-markdown.md) | shipped |
| [resizable-markdown-tables](ui/resizable-markdown-tables.md) | building |
| [transcript-auto-scroll](ui/transcript-auto-scroll.md) | building |
| [empty-turn-notice](ui/empty-turn-notice.md) | shipped |
| [acp-shell-command-output](ui/acp-shell-command-output.md) | shipped |
+122
View File
@@ -0,0 +1,122 @@
---
status: building
created: 2026-08-05
owner: kandev
---
# Resizable Markdown Table Columns
## Why
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.
## What
- Tables rendered through Kandev's shared GitHub-flavored Markdown renderer
expose a resize separator at every internal column boundary on non-phone,
fine-pointer layouts.
- Each separator has a narrow visual line and a forgiving hit area spanning the
full rendered table height, including the header and body.
- Dragging a separator changes only the two adjacent columns. Their combined
width and the table's total width remain unchanged, and neither column may be
reduced below 64 CSS pixels. Separator positions are re-measured after every
controlled width change and streamed cell-text mutation so the drag target
remains on the rendered boundary.
- A separator is exposed only while both adjacent measured columns meet the
64-pixel minimum. This omits every pair measuring less than 128 CSS pixels and
keeps each exposed separator's current ARIA value inside its declared range.
- The first resize begins from the browser's measured automatic layout. Custom
widths activate only after a non-zero pointer movement, then remain in
component memory for that mounted table only. Clicking a separator solely to
focus it leaves automatic layout active.
- 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.
- 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.
- 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.
- Resize state is ephemeral: it is not stored in local storage, backend state,
message content, or shared with another rendering of the same Markdown.
- Phone and coarse-pointer layouts do not show or activate resize separators.
They keep the existing automatic wrapping for ordinary tables and local
horizontal scrolling for wide tables.
- If resize capability becomes unavailable during a drag, the drag ends and
any document-level cursor or text-selection override is cleared.
- Table-local scrolling remains the only horizontal scroll owner. Resizing must
not create document-level or chat-level horizontal overflow.
## Scenarios
- **GIVEN** a three-column Markdown table on desktop, **WHEN** the user drags the
first separator from a point beside a body row, **THEN** the first column grows,
the second shrinks by the same amount, and the third column is unchanged.
- **GIVEN** a drag has started, **WHEN** the pointer leaves the table before
release, **THEN** pointer capture keeps the resize active until release.
- **GIVEN** an adjacent column reaches 64 pixels, **WHEN** the user continues
dragging toward it, **THEN** that boundary stops moving while the table width
stays constant.
- **GIVEN** a resized table, **WHEN** the user double-clicks any separator,
**THEN** all custom widths are removed and the browser recalculates the table's
automatic layout from current content and available width.
- **GIVEN** a focused separator, **WHEN** the user presses an arrow key,
**THEN** the adjacent widths change by 8 pixels subject to the same minimum;
**WHEN** the user presses `Enter`, **THEN** the table resets.
- **GIVEN** a resize is interrupted by pointer cancellation, **WHEN** cancellation
is received, **THEN** the table returns to the widths captured at drag start.
- **GIVEN** a resized streamed message, **WHEN** its table column count changes,
**THEN** custom widths are discarded and the new table uses automatic layout.
- **GIVEN** a resized table, **WHEN** it unmounts or the page reloads, **THEN** no
width preference survives.
- **GIVEN** the same Markdown table on a phone or coarse-pointer device, **WHEN**
it renders, **THEN** no resize control obstructs the cells and the existing
wrapping or table-local scrolling behavior remains intact.
- **GIVEN** a wide table that already scrolls locally, **WHEN** it is resized,
**THEN** separators stay aligned with their boundaries while the table scrolls,
and the chat and document do not overflow.
## Responsive and mobile contract
- **Desktop entry point:** inline separators on the rendered table; no menu or
configuration panel.
- **Mobile counterpart:** the existing readable automatic layout. Resizing is an
optional precision adjustment, so touch users retain full content access
without controls that would cover narrow cells.
- **Presentation:** the existing inline Markdown table and its local scroll
wrapper remain authoritative at every breakpoint.
- **Shared behavior:** Markdown parsing, table content, automatic wrapping, and
wide-table scroll policy are shared. Only the separator affordance is gated to
non-phone fine pointers.
- **Mobile verification:** phone tests assert that controls are absent, ordinary
two-column tables wrap, wide tables scroll locally, and neither chat nor the
document overflows.
## Failure modes
- If column geometry cannot be measured or fewer than two columns are present,
the table remains in automatic layout and exposes no separators.
- Pointer capture, document cursor, and text-selection overrides are always
cleaned up after release, cancellation, or unmount.
- A content update that changes the detected column count clears custom widths
instead of applying stale dimensions to a different structure.
## Out of scope
- Persisting widths across reloads, sessions, messages, or render locations.
- Encoding widths in Markdown or modifying stored message content.
- Touch dragging, a mobile column-width editor, or large touch resize targets.
- Resizing rows, reordering columns, hiding columns, or resizing non-Markdown
data grids.
- Choosing an exact width from a numeric input.
## Open questions
None.