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
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 @@ -1570,6 +1571,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 @@ -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 */}
<header
Expand Down Expand Up @@ -11589,9 +11592,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 @@ -436,6 +437,9 @@ export const ChatHeader = memo(function ChatHeader({
)}
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>
<div className="shrink-0">
<ThreadWidthControl />
</div>
</div>
);
});
33 changes: 14 additions & 19 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLElement>("[data-timeline-root]")
?.getBoundingClientRect().width ?? viewportWidth;
return observeTimelineColumn(timelineViewportElement, (viewportWidth, contentWidth) => {
const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(
viewportWidth,
contentWidth,
Expand All @@ -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<TimelineRowSharedState>(
() => ({
Expand Down Expand Up @@ -2117,7 +2112,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>
);
}
102 changes: 102 additions & 0 deletions apps/web/src/components/chat/observeTimelineColumn.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
49 changes: 49 additions & 0 deletions apps/web/src/components/chat/observeTimelineColumn.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>("[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();
};
}
Loading
Loading