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 @@ -14,7 +14,7 @@ import {
type ServerProvider,
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 @@ -91,7 +91,6 @@ import {
observeProactivePanelUserChoice,
shouldShowPlanFollowUpPrompt,
shouldWriteThreadErrorToCurrentServerThread,
toolGroupConsumesUpwardNavigation,
getAntigravitySendBlockReason,
waitForRevertedMessage,
prepareRevertedMessageAttachments,
Expand Down Expand Up @@ -418,134 +417,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 @@ -225,22 +225,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
59 changes: 36 additions & 23 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,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 @@ -453,7 +454,6 @@ import {
import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
import {
DRAFT_HERO_TRANSITION_ANIMATION_ID,
DRAFT_HERO_TRANSITION_DURATION_MS,
DRAFT_HERO_TRANSITION_EASING,
MOBILE_COMPOSER_VIEW_TRANSITION_NAME,
MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,
Expand Down Expand Up @@ -522,7 +522,6 @@ import {
shouldWriteThreadErrorToCurrentServerThread,
startNewThreadForProject,
codexArtifactTemplatePromptToAppend,
toolGroupConsumesUpwardNavigation,
waitForStartedServerThread,
shouldRefocusComposerOnWindowFocus,
} from "./ChatView.logic";
Expand Down Expand Up @@ -586,7 +585,11 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
const EMPTY_PROVIDERS: ServerProvider[] = [];
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
function useDraftHeroLayoutTransition(
isDraftHeroState: boolean,
animationsActive: boolean,
animationDurationMs: number,
) {
const transitionGroupRef = useRef<HTMLDivElement | null>(null);
const composerAnchorRef = useRef<HTMLDivElement | null>(null);
const previousStateRef = useRef(isDraftHeroState);
Expand All @@ -606,9 +609,6 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const transitionGroup = transitionGroupRef.current;
const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null;
const stateChanged = previousStateRef.current !== isDraftHeroState;
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const mobileComposerTransitionActive =
typeof document !== "undefined" &&
document.documentElement.dataset.mobileComposerRouteTransition === "true";
Expand All @@ -619,7 +619,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const previousComposerRect = previousComposerRectRef.current;
if (
stateChanged &&
!prefersReducedMotion &&
animationsActive &&
!mobileComposerTransitionActive &&
transitionGroup &&
previousComposerRect &&
Expand All @@ -635,7 +635,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
{ transform: "translate3d(0, 0, 0)" },
],
{
duration: DRAFT_HERO_TRANSITION_DURATION_MS,
duration: animationDurationMs,
easing: DRAFT_HERO_TRANSITION_EASING,
},
);
Expand All @@ -654,7 +654,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {

previousStateRef.current = isDraftHeroState;
previousComposerRectRef.current = nextComposerRect;
}, [isDraftHeroState]);
}, [animationDurationMs, animationsActive, isDraftHeroState]);

return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const;
}
Expand Down Expand Up @@ -3778,7 +3778,11 @@ export default function ChatView(props: ChatViewProps) {
attachDraftHeroTransitionGroupRef,
attachDraftHeroComposerAnchorRef,
captureDraftHeroComposerRect,
] = useDraftHeroLayoutTransition(isDraftHeroState);
] = useDraftHeroLayoutTransition(
isDraftHeroState,
panelAnimationsActive,
panelAnimationDurationMs,
);
const rollbackActive = isRollbackActive(activeThread?.rollbackStatus);
const rollbackTargetIdle =
activeThread?.session !== null &&
Expand Down Expand Up @@ -5656,6 +5660,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 @@ -5664,11 +5670,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 @@ -5718,12 +5720,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 Expand Up @@ -8091,13 +8101,16 @@ export default function ChatView(props: ChatViewProps) {
const dockStarted = new Promise<void>((resolve) => {
resolveDockStarted = resolve;
});
const dockTransition = runMobileComposerTransition(() => {
flushSync(() => {
captureDraftHeroComposerRect();
setDockedDraftHeroThreadKey(activeThreadKey);
});
resolveDockStarted?.();
});
const dockTransition = runMobileComposerTransition(
() => {
flushSync(() => {
captureDraftHeroComposerRect();
setDockedDraftHeroThreadKey(activeThreadKey);
});
resolveDockStarted?.();
},
{ active: panelAnimationsActive, durationMs: panelAnimationDurationMs },
);
void dockTransition.catch(() => resolveDockStarted?.());
await dockStarted;
}
Expand Down
Loading
Loading