diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2aeca85e6aa2..d9462a33b29e 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 { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; @@ -1570,6 +1571,7 @@ function releaseChatTimelineAnchor(); export default function ChatView(props: ChatViewProps) { + const [threadWidthExpansion] = useThreadWidth(); const { environmentId, threadId, @@ -11323,6 +11325,7 @@ export default function ChatView(props: ChatViewProps) { rightPanelMaximized ? "w-0 flex-none" : "flex-1", )} data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} + style={threadWidthStyle(threadWidthExpansion)} > {/* 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 0518c96afc58..39f36143db83 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -270,6 +270,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"; @@ -1052,6 +1054,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); // 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, @@ -1268,13 +1271,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ 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, @@ -1284,17 +1281,15 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth)); reportContentOverflow(); - }; - - const frame = requestAnimationFrame(measure); - - const stopObserving = observeResize(timelineViewportElement, measure); - - return () => { - cancelAnimationFrame(frame); - stopObserving(); - }; - }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]); + }); + }, [ + timelineViewportElement, + rows.length, + listIdentityKey, + reportContentOverflow, + chatWidth, + threadWidthExpansion, + ]); const sharedState = useMemo( () => ({ @@ -2117,7 +2112,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..9be31eec8baa --- /dev/null +++ b/apps/web/src/components/chat/observeTimelineColumn.test.ts @@ -0,0 +1,102 @@ +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: (entries: readonly ResizeObserverEntry[]) => void) { + measure = () => + callback([ + { target: viewport, contentRect: { width: 1400, height: 0 } }, + ] as unknown as ResizeObserverEntry[]); + } + 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..c585a61446d6 --- /dev/null +++ b/apps/web/src/components/chat/observeTimelineColumn.ts @@ -0,0 +1,49 @@ +import { observeResize } from "~/lib/observeResize"; + +/** 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; + let stopColumn: (() => void) | null = null; + const measure = () => { + const candidate = viewport.querySelector("[data-timeline-root]"); + const column = candidate?.isConnected ? candidate : null; + if (column !== observedColumn) { + stopColumn?.(); + observedColumn = column; + stopColumn = column ? observeResize(column, measure) : null; + } + 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 stopViewport = observeResize(viewport, measure); + 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); + stopColumn?.(); + stopViewport(); + 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 b59b8323f8a3..95f2eb5d8646 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -125,7 +125,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: 17.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); @@ -2081,14 +2082,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 ba8d26fd81c2..cdb999c945ab 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -309,3 +309,10 @@ including filenames or folders containing `#` or `?`. 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.