diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 765db9920f..776fa8274d 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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 { diff --git a/apps/web/components/shared/markdown-components.tsx b/apps/web/components/shared/markdown-components.tsx index 30f972d98c..60b414ac55 100644 --- a/apps/web/components/shared/markdown-components.tsx +++ b/apps/web/components/shared/markdown-components.tsx @@ -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 }) => ( -
- {children}
-
- ), + table: ResizableMarkdownTable, }; diff --git a/apps/web/components/shared/resizable-markdown-table.tsx b/apps/web/components/shared/resizable-markdown-table.tsx new file mode 100644 index 0000000000..18f80a30aa --- /dev/null +++ b/apps/web/components/shared/resizable-markdown-table.tsx @@ -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 ( +
+ + {resize.columnWidths && ( + + {resize.columnWidths.map((width, index) => ( + + ))} + + )} + {children} +
+ {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 ( +
+ ); +} diff --git a/apps/web/components/shared/use-markdown-table-resize.test.ts b/apps/web/components/shared/use-markdown-table-resize.test.ts new file mode 100644 index 0000000000..a4f9f53ab0 --- /dev/null +++ b/apps/web/components/shared/use-markdown-table-resize.test.ts @@ -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(); + }); +}); diff --git a/apps/web/components/shared/use-markdown-table-resize.ts b/apps/web/components/shared/use-markdown-table-resize.ts new file mode 100644 index 0000000000..10001db7e0 --- /dev/null +++ b/apps/web/components/shared/use-markdown-table-resize.ts @@ -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; + wrapperRef: RefObject; +}; + +function useTableGeometry( + enabled: boolean, + refs: TableRefs, + widthsRef: RefObject, + reset: () => void, + columnWidths: number[] | null, +) { + const [geometry, setGeometry] = useState(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>; + widthsRef: RefObject; +}; + +function usePointerResize({ + enabled, + fixedTableWidth, + refs, + setColumnWidths, + setFixedTableWidth, + widthsRef, +}: PointerResizeOptions) { + const dragRef = useRef(null); + const [activeBoundary, setActiveBoundary] = useState(null); + const finishDrag = useCallback( + (event: PointerEvent, 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) => { + 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) => { + 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(null); + const tableRef = useRef(null); + const refs = useMemo(() => ({ tableRef, wrapperRef }), []); + const widthsRef = useRef(null); + const [columnWidths, setColumnWidthsState] = useState(null); + const [fixedTableWidth, setFixedTableWidth] = useState(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) => { + 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, + }; +} diff --git a/apps/web/components/task/chat/message-renderer.test.tsx b/apps/web/components/task/chat/message-renderer.test.tsx index d4fff73c15..73e43d2a00 100644 --- a/apps/web/components/task/chat/message-renderer.test.tsx +++ b/apps/web/components/task/chat/message-renderer.test.tsx @@ -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( + , + ); + + 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(); + }); }); diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 84a39b495d..61aecba546 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -159,7 +159,13 @@ type MessageAdapter = { const adapters: MessageAdapter[] = [ { matches: (comment) => comment.type === "thinking", - render: (comment) => , + render: (comment, ctx) => ( + + ), }, { matches: (comment) => comment.type === "todo", diff --git a/apps/web/components/task/chat/messages/thinking-message.tsx b/apps/web/components/task/chat/messages/thinking-message.tsx index 4680887888..00495d1127 100644 --- a/apps/web/components/task/chat/messages/thinking-message.tsx +++ b/apps/web/components/task/chat/messages/thinking-message.tsx @@ -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 && (
- {text} +
)} diff --git a/apps/web/e2e/tests/chat/markdown-wrap.spec.ts b/apps/web/e2e/tests/chat/markdown-wrap.spec.ts index 7369431c90..ebe54a5266 100644 --- a/apps/web/e2e/tests/chat/markdown-wrap.spec.ts +++ b/apps/web/e2e/tests/chat/markdown-wrap.spec.ts @@ -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 { 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); + }); }); diff --git a/apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts b/apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts new file mode 100644 index 0000000000..e0e0d7f0cf --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-markdown-wrap.spec.ts @@ -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); + }); +}); diff --git a/apps/web/lib/markdown/table-resize.test.ts b/apps/web/lib/markdown/table-resize.test.ts new file mode 100644 index 0000000000..97ddae4a7d --- /dev/null +++ b/apps/web/lib/markdown/table-resize.test.ts @@ -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]); + }); +}); diff --git a/apps/web/lib/markdown/table-resize.ts b/apps/web/lib/markdown/table-resize.ts new file mode 100644 index 0000000000..72f7200328 --- /dev/null +++ b/apps/web/lib/markdown/table-resize.ts @@ -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; +} diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index 02fd5301cc..f5b2e2dbe9 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -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.", diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index 7e58fc7ca5..ec703791a9 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -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ēŕŷ ćũŕŕēńţĺŷ-ḿàţćĥĩńĝ ĩţēḿ. Ţĥĩś ćàńńōţ ƀē ũńďōńē.", diff --git a/docs/plans/resizable-markdown-tables/plan.md b/docs/plans/resizable-markdown-tables/plan.md new file mode 100644 index 0000000000..866060c92b --- /dev/null +++ b/docs/plans/resizable-markdown-tables/plan.md @@ -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 `` 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 + `` 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. diff --git a/docs/plans/resizable-markdown-tables/task-01-build-resize-renderer.md b/docs/plans/resizable-markdown-tables/task-01-build-resize-renderer.md new file mode 100644 index 0000000000..1e4a718c89 --- /dev/null +++ b/docs/plans/resizable-markdown-tables/task-01-build-resize-renderer.md @@ -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. diff --git a/docs/plans/resizable-markdown-tables/task-02-prove-resize-interaction.md b/docs/plans/resizable-markdown-tables/task-02-prove-resize-interaction.md new file mode 100644 index 0000000000..69ac009d33 --- /dev/null +++ b/docs/plans/resizable-markdown-tables/task-02-prove-resize-interaction.md @@ -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. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 425672479a..f01093f8a3 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 | diff --git a/docs/specs/ui/resizable-markdown-tables.md b/docs/specs/ui/resizable-markdown-tables.md new file mode 100644 index 0000000000..58030f4e49 --- /dev/null +++ b/docs/specs/ui/resizable-markdown-tables.md @@ -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.