Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -1475,6 +1476,7 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n
}

export default function ChatView(props: ChatViewProps) {
const [threadWidthExpansion] = useThreadWidth();
const {
environmentId,
threadId,
Expand Down Expand Up @@ -9731,6 +9733,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 */}
<WorkspacePageHeader
Expand Down Expand Up @@ -9948,7 +9951,7 @@ export default function ChatView(props: ChatViewProps) {
>
<div
data-chat-composer-stack="true"
className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-max-width)"
className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--thread-content-max-width)"
>
{isDraftHeroState ? (
<div className="absolute inset-x-0 bottom-full z-0">
Expand All @@ -9970,8 +9973,9 @@ export default function ChatView(props: ChatViewProps) {
</div>
</div>
) : null}
{/* The stack already applies the width; nested composer surfaces fill it. */}
<div
className="relative"
className="relative [--chat-max-width:100%]"
style={
forceExpandedMobileComposer
? { viewTransitionName: MOBILE_COMPOSER_VIEW_TRANSITION_NAME }
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ThreadWidthControl } from "./ThreadWidthControl";
import {
type EnvironmentId,
type EditorId,
Expand Down Expand Up @@ -498,6 +499,7 @@ export const ChatHeader = memo(function ChatHeader({
"[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out",
)}
>
<ThreadWidthControl />
<Menu open={actionsCollapsed && actionsOpen} onOpenChange={setActionsOpen}>
<MenuTrigger
className={
Expand Down
38 changes: 16 additions & 22 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,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";
Expand Down Expand Up @@ -348,7 +350,7 @@ function TimelineLoadEarlierHeader({
}) {
return (
<div className={fade ? "pt-(--workspace-titlebar-scroll-fade-height)" : "pt-3 sm:pt-4"}>
<div className="mx-auto w-full max-w-(--chat-max-width) pb-2">
<div className="mx-auto w-full max-w-(--thread-content-max-width) pb-2">
<button
type="button"
onClick={onLoadEarlier}
Expand Down Expand Up @@ -940,6 +942,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,
Expand Down Expand Up @@ -1109,13 +1112,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<HTMLElement>("[data-timeline-root]")
?.getBoundingClientRect().width ?? viewportWidth;
return observeTimelineColumn(timelineViewportElement, (viewportWidth, contentWidth) => {
const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(
viewportWidth,
contentWidth,
Expand All @@ -1125,18 +1122,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<TimelineRowSharedState>(
() => ({
Expand Down Expand Up @@ -1252,7 +1246,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const renderItem = useCallback(
({ item }: { item: MessagesTimelineRow }) => (
<div
className="mx-auto w-full min-w-0 max-w-(--chat-max-width) overflow-x-clip"
className="mx-auto w-full min-w-0 max-w-(--thread-content-max-width) overflow-x-clip"
data-timeline-root="true"
>
<TimelineRowContent row={item} />
Expand Down Expand Up @@ -1886,7 +1880,7 @@ function ContextCompactionTimelineRow({
<div
role="separator"
aria-label={row.label}
className="mx-auto flex w-full max-w-(--chat-max-width) items-center gap-3 py-1 text-muted-foreground text-xs"
className="mx-auto flex w-full max-w-(--thread-content-max-width) items-center gap-3 py-1 text-muted-foreground text-xs"
>
<span className="h-px flex-1 bg-border/70" />
<span className="flex shrink-0 items-center gap-1.5">
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/ThreadWidthControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ArrowLeftRightIcon, MinusIcon, PlusIcon } from "lucide-react";
import { useId, type CSSProperties } from "react";

import { THREAD_WIDTH_STEP, useThreadWidth } from "~/hooks/useThreadWidth";
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";

/** Adjust conversation width without leaving the thread. */
export function ThreadWidthControl() {
const [expansion, setExpansion] = useThreadWidth();
const sliderId = useId();
const sliderStyle = {
"--settings-slider-progress": `${expansion}%`,
"--settings-slider-fill-offset": `${0.5 - expansion / 100}rem`,
} as CSSProperties;

return (
<Popover>
<PopoverTrigger
data-toolbar-control
render={<Button size="xs" variant="outline" aria-label={`Thread width: ${expansion}%`} />}
>
<ArrowLeftRightIcon aria-hidden="true" className="size-3.5" />
<span className="hidden @3xl/header-actions:inline">Width</span>
<span className="tabular-nums">{expansion}%</span>
</PopoverTrigger>
<PopoverPopup align="end" width="sm">
<div className="space-y-3">
<PopoverTitle>Thread width</PopoverTitle>
<div className="flex items-center justify-between gap-3">
<Button
aria-label="Decrease thread width"
size="icon-xs"
variant="outline"
disabled={expansion === 0}
onClick={() => setExpansion(expansion - THREAD_WIDTH_STEP)}
>
<MinusIcon aria-hidden="true" />
</Button>
<output htmlFor={sliderId} className="text-sm tabular-nums">
{expansion}%
</output>
<Button
aria-label="Increase thread width"
size="icon-xs"
variant="outline"
disabled={expansion === 100}
onClick={() => setExpansion(expansion + THREAD_WIDTH_STEP)}
>
<PlusIcon aria-hidden="true" />
</Button>
</div>
<div>
<input
id={sliderId}
aria-label="Thread width"
className="settings-slider block w-full"
type="range"
min={0}
max={100}
step={THREAD_WIDTH_STEP}
value={expansion}
style={sliderStyle}
onChange={(event) => setExpansion(Number(event.currentTarget.value))}
/>
<div className="flex justify-between text-xs text-muted-foreground" aria-hidden="true">
<span>0%</span>
<span>100%</span>
</div>
</div>
</div>
</PopoverPopup>
</Popover>
);
}
99 changes: 99 additions & 0 deletions apps/web/src/components/chat/observeTimelineColumn.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
46 changes: 46 additions & 0 deletions apps/web/src/components/chat/observeTimelineColumn.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>("[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();
};
}
Loading
Loading