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'>) => (
|
),
|