+
{members.map((item) => (
(animation as CSSTransition).transitionProperty === "width");
+ const keyframes =
+ transition?.effect instanceof KeyframeEffect ? transition.effect.getKeyframes() : null;
+ const width = Number.parseFloat(String(keyframes?.at(-1)?.width));
+ return Number.isFinite(width) ? width : null;
+}
+
+/**
+ * While a neighboring panel slides, holds the chat at the width it will end up with, so text
+ * wraps once instead of on every frame. The canvas still shrinks around it, and the chat stays
+ * centered in it, which keeps the glide on the compositor for everything but the outer frame.
+ */
+export function useChatLaneFreeze(
+ canvasRef: RefObject,
+ onLayout: (heldWidth?: number) => void,
+) {
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- follows CSS transitions on panels owned by other subtrees and writes the held width straight to the DOM; no React state involved
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const sliding = new Map();
+ let watchdog = 0;
+ const release = () => {
+ window.clearTimeout(watchdog);
+ sliding.clear();
+ delete canvas.dataset.laneHeld;
+ canvas.style.removeProperty("--chat-held-width");
+ onLayout();
+ };
+ const handleRun = (event: TransitionEvent) => {
+ if (event.propertyName !== "width" || !(event.target instanceof Element)) return;
+ if (!event.target.matches(SLIDING_NEIGHBORS) || canvas.contains(event.target)) return;
+ const final = finalWidth(event.target);
+ if (final === null) return;
+ sliding.set(event.target, final);
+ // Each neighbor only has what is left of its own slide still to give or take.
+ let remaining = 0;
+ for (const [element, width] of sliding) {
+ remaining += width - Number.parseFloat(getComputedStyle(element).width);
+ }
+ const heldWidth = canvas.clientWidth - remaining;
+ canvas.style.setProperty("--chat-held-width", `${heldWidth}px`);
+ canvas.dataset.laneHeld = "";
+ onLayout(heldWidth);
+ // A neighbor removed mid-slide never reports an end, and the lane must not stay held.
+ window.clearTimeout(watchdog);
+ watchdog = window.setTimeout(release, RELEASE_TIMEOUT_MS);
+ };
+ const handleDone = (event: TransitionEvent) => {
+ if (event.propertyName !== "width" || !(event.target instanceof Element)) return;
+ if (!sliding.delete(event.target) || sliding.size > 0) return;
+ release();
+ };
+ const ownerDocument = canvas.ownerDocument;
+ ownerDocument.addEventListener("transitionrun", handleRun, true);
+ ownerDocument.addEventListener("transitionend", handleDone, true);
+ ownerDocument.addEventListener("transitioncancel", handleDone, true);
+ return () => {
+ ownerDocument.removeEventListener("transitionrun", handleRun, true);
+ ownerDocument.removeEventListener("transitionend", handleDone, true);
+ ownerDocument.removeEventListener("transitioncancel", handleDone, true);
+ window.clearTimeout(watchdog);
+ };
+ }, [canvasRef, onLayout]);
+}
diff --git a/apps/web/src/components/chat/useTimelineRows.ts b/apps/web/src/components/chat/useTimelineRows.ts
index 847dcc594af7a..d08e02559a0ab 100644
--- a/apps/web/src/components/chat/useTimelineRows.ts
+++ b/apps/web/src/components/chat/useTimelineRows.ts
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
import {
computeStableMessagesTimelineRows,
handoffRunsSignature,
@@ -76,3 +76,66 @@ export function useStableRows(
setState({ identity, source: rows, stable });
return stable.result;
}
+
+const ROW_ARRIVAL_WINDOW_MS = 600;
+const MAX_ANIMATED_ARRIVALS = 16;
+
+/**
+ * Rows that joined the list since the last update, with their position in the batch. Opening a
+ * thread is not an arrival, and neither is a batch too large to read as individual rows.
+ */
+export function useTimelineRowArrivals(
+ rows: MessagesTimelineRow[],
+ identity: string,
+): ReadonlyMap {
+ const [state, setState] = useState<{
+ readonly identity: string;
+ readonly rows: MessagesTimelineRow[];
+ readonly known: ReadonlySet;
+ readonly arrivals: ReadonlyMap;
+ }>({
+ identity,
+ rows,
+ known: new Set(rows.map((row) => row.id)),
+ arrivals: new Map(),
+ });
+ let current = state;
+ if (state.identity !== identity) {
+ current = { identity, rows, known: new Set(rows.map((row) => row.id)), arrivals: new Map() };
+ setState(current);
+ } else if (state.rows !== rows) {
+ const added = rows.filter((row) => !state.known.has(row.id));
+ const arrivals = new Map(state.arrivals);
+ const rowIds = new Set(rows.map((row) => row.id));
+ const removedCount = state.rows.filter((row) => !rowIds.has(row.id)).length;
+ // A live row settling into its final row swaps ids in place; fading that would read as a blink.
+ const isReplacement = removedCount >= added.length;
+ if (state.known.size > 0 && !isReplacement && added.length <= MAX_ANIMATED_ARRIVALS) {
+ added.forEach((row, index) => arrivals.set(row.id, index));
+ }
+ current = {
+ identity,
+ rows,
+ known: added.length === 0 ? state.known : rowIds,
+ arrivals,
+ };
+ setState(current);
+ }
+
+ const hasArrivals = current.arrivals.size > 0;
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- arrivals expire on a timer so a row scrolled back into view later does not replay its entrance
+ useEffect(() => {
+ if (!hasArrivals) return;
+ const timeout = window.setTimeout(
+ () => setState((latest) => ({ ...latest, arrivals: new Map() })),
+ ROW_ARRIVAL_WINDOW_MS,
+ );
+ return () => window.clearTimeout(timeout);
+ }, [
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- each new batch restarts the window so it stays visible for its full entrance
+ current.arrivals,
+ hasArrivals,
+ ]);
+
+ return current.arrivals;
+}
diff --git a/apps/web/src/components/cloud/TunnelUsage.tsx b/apps/web/src/components/cloud/TunnelUsage.tsx
index 0b392f77b5416..bea3fabe77824 100644
--- a/apps/web/src/components/cloud/TunnelUsage.tsx
+++ b/apps/web/src/components/cloud/TunnelUsage.tsx
@@ -64,7 +64,7 @@ export function TunnelUsageMeter({
) : (
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx
index 9674bcfdebdc7..134d69bcb2d26 100644
--- a/apps/web/src/components/files/FilePreviewPanel.tsx
+++ b/apps/web/src/components/files/FilePreviewPanel.tsx
@@ -1,4 +1,5 @@
-import { DownloadIcon, CopyIcon, CheckIcon } from "lucide-react";
+import { Check, Copy } from "lucide";
+import { DownloadIcon } from "lucide-react";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { Spinner } from "~/components/ui/spinner";
import {
@@ -1630,7 +1631,7 @@ export function WorkspaceLargeTextPreview(props: {
.catch(showError);
}}
>
- {isCopied ? : }
+
- {follow.showScrollToBottom ? (
- follow.scrollToEnd(true)}
- />
- ) : null}
+ follow.scrollToEnd(true)}
+ />
diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx
index 2eb6bcd673957..65a64e5e4463b 100644
--- a/apps/web/src/components/onboarding/WelcomeWizard.tsx
+++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx
@@ -261,39 +261,41 @@ export function WelcomeWizard({
- {step === "connection" ? (
-
- setSelection((current) => {
- const next = new Set(current ?? selectedIds);
- if (checked) next.add(environmentId);
- else next.delete(environmentId);
- return next;
- })
- }
- onContinue={() =>
- startSetup(resolveOnboardingSetup(environments, selectedIds).environmentIds)
- }
- onPaired={(environmentId) => {
- setSelection(new Set([...selectedIds, environmentId]));
- }}
- />
- ) : step === "agents" ? (
- setStep("import")} />
- ) : (
-
- )}
+
+ {step === "connection" ? (
+
+ setSelection((current) => {
+ const next = new Set(current ?? selectedIds);
+ if (checked) next.add(environmentId);
+ else next.delete(environmentId);
+ return next;
+ })
+ }
+ onContinue={() =>
+ startSetup(resolveOnboardingSetup(environments, selectedIds).environmentIds)
+ }
+ onPaired={(environmentId) => {
+ setSelection(new Set([...selectedIds, environmentId]));
+ }}
+ />
+ ) : step === "agents" ? (
+ setStep("import")} />
+ ) : (
+
+ )}
+
diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx
index 9afe2882b09fa..bc97bbe4b2d69 100644
--- a/apps/web/src/components/preview/PreviewPanelShell.tsx
+++ b/apps/web/src/components/preview/PreviewPanelShell.tsx
@@ -3,6 +3,7 @@ import {
type PreviewPanelInlineSize,
usePreviewPanelInlineSize,
} from "~/hooks/usePreviewPanelInlineSize";
+import { useEnterMotion } from "~/hooks/useEnterMotion";
import { RESIZABLE_WIDTH_PROPERTY } from "~/hooks/useResizableWidth";
import { workspaceAreaProps } from "../../lib/workspaceFocus";
import { cn } from "~/lib/utils";
@@ -114,6 +115,9 @@ function PreviewPanelShellFrame(
}
host.toggleAttribute("inert", collapsed);
}, [collapsible, open, hostRef]);
+ const [contentElement, setContentElement] = useState
(null);
+ useEnterMotion(contentElement, props.workspaceFocusKey ?? "", { distance: 0, durationMs: 200 });
+
return (
diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
index 2ffef724ab782..716ffba01af85 100644
--- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
@@ -279,7 +279,7 @@ function CollapsedComment({
{open ? (
-
+
{body === null && !editing.canEdit(comment) ? null : (
)}
diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx
index 1f41f2381148f..45f6b98adcd89 100644
--- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx
@@ -347,7 +347,7 @@ function ConversationGroup({
{open ? (
-
+
{events.map((event) => (
{open ? (
-
+
{entry.noticeText}
diff --git a/apps/web/src/components/settings/PanelAnimationsPreview.tsx b/apps/web/src/components/settings/PanelAnimationsPreview.tsx
index 9bc2478923b18..d687a25496f2b 100644
--- a/apps/web/src/components/settings/PanelAnimationsPreview.tsx
+++ b/apps/web/src/components/settings/PanelAnimationsPreview.tsx
@@ -5,7 +5,7 @@ import { cn } from "~/lib/utils";
export function PanelAnimationsPreview({ durationMs }: { durationMs: number }) {
const [panelsOpen, setPanelsOpen] = useState(true);
const transitionClass =
- "transition-[width,height,border-width] duration-(--preview-duration) ease-out motion-reduce:transition-none";
+ "transition-[width,height,border-width] duration-(--preview-duration) ease-(--ease-spring) motion-reduce:transition-none";
return (