Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
6 changes: 5 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -1568,6 +1569,7 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n
const retryingWorkspacePreparationRunIds = new Set<RunId>();

export default function ChatView(props: ChatViewProps) {
const [threadWidthExpansion] = useThreadWidth();
const {
environmentId,
threadId,
Expand Down Expand Up @@ -11275,6 +11277,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 */}
<header
Expand Down Expand Up @@ -11539,9 +11542,10 @@ export default function ChatView(props: ChatViewProps) {
</div>
</div>
) : null}
{/* The stack already applies the width; nested composer surfaces fill it. */}
<div
ref={draftHeroTransition.composerAnchorRef}
className="relative z-10"
className="relative z-10 [--chat-content-max-width:100%]"
style={
forceExpandedMobileComposer
? { viewTransitionName: MOBILE_COMPOSER_VIEW_TRANSITION_NAME }
Expand Down
4 changes: 4 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 {
AuthOrchestrationOperateScope,
type EnvironmentId,
Expand Down Expand Up @@ -355,6 +356,9 @@ export const ChatHeader = memo(function ChatHeader({
)}
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>
<div className="shrink-0">
<ThreadWidthControl />
</div>
</div>
);
});
34 changes: 14 additions & 20 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,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 @@ -1051,6 +1053,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,
Expand Down Expand Up @@ -1267,13 +1270,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<HTMLElement>("[data-timeline-root]")
?.getBoundingClientRect().width ?? viewportWidth;
return observeTimelineColumn(timelineViewportElement, (viewportWidth, contentWidth) => {
const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(
viewportWidth,
contentWidth,
Expand All @@ -1283,18 +1280,15 @@ const ConversationTimeline = memo(function ConversationTimeline({
);
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 @@ -2117,7 +2111,7 @@ function ContextCompactionTimelineRow({
<div
role="separator"
aria-label={row.label}
className="mx-auto flex w-full max-w-(--chat-content-max-width) items-center gap-3 py-1 text-muted-foreground text-xs"
className="mx-auto flex w-full items-center gap-3 py-1 text-muted-foreground text-xs"
>
<span className="h-px flex-1 bg-border/70" />
<span
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 sm: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