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
131 changes: 1 addition & 130 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
TurnId,
type WorktreeSetupSnapshot,
} from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { Atom, AsyncResult } from "effect/unstable/reactivity";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentThreadDetails } from "../state/threads";
Expand Down Expand Up @@ -86,7 +86,6 @@ import {
shouldShowBranchMismatchBanner,
shouldShowPlanFollowUpPrompt,
shouldWriteThreadErrorToCurrentServerThread,
toolGroupConsumesUpwardNavigation,
waitForRevertedMessage,
prepareRevertedMessageAttachments,
} from "./ChatView.logic";
Expand Down Expand Up @@ -446,134 +445,6 @@ describe("proactive panels", () => {
});
});

describe("toolGroupConsumesUpwardNavigation", () => {
class ScrollElement extends EventTarget {
scrollTop = 0;
scrollHeight = 100;
clientHeight = 100;
overflowY = "visible";

constructor(
readonly parentElement: ScrollElement | null = null,
readonly isToolGroup = false,
) {
super();
}

closest(selector: string): ScrollElement | null {
if (selector !== "[data-tool-group-scroll]") return null;
return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null);
}
}

beforeEach(() => {
vi.stubGlobal("Element", ScrollElement);
vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({
overflowY: element.overflowY,
}));
});
afterEach(() => vi.unstubAllGlobals());

it("releases upward navigation when an overflowing group is at the top", () => {
const group = Object.assign(new ScrollElement(null, true), {
overflowY: "auto",
scrollHeight: 300,
});

expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false);
});

it.each([
{ overflowY: "auto", scrollTop: 1 },
{ overflowY: "auto", scrollTop: 0.25 },
{ overflowY: "scroll", scrollTop: 80 },
])("consumes upward navigation within a scrolled group: %j", (scroll) => {
const group = Object.assign(new ScrollElement(null, true), {
scrollHeight: 300,
...scroll,
});

expect(toolGroupConsumesUpwardNavigation(group)).toBe(true);
});

it.each([100, 300])(
"consumes scrolling in a nested result with a group content height of %i",
(scrollHeight) => {
const group = Object.assign(new ScrollElement(null, true), {
overflowY: "auto",
scrollHeight,
});
const result = Object.assign(new ScrollElement(group), {
overflowY: "auto",
scrollHeight: 300,
scrollTop: 0.25,
});

expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true);
},
);

it("releases upward navigation when the group and nested result are both at the top", () => {
const group = Object.assign(new ScrollElement(null, true), {
overflowY: "auto",
scrollHeight: 300,
});
const result = Object.assign(new ScrollElement(group), {
overflowY: "scroll",
scrollHeight: 300,
});

expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false);
});

it("ignores targets outside a tool group and non-element targets", () => {
const outside = Object.assign(new ScrollElement(), {
overflowY: "auto",
scrollHeight: 300,
scrollTop: 40,
});

expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false);
expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false);
expect(toolGroupConsumesUpwardNavigation(null)).toBe(false);
});

it("does not consume scrolling from an ancestor beyond the tool group", () => {
const timeline = Object.assign(new ScrollElement(), {
overflowY: "auto",
scrollHeight: 300,
scrollTop: 40,
});
const group = new ScrollElement(timeline, true);

expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false);
});

it.each(["hidden", "clip", "visible"])(
"ignores a non-scrollable child with overflow-y %s",
(overflowY) => {
const group = new ScrollElement(null, true);
const result = Object.assign(new ScrollElement(group), {
overflowY,
scrollHeight: 300,
scrollTop: 40,
});

expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false);
},
);

it("does not consume programmatic scrolling on an overflow-hidden group", () => {
const group = Object.assign(new ScrollElement(null, true), {
overflowY: "hidden",
scrollHeight: 300,
scrollTop: 40,
});

expect(toolGroupConsumesUpwardNavigation(group)).toBe(false);
});
});

const environmentId = EnvironmentId.make("environment-local");
const projectId = ProjectId.make("project-1");
const threadId = ThreadId.make("thread-1");
Expand Down
16 changes: 0 additions & 16 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,22 +239,6 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: {
});
}

export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean {
const elementTarget = target instanceof Element ? target : null;
const group = elementTarget?.closest<HTMLElement>("[data-tool-group-scroll]");
if (!group) return false;

// A nested result or the group itself can consume an upward scroll.
for (let element = elementTarget; element; element = element.parentElement) {
if (element.scrollTop > 0) {
const overflowY = getComputedStyle(element).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return true;
}
if (element === group) break;
}
return false;
}

export {
findRecordedWorktreeSetup,
resolveVisibleWorktreeSetup,
Expand Down
20 changes: 13 additions & 7 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ import {
import { environmentShell } from "../state/shell";
import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController";
import { isTimelineScrollTarget } from "./chat/timelineScrollTarget";
import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
Expand Down Expand Up @@ -474,7 +475,6 @@ import {
shouldWriteThreadErrorToCurrentServerThread,
startNewThreadForProject,
codexArtifactTemplatePromptToAppend,
toolGroupConsumesUpwardNavigation,
waitForStartedServerThread,
shouldRefocusComposerOnWindowFocus,
} from "./ChatView.logic";
Expand Down Expand Up @@ -5466,6 +5466,8 @@ export default function ChatView(props: ChatViewProps) {
// Only an upward wheel is a navigation intent; wheeling down while
// following either does nothing (at the end) or moves toward it.
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY))
return;
if (event.deltaY > 0) {
timelineScrollIntentRef.current = "toward-end";
if (isAtEndRef.current) {
Expand All @@ -5474,11 +5476,7 @@ export default function ChatView(props: ChatViewProps) {
} else if (event.deltaY < 0) {
timelineScrollIntentRef.current = "away-from-end";
}
if (
event.deltaY < 0 &&
contentScrollsUp() &&
!toolGroupConsumesUpwardNavigation(event.target)
) {
if (event.deltaY < 0 && contentScrollsUp()) {
handleManualNavigation();
}
};
Expand Down Expand Up @@ -5528,12 +5526,20 @@ export default function ChatView(props: ChatViewProps) {
) {
return;
}
if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key))
return;
const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1;
if (
scrollNode.contains(event.target) &&
!isTimelineScrollTarget(event.target, scrollNode, scrollDirection)
)
return;
switch (event.key) {
case "PageUp":
case "Home":
case "ArrowUp":
timelineScrollIntentRef.current = "away-from-end";
if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {
if (contentScrollsUp()) {
handleManualNavigation();
composerRef.current?.collapseForTimelineScrollKey(event.key);
}
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ import {
import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
import { PierreEntryIcon } from "./PierreEntryIcon";
import { pendingDraftWork } from "./pendingDraftWork";
import { isTimelineScrollTarget } from "./timelineScrollTarget";
import {
createComposerScrollGestureState,
recordComposerScrollGestureEvent,
Expand Down Expand Up @@ -4894,8 +4895,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)

const scrollNode = getTimelineScrollableNode();
if (!scrollNode) return;
const targetsTimeline = scrollNode.contains(event.target);
if (!targetsTimeline && !composerScrollGestureRef.current.collapseSuppressed) return;
const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY);
if (
!scrollNode.contains(event.target) &&
!composerScrollGestureRef.current.collapseSuppressed
)
return;

if (composerScrollCollapseTimeoutRef.current !== null) {
window.clearTimeout(composerScrollCollapseTimeoutRef.current);
Expand Down
Loading
Loading