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
13 changes: 10 additions & 3 deletions apps/web/src/components/AnimatedHeight.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { observeResize } from "../lib/observeResize";

const HEIGHT_TRANSITION_FALLBACK_MS = 250;
const HEIGHT_TRANSITION_FALLBACK_MS = 380;

export function AnimatedHeight({
children,
Expand Down Expand Up @@ -73,7 +73,14 @@ export function AnimatedHeight({
};

updateHeightAfterPaint();
const stopObserving = observeResize(element, updateHeightAfterPaint);
// Width-only resizes, like a neighboring panel sliding, must not force layout reads every frame.
let observedHeight: number | undefined;
const stopObserving = observeResize(element, (entries) => {
const height = entries.at(-1)?.contentRect.height;
if (height === observedHeight) return;
observedHeight = height;
updateHeightAfterPaint();
});
return () => {
stopObserving();
cancelPendingFrames();
Expand All @@ -83,7 +90,7 @@ export function AnimatedHeight({
return (
<div
data-slot="animated-height"
className="transition-[height] duration-200 ease-out motion-reduce:transition-none [overflow-clip-margin:2px]"
className="transition-[height] duration-300 ease-(--ease-spring) motion-reduce:transition-none [overflow-clip-margin:2px]"
style={
heightState.height === null
? undefined
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/AnimatedWidth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function AnimatedWidth({
<span
data-slot="animated-width"
className={cn(
"inline-block overflow-hidden transition-[width] duration-150 ease-out motion-reduce:transition-none",
"inline-block overflow-hidden transition-[width] duration-250 ease-(--ease-spring) motion-reduce:transition-none",
className,
)}
style={width === null ? undefined : { width }}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/AppRouteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function AppRouteModal({ label, children }: { label: string; children: Re
aria-label={label}
data-slot="dialog-popup"
data-app-modal=""
className="relative flex size-full min-h-0 min-w-0 flex-col overflow-hidden bg-background max-sm:pt-safe max-sm:pb-safe text-foreground outline-none transition-[opacity,scale] duration-150 ease-out [-webkit-app-region:no-drag] data-ending-style:scale-[0.985] data-ending-style:opacity-0 data-starting-style:scale-[0.985] data-starting-style:opacity-0 sm:max-h-[60rem] sm:max-w-[80rem] sm:rounded-2xl sm:border sm:shadow-2xl/40"
className="enter-modal relative flex size-full min-h-0 min-w-0 flex-col overflow-hidden bg-background max-sm:pt-safe max-sm:pb-safe text-foreground outline-none transition-[opacity,scale] duration-300 [-webkit-app-region:no-drag] data-ending-style:scale-[0.97] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-(--ease-exit) data-starting-style:scale-[0.96] motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100 data-starting-style:opacity-0 sm:max-h-[60rem] sm:max-w-[80rem] sm:rounded-2xl sm:border sm:shadow-2xl/40"
>
{children}
</DialogPrimitive.Popup>
Expand Down
15 changes: 7 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3303,7 +3303,7 @@ export default function ChatView(props: ChatViewProps) {
ref={threadPanelPopoverAnchorRef}
data-chat-header
className={cn(
"relative bg-background [[data-panel-animations=true]_&]:motion-safe:transition-[padding-left] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out",
"relative bg-background [[data-panel-animations=true]_&]:motion-safe:transition-[padding-left] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-(--ease-spring)",
isElectron
? cn(
"drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5",
Expand Down Expand Up @@ -3486,13 +3486,12 @@ export default function ChatView(props: ChatViewProps) {
/>
</WorkspaceMutationIdContext>

{showScrollToBottom && !isThreadFindActive && (
<ScrollToEndButton
bottom={scrollToEndClearance + 4}
summary={scrollToEndSummary}
onScrollToEnd={() => scrollToEnd(true)}
/>
)}
<ScrollToEndButton
show={showScrollToBottom && !isThreadFindActive}
bottom={scrollToEndClearance + 4}
summary={scrollToEndSummary}
onScrollToEnd={() => scrollToEnd(true)}
/>
</div>

<div
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/PersistentThreadTerminals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm
"grid shrink-0 overflow-clip",
active ? (visible ? "grid-rows-[1fr]" : "grid-rows-[0fr]") : "hidden",
active &&
"[[data-panel-animations=true]_&]:transition-[grid-template-rows] [[data-panel-animations=true]_&]:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:ease-out",
"[[data-panel-animations=true]_&]:transition-[grid-template-rows] [[data-panel-animations=true]_&]:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:ease-(--ease-spring)",
active && visible && "[[data-panel-animations=true]_&]:starting:grid-rows-[0fr]!",
)}
>
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/components/Presence.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { CSSProperties, ReactNode } from "react";

import { useMediaQuery } from "~/hooks/useMediaQuery";
import { usePresence } from "~/hooks/usePresence";
import { cn } from "~/lib/utils";

const EXIT_DURATION_MS = { pop: 140, collapse: 210 } as const;

/**
* Keeps conditional content mounted long enough to animate out. The last children shown stay
* rendered while closing, so callers can pass `null` children once `show` turns false.
*/
export function Presence({
show,
children,
className,
style,
variant = "pop",
}: {
readonly show: boolean;
readonly children: ReactNode;
readonly className?: string;
readonly style?: CSSProperties;
readonly variant?: "pop" | "collapse";
}) {
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const presence = usePresence(
show,
children,
!prefersReducedMotion,
null,
EXIT_DURATION_MS[variant],
);
if (!presence.present) return null;
return (
<div
data-slot="presence"
data-closing={show ? undefined : ""}
inert={!show}
className={cn(variant === "pop" ? "presence-pop" : "presence-collapse", className)}
style={style}
>
{presence.value}
</div>
);
}
11 changes: 8 additions & 3 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
} from "~/components/ui/menu";
import { useBrowserDefaults } from "~/browser/browserDefaults";
import { ScrollArea } from "~/components/ui/scroll-area";
import { SlidingIndicator } from "~/components/SlidingIndicator";
import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
import { useTheme } from "~/hooks/useTheme";
Expand Down Expand Up @@ -1009,10 +1010,10 @@ function RightPanelTab(
ref={setNodeRef}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={cn(
"cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs",
"relative cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs",
props.ownsDesktopTitleBar && "[-webkit-app-region:no-drag]",
props.active
? "bg-accent text-foreground"
? "text-foreground"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
isDragging && "relative z-10",
)}
Expand Down Expand Up @@ -1349,7 +1350,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
className="min-w-0 flex-1"
data-right-panel-tab-list
>
<div className="flex h-full w-max min-w-full items-center gap-1">
<div className="relative flex h-full w-max min-w-full items-center gap-1">
<SlidingIndicator
selector='[data-active-tab="true"]'
className="rounded-md bg-accent"
/>
<SortableTabList surfaces={props.surfaces} onMove={props.onMoveSurface}>
{props.surfaces.map((surface) => {
const previewTabId = previewTabIdOf(surface, props.previewSessions);
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/Sidebar.motion.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const motionTiming = { duration: 150, easing: "ease-out" };
const motionTiming = { duration: 260, easing: "cubic-bezier(0.22, 1, 0.36, 1)" };
const ABOVE_NEIGHBORS = { zIndex: 2, backgroundColor: "var(--sidebar)" };
const rowTravel = (height: number) => Math.min(height, 40);
// Fades clone removed rows and animate clones and entries; intervening layout reads force reflows.
const MAX_FADED_ROWS_PER_UPDATE = 40;
Expand Down Expand Up @@ -103,8 +104,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
const entry = entering.get(node);
if (entry) entry.travel = 0;
if (offset === 0 && !entry) return;
// A row jumping past its neighbors rides above them on a solid fill, so labels never overlap.
const lifted = Math.abs(offset) > node.offsetHeight ? ABOVE_NEIGHBORS : null;
const animation = node.animate(
[{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
[
{ transform: `translateY(${offset}px)`, ...lifted },
{ transform: "translateY(0px)", ...lifted },
],
motionTiming,
);
running.set(node, { animation, offset });
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/components/SlidingIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useLayoutEffect, useRef } from "react";

import { observeResize } from "~/lib/observeResize";
import { cn } from "~/lib/utils";

/**
* A highlight that glides to whichever descendant of its parent matches `selector`, so changing
* selection reads as one thing moving instead of two things blinking. The parent must be
* positioned. Nothing moves on first paint, and it hides when nothing matches.
*/
export function SlidingIndicator({
selector,
className,
}: {
readonly selector: string;
readonly className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- follows DOM selection attributes that React state in sibling subtrees owns, writing styles before paint
useLayoutEffect(() => {
const indicator = ref.current;
const parent = indicator?.parentElement;
if (!indicator || !parent) return;
let settleFrame = 0;

const update = () => {
const target = parent.querySelector<HTMLElement>(selector);
if (!target) {
indicator.style.opacity = "0";
delete indicator.dataset.ready;
return;
}
const parentRect = parent.getBoundingClientRect();
const rect = target.getBoundingClientRect();
const x = rect.left - parentRect.left - parent.clientLeft + parent.scrollLeft;
const y = rect.top - parentRect.top - parent.clientTop + parent.scrollTop;
indicator.style.width = `${rect.width}px`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/SlidingIndicator.tsx:38

When update() runs while an ancestor is scaled, the indicator copies getBoundingClientRect()'s rendered dimensions and position into CSS layout coordinates, leaving the highlight undersized and mispositioned after the scale animation ends. Because CSS transforms do not trigger resize observations, this incorrect geometry persists until another observed change; convert the measured geometry into the parent’s untransformed coordinate space before assigning it.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/SlidingIndicator.tsx around line 38:

When `update()` runs while an ancestor is scaled, the indicator copies `getBoundingClientRect()`'s rendered dimensions and position into CSS layout coordinates, leaving the highlight undersized and mispositioned after the scale animation ends. Because CSS transforms do not trigger resize observations, this incorrect geometry persists until another observed change; convert the measured geometry into the parent’s untransformed coordinate space before assigning it.

indicator.style.height = `${rect.height}px`;
indicator.style.translate = `${x}px ${y}px`;
indicator.style.opacity = "1";
if (indicator.dataset.ready === undefined) {
cancelAnimationFrame(settleFrame);
settleFrame = requestAnimationFrame(() => {
indicator.dataset.ready = "";
});
}
};

update();
const mutations = new MutationObserver(update);
mutations.observe(parent, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["data-pressed", "aria-current", "aria-selected", "data-active-tab"],
});
const stopObserving = observeResize(parent, update);
return () => {
cancelAnimationFrame(settleFrame);
mutations.disconnect();
stopObserving();
};
}, [selector]);

return (
<span
ref={ref}
aria-hidden
data-slot="sliding-indicator"
className={cn("sliding-indicator pointer-events-none absolute top-0 left-0", className)}
/>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/components/WorkspacePageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function WorkspacePageHeader({
return (
<header
className={cn(
"flex h-[var(--workspace-topbar-height)]! min-h-[var(--workspace-topbar-height)]! max-h-[var(--workspace-topbar-height)]! shrink-0 items-center gap-3 pl-(--workspace-gutter-start) pr-(--workspace-gutter-end) [[data-panel-animations=true]_&]:motion-safe:transition-[padding-left,padding-right] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out",
"flex h-[var(--workspace-topbar-height)]! min-h-[var(--workspace-topbar-height)]! max-h-[var(--workspace-topbar-height)]! shrink-0 items-center gap-3 pl-(--workspace-gutter-start) pr-(--workspace-gutter-end) [[data-panel-animations=true]_&]:motion-safe:transition-[padding-left,padding-right] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-(--ease-spring)",
electron && "drag-region",
reserveNativeControls && "wco:pr-(--workspace-native-controls-inset)",
COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
Expand Down
37 changes: 21 additions & 16 deletions apps/web/src/components/chat/ChatBannerLane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ThreadId,
} from "@t3tools/contracts";
import type { Dispatch, SetStateAction } from "react";
import { Presence } from "../Presence";
import { ProviderStatusBanner } from "./ProviderStatusBanner";
import { dismissThreadErrorBannerForSession, ThreadErrorBanner } from "./ThreadErrorBanner";

Expand Down Expand Up @@ -42,22 +43,26 @@ export function ChatBannerLane(props: {
} = props;
return (
<div className="chat-banner-lane pointer-events-none absolute top-0 z-30 flex flex-col">
<ProviderStatusBanner
environmentId={props.environmentId}
status={providerStatus}
onDismiss={() => setDismissedProviderStatusBannerKey(providerStatusBannerKey)}
onOpenProviderSetup={onOpenProviderSetup}
/>
<ThreadErrorBanner
error={threadError}
errorClass={threadErrorClass}
errorCode={threadErrorCode}
onDismiss={() => {
setThreadError(threadId, null);
dismissThreadErrorBannerForSession(threadErrorBannerKey);
setThreadErrorBannerDismissTick((tick) => tick + 1);
}}
/>
<Presence show={providerStatus !== null}>
<ProviderStatusBanner
environmentId={props.environmentId}
status={providerStatus}
onDismiss={() => setDismissedProviderStatusBannerKey(providerStatusBannerKey)}
onOpenProviderSetup={onOpenProviderSetup}
/>
</Presence>
<Presence show={threadError !== null}>
<ThreadErrorBanner
error={threadError}
errorClass={threadErrorClass}
errorCode={threadErrorCode}
onDismiss={() => {
setThreadError(threadId, null);
dismissThreadErrorBannerForSession(threadErrorBannerKey);
setThreadErrorBannerDismissTick((tick) => tick + 1);
}}
/>
</Presence>
</div>
);
}
Loading
Loading