Skip to content
Open
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: 7 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -328,11 +328,17 @@ const overrides = new Map([
// new model-clear test. Load-bearing chimera fix.
// keyring-dev-isolation: run_boot_migrations wires agent-key migration.
["src-tauri/src/migration.rs", 1436],
// Bounded overlay-scrollbar host wiring adds 13 lines to ChannelPane after
// focused-thread mode brought the upstream file to the default limit. The
// geometry and interaction logic remain extracted in the shared hook.
["src/features/channels/ui/ChannelPane.tsx", 1013],
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
// already here) for the single-toggle mark-read/unread menu item — a small
// overage from load-bearing per-message plumbing, not generic debt growth.
// +2 for the bounded overlay-scrollbar host wiring; shared geometry and
// interaction logic remain extracted outside this already-large panel.
// Approved override; still queued to split with the rest of this list.
["src/features/messages/ui/MessageThreadPanel.tsx", 1006],
["src/features/messages/ui/MessageThreadPanel.tsx", 1008],
// AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge
// panel — a small overage from load-bearing UI plumbing, not generic debt
// growth. Approved override; still queued to split with the rest of this list.
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { OverlayScrollbar } from "@/shared/ui/OverlayScrollbar";
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
agentPubkeys,
Expand Down Expand Up @@ -164,6 +165,12 @@ export const ChannelPane = React.memo(function ChannelPane({
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
const messageTimelineRef = React.useRef<MessageTimelineHandle>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
const [activeTimelineScrollElement, setActiveTimelineScrollElement] =
React.useState<HTMLDivElement | null>(null);
const activeTimelineScrollRef = React.useMemo(
() => ({ current: activeTimelineScrollElement }),
[activeTimelineScrollElement],
);
const completedWelcomeBannerChannelIdsRef = React.useRef(new Set<string>());
const welcomeComposerDismissTimerRef = React.useRef<number | null>(null);
const welcomeComposerHideTimerRef = React.useRef<number | null>(null);
Expand Down Expand Up @@ -682,6 +689,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onActiveScrollContainerChange={setActiveTimelineScrollElement}
onReply={activeChannel?.archivedAt ? undefined : onOpenThread}
channelName={activeChannel?.name}
channelType={activeChannel?.channelType ?? null}
Expand All @@ -702,6 +710,11 @@ export const ChannelPane = React.memo(function ChannelPane({
}
threadUnreadCounts={threadUnreadCounts}
/>
<OverlayScrollbar
composerRef={composerWrapperRef}
resetKey={`${activeChannelId}:${hasMainComposerOverlay}`}
scrollRef={activeTimelineScrollRef}
/>
{isNonMemberView ? (
<div
data-testid="join-banner"
Expand Down
16 changes: 11 additions & 5 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanel
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { cn } from "@/shared/lib/cn";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import {
AuxiliaryPanel,
AuxiliaryPanelBody,
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
Expand All @@ -35,6 +35,7 @@ import {
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
} from "@/features/messages/lib/messageThreadPanelLayout";
import { OverlayScrollbar } from "@/shared/ui/OverlayScrollbar";
import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
Expand Down Expand Up @@ -246,7 +247,6 @@ export function MessageThreadPanel({
threadComposerWrapperRef,
isSinglePanelView,
);

// Live ref so onCaptureSendContext can read reply state at submit time
// (before any async mention-flow awaits change navigation state).
const replyTargetMessageRef = React.useRef(replyTargetMessage);
Expand Down Expand Up @@ -522,7 +522,7 @@ export function MessageThreadPanel({

const threadScrollRegion = (
<AuxiliaryPanelBody
className="overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
className="buzz-content-scrollbar overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
data-buzz-conversation-scroll
data-testid="message-thread-body"
onScroll={onScroll}
Expand Down Expand Up @@ -913,7 +913,13 @@ export function MessageThreadPanel({
transparentChrome={transparentChrome}
widthPx={widthPx}
>
{threadScrollRegion}
<div className="relative flex min-h-0 flex-1 flex-col">
{threadScrollRegion}
<OverlayScrollbar
composerRef={threadComposerWrapperRef}
scrollRef={threadBodyRef}
/>
</div>
</AuxiliaryPanel>
);
}
7 changes: 7 additions & 0 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ type MessageTimelineProps = {
/** Optional external ref to the scroll container — used by the parent to
* observe scroll position or adjust padding dynamically. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
/** Reports the element that currently owns timeline scrolling. */
onActiveScrollContainerChange?: (element: HTMLDivElement | null) => void;
/** True when the timeline has the composer overlay below it. */
hasComposerOverlay?: boolean;
isFetchingOlder?: boolean;
Expand Down Expand Up @@ -177,6 +179,7 @@ const MessageTimelineBase = React.forwardRef<
onMarkUnread,
onMarkRead,
onReply,
onActiveScrollContainerChange,
channelName,
channelType,
isSendingVideoReviewComment = false,
Expand Down Expand Up @@ -215,6 +218,9 @@ const MessageTimelineBase = React.forwardRef<
}),
[scrollContainerRef, virtualizerScrollParent],
);
React.useLayoutEffect(() => {
onActiveScrollContainerChange?.(activeScrollContainerRef.current);
}, [activeScrollContainerRef, onActiveScrollContainerChange]);

// Gate the heavy timeline render (each row runs a synchronous
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
Expand Down Expand Up @@ -690,6 +696,7 @@ const MessageTimelineBase = React.forwardRef<
(!useTimelineVirtualizer || !showMessageList) &&
cn(
"overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1",
"buzz-content-scrollbar",
hasComposerOverlay
? "pb-[var(--composer-overlay-height,6rem)]"
: "pb-4",
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/messages/ui/TimelineMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ function VirtualizedTimelineRows({
<PreserveVirtualizedItemVisibilityContext value={isPrepend}>
<VList
ref={listRef}
className="h-full min-h-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-[var(--channel-top-chrome-height,4.5rem)]"
className="buzz-content-scrollbar h-full min-h-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-[var(--channel-top-chrome-height,4.5rem)]"
data={items}
item={VirtualizedTimelineItemShell}
itemSize={estimateItemSize}
Expand Down
163 changes: 163 additions & 0 deletions desktop/src/shared/hooks/useOverlayScrollbar.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
acquireBodySelectionLock,
calculateOverlayScrollbarGeometry,
calculateScrollTopFromThumbDrag,
} from "./useOverlayScrollbar.ts";

test("restores selection after overlapping drags finish in either order", () => {
for (const releaseOrder of ["first-first", "second-first"]) {
const style = { userSelect: "text" };
const releaseFirstDrag = acquireBodySelectionLock(style);
const releaseSecondDrag = acquireBodySelectionLock(style);

assert.equal(style.userSelect, "none");

if (releaseOrder === "first-first") {
releaseFirstDrag();
assert.equal(style.userSelect, "none");
releaseSecondDrag();
} else {
releaseSecondDrag();
assert.equal(style.userSelect, "none");
releaseFirstDrag();
}

assert.equal(style.userSelect, "text");
}
});

test("selection lock release is idempotent across finish and cleanup", () => {
const style = { userSelect: "contain" };
const releaseDrag = acquireBodySelectionLock(style);

releaseDrag();
releaseDrag();

assert.equal(style.userSelect, "contain");

const releaseNextDrag = acquireBodySelectionLock(style);
assert.equal(style.userSelect, "none");
releaseNextDrag();
assert.equal(style.userSelect, "contain");
});

test("bounds the thumb above the composer at maximum scroll", () => {
for (const bottomInset of [64, 96, 180]) {
const clientHeight = 600;
const scrollHeight = 3_000;
const geometry = calculateOverlayScrollbarGeometry({
bottomInset,
clientHeight,
scrollHeight,
scrollTop: scrollHeight - clientHeight,
});

assert.ok(geometry);
assert.equal(geometry.trackHeight, clientHeight - bottomInset);
assert.equal(
geometry.thumbOffset + geometry.thumbHeight,
geometry.trackHeight,
);
}
});

test("uses a minimum grab target without exceeding the bounded track", () => {
const geometry = calculateOverlayScrollbarGeometry({
bottomInset: 120,
clientHeight: 400,
scrollHeight: 40_000,
scrollTop: 20_000,
});

assert.ok(geometry);
assert.equal(geometry.thumbHeight, 24);
assert.ok(geometry.thumbOffset >= 0);
assert.ok(
geometry.thumbOffset + geometry.thumbHeight <= geometry.trackHeight,
);
});

test("clamps overscrolled positions into the available thumb travel", () => {
const beforeStart = calculateOverlayScrollbarGeometry({
bottomInset: 100,
clientHeight: 500,
scrollHeight: 2_000,
scrollTop: -50,
});
const afterEnd = calculateOverlayScrollbarGeometry({
bottomInset: 100,
clientHeight: 500,
scrollHeight: 2_000,
scrollTop: 2_000,
});

assert.ok(beforeStart);
assert.ok(afterEnd);
assert.equal(beforeStart.thumbOffset, 0);
assert.equal(afterEnd.thumbOffset, afterEnd.maxThumbOffset);
});

test("hides when content does not overflow or the visible track is too small", () => {
assert.equal(
calculateOverlayScrollbarGeometry({
bottomInset: 100,
clientHeight: 500,
scrollHeight: 500,
scrollTop: 0,
}),
null,
);
assert.equal(
calculateOverlayScrollbarGeometry({
bottomInset: 480,
clientHeight: 500,
scrollHeight: 2_000,
scrollTop: 0,
}),
null,
);
});

test("maps a full bounded-thumb drag across the full scroll range", () => {
const geometry = calculateOverlayScrollbarGeometry({
bottomInset: 120,
clientHeight: 600,
scrollHeight: 3_000,
scrollTop: 0,
});

assert.ok(geometry);
assert.equal(
calculateScrollTopFromThumbDrag({
deltaY: geometry.maxThumbOffset,
dragStartScrollTop: 0,
maxThumbOffset: geometry.maxThumbOffset,
scrollRange: geometry.scrollRange,
}),
geometry.scrollRange,
);
});

test("clamps thumb drags at both ends of the scroll range", () => {
assert.equal(
calculateScrollTopFromThumbDrag({
deltaY: -1_000,
dragStartScrollTop: 300,
maxThumbOffset: 200,
scrollRange: 1_500,
}),
0,
);
assert.equal(
calculateScrollTopFromThumbDrag({
deltaY: 1_000,
dragStartScrollTop: 300,
maxThumbOffset: 200,
scrollRange: 1_500,
}),
1_500,
);
});
Loading
Loading