Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,57 @@ describe("ChatScrollControls", () => {
expect(scrollTop).toBe(1200);
});

it("re-pins an automatic tool-group collapse immediately on touch-first devices", () => {
vi.spyOn(window, "matchMedia").mockImplementation(
(query) =>
({
matches: query === "(hover: none) and (pointer: coarse)",
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}) as MediaQueryList,
);
let scrollHeight = 1400;
let scrollTop = 1200;
const scrollEl = document.createElement("div");
Object.defineProperties(scrollEl, {
scrollHeight: { configurable: true, get: () => scrollHeight },
clientHeight: { configurable: true, get: () => 200 },
scrollTop: {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.min(value, scrollHeight - 200);
},
},
});
const controlsRef = createRef<ChatScrollControlsHandle>();

renderWithI18n(
<Harness
scrollEl={scrollEl}
controlsRef={controlsRef}
virtualScrollToBottom={() => undefined}
/>,
);

act(() => {
// When the next assistant item arrives, the live tool group automatically
// collapses. Its row shrinks before the post-commit height notification,
// and LegendList's visible-content compensation rewrites scrollTop.
scrollHeight = 1000;
scrollTop = 500;
fireEvent.scroll(scrollEl);
});

expect(scrollTop).toBe(800);
expect(controlsRef.current?.isStickToBottom()).toBe(true);
});

it("re-pins after the submitted message is appended", () => {
let scrollHeight = 1000;
let scrollTop = 800;
Expand Down
28 changes: 20 additions & 8 deletions src/renderer/components/thread/ChatPane/ChatScrollControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const USER_SCROLL_INTENT_MS = 750;
const VIRTUALIZER_LAYOUT_SETTLE_MS = 250;
/** Minimum at-bottom cache when no coalesce window is active. */
const AT_BOTTOM_CACHE_MS = 16;
const TOUCH_FIRST_POINTER_QUERY = "(hover: none) and (pointer: coarse)";
/**
* How long sticky pins pause after an untagged upward scroll that release
* logic classified as layout-driven. Long enough to bridge the gap between a
Expand Down Expand Up @@ -112,6 +113,7 @@ export const ChatScrollControls = forwardRef<
const previousInitialScrollSettledRef = useRef(initialScrollSettled);
const disableStickToBottomRef = useRef<() => void>(() => undefined);
const pinHoldoffUntilRef = useRef(0);
const touchFirstPointer = window.matchMedia(TOUCH_FIRST_POINTER_QUERY).matches;
const [showScrollDown, setShowScrollDown] = useState(false);

function cancelVirtualizerLayoutChange() {
Expand Down Expand Up @@ -626,13 +628,23 @@ export const ChatScrollControls = forwardRef<
!viewportHeightChanged &&
nextScrollTop < prevScrollTop
) {
// Release was suppressed as layout-driven (virtualizer window / height
// growth), but the untagged upward move could equally be a native
// scrollbar-thumb drag — those emit no pointer events. Hold pins off:
// a real drag keeps re-arming this via its scroll stream and is never
// yanked, while a one-shot virtualizer anchor adjustment lets it lapse
// and the next streaming pin reattaches. See scrollToBottom.
pinHoldoffUntilRef.current = performance.now() + PIN_HOLDOFF_MS;
if (touchFirstPointer) {
// Touch scrolling always starts with the pane's pointerdown handler,
// which disables sticky mode before the first scroll event. With no
// recorded intent, an upward move inside a virtualizer/height-change
// window is therefore LegendList's visible-content compensation.
// Re-pin in this event, before Safari can paint the live tail below
// the floating composer while waiting for another provider delta.
pinHoldoffUntilRef.current = 0;
atBottomCachedUntilRef.current = 0;
writeBottomPin(el);
} else {
// Desktop native scrollbar thumbs can emit scroll without pointer
// events. Hold pins off briefly so a real drag keeps re-arming the
// guard, while a one-shot virtualizer adjustment lets it lapse and
// the next streaming pin reattaches. See scrollToBottom.
pinHoldoffUntilRef.current = performance.now() + PIN_HOLDOFF_MS;
}
}
setShowScrollDown(
nextShowScrollDown({ stickToBottom: stickToBottomRef.current, isAtBottom }),
Expand All @@ -645,7 +657,7 @@ export const ChatScrollControls = forwardRef<
handleScroll();
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [scrollRef, threadId]);
}, [scrollRef, threadId, touchFirstPointer]);

useEffect(() => {
const el = scrollRef.current;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,35 @@ describe("ToolCallGroup", () => {
expect(onHeightChange).toHaveBeenCalledOnce();
});

it("auto-collapses and remeasures when it stops being the live tail", () => {
const threadId = "thread-1";
const items = [makeToolItem("tool-1", "Read file one")];
seedThread(threadId, items);
let container: HTMLElement | null = null;
const onHeightChange = vi.fn<() => void>(() => {
expect(container?.querySelector(".poracode-tool-call-group-viewport")).toBeNull();
});
const view = renderToolCallGroup(threadId, [items[0]!.id], true, onHeightChange);
container = view.container;

expect(screen.getByText("Read file one")).toBeInTheDocument();

view.rerender(
<AppProvider>
<ToolCallGroup
threadId={threadId}
itemIds={[items[0]!.id]}
isLive={false}
onHeightChange={onHeightChange}
/>
</AppProvider>,
);

expect(view.container.querySelector(".poracode-tool-call-group-viewport")).toBeNull();
expect(screen.queryByText("Read file one")).not.toBeInTheDocument();
expect(onHeightChange).toHaveBeenCalledOnce();
});

it("renders every row inline when the group fits under the cap", () => {
const threadId = "thread-1";
const items = Array.from({ length: 6 }, (_, index) =>
Expand Down