diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index db13c8f21c80e..96ffd0a6ba701 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,4 +1,5 @@ import { isChatGptUsageLimitError } from "@t3tools/shared/usageLimits"; +import { threadWidthStyle, useThreadWidth } from "~/hooks/useThreadWidth"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; @@ -1475,6 +1476,7 @@ function releaseChatTimelineAnchor {/* Top bar */}
{isDraftHeroState ? (
@@ -9970,8 +9973,9 @@ export default function ChatView(props: ChatViewProps) {
) : null} + {/* The stack already applies the width; nested composer surfaces fill it. */}
+ -
+
+ + {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 0000000000000..c0e192b5eeeb0 --- /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 0000000000000..153b5a331c07d --- /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 0000000000000..ea7eecd8461f9 --- /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({ + "--thread-content-max-width": "var(--chat-max-width, 48rem)", + }); + expect(threadWidthStyle(50)).toEqual({ + "--thread-content-max-width": "calc(var(--chat-max-width, 48rem) * 0.5 + 100% * 0.5)", + }); + expect(threadWidthStyle(100)).toEqual({ + "--thread-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 0000000000000..3429c8d5af824 --- /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 { + "--thread-content-max-width": + ratio === 0 + ? "var(--chat-max-width, 48rem)" + : ratio === 1 + ? "100%" + : `calc(var(--chat-max-width, 48rem) * ${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/docs/user/composer.md b/docs/user/composer.md index 7ee8edcbb1215..94189de774959 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -234,3 +234,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.