From 277bc7a49d1f012c4bacc3815cd1e1338fe0af21 Mon Sep 17 00:00:00 2001 From: rohanpatnaik Date: Mon, 21 Sep 2026 01:54:18 +0530 Subject: [PATCH 1/7] feat(web): add thread width and fit tables controls --- apps/web/src/components/ChatMarkdown.tsx | 58 ++++-- apps/web/src/components/ChatView.tsx | 6 +- apps/web/src/components/chat/ChatHeader.tsx | 2 + .../src/components/chat/MessagesTimeline.tsx | 14 +- .../components/chat/ThreadWidthControl.tsx | 89 ++++++++ apps/web/src/hooks/useThreadWidth.test.tsx | 192 ++++++++++++++++++ apps/web/src/hooks/useThreadWidth.ts | 47 +++++ apps/web/src/index.css | 54 +++++ docs/user/composer.md | 12 ++ 9 files changed, 451 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadWidthControl.tsx create mode 100644 apps/web/src/hooks/useThreadWidth.test.tsx create mode 100644 apps/web/src/hooks/useThreadWidth.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4a1f616aed3b..d16388e93a3f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { useFitTables } from "~/hooks/useThreadWidth"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { useAtomValue } from "@effect/atom-react"; import { @@ -68,6 +69,7 @@ import React, { useCallback, memo, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -722,6 +724,19 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); const [expanded, setExpanded] = useState(readInitialWordWrapSetting); + const [fitTables] = useFitTables(); + + useLayoutEffect(() => { + const table = tableRef.current; + if (!table) return; + // Read the rendered header so streaming and column spans keep the same + // readable minimum. CSS handles pane resizing without a resize observer. + const columns = [...(table.rows[0]?.cells ?? [])].reduce( + (count, cell) => count + cell.colSpan, + 0, + ); + table.style.setProperty("--table-column-count", String(Math.max(1, columns))); + }); const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; @@ -792,6 +807,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { ref={containerRef} className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} + data-fit={fitTables ? "true" : "false"} > @@ -799,23 +815,31 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
- - - } - > - {expanded ? : } - - {expandLabel} - + {fitTables ? ( + + ) : ( + + + } + > + {expanded ? ( + + ) : ( + + )} + + {expandLabel} + + )} {/* Top bar */}
) : null} + {/* The stack already applies the width; nested composer surfaces fill it. */}
+ ("[data-timeline-root]"); const measure = () => { const viewportWidth = timelineViewportElement.getBoundingClientRect().width; // Without a mounted row, treat the column as full width so the strip stays inert. - const contentWidth = - timelineViewportElement - .querySelector("[data-timeline-root]") - ?.getBoundingClientRect().width ?? viewportWidth; + const contentWidth = contentElement?.getBoundingClientRect().width ?? viewportWidth; const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter( viewportWidth, contentWidth, @@ -1131,12 +1130,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const observer = new ResizeObserver(measure); observer.observe(timelineViewportElement); + if (contentElement) { + // Width can change without resizing the viewport when the header control + // or the Appearance setting changes the shared content-column cap. + observer.observe(contentElement); + } return () => { cancelAnimationFrame(frame); observer.disconnect(); }; - }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]); + }, [timelineViewportElement, rows.length, listIdentityKey, reportContentOverflow, chatWidth]); const sharedState = useMemo( () => ({ diff --git a/apps/web/src/components/chat/ThreadWidthControl.tsx b/apps/web/src/components/chat/ThreadWidthControl.tsx new file mode 100644 index 000000000000..c2e3838e30d3 --- /dev/null +++ b/apps/web/src/components/chat/ThreadWidthControl.tsx @@ -0,0 +1,89 @@ +import { ArrowLeftRightIcon, MinusIcon, PlusIcon } from "lucide-react"; +import { useId, type CSSProperties } from "react"; + +import { THREAD_WIDTH_STEP, useFitTables, useThreadWidth } from "~/hooks/useThreadWidth"; +import { Switch } from "../ui/switch"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; + +export function ThreadWidthControl() { + const [expansion, setExpansion] = useThreadWidth(); + const sliderId = useId(); + const fitId = useId(); + const [fitTables, setFitTables] = useFitTables(); + const sliderStyle = { + "--settings-slider-progress": `${expansion}%`, + "--settings-slider-fill-offset": `${0.5 - expansion / 100}rem`, + } as CSSProperties; + + return ( + + + } + > + + + Thread width +
+ + + {expansion}% + + +
+
+ setExpansion(Number(event.currentTarget.value))} + /> + +
+ +
+
+ ); +} diff --git a/apps/web/src/hooks/useThreadWidth.test.tsx b/apps/web/src/hooks/useThreadWidth.test.tsx new file mode 100644 index 000000000000..38bfdda64d1c --- /dev/null +++ b/apps/web/src/hooks/useThreadWidth.test.tsx @@ -0,0 +1,192 @@ +import { act, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { + FIT_TABLES_STORAGE_KEY, + useFitTables, + THREAD_WIDTH_STORAGE_KEY, + useThreadWidth, +} from "./useThreadWidth"; + +let renderer: ReactTestRenderer | undefined; +let saved: Map; +let events: EventTarget; +let current: ReturnType; +let other: ReturnType; + +function Reader({ secondary = false }: { secondary?: boolean }) { + const value = useThreadWidth(); + useEffect(() => { + if (secondary) other = value; + else current = value; + }, [secondary, value]); + return null; +} + +beforeEach(() => { + saved = new Map(); + events = new EventTarget(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + dispatchEvent: events.dispatchEvent.bind(events), + localStorage: { + getItem: (key: string) => saved.get(key) ?? null, + setItem: (key: string, value: string) => saved.set(key, value), + removeItem: (key: string) => saved.delete(key), + }, + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +it("starts at the original width and shares changes between the toolbar and conversation", async () => { + await act(() => { + renderer = create( + <> + + + , + ); + }); + expect(current[0]).toBe(0); + await act(() => current[1](5)); + expect(other[0]).toBe(5); + expect(saved.get(THREAD_WIDTH_STORAGE_KEY)).toBe("5"); + await act(() => other[1](100)); + expect(current[0]).toBe(100); + await act(() => current[1](0)); + expect(other[0]).toBe(0); +}); + +it("retains the preference when navigating or restarting the client", async () => { + await act(() => { + renderer = create(); + }); + await act(() => current[1](65)); + await act(() => renderer!.unmount()); + await act(() => { + renderer = create(); + }); + expect(current[0]).toBe(65); +}); + +it("follows a preference changed by another window", async () => { + await act(() => { + renderer = create(); + }); + saved.set(THREAD_WIDTH_STORAGE_KEY, "85"); + await act(() => { + const event = new Event("storage"); + Object.defineProperty(event, "key", { value: THREAD_WIDTH_STORAGE_KEY }); + events.dispatchEvent(event); + }); + expect(current[0]).toBe(85); +}); + +it.each([ + { value: -20, expected: 0 }, + { value: 123, expected: 100 }, + { value: 32, expected: 30 }, + { value: null, expected: 0 }, + { value: "wide", expected: 0 }, + { value: {}, expected: 0 }, +])( + "keeps invalid stored width $value usable and accepts a subsequent adjustment", + async ({ value, expected }) => { + saved.set(THREAD_WIDTH_STORAGE_KEY, JSON.stringify(value)); + await act(() => { + renderer = create(); + }); + expect(current[0]).toBe(expected); + await act(() => current[1](55)); + expect(current[0]).toBe(55); + expect(saved.get(THREAD_WIDTH_STORAGE_KEY)).toBe("55"); + }, +); + +it("clamps controls at the endpoints and quantizes every adjustment to five percent", async () => { + await act(() => { + renderer = create(); + }); + for (const [input, expected] of [ + [-5, 0], + [105, 100], + [27, 25], + [28, 30], + [95, 95], + ]) { + await act(() => current[1](input!)); + expect(current[0]).toBe(expected); + } +}); + +let fit: ReturnType; +let otherFit: ReturnType; +function FitReader({ secondary = false }: { secondary?: boolean }) { + const value = useFitTables(); + useEffect(() => { + if (secondary) otherFit = value; + else fit = value; + }, [secondary, value]); + return null; +} +it("fits tables by default and synchronizes existing tables with the toolbar", async () => { + await act(() => { + renderer = create( + <> + + + + , + ); + }); + expect(fit[0]).toBe(true); + await act(() => fit[1](false)); + expect(otherFit[0]).toBe(false); + expect(saved.get(FIT_TABLES_STORAGE_KEY)).toBe("false"); + expect(current[0]).toBe(0); + await act(() => otherFit[1](true)); + expect(fit[0]).toBe(true); +}); +it("preserves an explicit Fit tables off choice across navigation and restart", async () => { + await act(() => { + renderer = create(); + }); + await act(() => fit[1](false)); + await act(() => renderer!.unmount()); + await act(() => { + renderer = create(); + }); + expect(fit[0]).toBe(false); +}); +it("updates tables when another window changes the fit preference", async () => { + await act(() => { + renderer = create(); + }); + saved.set(FIT_TABLES_STORAGE_KEY, "false"); + await act(() => { + const event = new Event("storage"); + Object.defineProperty(event, "key", { value: FIT_TABLES_STORAGE_KEY }); + events.dispatchEvent(event); + }); + expect(fit[0]).toBe(false); +}); +it.each(["{}", "null", '"false"'])( + "defaults safely with invalid Fit tables storage %s", + async (value) => { + saved.set(FIT_TABLES_STORAGE_KEY, value); + await act(() => { + renderer = create(); + }); + expect(fit[0]).toBe(true); + await act(() => fit[1](false)); + expect(fit[0]).toBe(false); + }, +); diff --git a/apps/web/src/hooks/useThreadWidth.ts b/apps/web/src/hooks/useThreadWidth.ts new file mode 100644 index 000000000000..c44c79710b8d --- /dev/null +++ b/apps/web/src/hooks/useThreadWidth.ts @@ -0,0 +1,47 @@ +import * as Schema from "effect/Schema"; +import { useCallback, type CSSProperties } from "react"; + +import { useLocalStorage } from "./useLocalStorage"; + +export const THREAD_WIDTH_STORAGE_KEY = "t3code:thread-width-expansion"; +export const THREAD_WIDTH_STEP = 5; + +export function normalizeThreadWidth(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(100, Math.max(0, Math.round(value / THREAD_WIDTH_STEP) * THREAD_WIDTH_STEP)) + : 0; +} + +export function threadWidthStyle(expansion: number): CSSProperties { + // Percentages resolve against the chat pane, including when a right + // panel takes space. w-full keeps narrow panes at their original width. + const ratio = normalizeThreadWidth(expansion) / 100; + return { + "--thread-content-max-width": `calc(48rem * ${1 - ratio} + 100% * ${ratio})`, + } as CSSProperties; +} + +export function useThreadWidth() { + const [stored, setStored] = useLocalStorage( + THREAD_WIDTH_STORAGE_KEY, + 0, + Schema.Unknown, + ); + const setExpansion = useCallback( + (value: number) => setStored(normalizeThreadWidth(value)), + [setStored], + ); + return [normalizeThreadWidth(stored), setExpansion] as const; +} + +export const FIT_TABLES_STORAGE_KEY = "t3code:fit-tables"; + +export function useFitTables() { + const [stored, setStored] = useLocalStorage( + FIT_TABLES_STORAGE_KEY, + true, + Schema.Unknown, + ); + const setFitTables = useCallback((value: boolean) => setStored(value), [setStored]); + return [typeof stored === "boolean" ? stored : true, setFitTables] as const; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d1ca0f4cf60e..eab07fd8361a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1987,6 +1987,60 @@ code { overflow-wrap: anywhere; } +/* Fit the available pane, but retain horizontal scrolling when there are too + many columns to read comfortably. The legacy cell expansion state is kept + intact underneath, so switching Fit tables off restores it. */ +.chat-markdown .chat-markdown-table-container[data-fit="true"] table { + table-layout: fixed; + min-width: calc(var(--table-column-count, 1) * 7.5rem); +} + +.chat-markdown .chat-markdown-table-container[data-fit="true"] :is(th, td) { + /* Legacy expansion writes inline min-widths to headers. */ + min-width: 0 !important; + max-width: none; + white-space: normal; + overflow-wrap: anywhere; + word-break: normal; + overflow: visible; + text-overflow: clip; + vertical-align: top; +} + +/* Prompt-stash save acknowledgement: the new count fades up from just below + its resting position, once, then stops. One-shot and event-driven (React + remounts the element by key on each stash) — no continuous animation. */ +@keyframes prompt-stash-count-enter { + from { + opacity: 0; + transform: translateY(2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Expanded capture preview swaps between the screenshot and its extracted text. + One-shot fade on whichever panel mounts, so nothing repaints continuously. */ +@keyframes snap-shot-contents-enter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes provider-update-pill-countdown { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + :root { --diff-addition: var(--success); --diff-deletion: var(--destructive); diff --git a/docs/user/composer.md b/docs/user/composer.md index 7ee8edcbb121..0d887e9a0409 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -234,3 +234,15 @@ automatically. HTML previews cannot access your T3 Code session. On mobile, select a PDF attachment or link to open it. iOS uses the native viewer; Android opens a compatible installed file viewer. + +## Thread width + +Use **Width** in the thread header to expand the conversation and composer in +5% steps. **0%** keeps the original reading width; **100%** uses the available +chat pane. The preference stays on this device across threads and reloads, and +the content adapts when side panels open or the window shrinks. + +**Fit tables** in the same menu wraps cells to use that width. Tables with many +columns retain horizontal scrolling to keep cells readable. Turn it off to +restore the existing per-table expand/collapse behavior. This preference also +stays on this device. From 98a69e1af3bcd011948834bfdbf14a760a22135c Mon Sep 17 00:00:00 2001 From: rohanpatnaik Date: Mon, 21 Sep 2026 02:44:51 +0530 Subject: [PATCH 2/7] fix(web): make table fitting opt-in --- .../components/chat/ThreadWidthControl.tsx | 108 +++++++++--------- apps/web/src/hooks/useThreadWidth.test.tsx | 47 ++++---- apps/web/src/hooks/useThreadWidth.ts | 8 +- docs/user/composer.md | 2 +- 4 files changed, 85 insertions(+), 80 deletions(-) diff --git a/apps/web/src/components/chat/ThreadWidthControl.tsx b/apps/web/src/components/chat/ThreadWidthControl.tsx index c2e3838e30d3..27275ead15a1 100644 --- a/apps/web/src/components/chat/ThreadWidthControl.tsx +++ b/apps/web/src/components/chat/ThreadWidthControl.tsx @@ -6,6 +6,7 @@ import { Switch } from "../ui/switch"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; +/** Adjust conversation width and optional table wrapping without leaving the thread. */ export function ThreadWidthControl() { const [expansion, setExpansion] = useThreadWidth(); const sliderId = useId(); @@ -20,69 +21,64 @@ export function ThreadWidthControl() { - } + render={ - - {expansion}% - - -
-
- setExpansion(Number(event.currentTarget.value))} - /> -