diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a87d1a64f7e5..ee5661cee124 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 { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; import { @@ -396,6 +397,7 @@ const noopHeldRevert = (_targetTurnCount: number) => {}; const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; export default function ChatView(props: ChatViewProps) { + const [threadWidthExpansion] = useThreadWidth(); const { environmentId, threadId, @@ -3165,6 +3167,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)} data-chat-column >
) : 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 c3e2007c9aad..f47b565cb140 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -17,7 +17,6 @@ import { import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; -import { observeResize } from "~/lib/observeResize"; import { useStableCallback } from "~/hooks/useStableCallback"; const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; @@ -76,7 +75,7 @@ import type { ThreadFindPositionReader } from "./threadFind"; import { TooltipScrollDismissArea } from "../ui/tooltip"; import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; -import { useClientSettings } from "~/hooks/useSettings"; +import { observeTimelineColumn } from "./observeTimelineColumn"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -626,7 +625,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); - const chatWidth = useClientSettings((settings) => settings.chatWidth); const { target: readyCitationRequest, positioning: citationPositioning, @@ -866,12 +864,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ return; } - const measure = () => { - const viewportWidth = timelineViewportElement.getBoundingClientRect().width; - const contentWidth = - timelineViewportElement - .querySelector("[data-timeline-root]") - ?.getBoundingClientRect().width ?? viewportWidth; + return observeTimelineColumn(timelineViewportElement, (viewportWidth, contentWidth) => { const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter( viewportWidth, contentWidth, @@ -881,18 +874,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth)); reportContentOverflow(); - }; - - const frame = requestAnimationFrame(measure); - - const stopObserving = observeResize(timelineViewportElement, measure); - - return () => { - cancelAnimationFrame(frame); - stopObserving(); - }; - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Rows and chat width resize the content without resizing the viewport. - }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]); + }); + }, [timelineViewportElement, reportContentOverflow]); const sharedState: TimelineRowSharedState = { citationRequest: readyCitationRequest, @@ -1318,7 +1301,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..a2a9229b1207 --- /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!: (target: object) => 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 = (target) => + callback([ + { target, 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(viewport); + 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(replacement); + expect(hitWidths.at(-1)).toBe(0); + + current = null; + measure(viewport); + 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/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index cde1f05e93d8..bcd570f01d1a 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -172,7 +172,7 @@ export function useLocalStorage( const subscribe = (onStoreChange: () => void) => { const handleStorageChange = (event: StorageEvent) => { - if (event.key === key) { + if (event.key === key || event.key === null) { onStoreChange(); } }; diff --git a/apps/web/src/hooks/useThreadWidth.browser.test.tsx b/apps/web/src/hooks/useThreadWidth.browser.test.tsx new file mode 100644 index 000000000000..1e9514690ab6 --- /dev/null +++ b/apps/web/src/hooks/useThreadWidth.browser.test.tsx @@ -0,0 +1,119 @@ +import { useEffect } from "react"; +import { expect, it } from "vite-plus/test"; +import { act, render } from "../../test/render"; +import { threadWidthStyle, THREAD_WIDTH_STORAGE_KEY, useThreadWidth } from "./useThreadWidth"; + +function setup(value?: unknown) { + const original = localStorage.getItem(THREAD_WIDTH_STORAGE_KEY); + if (value === undefined) localStorage.removeItem(THREAD_WIDTH_STORAGE_KEY); + else localStorage.setItem(THREAD_WIDTH_STORAGE_KEY, JSON.stringify(value)); + let current: ReturnType; + let other: ReturnType; + function Reader({ secondary = false }: { secondary?: boolean }) { + const result = useThreadWidth(); + useEffect(() => { + if (secondary) other = result; + else current = result; + }); + return null; + } + return { + Reader, + get current() { + return current; + }, + get other() { + return other; + }, + [Symbol.dispose]() { + if (original === null) localStorage.removeItem(THREAD_WIDTH_STORAGE_KEY); + else localStorage.setItem(THREAD_WIDTH_STORAGE_KEY, original); + }, + }; +} + +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%" }); +}); + +it("shares changes between the toolbar and conversation", async () => { + using fixture = setup(); + await using screen = await render( + <> + + + , + ); + expect(fixture.current[0]).toBe(0); + await act(() => fixture.current[1](5)); + expect(fixture.other[0]).toBe(5); + expect(localStorage.getItem(THREAD_WIDTH_STORAGE_KEY)).toBe("5"); + await act(() => fixture.other[1](100)); + expect(fixture.current[0]).toBe(100); + await act(() => fixture.current[1](0)); + expect(fixture.other[0]).toBe(0); +}); + +it("retains the preference when navigating or restarting the client", async () => { + using fixture = setup(); + await using screen = await render(); + await act(() => fixture.current[1](65)); + await screen.rerender(null); + await screen.rerender(); + expect(fixture.current[0]).toBe(65); +}); + +it("follows a preference changed or cleared by another window", async () => { + using fixture = setup(); + await using screen = await render(); + await act(() => { + localStorage.setItem(THREAD_WIDTH_STORAGE_KEY, "85"); + window.dispatchEvent(new StorageEvent("storage", { key: THREAD_WIDTH_STORAGE_KEY })); + }); + expect(fixture.current[0]).toBe(85); + await act(() => { + localStorage.removeItem(THREAD_WIDTH_STORAGE_KEY); + window.dispatchEvent(new StorageEvent("storage", { key: null })); + }); + expect(fixture.current[0]).toBe(0); +}); + +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 }) => { + using fixture = setup(value); + await using screen = await render(); + expect(fixture.current[0]).toBe(expected); + await act(() => fixture.current[1](55)); + expect(fixture.current[0]).toBe(55); + expect(localStorage.getItem(THREAD_WIDTH_STORAGE_KEY)).toBe("55"); + }, +); + +it("clamps controls at the endpoints and quantizes adjustments to five percent", async () => { + using fixture = setup(); + await using screen = await render(); + for (const [input, expected] of [ + [-5, 0], + [105, 100], + [27, 25], + [28, 30], + [95, 95], + ]) { + await act(() => fixture.current[1](input!)); + expect(fixture.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..a2860d562d28 --- /dev/null +++ b/apps/web/src/hooks/useThreadWidth.ts @@ -0,0 +1,40 @@ +import * as Schema from "effect/Schema"; +import { 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. */ +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 = (value: number) => setStored(normalizeThreadWidth(value)); + return [normalizeThreadWidth(stored), setExpansion] as const; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 01fa601ab666..ca56955a492d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -126,7 +126,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); @@ -2191,14 +2192,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 bdb67cae4b31..fe8e9f4b7805 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -349,3 +349,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.