From e153dade7a30ce1f0bcdef240b72ec69c2bac242 Mon Sep 17 00:00:00 2001 From: Rohan Patnaik Date: Sat, 3 Oct 2026 14:12:14 +0530 Subject: [PATCH] feat(web): add thread width control for orchestrator V2 --- apps/web/src/components/ChatView.tsx | 6 +- apps/web/src/components/chat/ChatHeader.tsx | 4 + .../src/components/chat/MessagesTimeline.tsx | 34 ++--- .../components/chat/ThreadWidthControl.tsx | 75 ++++++++++ .../chat/observeTimelineColumn.test.ts | 99 +++++++++++++ .../components/chat/observeTimelineColumn.ts | 46 ++++++ apps/web/src/hooks/useThreadWidth.test.tsx | 135 ++++++++++++++++++ apps/web/src/hooks/useThreadWidth.ts | 43 ++++++ apps/web/src/index.css | 9 +- docs/user/composer.md | 7 + 10 files changed, 433 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadWidthControl.tsx create mode 100644 apps/web/src/components/chat/observeTimelineColumn.test.ts create mode 100644 apps/web/src/components/chat/observeTimelineColumn.ts 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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8e105a49f37f..1f24287f16f4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { threadWidthStyle, useThreadWidth } from "~/hooks/useThreadWidth"; import { ChatCanvas } from "./chat/ChatCanvas"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; import { @@ -1503,6 +1504,7 @@ function releaseChatTimelineAnchor {/* Top bar */}
) : null} + {/* The stack already applies the width; nested composer surfaces fill it. */}
+
+ +
); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5dec1b6d411d..3b71941ca4a2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -246,6 +246,8 @@ import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextC import { createContextPresentationRegistry } from "../contextPresentationRegistry"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useClientSettings } from "~/hooks/useSettings"; +import { useThreadWidth } from "~/hooks/useThreadWidth"; +import { observeTimelineColumn } from "./observeTimelineColumn"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; @@ -913,6 +915,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); // Re-measure the minimap gutter when the chat column changes width without a viewport resize. const chatWidth = useClientSettings((settings) => settings.chatWidth); + const [threadWidthExpansion] = useThreadWidth(); const { target: readyCitationRequest, positioning: citationPositioning, @@ -1101,13 +1104,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return; } - 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; + return observeTimelineColumn(timelineViewportElement, (viewportWidth, contentWidth) => { const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter( viewportWidth, contentWidth, @@ -1117,18 +1114,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth)); reportContentOverflow(); - }; - - const frame = requestAnimationFrame(measure); - - const observer = new ResizeObserver(measure); - observer.observe(timelineViewportElement); - - return () => { - cancelAnimationFrame(frame); - observer.disconnect(); - }; - }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]); + }); + }, [ + timelineViewportElement, + rows.length, + listIdentityKey, + reportContentOverflow, + chatWidth, + threadWidthExpansion, + ]); const sharedState = useMemo( () => ({ @@ -1846,7 +1840,7 @@ function ContextCompactionTimelineRow({
+ } + > + + +
+ Thread width +
+ + + {expansion}% + + +
+
+ setExpansion(Number(event.currentTarget.value))} + /> + +
+
+
+ + ); +} diff --git a/apps/web/src/components/chat/observeTimelineColumn.test.ts b/apps/web/src/components/chat/observeTimelineColumn.test.ts new file mode 100644 index 000000000000..c0e192b5eeeb --- /dev/null +++ b/apps/web/src/components/chat/observeTimelineColumn.test.ts @@ -0,0 +1,99 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { observeTimelineColumn } from "./observeTimelineColumn"; +import { resolveTimelineMinimapHitStripWidth } from "./MessagesTimeline.logic"; + +afterEach(() => vi.unstubAllGlobals()); + +it("disables the hit strip for detached rows and observes their mounted replacement", () => { + class ElementStub { + matches = () => true; + querySelector = () => null; + closest = () => null; + } + vi.stubGlobal("Element", ElementStub); + const first = { isConnected: true, getBoundingClientRect: () => ({ width: 768 }) }; + const replacement = Object.assign(new ElementStub(), { + isConnected: true, + getBoundingClientRect: () => ({ width: 768 }), + }); + let current: typeof first | null = first; + const viewport = { + querySelector: () => current, + getBoundingClientRect: () => ({ width: 1400 }), + } as unknown as HTMLElement; + let measure!: () => void; + const observe = vi.fn(); + const unobserve = vi.fn(); + const disconnect = vi.fn(); + const cancelFrame = vi.fn(); + let rowsChanged!: (records: MutationRecord[]) => void; + const observeRows = vi.fn(); + const disconnectRows = vi.fn(); + vi.stubGlobal( + "MutationObserver", + class { + constructor(callback: (records: MutationRecord[]) => void) { + rowsChanged = callback; + } + observe = observeRows; + disconnect = disconnectRows; + }, + ); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + measure = callback; + } + observe = observe; + unobserve = unobserve; + disconnect = disconnect; + }, + ); + let initialMeasure!: FrameRequestCallback; + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + initialMeasure = callback; + return 7; + }); + vi.stubGlobal("requestAnimationFrame", requestFrame); + vi.stubGlobal("cancelAnimationFrame", cancelFrame); + const hitWidths: number[] = []; + const cleanup = observeTimelineColumn(viewport, (width, contentWidth) => { + hitWidths.push(resolveTimelineMinimapHitStripWidth(width, contentWidth)); + }); + initialMeasure(0); + expect(hitWidths.at(-1)).toBe(40); + expect(observe).toHaveBeenCalledWith(first); + + first.isConnected = false; + measure(); + expect(hitWidths.at(-1)).toBe(0); + expect(unobserve).toHaveBeenCalledWith(first); + + current = replacement; + // Only insertion fires: the viewport and old row do not resize again. + rowsChanged([{ target: viewport, addedNodes: [replacement] } as unknown as MutationRecord]); + initialMeasure(0); + expect(hitWidths.at(-1)).toBe(40); + expect(observe).toHaveBeenCalledWith(replacement); + expect(observeRows).toHaveBeenCalledWith(viewport, { childList: true, subtree: true }); + + const streamedNode = new ElementStub(); + streamedNode.matches = () => false; + const scheduledCount = requestFrame.mock.calls.length; + rowsChanged([{ target: replacement, addedNodes: [streamedNode] } as unknown as MutationRecord]); + expect(requestFrame.mock.calls.length).toBe(scheduledCount); + + replacement.getBoundingClientRect = () => ({ width: 1400 }); + measure(); + expect(hitWidths.at(-1)).toBe(0); + + current = null; + measure(); + expect(hitWidths.at(-1)).toBe(0); + expect(unobserve).toHaveBeenCalledWith(replacement); + cleanup(); + expect(disconnect).toHaveBeenCalledOnce(); + expect(disconnectRows).toHaveBeenCalledOnce(); + expect(cancelFrame).toHaveBeenCalledWith(7); +}); diff --git a/apps/web/src/components/chat/observeTimelineColumn.ts b/apps/web/src/components/chat/observeTimelineColumn.ts new file mode 100644 index 000000000000..153b5a331c07 --- /dev/null +++ b/apps/web/src/components/chat/observeTimelineColumn.ts @@ -0,0 +1,46 @@ +/** Measure the currently mounted column and follow replacements made by virtualization. */ +export function observeTimelineColumn( + viewport: HTMLElement, + onMeasure: (viewportWidth: number, contentWidth: number) => void, +) { + let observedColumn: HTMLElement | null = null; + const measure = () => { + const candidate = viewport.querySelector("[data-timeline-root]"); + const column = candidate?.isConnected ? candidate : null; + if (column !== observedColumn) { + if (observedColumn) observer.unobserve(observedColumn); + observedColumn = column; + if (column) observer.observe(column); + } + const viewportWidth = viewport.getBoundingClientRect().width; + // No mounted row means no usable gutter; the hit strip must stay inert. + onMeasure(viewportWidth, column?.getBoundingClientRect().width ?? viewportWidth); + }; + const observer = new ResizeObserver(measure); + observer.observe(viewport); + let frame = requestAnimationFrame(measure); + const rowObserver = new MutationObserver((records) => { + const rowAdded = records.some((record) => { + if (record.target instanceof Element && record.target.closest("[data-timeline-root]")) { + return false; + } + return Array.from(record.addedNodes).some( + (node) => + node instanceof Element && + (node.matches("[data-timeline-root]") || node.querySelector("[data-timeline-root]")), + ); + }); + // Row insertion/removal can leave viewport geometry unchanged. Coalesce it + // into one measurement; ordinary streamed text does not change the column. + if (!observedColumn?.isConnected || rowAdded) { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + } + }); + rowObserver.observe(viewport, { childList: true, subtree: true }); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + rowObserver.disconnect(); + }; +} diff --git a/apps/web/src/hooks/useThreadWidth.test.tsx b/apps/web/src/hooks/useThreadWidth.test.tsx new file mode 100644 index 000000000000..1ec513c2c247 --- /dev/null +++ b/apps/web/src/hooks/useThreadWidth.test.tsx @@ -0,0 +1,135 @@ +import { act, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { threadWidthStyle, THREAD_WIDTH_STORAGE_KEY, useThreadWidth } from "./useThreadWidth"; + +it("expands the Appearance width without replacing its zero-percent default", () => { + expect(threadWidthStyle(0)).toEqual({ + "--chat-content-max-width": "var(--chat-appearance-max-width, 46rem)", + }); + expect(threadWidthStyle(50)).toEqual({ + "--chat-content-max-width": "calc(var(--chat-appearance-max-width, 46rem) * 0.5 + 100% * 0.5)", + }); + expect(threadWidthStyle(100)).toEqual({ + "--chat-content-max-width": "100%", + }); +}); + +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); + } +}); diff --git a/apps/web/src/hooks/useThreadWidth.ts b/apps/web/src/hooks/useThreadWidth.ts new file mode 100644 index 000000000000..3a345ec2d9f1 --- /dev/null +++ b/apps/web/src/hooks/useThreadWidth.ts @@ -0,0 +1,43 @@ +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; + +/** Normalize stored input to a bounded expansion in five-percent steps. */ +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; +} + +/** Expand the configured Appearance width toward the available chat pane width. */ +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 { + "--chat-content-max-width": + ratio === 0 + ? "var(--chat-appearance-max-width, 46rem)" + : ratio === 1 + ? "100%" + : `calc(var(--chat-appearance-max-width, 46rem) * ${1 - ratio} + 100% * ${ratio})`, + } as CSSProperties; +} + +/** Share a device-local width preference between the toolbar and chat layout. */ +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; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6a4b043f9612..df1e0a8b27ab 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -116,7 +116,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --workspace-titlebar-control-size: 1.75rem; --workspace-titlebar-control-gap: 0.75rem; --workspace-titlebar-scroll-fade-height: 1.5rem; - --chat-content-max-width: 46rem; + --chat-appearance-max-width: 46rem; + --chat-content-max-width: var(--chat-appearance-max-width); --thread-details-panel-width: 19.5rem; /* The prompt on phones: iOS zooms into any input under 16px on focus. */ --font-size-prompt-touch: max(var(--font-size-prompt, 1rem), 16px); @@ -2019,14 +2020,14 @@ code { --diff-deletion-foreground: light-dark(var(--color-red-600), var(--color-red-400)); } -/* Chat width setting. Comfortable keeps the default --chat-content-max-width, +/* Chat width setting. Comfortable keeps the default --chat-appearance-max-width, which sizes the timeline lane, the composer, and ChatCanvas's width probe. */ :root[data-chat-width="wide"] { - --chat-content-max-width: 72rem; + --chat-appearance-max-width: 72rem; } :root[data-chat-width="full"] { - --chat-content-max-width: 100%; + --chat-appearance-max-width: 100%; } :root[data-diff-color-scheme="blue-orange"] { diff --git a/docs/user/composer.md b/docs/user/composer.md index 59efc3c30f01..594f1956fcf3 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -276,3 +276,10 @@ 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 your **Settings → Appearance → Chat width** choice; +**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.