feat(web): add responsive, resizable markdown tables (#2098)
This commit is contained in:
+41
-12
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user