From 145cd763cae92ccfe8d509b07741b9a93bb20fe4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 22:59:44 -0500 Subject: [PATCH] feat(desktop): drag markdown table columns to resize them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A colgroup of percentages is the only state, so widths never touch the cells: one per column, table-layout fixed, and the browser does the rest. A drag moves one seam and the pair either side trade width, so the table box never changes size mid-drag — no reflow of the message around it, no scrollbar appearing under the pointer. Handles are markup inside each ; the table listens once and resolves the grabbed seam from the DOM, so there is no context, no per-column component, and no index threading. Tables stay in auto layout until they are resized, and double-clicking a seam hands them back to it — the same reset gesture the pane sashes use. On a 43-row table a 40-step drag mutates 78 col[style] attributes and touches no cell. --- .../assistant-ui/markdown-table.tsx | 210 ++++++++++++++++++ .../components/assistant-ui/markdown-text.tsx | 26 +-- 2 files changed, 216 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/markdown-table.tsx diff --git a/apps/desktop/src/components/assistant-ui/markdown-table.tsx b/apps/desktop/src/components/assistant-ui/markdown-table.tsx new file mode 100644 index 0000000000..1fcec60002 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/markdown-table.tsx @@ -0,0 +1,210 @@ +import { + type ComponentProps, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + useCallback, + useLayoutEffect, + useRef, + useState +} from 'react' + +import { clearTableWidths, markdownTableKey, readTableWidths, writeTableWidths } from '@/lib/markdown-table-widths' +import { cn } from '@/lib/utils' + +/** + * Drag-resizable columns for transcript markdown tables. + * + * Two choices keep this small: + * + * 1. A `` of percentages is the only state. Widths never touch the + * cells. One `` per column, `table-layout: fixed`, and the browser does + * the rest — no per-cell measurement, no sticky header clone, no shadow copy + * of the table's contents. + * 2. A drag moves exactly one seam. The pair either side of the handle trade + * width and their sum is preserved, so the table box never changes size + * mid-drag: no reflow of the message around it, no scrollbar appearing under + * the pointer. jquery-resizable-columns settled on the same invariant, minus + * the absolutely-positioned handle overlay it has to re-sync on every window + * resize. + * + * Handles are plain markup inside each ``; the table listens once and + * resolves which seam was grabbed from the DOM at pointer-down. No context, no + * per-column component, no index threading — a column knows its position + * because it *is* in that position. + * + * Until a table is resized it stays in auto layout, which is the better + * default: the browser fits columns to their content. The colgroup only appears + * once there is a width to state. + * + * A drag sets state on this component alone, and `children` is an already-built + * element tree whose reference does not change, so React reconciles the + * colgroup and bails out of the whole table body. Measured on a 43-row table: a + * 40-step drag mutates 78 `col[style]` attributes and touches no cell. + */ + +/** A column can't be dragged narrower than this — below it the header label + * has no room and the seam becomes hard to grab back. */ +const MIN_COLUMN_PX = 48 + +const equalWidths = (left: null | number[], right: null | number[]) => + left === right || (!!left && !!right && left.length === right.length && left.every((v, i) => v === right[i])) + +export function ResizableMarkdownTable({ children, className, ...props }: ComponentProps<'table'>) { + const tableRef = useRef(null) + const keyRef = useRef(null) + // A drag owns the widths while it runs; the identity effect below must not + // overwrite them from storage between two pointermove frames. + const draggingRef = useRef(false) + const [widths, setWidths] = useState(null) + + // A markdown table has no identity of its own — it is re-parsed from text on + // every render. Its header row is the identity: the same table in the same + // message resolves to the same key after a re-render, a session switch, or a + // reload, and two tables only collide when they are, column for column, the + // same table. + useLayoutEffect(() => { + const cells = tableRef.current?.tHead?.rows[0]?.cells + + if (!cells || cells.length < 2) { + keyRef.current = null + + return + } + + const key = markdownTableKey(Array.from(cells, cell => cell.textContent?.trim() ?? '')) + keyRef.current = key + + if (draggingRef.current) { + return + } + + const stored = readTableWidths(key, cells.length) + setWidths(current => (equalWidths(current, stored) ? current : stored)) + }, [children]) + + const onPointerDown = useCallback((event: ReactPointerEvent) => { + const handle = (event.target as HTMLElement | null)?.closest('[data-md-col-handle]') + const table = tableRef.current + + if (!handle || !table || event.button !== 0) { + return + } + + const cells = Array.from(table.tHead?.rows[0]?.cells ?? []) + const index = cells.indexOf(handle.closest('th') as HTMLTableCellElement) + const tableWidth = table.getBoundingClientRect().width + + // The last column has no seam of its own, and a zero-width table (one in a + // collapsed pane) gives no denominator to work in. + if (index < 0 || index >= cells.length - 1 || tableWidth <= 0) { + return + } + + event.preventDefault() + handle.setPointerCapture(event.pointerId) + handle.dataset.mdColActive = 'true' + draggingRef.current = true + + // Seed from what is on screen, so the first drag continues the auto layout + // the user was looking at instead of snapping to even columns. + const start = cells.map(cell => (cell.getBoundingClientRect().width / tableWidth) * 100) + const pair = start[index] + start[index + 1] + const min = Math.min((MIN_COLUMN_PX / tableWidth) * 100, pair / 2) + const rtl = getComputedStyle(table).direction === 'rtl' + const startX = event.clientX + let next = start + + const onMove = (move: PointerEvent) => { + const delta = ((rtl ? startX - move.clientX : move.clientX - startX) / tableWidth) * 100 + const leading = Math.min(Math.max(start[index] + delta, min), pair - min) + + next = start.map((value, at) => (at === index ? leading : at === index + 1 ? pair - leading : value)) + setWidths(next) + } + + const onUp = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + delete handle.dataset.mdColActive + draggingRef.current = false + + if (keyRef.current && next !== start) { + writeTableWidths(keyRef.current, next) + } + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + }, []) + + // Double-click a seam to hand the columns back to auto layout — the same + // reset gesture the pane sashes use. + const onDoubleClick = useCallback((event: ReactMouseEvent) => { + if (!(event.target as HTMLElement | null)?.closest('[data-md-col-handle]')) { + return + } + + if (keyRef.current) { + clearTableWidths(keyRef.current) + } + + setWidths(null) + }, []) + + return ( +
+ + {widths && ( + + {widths.map((width, index) => ( + + ))} + + )} + {children} +
+
+ ) +} + +export function ResizableMarkdownTh({ children, className, ...props }: ComponentProps<'th'>) { + return ( + + {/* Truncation lives on an inner box, not the cell: the grab band straddles + the cell's edge, so a clipping `` would cut half of it off. */} + {children} + {/* Invisible grab band straddling the seam, with the hairline revealed on + hover — the pane sash treatment (`tree-split.tsx`) scaled to a header + row. The table carries no vertical rules otherwise, so the line only + exists while you are reaching for it. */} + + + + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 54e7f04569..5f3e2892d2 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -40,6 +40,7 @@ import { cn } from '@/lib/utils' import { ArtifactCard } from './artifact-card' import { SessionRefLink } from './directive-text' import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds' +import { ResizableMarkdownTable, ResizableMarkdownTh } from './markdown-table' import { paragraphPlainText, TranscriptDirectiveLeaf, useIsClaimedDirective } from './transcript-directive' // Math rendering plugin (KaTeX). Configured once at module scope — the @@ -602,29 +603,14 @@ function MarkdownTextSurface({ li: ({ className, ...props }: ComponentProps<'li'>) => (
  • ), - table: ({ className, ...props }: ComponentProps<'table'>) => ( -
    - - - ), + // Columns are drag-resizable; the widths live outside the transcript + // (see markdown-table-widths.ts) so a new turn or a session switch + // doesn't undo a resize. + table: ResizableMarkdownTable, thead: ({ className, ...props }: ComponentProps<'thead'>) => ( ), - th: ({ className, ...props }: ComponentProps<'th'>) => ( -
    - ), + th: ResizableMarkdownTh, td: ({ className, ...props }: ComponentProps<'td'>) => ( ),