History

feat(web): add opt-in panel animations (#8830)

maria committed 2 months ago

Commit 91c8d471 parent ca63d4220 files · +598 −106Browse files

Unified · Split

20 files
  1. apps/desktop/src/settings/DesktopClientSettings.test.ts +1 −0
  2. apps/web/src/components/AppSidebarLayout.tsx +10 −1
  3. apps/web/src/components/ChatView.tsx +147 −79
  4. apps/web/src/components/RightPanelSheet.tsx +2 −0
  5. apps/web/src/components/RightPanelTabs.tsx +2 −0
  6. apps/web/src/components/WorkspacePageHeader.tsx +1 −1
  7. apps/web/src/components/chat/ChatComposer.tsx +36 −2
  8. apps/web/src/components/chat/ChatHeader.tsx +20 −0
  9. apps/web/src/components/preview/PreviewPanelShell.tsx +41 −4
  10. apps/web/src/components/settings/PanelAnimationsPreview.tsx +52 −0
  11. apps/web/src/components/settings/SettingsPanels.tsx +69 −0
  12. apps/web/src/components/settings/settingsSearch.test.ts +1 −0
  13. apps/web/src/components/settings/settingsSearch.ts +5 −0
  14. apps/web/src/components/ui/sheet.tsx +21 −1
  15. apps/web/src/components/ui/sidebar.tsx +6 −3
  16. apps/web/src/panelAnimations.ts +88 −0
  17. apps/web/src/routes/_chat.pull-requests.tsx +57 −15
  18. docs/user/thread-sidebar.md +7 −0
  19. packages/contracts/src/settings.test.ts +16 −0
  20. packages/contracts/src/settings.ts +16 −0
apps/desktop/src/settings/DesktopClientSettings.test.ts+1−0
@@ -41,6 +41,7 @@ const clientSettings: ClientSettings = { · 40 unmodified lines
4141 fontSizeTerminal: 12,
4242 fontSmoothing: true,
4343 glassOpacity: 80,
44+ panelAnimationDurationMs: 0,
4445 planModeEnabled: false,
4546 showSkillsInSlashMenu: false,
4647 providerModelPreferences: {},
apps/web/src/components/AppSidebarLayout.tsx+10−1
@@ -17,6 +17,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" · 16 unmodified lines
1717 import { cn, isMacPlatform } from "../lib/utils";
1818 import { primaryServerKeybindingsAtom } from "../state/server";
1919 import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
20+import { usePanelAnimationSettings } from "../panelAnimations";
2021 import LegacyThreadSidebar from "./LegacySidebar";
2122 import ThreadSidebar from "./Sidebar";
2223 import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
@@ -148,6 +149,8 @@ function ProjectProjectionRetention() { · 125 unmodified lines
148149 export function AppSidebarLayout({ children }: { children: ReactNode }) {
149150 const navigate = useNavigate();
150151 const legacySidebarEnabled = useLegacySidebarEnabled();
152+ const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
153+ usePanelAnimationSettings();
151154 // Settings routes show the settings nav in place of whichever thread
152155 // sidebar is active.
153156 const pathname = useLocation({ select: (location) => location.pathname });
@@ -175,6 +178,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { · 21 unmodified lines
175178 });
176179 const sidebarProviderStyle = {
177180 "--sidebar-width": `${sidebarWidth}px`,
181+ "--panel-animation-duration": `${panelAnimationDurationMs}ms`,
178182 ...(isMacosDesktop && !isWindowFullscreen
179183 ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
180184 : {}),
@@ -218,7 +222,12 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { · 37 unmodified lines
218222 }, [navigate, pathname]);
219223
220224 return (
221− <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={sidebarProviderStyle}>
225+ <SidebarProvider
226+ className="h-dvh! min-h-0!"
227+ data-panel-animations={panelAnimationsActive ? "true" : "false"}
228+ defaultOpen
229+ style={sidebarProviderStyle}
230+ >
222231 <ProjectProjectionRetention />
223232 <Sidebar
224233 side="left"
apps/web/src/components/ChatView.tsx+147−79
@@ -217,6 +217,7 @@ import { · 216 unmodified lines
217217 useEnvironmentSettings,
218218 } from "../hooks/useSettings";
219219 import { useNowMinute } from "../hooks/useNowMinute";
220+import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
220221 import { useNewThreadHandler } from "../hooks/useHandleNewThread";
221222 import { useThreadActions } from "../hooks/useThreadActions";
222223 import { resolveAppModelSelectionForInstance } from "../modelSelection";
@@ -741,7 +742,7 @@ function serverTerminalIdsStrictSubsetOfClient( · 518 unmodified lines
741742 interface PersistentThreadTerminalDrawerProps {
742743 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
743744 threadId: ThreadId;
744− visible: boolean;
745+ active: boolean;
745746 launchContext: PersistentTerminalLaunchContext | null;
746747 focusRequestId: number;
747748 splitShortcutLabel: string | undefined;
@@ -755,7 +756,7 @@ interface PersistentThreadTerminalDrawerProps { · 7 unmodified lines
755756 const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({
756757 threadRef,
757758 threadId,
758− visible,
759+ active,
759760 launchContext,
760761 focusRequestId,
761762 splitShortcutLabel,
@@ -779,6 +780,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 17 unmodified lines
779780 const terminalUiState = useTerminalUiStateStore((state) =>
780781 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),
781782 );
783+ const visible = active && terminalUiState.terminalOpen;
782784 const knownTerminalSessions = useKnownTerminalSessions({
783785 environmentId: threadRef.environmentId,
784786 threadId,
@@ -1063,41 +1065,51 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 278 unmodified lines
10631065 [onAddTerminalContext, visible],
10641066 );
10651067
1066− if (!project || !terminalUiState.terminalOpen || !cwd) {
1068+ if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) {
10671069 return null;
10681070 }
10691071
10701072 return (
1071− <div className={visible ? undefined : "hidden"}>
1072− <ThreadTerminalDrawer
1073− threadRef={threadRef}
1074− threadId={threadId}
1075− cwd={cwd}
1076− worktreePath={effectiveWorktreePath}
1077− runtimeEnv={runtimeEnv}
1078− visible={visible}
1079− height={terminalUiState.terminalHeight}
1080− // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.
1081− terminalIds={terminalUiState.terminalIds}
1082− activeTerminalId={terminalUiState.activeTerminalId}
1083− terminalGroups={terminalUiState.terminalGroups}
1084− activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
1085− focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
1086− onSplitTerminal={splitTerminal}
1087− onSplitTerminalVertical={splitTerminalVertical}
1088− onNewTerminal={createNewTerminal}
1089− splitShortcutLabel={visible ? splitShortcutLabel : undefined}
1090− splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
1091− newShortcutLabel={visible ? newShortcutLabel : undefined}
1092− closeShortcutLabel={visible ? closeShortcutLabel : undefined}
1093− keybindings={keybindings}
1094− onActiveTerminalChange={activateTerminal}
1095− onCloseTerminal={closeTerminal}
1096− onHeightChange={setTerminalHeight}
1097− onAddTerminalContext={handleAddTerminalContext}
1098− terminalLabelsById={terminalLabelsById}
1099− terminalLaunchLocationsById={terminalLaunchLocationsById}
1100− />
1073+ <div
1074+ className={cn(
1075+ "grid shrink-0 overflow-clip",
1076+ active ? (visible ? "grid-rows-[1fr]" : "grid-rows-[0fr]") : "hidden",
1077+ active &&
1078+ "[[data-panel-animations=true]_&]:transition-[grid-template-rows] [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out",
1079+ active && visible && "[[data-panel-animations=true]_&]:starting:grid-rows-[0fr]!",
1080+ )}
1081+ >
1082+ <div className="min-h-0 overflow-clip">
1083+ <ThreadTerminalDrawer
1084+ threadRef={threadRef}
1085+ threadId={threadId}
1086+ cwd={cwd}
1087+ worktreePath={effectiveWorktreePath}
1088+ runtimeEnv={runtimeEnv}
1089+ visible={visible}
1090+ height={terminalUiState.terminalHeight}
1091+ // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.
1092+ terminalIds={terminalUiState.terminalIds}
1093+ activeTerminalId={terminalUiState.activeTerminalId}
1094+ terminalGroups={terminalUiState.terminalGroups}
1095+ activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
1096+ focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
1097+ onSplitTerminal={splitTerminal}
1098+ onSplitTerminalVertical={splitTerminalVertical}
1099+ onNewTerminal={createNewTerminal}
1100+ splitShortcutLabel={visible ? splitShortcutLabel : undefined}
1101+ splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
1102+ newShortcutLabel={visible ? newShortcutLabel : undefined}
1103+ closeShortcutLabel={visible ? closeShortcutLabel : undefined}
1104+ keybindings={keybindings}
1105+ onActiveTerminalChange={activateTerminal}
1106+ onCloseTerminal={closeTerminal}
1107+ onHeightChange={setTerminalHeight}
1108+ onAddTerminalContext={handleAddTerminalContext}
1109+ terminalLabelsById={terminalLabelsById}
1110+ terminalLaunchLocationsById={terminalLaunchLocationsById}
1111+ />
1112+ </div>
11011113 </div>
11021114 );
11031115 });
@@ -1791,8 +1803,6 @@ function ChatViewContent(props: ChatViewProps) { · 687 unmodified lines
17911803 );
17921804 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
17931805 const sidebarPrRefreshKeyRef = useRef<string | null>(null);
1794− const activeFileSurface =
1795− activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
17961806 const activePreviewState = useThreadPreviewState(activeThreadRef);
17971807 const activePreviewServerEpoch = activePreviewState.serverEpoch;
17981808 const resolvePreviewRuntimeTabId = useMemo(
@@ -1820,6 +1830,34 @@ function ChatViewContent(props: ChatViewProps) { · 21 unmodified lines
18201830 );
18211831 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
18221832 const rightPanelOpen = rightPanelState.isOpen;
1833+ const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
1834+ usePanelAnimationSettings();
1835+ const activeTerminalDrawerPresence = usePanelPresence(
1836+ Boolean(activeThreadKey && terminalUiState.terminalOpen),
1837+ true,
1838+ panelAnimationsActive,
1839+ activeThreadKey,
1840+ panelAnimationDurationMs,
1841+ );
1842+ const rightPanelPresenceValue = useMemo(
1843+ () => ({
1844+ activeSurface: activeRightPanelSurface,
1845+ surfaces: rightPanelState.surfaces,
1846+ }),
1847+ [activeRightPanelSurface, rightPanelState.surfaces],
1848+ );
1849+ const rightPanelPresence = usePanelPresence(
1850+ rightPanelOpen && activeThreadRef !== null,
1851+ rightPanelPresenceValue,
1852+ panelAnimationsActive,
1853+ activeThreadKey,
1854+ panelAnimationDurationMs,
1855+ );
1856+ const rightPanelPresent = rightPanelPresence.present;
1857+ const rightPanelControlsInPanel =
1858+ rightPanelPresent && (!shouldUseRightPanelSheet || rightPanelOpen);
1859+ const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;
1860+ const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
18231861 const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet;
18241862 const rightPanelMaximized =
18251863 canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey;
@@ -1882,7 +1920,7 @@ function ChatViewContent(props: ChatViewProps) { · 56 unmodified lines
18821920 currentThreadIds,
18831921 openThreadIds: existingOpenTerminalThreadKeys,
18841922 activeThreadId: activeThreadKey,
1885− activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen),
1923+ activeThreadTerminalOpen: activeTerminalDrawerPresence.present,
18861924 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
18871925 });
18881926 return currentThreadIds.length === nextThreadIds.length &&
@@ -1890,7 +1928,7 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
18901928 ? currentThreadIds
18911929 : nextThreadIds;
18921930 });
1893− }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);
1931+ }, [activeTerminalDrawerPresence.present, activeThreadKey, existingOpenTerminalThreadKeys]);
18941932 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
18951933 const activeProjectRef = useMemo(
18961934 () =>
@@ -7095,29 +7133,39 @@ function ChatViewContent(props: ChatViewProps) { · 5198 unmodified lines
70957133 const panelLayoutControls = (
70967134 <div
70977135 className={cn(
7098− // One inset in both states: the controls move between containers when
7099− // the right panel opens, and a different right offset made them jump
7100− // sideways on every toggle.
7101− "absolute top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 mr-px flex h-[var(--workspace-topbar-height)] items-center gap-1 [-webkit-app-region:no-drag]",
7136+ // Keep one viewport anchor inside the header's no-drag region. The
7137+ // header can shrink behind the right panel without moving the controls.
7138+ "pointer-events-none fixed top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 mr-px flex h-[var(--workspace-topbar-height)] items-center gap-1 [-webkit-app-region:no-drag]",
71027139 )}
71037140 data-workspace-titlebar-controls
71047141 >
7105− {rightPanelOpen && !shouldUseRightPanelSheet ? (
7106− <RightPanelMaximizeControl
7107− maximized={rightPanelMaximized}
7108− onToggle={toggleRightPanelMaximized}
7109− />
7142+ {!shouldUseRightPanelSheet ? (
7143+ <span
7144+ aria-hidden={!rightPanelOpen}
7145+ className={cn(
7146+ "flex shrink-0",
7147+ panelAnimationsActive &&
7148+ "motion-safe:transition-opacity motion-safe:[transition-duration:var(--panel-animation-duration)] motion-safe:ease-out",
7149+ rightPanelOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
7150+ )}
7151+ inert={!rightPanelOpen}
7152+ >
7153+ <RightPanelMaximizeControl
7154+ maximized={rightPanelMaximized}
7155+ onToggle={toggleRightPanelMaximized}
7156+ />
7157+ </span>
71107158 ) : null}
7111− {panelToggleControls}
7159+ <div className="pointer-events-auto flex h-full items-center">{panelToggleControls}</div>
71127160 </div>
71137161 );
71147162 const rightPanelContent = activeThreadRef ? (
7115− activeRightPanelSurface?.kind === "preview" ? (
7163+ renderedRightPanelSurface?.kind === "preview" ? (
71167164 <Suspense fallback={null}>
71177165 <PreviewPanel
71187166 mode="embedded"
71197167 threadRef={activeThreadRef}
7120− tabId={activeRightPanelSurface.resourceId}
7168+ tabId={renderedRightPanelSurface.resourceId}
71217169 configuredUrls={configuredPreviewUrls}
71227170 visible
71237171 onSendAnnotation={(annotation, image) => {
@@ -7125,10 +7173,10 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
71257173 }}
71267174 />
71277175 </Suspense>
7128− ) : activeRightPanelSurface?.kind === "terminal" ? (
7176+ ) : renderedRightPanelSurface?.kind === "terminal" ? (
71297177 <PersistentThreadTerminalPanel
71307178 threadRef={activeThreadRef}
7131− surface={activeRightPanelSurface}
7179+ surface={renderedRightPanelSurface}
71327180 launchContext={activeTerminalLaunchContext ?? null}
71337181 focusRequestId={terminalFocusRequestId}
71347182 keybindings={keybindings}
@@ -7143,7 +7191,7 @@ function ChatViewContent(props: ChatViewProps) { · 8 unmodified lines
71437191 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
71447192 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
71457193 />
7146− ) : activeRightPanelSurface?.kind === "diff" ? (
7194+ ) : renderedRightPanelSurface?.kind === "diff" ? (
71477195 <Suspense fallback={null}>
71487196 <DiffPanel
71497197 key={`${activeThreadKey}:${diffPanelGitStatusResolutionKey}`}
@@ -7153,26 +7201,26 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines
71537201 workspaceMutationId={workspaceMutationId}
71547202 />
71557203 </Suspense>
7156− ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
7204+ ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
71577205 <PullRequestDetailGhost />
7158− ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? (
7206+ ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? (
71597207 <PullRequestsUnavailableState
71607208 title="Pull requests unavailable"
71617209 error="Update this environment's T3 Code server to browse pull requests."
71627210 />
7163− ) : activeRightPanelSurface?.kind === "pull-request" ? (
7211+ ) : renderedRightPanelSurface?.kind === "pull-request" ? (
71647212 // No onClose: the surface tab's own X owns closing here, and a second X in the header
71657213 // would be the same action twice. The thread context also drops the checkout button, so it
71667214 // is only right for the thread's own pull request, whose branch is already under the
71677215 // reader's feet. A link the agent wrote can open any other one here, and that one has to be
71687216 // checkable out like it is anywhere else.
71697217 <PullRequestDetailPanel
7170− key={`${activeRightPanelSurface.repository}#${activeRightPanelSurface.number}`}
7218+ key={`${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
71717219 environmentId={activeThread.environmentId}
71727220 reference={{
7173− projectId: activeRightPanelSurface.projectId as ProjectId,
7174− repository: activeRightPanelSurface.repository,
7175− number: activeRightPanelSurface.number,
7221+ projectId: renderedRightPanelSurface.projectId as ProjectId,
7222+ repository: renderedRightPanelSurface.repository,
7223+ number: renderedRightPanelSurface.number,
71767224 }}
71777225 context={
71787226 isThreadOwnPullRequest(
@@ -7182,9 +7230,9 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines
71827230 number: activeThreadPr?.number ?? null,
71837231 },
71847232 {
7185− projectId: activeRightPanelSurface.projectId,
7186− repository: activeRightPanelSurface.repository,
7187− number: activeRightPanelSurface.number,
7233+ projectId: renderedRightPanelSurface.projectId,
7234+ repository: renderedRightPanelSurface.repository,
7235+ number: renderedRightPanelSurface.number,
71887236 },
71897237 )
71907238 ? "thread"
@@ -7193,13 +7241,14 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines
71937241 composerDraftTarget={composerDraftTarget}
71947242 onStateChange={handlePullRequestTabStatusChange}
71957243 />
7196− ) : activeRightPanelSurface?.kind === "agents" ? (
7244+ ) : renderedRightPanelSurface?.kind === "agents" ? (
71977245 <AgentsPanel
71987246 model={agentPanelModel}
71997247 environmentId={activeThreadRef?.environmentId ?? null}
72007248 threadId={activeThreadRef?.threadId ?? null}
72017249 />
7202− ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") &&
7250+ ) : (renderedRightPanelSurface?.kind === "files" ||
7251+ renderedRightPanelSurface?.kind === "file") &&
72037252 activeProject &&
72047253 activeWorkspaceRoot ? (
72057254 <Suspense fallback={null}>
@@ -7213,14 +7262,25 @@ function ChatViewContent(props: ChatViewProps) { · 7 unmodified lines
72137262 keybindings={keybindings}
72147263 availableEditors={availableEditors}
72157264 relativePath={
7216− activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null
7265+ renderedRightPanelSurface.kind === "file"
7266+ ? renderedRightPanelSurface.relativePath
7267+ : null
7268+ }
7269+ revealLine={
7270+ renderedRightPanelSurface.kind === "file"
7271+ ? (renderedRightPanelSurface.revealLine ?? null)
7272+ : null
7273+ }
7274+ revealRequestId={
7275+ renderedRightPanelSurface.kind === "file"
7276+ ? renderedRightPanelSurface.revealRequestId
7277+ : 0
72177278 }
7218− revealLine={activeFileSurface?.revealLine ?? null}
7219− revealRequestId={activeFileSurface?.revealRequestId ?? 0}
72207279 onOpenFile={openFileSurface}
72217280 onPendingChange={handleFilePendingChange}
72227281 selectedFilePending={
7223− activeFileSurface !== null && pendingFileSurfaceIds.has(activeFileSurface.id)
7282+ renderedRightPanelSurface.kind === "file" &&
7283+ pendingFileSurfaceIds.has(renderedRightPanelSurface.id)
72247284 }
72257285 workspaceMutationId={workspaceMutationId}
72267286 />
@@ -7235,7 +7295,6 @@ function ChatViewContent(props: ChatViewProps) { · 8 unmodified lines
72357295
72367296 return (
72377297 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
7238− {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
72397298 <div
72407299 className={cn(
72417300 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",
@@ -7250,7 +7309,7 @@ function ChatViewContent(props: ChatViewProps) { · 8 unmodified lines
72507309 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}
72517310 className="relative bg-background"
72527311 >
7253− {!rightPanelOpen ? panelLayoutControls : null}
7312+ {!shouldUseRightPanelSheet || !rightPanelControlsInPanel ? panelLayoutControls : null}
72547313 <ChatHeader
72557314 {...(!supportsPullRequests || activeProjectRepository === null
72567315 ? {}
@@ -7639,7 +7698,7 @@ function ChatViewContent(props: ChatViewProps) { · 382 unmodified lines
76397698 key={mountedThreadKey}
76407699 threadRef={mountedThreadRef}
76417700 threadId={mountedThreadRef.threadId}
7642− visible={mountedThreadKey === activeThreadKey && terminalUiState.terminalOpen}
7701+ active={mountedThreadKey === activeThreadKey}
76437702 launchContext={
76447703 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
76457704 }
@@ -7654,12 +7713,13 @@ function ChatViewContent(props: ChatViewProps) { · 8 unmodified lines
76547713 ))}
76557714 </div>
76567715
7657− {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (
7716+ {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? (
76587717 <RightPanelTabs
76597718 mode="inline"
7719+ open={rightPanelOpen}
76607720 maximized={rightPanelMaximized}
7661− surfaces={rightPanelState.surfaces}
7662− activeSurfaceId={activeRightPanelSurface?.id ?? null}
7721+ surfaces={renderedRightPanelSurfaces}
7722+ activeSurfaceId={renderedRightPanelSurface?.id ?? null}
76637723 pendingSurfaceIds={pendingFileSurfaceIds}
76647724 previewSessions={activePreviewState.sessions}
76657725 desktopByTabId={activePreviewState.desktopByTabId}
@@ -7690,17 +7750,25 @@ function ChatViewContent(props: ChatViewProps) { · 24 unmodified lines
76907750 {rightPanelContent}
76917751 </RightPanelTabs>
76927752 ) : null}
7693− {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (
7694− <RightPanelSheet open onClose={closePreviewPanel}>
7753+ {rightPanelPresent && shouldUseRightPanelSheet && activeThreadRef ? (
7754+ <RightPanelSheet
7755+ animationDurationMs={panelAnimationsActive ? panelAnimationDurationMs : 0}
7756+ open={rightPanelOpen}
7757+ onClose={closePreviewPanel}
7758+ >
76957759 <RightPanelTabs
76967760 mode="sheet"
76977761 // Same effective inset as the closed-state titlebar controls
76987762 // (pr-3 in the tab bar plus this pixel equals the absolute
76997763 // right inset plus mr-px), so the cluster does not creep when
77007764 // the sheet opens.
7701− layoutControls={<div className="mr-px flex items-center">{panelToggleControls}</div>}
7702− surfaces={rightPanelState.surfaces}
7703− activeSurfaceId={activeRightPanelSurface?.id ?? null}
7765+ layoutControls={
7766+ rightPanelOpen ? (
7767+ <div className="mr-px flex items-center">{panelToggleControls}</div>
7768+ ) : null
7769+ }
7770+ surfaces={renderedRightPanelSurfaces}
7771+ activeSurfaceId={renderedRightPanelSurface?.id ?? null}
77047772 pendingSurfaceIds={pendingFileSurfaceIds}
77057773 previewSessions={activePreviewState.sessions}
77067774 desktopByTabId={activePreviewState.desktopByTabId}
apps/web/src/components/RightPanelSheet.tsx+2−0
@@ -4,6 +4,7 @@ import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout"; · 3 unmodified lines
44 import { Sheet, SheetPopup } from "./ui/sheet";
55
66 export function RightPanelSheet(props: {
7+ animationDurationMs: number;
78 children: ReactNode;
89 open: boolean;
910 onClose: () => void;
@@ -18,6 +19,7 @@ export function RightPanelSheet(props: { · 8 unmodified lines
1819 }}
1920 >
2021 <SheetPopup
22+ transitionDurationMs={props.animationDurationMs}
2123 side="right"
2224 showCloseButton={false}
2325 keepMounted
apps/web/src/components/RightPanelTabs.tsx+2−0
@@ -55,6 +55,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; · 54 unmodified lines
5555 interface RightPanelTabsProps {
5656 mode: PreviewPanelMode;
5757 maximized?: boolean;
58+ open?: boolean;
5859 /** Forwarded to PreviewPanelShell so this surface persists its own width. */
5960 widthStorageKey?: string;
6061 /** Forwarded to PreviewPanelShell as the initial width before a user resize. */
@@ -798,6 +799,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 737 unmodified lines
798799 <PreviewPanelShell
799800 mode={props.mode}
800801 {...(props.maximized !== undefined ? { maximized: props.maximized } : {})}
802+ {...(props.open !== undefined ? { open: props.open } : {})}
801803 {...(props.widthStorageKey !== undefined ? { widthStorageKey: props.widthStorageKey } : {})}
802804 {...(props.defaultWidth !== undefined ? { defaultWidth: props.defaultWidth } : {})}
803805 >
apps/web/src/components/WorkspacePageHeader.tsx+1−1
@@ -16,7 +16,7 @@ export function WorkspacePageHeader({ · 15 unmodified lines
1616 return (
1717 <header
1818 className={cn(
19− "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-3 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
19+ "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-3 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] [[data-panel-animations=true]_&]:motion-safe:transition-[padding-left,padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
2020 electron && "drag-region",
2121 reserveNativeControls && "wco:pr-[var(--workspace-native-controls-inset)]",
2222 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
apps/web/src/components/chat/ChatComposer.tsx+36−2
@@ -135,9 +135,12 @@ import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts · 134 unmodified lines
135135 import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";
136136 import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards";
137137 import {
138+ COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
139+ COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
138140 shouldUseCompactComposerPrimaryActions,
139141 shouldUseCompactComposerFooter,
140142 } from "../composerFooterLayout";
143+import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
141144 import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
142145 import { ProviderModelPicker } from "./ProviderModelPicker";
143146 import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
@@ -1283,6 +1286,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1139 unmodified lines
12831286 active: false,
12841287 });
12851288 const isMobileViewport = useMediaQuery("max-sm");
1289+ const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
1290+ usePanelAnimationSettings();
12861291 const isComposerCollapsedMobile =
12871292 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;
12881293
@@ -1292,6 +1297,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3 unmodified lines
12921297 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
12931298 const attachmentInputRef = useRef<HTMLInputElement>(null);
12941299 const composerFormRef = useRef<HTMLFormElement>(null);
1300+ const composerFooterControlsRef = useRef<HTMLDivElement>(null);
12951301 const composerSurfaceRef = useRef<HTMLDivElement>(null);
12961302 const providerInputRejectedRef = useRef(false);
12971303 const composerSelectLockRef = useRef(false);
@@ -1829,6 +1835,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 531 unmodified lines
18291835 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
18301836 setIsComposerFooterCompact(initialCompactness.footerCompact);
18311837 if (typeof ResizeObserver === "undefined") return;
1838+ const footerControls = composerFooterControlsRef.current;
1839+ const stopFooterControlsFade = footerControls
1840+ ? observeResponsiveBreakpointFade({
1841+ target: footerControls,
1842+ container: composerForm,
1843+ active: panelAnimationsActive,
1844+ durationMs: panelAnimationDurationMs,
1845+ breakpoint: {
1846+ value: composerFooterHasWideActions
1847+ ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX
1848+ : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
1849+ unit: "px",
1850+ },
1851+ })
1852+ : undefined;
18321853
18331854 const observer = new ResizeObserver(() => {
18341855 const nextCompactness = measureFooterCompactness();
@@ -1845,8 +1866,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 10 unmodified lines
18451866 observer.observe(composerForm);
18461867 return () => {
18471868 observer.disconnect();
1869+ stopFooterControlsFade?.();
18481870 };
1849− }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]);
1871+ }, [
1872+ activeThreadId,
1873+ composerFooterActionLayoutKey,
1874+ composerFooterHasWideActions,
1875+ isComposerApprovalState,
1876+ isComposerCollapsedMobile,
1877+ panelAnimationDurationMs,
1878+ panelAnimationsActive,
1879+ ]);
18501880
18511881 // ------------------------------------------------------------------
18521882 // Image persist effect
@@ -4223,7 +4253,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2370 unmodified lines
42234253 showMobilePendingAnswerActions && "hidden sm:flex",
42244254 )}
42254255 >
4226− <div className="-m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
4256+ <div
4257+ ref={composerFooterControlsRef}
4258+ data-chat-composer-footer-controls="true"
4259+ className="-m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
4260+ >
42274261 {noProviderAvailable ? (
42284262 <Button
42294263 type="button"
apps/web/src/components/chat/ChatHeader.tsx+20−0
@@ -37,6 +37,7 @@ import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; · 36 unmodified lines
3737 import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
3838 import { threadEnvironment } from "../../state/threads";
3939 import { useAtomCommand } from "../../state/use-atom-command";
40+import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
4041 import { ProjectFavicon } from "../ProjectFavicon";
4142 import {
4243 WorkspaceBreadcrumb,
@@ -94,6 +95,8 @@ export function resolveRenameCommit(input: { · 51 unmodified lines
9495 // events (the second click dismisses it and dblclick still fires), so it
9596 // opens immediately.
9697 const TITLE_MENU_OPEN_DELAY_MS = 500;
98+// Matches the @3xl/header-actions container breakpoint owned by this header.
99+const HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM = 48;
97100
98101 export function shouldShowOpenInPicker(input: {
99102 readonly activeProjectName: string | undefined;
@@ -137,6 +140,21 @@ export const ChatHeader = memo(function ChatHeader({ · 37 unmodified lines
137140 onUpdateProjectScript,
138141 onDeleteProjectScript,
139142 }: ChatHeaderProps) {
143+ const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
144+ usePanelAnimationSettings();
145+ const headerActionsRef = useRef<HTMLDivElement | null>(null);
146+ useEffect(() => {
147+ const actions = headerActionsRef.current;
148+ const container = actions?.parentElement;
149+ if (!actions || !container) return;
150+ return observeResponsiveBreakpointFade({
151+ target: actions,
152+ container,
153+ active: panelAnimationsActive,
154+ durationMs: panelAnimationDurationMs,
155+ breakpoint: { value: HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM, unit: "rem" },
156+ });
157+ }, [panelAnimationDurationMs, panelAnimationsActive]);
140158 const primaryEnvironmentId = usePrimaryEnvironmentId();
141159 const fileScripts = useT3ProjectFileScripts(
142160 activeThreadEnvironmentId,
@@ -372,10 +390,12 @@ export const ChatHeader = memo(function ChatHeader({ · 229 unmodified lines
372390 </WorkspaceBreadcrumbItem>
373391 </WorkspaceBreadcrumb>
374392 <div
393+ ref={headerActionsRef}
375394 data-chat-header-actions
376395 className={cn(
377396 "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",
378397 rightPanelOpen ? "pr-0" : "pr-16",
398+ "[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out",
379399 )}
380400 >
381401 {activeProjectScripts && (
apps/web/src/components/preview/PreviewPanelShell.tsx+41−4
@@ -52,6 +52,7 @@ export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: · 51 unmodified lines
5252 export function PreviewPanelShell(props: {
5353 mode: PreviewPanelMode;
5454 maximized?: boolean;
55+ open?: boolean;
5556 /**
5657 * Overrides the localStorage key used to persist the panel width. Callers
5758 * embedding this shell for a different surface (e.g. the pull requests
@@ -65,6 +66,8 @@ export function PreviewPanelShell(props: { · 7 unmodified lines
6566 }) {
6667 const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded";
6768 const isInline = props.mode === "inline";
69+ const collapsible = isInline && props.open !== undefined;
70+ const open = props.open ?? true;
6871 const hostRef = useRef<HTMLDivElement | null>(null);
6972 // Only inline non-maximized mode applies `width`/`maxWidth`; skip the
7073 // container measurement (and its re-renders) everywhere else.
@@ -76,7 +79,26 @@ export function PreviewPanelShell(props: { · 5 unmodified lines
7679 maxWidth,
7780 edge: "left",
7881 });
79−
82+ const previousLayoutRef = useRef({ open, width });
83+ useLayoutEffect(() => {
84+ const previous = previousLayoutRef.current;
85+ previousLayoutRef.current = { open, width };
86+ if (!collapsible || previous.open !== open || previous.width === width) return;
87+ const host = hostRef.current;
88+ if (!host?.closest("[data-panel-animations=true]")) return;
89+ host.style.setProperty("transition-duration", "0ms");
90+ let restoreFrame = 0;
91+ const paintFrame = window.requestAnimationFrame(() => {
92+ restoreFrame = window.requestAnimationFrame(() => {
93+ host.style.removeProperty("transition-duration");
94+ });
95+ });
96+ return () => {
97+ window.cancelAnimationFrame(paintFrame);
98+ window.cancelAnimationFrame(restoreFrame);
99+ host.style.removeProperty("transition-duration");
100+ };
101+ }, [collapsible, open, width]);
80102 return (
81103 <div
82104 ref={hostRef}
@@ -87,14 +109,29 @@ export function PreviewPanelShell(props: { · 4 unmodified lines
87109 ? "flex-1 border-l border-border"
88110 : "shrink-0 border-l border-border"
89111 : "w-full",
112+ collapsible &&
113+ "[[data-panel-animations=true]_&]:transition-[width] [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out",
114+ collapsible && open && "[[data-panel-animations=true]_&]:starting:w-0!",
115+ collapsible && !open && "pointer-events-none",
90116 )}
91− style={isInline && !props.maximized ? { width: `${width}px` } : undefined}
117+ style={
118+ isInline
119+ ? { width: props.maximized ? "100%" : collapsible && !open ? "0px" : `${width}px` }
120+ : undefined
121+ }
92122 data-preview-panel-mode={props.mode}
93123 data-preview-panel-maximized={props.maximized ? "true" : "false"}
94124 >
95125 {isInline && !props.maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}
96− {useDragRegion ? <div className="electron-drag-region h-0 w-full" aria-hidden /> : null}
97− {props.children}
126+ <div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}>
127+ <div
128+ className="flex h-full min-h-0 min-w-0 flex-col"
129+ style={collapsible && !props.maximized ? { width: `calc(${width}px - 1px)` } : undefined}
130+ >
131+ {useDragRegion ? <div className="electron-drag-region h-0 w-full" aria-hidden /> : null}
132+ {props.children}
133+ </div>
134+ </div>
98135 </div>
99136 );
100137 }
apps/web/src/components/settings/PanelAnimationsPreview.tsx+52−0
@@ -0,0 +1,52 @@
1+import { type CSSProperties, useState } from "react";
2+
3+import { cn } from "~/lib/utils";
4+
5+export function PanelAnimationsPreview({ durationMs }: { durationMs: number }) {
6+ const [panelsOpen, setPanelsOpen] = useState(true);
7+ const transitionClass =
8+ "transition-[width,height,border-width] [transition-duration:var(--preview-duration)] ease-out motion-reduce:transition-none";
9+
10+ return (
11+ <button
12+ type="button"
13+ aria-label="Replay panel animation preview"
14+ className="flex h-10 w-full cursor-pointer overflow-hidden rounded-lg border border-border bg-background p-1 shadow-xs/5 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
15+ onClick={() => setPanelsOpen((open) => !open)}
16+ style={{ "--preview-duration": `${durationMs}ms` } as CSSProperties}
17+ >
18+ <span
19+ aria-hidden
20+ className={cn(
21+ "h-full shrink-0 overflow-hidden rounded-md bg-sidebar",
22+ transitionClass,
23+ panelsOpen ? "w-4" : "w-0",
24+ )}
25+ />
26+ <span aria-hidden className="flex min-w-0 flex-1 flex-col px-1">
27+ <span className="flex min-h-0 flex-1 flex-col gap-1 pt-1">
28+ <span className="h-0.5 w-full rounded-full bg-muted-foreground/25" />
29+ <span className="h-0.5 w-4/5 rounded-full bg-muted-foreground/20" />
30+ <span className="h-0.5 w-3/5 rounded-full bg-muted-foreground/15" />
31+ </span>
32+ <span
33+ className={cn(
34+ "flex shrink-0 items-center overflow-hidden bg-foreground/5 px-2",
35+ transitionClass,
36+ panelsOpen ? "h-2 border-t border-border/70" : "h-0 border-t-0",
37+ )}
38+ >
39+ <span className="h-px w-2/3 rounded-full bg-muted-foreground/25" />
40+ </span>
41+ </span>
42+ <span
43+ aria-hidden
44+ className={cn(
45+ "h-full shrink-0 overflow-hidden rounded-md bg-muted",
46+ transitionClass,
47+ panelsOpen ? "w-5" : "w-0",
48+ )}
49+ />
50+ </button>
51+ );
52+}
apps/web/src/components/settings/SettingsPanels.tsx+69−0
@@ -24,6 +24,7 @@ import { · 23 unmodified lines
2424 MAX_CODE_FONT_SIZE,
2525 MAX_GLASS_OPACITY,
2626 MAX_INTERFACE_FONT_SIZE,
27+ MAX_PANEL_ANIMATION_DURATION_MS,
2728 MAX_PROMPT_FONT_SIZE,
2829 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
2930 MAX_TERMINAL_FONT_SIZE,
@@ -31,6 +32,7 @@ import { · 1 unmodified line
3132 MIN_APPEARANCE_CONTRAST,
3233 MIN_GLASS_OPACITY,
3334 MIN_INTERFACE_FONT_SIZE,
35+ MIN_PANEL_ANIMATION_DURATION_MS,
3436 MIN_PROMPT_FONT_SIZE,
3537 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
3638 MIN_TERMINAL_FONT_SIZE,
@@ -152,6 +154,7 @@ import { · 115 unmodified lines
152154 } from "./settingsLayout";
153155 import { searchableSetting } from "./settingsSearch";
154156 import { ProjectFavicon } from "../ProjectFavicon";
157+import { PanelAnimationsPreview } from "./PanelAnimationsPreview";
155158
156159 const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
157160 artwork: "Artwork",
@@ -493,6 +496,9 @@ export function useSettingsRestore(onRestored?: () => void) { · 335 unmodified lines
493496 ? ["Contrast"]
494497 : []),
495498 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
499+ ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs
500+ ? ["Panel animations"]
501+ : []),
496502 ...(settings.environmentIdentificationMode !==
497503 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
498504 ? ["Environment identification"]
@@ -593,6 +599,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 94 unmodified lines
593599 settings.fontSizePrompt,
594600 settings.fontSizeTerminal,
595601 settings.glassOpacity,
602+ settings.panelAnimationDurationMs,
596603 settings.enableLegacyTokenStreaming,
597604 settings.enableProviderUpdateChecks,
598605 settings.continueThreadsAfterServerUpdate,
@@ -680,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 81 unmodified lines
680687 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
681688 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
682689 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
690+ panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
683691 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
684692 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
685693 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
@@ -1035,6 +1043,13 @@ export function AppearanceSettingsPanel() { · 349 unmodified lines
10351043 "--settings-slider-progress": `${appearanceContrastRatio * 100}%`,
10361044 "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`,
10371045 } as CSSProperties;
1046+ const panelAnimationDurationRatio =
1047+ (settings.panelAnimationDurationMs - MIN_PANEL_ANIMATION_DURATION_MS) /
1048+ (MAX_PANEL_ANIMATION_DURATION_MS - MIN_PANEL_ANIMATION_DURATION_MS);
1049+ const panelAnimationDurationSliderStyle = {
1050+ "--settings-slider-progress": `${panelAnimationDurationRatio * 100}%`,
1051+ "--settings-slider-fill-offset": `${0.5 - panelAnimationDurationRatio}rem`,
1052+ } as CSSProperties;
10381053
10391054 return (
10401055 <SettingsPageContainer>
@@ -1192,6 +1207,60 @@ export function AppearanceSettingsPanel() { · 151 unmodified lines
11921207 ) : null}
11931208 </SettingsSection>
11941209
1210+ <SettingsSection id="motion" title="Motion">
1211+ <SettingsRow
1212+ {...searchableSetting("panel-animations")}
1213+ description="Set how fast panels open and close."
1214+ control={
1215+ <div className="grid w-full grid-cols-[5rem_minmax(0,1fr)] items-center gap-3 sm:w-auto sm:grid-cols-[7rem_13rem] sm:gap-4">
1216+ <PanelAnimationsPreview durationMs={settings.panelAnimationDurationMs} />
1217+ <div className="flex w-full items-center gap-3">
1218+ <output
1219+ className="min-w-16 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
1220+ htmlFor="panel-animation-duration"
1221+ >
1222+ {settings.panelAnimationDurationMs} ms
1223+ </output>
1224+ <input
1225+ aria-label="Panel animation duration"
1226+ className="settings-slider min-w-0 flex-1"
1227+ id="panel-animation-duration"
1228+ max={MAX_PANEL_ANIMATION_DURATION_MS}
1229+ min={MIN_PANEL_ANIMATION_DURATION_MS}
1230+ onChange={(event) => {
1231+ const panelAnimationDurationMs = Number(event.currentTarget.value);
1232+ if (
1233+ Number.isInteger(panelAnimationDurationMs) &&
1234+ panelAnimationDurationMs >= MIN_PANEL_ANIMATION_DURATION_MS &&
1235+ panelAnimationDurationMs <= MAX_PANEL_ANIMATION_DURATION_MS
1236+ ) {
1237+ updateSettings({ panelAnimationDurationMs });
1238+ }
1239+ }}
1240+ step={25}
1241+ style={panelAnimationDurationSliderStyle}
1242+ type="range"
1243+ value={settings.panelAnimationDurationMs}
1244+ />
1245+ </div>
1246+ </div>
1247+ }
1248+ resetAction={
1249+ settings.panelAnimationDurationMs !==
1250+ DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? (
1251+ <SettingResetButton
1252+ label="panel animations"
1253+ onClick={() =>
1254+ updateSettings({
1255+ panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
1256+ })
1257+ }
1258+ />
1259+ ) : null
1260+ }
1261+ />
1262+ </SettingsSection>
1263+
11951264 <TypographySection />
11961265 </SettingsPageContainer>
11971266 );
apps/web/src/components/settings/settingsSearch.test.ts+1−0
@@ -51,6 +51,7 @@ describe("searchSettings", () => { · 50 unmodified lines
5151 it("matches normalized title substrings", () => {
5252 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
5353 expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]);
54+ expect(searchSettings("panel animations").map((item) => item.id)).toEqual(["panel-animations"]);
5455 expect(searchSettings("thè\u{1ab0}mes")[0]?.id).toBe("theme");
5556 const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt");
5657 try {
apps/web/src/components/settings/settingsSearch.ts+5−0
@@ -94,6 +94,11 @@ export const SETTINGS_SEARCH_ITEMS = [ · 93 unmodified lines
9494 to: "/settings/appearance",
9595 searchTerms: ["transparent transparency solid menus dialogs composer"],
9696 },
97+ {
98+ id: "panel-animations",
99+ title: "Panel animations",
100+ to: "/settings/appearance",
101+ },
97102 {
98103 id: "environment-identification",
99104 title: "Environment identification",
apps/web/src/components/ui/sheet.tsx+21−1
@@ -2,6 +2,7 @@ · 1 unmodified line
22
33 import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
44 import { XIcon } from "lucide-react";
5+import type { CSSProperties } from "react";
56 import { cn } from "~/lib/utils";
67 import { Button } from "~/components/ui/button";
78 import { ScrollArea } from "~/components/ui/scroll-area";
@@ -62,18 +63,34 @@ function SheetPopup({ · 54 unmodified lines
6263 children,
6364 showCloseButton = true,
6465 keepMounted = false,
66+ transitionDurationMs,
6567 side = "right",
6668 variant = "default",
69+ style,
6770 ...props
6871 }: SheetPrimitive.Popup.Props & {
6972 showCloseButton?: boolean;
7073 keepMounted?: boolean;
74+ transitionDurationMs?: number;
7175 side?: "right" | "left" | "top" | "bottom";
7276 variant?: "default" | "inset";
7377 }) {
78+ const transitionStyle =
79+ transitionDurationMs === undefined
80+ ? undefined
81+ : ({ transitionDuration: `${transitionDurationMs}ms` } satisfies CSSProperties);
82+ const instant = transitionDurationMs === 0;
83+
7484 return (
7585 <SheetPortal keepMounted={keepMounted}>
76− <SheetBackdrop />
86+ <SheetBackdrop
87+ className={
88+ instant
89+ ? "transition-none! data-ending-style:opacity-100! data-starting-style:opacity-100!"
90+ : undefined
91+ }
92+ style={transitionStyle}
93+ />
7794 <SheetViewport side={side} variant={variant}>
7895 <SheetPrimitive.Popup
7996 className={cn(
@@ -88,9 +105,12 @@ function SheetPopup({ · 8 unmodified lines
88105 "col-start-2 w-[calc(100%-(--spacing(12)))] max-w-md border-s data-ending-style:translate-x-8 data-starting-style:translate-x-8",
89106 variant === "inset" &&
90107 "before:hidden sm:rounded-2xl sm:border sm:before:rounded-[calc(var(--radius-2xl)-1px)] sm:**:data-[slot=sheet-footer]:rounded-b-[calc(var(--radius-2xl)-1px)]",
108+ instant &&
109+ "transition-none! will-change-auto! data-ending-style:translate-x-0! data-starting-style:translate-x-0! data-ending-style:translate-y-0! data-starting-style:translate-y-0! data-ending-style:opacity-100! data-starting-style:opacity-100!",
91110 className,
92111 )}
93112 data-slot="sheet-popup"
113+ style={{ ...transitionStyle, ...style }}
94114 {...props}
95115 >
96116 {children}
apps/web/src/components/ui/sidebar.tsx+6−3
@@ -281,7 +281,8 @@ function Sidebar({ · 280 unmodified lines
281281 {/* This is what handles the sidebar gap on desktop */}
282282 <div
283283 className={cn(
284− "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
284+ "relative w-(--sidebar-width) bg-transparent",
285+ "[[data-panel-animations=true]_&]:transition-[width] [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out",
285286 "group-data-[collapsible=offcanvas]:w-0",
286287 "group-data-[side=right]:rotate-180",
287288 variant === "floating" || variant === "inset"
@@ -292,7 +293,8 @@ function Sidebar({ · 4 unmodified lines
292293 />
293294 <div
294295 className={cn(
295− "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
296+ "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
297+ "[[data-panel-animations=true]_&]:transition-[left,right,width] [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out",
296298 side === "left"
297299 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
298300 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -601,7 +603,8 @@ function SidebarRail({ · 302 unmodified lines
601603 aria-label={railLabel}
602604 className={cn(
603605 /* disable pointer events only when offcanvas sidebar is collapsed, that's when the rail sits over the native scrollbar on windows and linux. icon mode stays fully clickable. */
604− "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none",
606+ "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none",
607+ "[[data-panel-animations=true]_&]:transition-all [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out",
605608 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
606609 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
607610 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
apps/web/src/panelAnimations.ts+88−0
@@ -0,0 +1,88 @@
1+import { useEffect, useRef, useState } from "react";
2+import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings";
3+
4+import { useMediaQuery } from "./hooks/useMediaQuery";
5+import { useClientSettings } from "./hooks/useSettings";
6+
7+export function observeResponsiveBreakpointFade(options: {
8+ target: HTMLElement;
9+ container: HTMLElement;
10+ active: boolean;
11+ durationMs: PanelAnimationDurationMs;
12+ breakpoint: { value: number; unit: "px" | "rem" };
13+}): () => void {
14+ const { target, container, active, durationMs, breakpoint } = options;
15+ if (!active || typeof ResizeObserver === "undefined") return () => {};
16+
17+ const rootFontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
18+ const breakpointPx =
19+ breakpoint.unit === "px"
20+ ? breakpoint.value
21+ : breakpoint.value * (Number.isFinite(rootFontSize) ? rootFontSize : 16);
22+ let expanded = container.getBoundingClientRect().width >= breakpointPx;
23+ let animation: Animation | null = null;
24+
25+ const observer = new ResizeObserver(([entry]) => {
26+ if (!entry) return;
27+ const nextExpanded = entry.contentRect.width >= breakpointPx;
28+ if (nextExpanded === expanded) return;
29+ expanded = nextExpanded;
30+ animation?.cancel();
31+ animation = target.animate([{ opacity: 0 }, { opacity: 1 }], {
32+ duration: Math.min(100, durationMs),
33+ easing: "ease-out",
34+ });
35+ });
36+
37+ observer.observe(container);
38+ return () => {
39+ observer.disconnect();
40+ animation?.cancel();
41+ };
42+}
43+
44+export function usePanelAnimationSettings(): {
45+ active: boolean;
46+ durationMs: PanelAnimationDurationMs;
47+} {
48+ const durationMs = useClientSettings((settings) => settings.panelAnimationDurationMs);
49+ const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
50+ return { active: durationMs > 0 && !prefersReducedMotion, durationMs };
51+}
52+
53+/** Keeps closing panel content mounted until its opt-in transition ends. */
54+export function usePanelPresence<T>(
55+ open: boolean,
56+ value: T | null,
57+ animated: boolean,
58+ scopeKey: string | null,
59+ durationMs: PanelAnimationDurationMs,
60+): { present: boolean; value: T | null } {
61+ const [present, setPresent] = useState(open);
62+ const retainedRef = useRef<{ scopeKey: string | null; value: T | null } | null>(
63+ open ? { scopeKey, value } : null,
64+ );
65+
66+ useEffect(() => {
67+ if (open) retainedRef.current = { scopeKey, value };
68+ }, [open, scopeKey, value]);
69+
70+ useEffect(() => {
71+ if (open) {
72+ setPresent(true);
73+ return;
74+ }
75+ if (!animated) {
76+ setPresent(false);
77+ return;
78+ }
79+
80+ const timeout = window.setTimeout(() => setPresent(false), durationMs);
81+ return () => window.clearTimeout(timeout);
82+ }, [animated, durationMs, open]);
83+
84+ const retainedValue =
85+ retainedRef.current?.scopeKey === scopeKey ? retainedRef.current.value : null;
86+ const visible = open || (animated && present && retainedRef.current?.scopeKey === scopeKey);
87+ return { present: visible, value: open ? value : visible ? retainedValue : null };
88+}
apps/web/src/routes/_chat.pull-requests.tsx+57−15
@@ -106,6 +106,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ · 105 unmodified lines
106106 import { SidebarInset } from "../components/ui/sidebar";
107107 import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
108108 import { useLiveRefresh } from "../hooks/useLiveRefresh";
109+import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
109110 import { toSortableTimestamp } from "../lib/threadSort";
110111 import {
111112 selectActiveRightPanelSurface,
@@ -449,10 +450,29 @@ function PullRequestsRouteView() { · 337 unmodified lines
449450 const selectedPullRequestSurface =
450451 selectedRightPanelSurface?.kind === "pull-request" ? selectedRightPanelSurface : null;
451452 const activePullRequestSurface = rightPanelState.isOpen ? selectedPullRequestSurface : null;
453+ const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
454+ usePanelAnimationSettings();
455+ const rightPanelPresenceValue = useMemo(
456+ () => ({
457+ activeSurface: selectedPullRequestSurface,
458+ surfaces: rightPanelState.surfaces,
459+ }),
460+ [rightPanelState.surfaces, selectedPullRequestSurface],
461+ );
462+ const rightPanelPresence = usePanelPresence(
463+ rightPanelState.isOpen && selectedPullRequestSurface !== null,
464+ rightPanelPresenceValue,
465+ panelAnimationsActive,
466+ rightPanelRef === null ? null : PULL_REQUESTS_PANEL_ID,
467+ panelAnimationDurationMs,
468+ );
469+ const rightPanelPresent = rightPanelPresence.present;
470+ const renderedPullRequestSurface = rightPanelPresence.value?.activeSurface ?? null;
471+ const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
452472 // The open tab names its own server; a link that arrived before any tab was opened names it
453473 // through the project it selected.
454474 const panelEnvironmentId =
455− (activePullRequestSurface?.environmentId as EnvironmentId | undefined) ??
475+ (renderedPullRequestSurface?.environmentId as EnvironmentId | undefined) ??
456476 selectedProject?.environmentId ??
457477 null;
458478 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<
@@ -1783,16 +1803,37 @@ function PullRequestsRouteView() { · 1324 unmodified lines
17831803 // mounted at the fixed titlebar inset in both states so it cannot move
17841804 // on toggle, and this spacer keeps refresh from sliding underneath it
17851805 // (sized per header padding so refresh ends a normal gap short of it).
1786− !pullRequestsSupported || rightPanelState.isOpen ? null : (
1787− <span aria-hidden className="w-7 shrink-0 sm:w-5" />
1806+ !pullRequestsSupported ? null : (
1807+ <span
1808+ aria-hidden
1809+ className={cn(
1810+ "shrink-0",
1811+ rightPanelState.isOpen ? "-ml-3 w-0" : "w-7 sm:w-5",
1812+ panelAnimationsActive && "transition-[width,margin] ease-out",
1813+ )}
1814+ style={
1815+ panelAnimationsActive
1816+ ? { transitionDuration: `${panelAnimationDurationMs}ms` }
1817+ : undefined
1818+ }
1819+ />
17881820 ),
17891821 titlebarControls:
17901822 // While the panel is closed the strip lives inside the header: a no-drag
17911823 // descendant beats the header's desktop drag-region, where a floating
1792− // sibling loses (app-region hit-testing ignores z-index). Open, it moves
1793− // back out to the route container, which spans the panel too, so the
1794− // toggle keeps one fixed top-right anchor and never jumps sideways.
1795− pullRequestsSupported && !rightPanelState.isOpen ? openPanelControls : null,
1824+ // sibling loses (app-region hit-testing ignores z-index). While the
1825+ // floating strip crosses the header during motion, the narrow extension
1826+ // keeps that overlap non-draggable without moving the toggle.
1827+ pullRequestsSupported ? (
1828+ rightPanelPresent ? (
1829+ <span
1830+ aria-hidden
1831+ className="pointer-events-none absolute inset-y-0 left-full w-7 [-webkit-app-region:no-drag]"
1832+ />
1833+ ) : (
1834+ openPanelControls
1835+ )
1836+ ) : null,
17961837 rightPanelOpen: rightPanelState.isOpen,
17971838 listBody,
17981839 scrollRef,
@@ -1835,19 +1876,20 @@ function PullRequestsRouteView() { · 36 unmodified lines
18351876 return (
18361877 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
18371878 <div className="relative flex min-h-0 flex-1">
1838− {pullRequestsSupported && rightPanelState.isOpen ? openPanelControls : null}
1879+ {pullRequestsSupported && rightPanelPresent ? openPanelControls : null}
18391880 <PullRequestsColumn {...columnProps} />
18401881
1841− {rightPanelState.isOpen && activePullRequestSurface && panelEnvironmentId !== null ? (
1882+ {rightPanelPresent && renderedPullRequestSurface && panelEnvironmentId !== null ? (
18421883 <RightPanelTabs
18431884 mode="inline"
1885+ open={rightPanelState.isOpen}
18441886 widthStorageKey="t3code:pull-request-panel-width"
18451887 // Default to roughly half the viewport: the PR list needs more
18461888 // room than a chat, so the 540px chat-preview default squashes
18471889 // it. SSR has no window, so fall back to a reasonable width.
18481890 defaultWidth={typeof window === "undefined" ? 640 : Math.floor(window.innerWidth / 2)}
1849− surfaces={rightPanelState.surfaces}
1850− activeSurfaceId={activePullRequestSurface.id}
1891+ surfaces={renderedRightPanelSurfaces}
1892+ activeSurfaceId={renderedPullRequestSurface.id}
18511893 pendingSurfaceIds={EMPTY_PENDING_SURFACES}
18521894 previewSessions={EMPTY_PREVIEW_SESSIONS}
18531895 desktopByTabId={EMPTY_PREVIEW_DESKTOP_STATE}
@@ -1883,12 +1925,12 @@ function PullRequestsRouteView() { · 29 unmodified lines
18831925 pullRequestStatuses={pullRequestTabStatuses}
18841926 >
18851927 <PullRequestDetailPanel
1886− key={activePullRequestSurface.id}
1928+ key={renderedPullRequestSurface.id}
18871929 environmentId={panelEnvironmentId}
18881930 reference={{
1889− projectId: activePullRequestSurface.projectId as ProjectId,
1890− repository: activePullRequestSurface.repository,
1891− number: activePullRequestSurface.number,
1931+ projectId: renderedPullRequestSurface.projectId as ProjectId,
1932+ repository: renderedPullRequestSurface.repository,
1933+ number: renderedPullRequestSurface.number,
18921934 }}
18931935 refreshToken={detailRefreshToken}
18941936 // Merging, closing or reopening changes the row this panel was opened from, so
docs/user/thread-sidebar.md+7−0
@@ -42,6 +42,13 @@ If reordering is unavailable for one environment, update the T3 Code server runn · 41 unmodified lines
4242 environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
4343 their pinned threads keep the default newest-first order below the ones you have arranged.
4444
45+## Panel motion
46+
47+The main sidebar, right panel, and terminal drawer open and close immediately by default. Under
48+**Settings → Appearance → Motion**, move the **Panel animations** slider above 0 ms to add motion.
49+The duration can be set up to 400 ms. Clicking the preview replays all three panel transitions; at
50+0 ms, it snaps between the same open and closed states.
51+
4552 ## Environment artwork
4653
4754 Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in
packages/contracts/src/settings.test.ts+16−0
@@ -144,6 +144,22 @@ describe("ClientSettings appearance contrast", () => { · 143 unmodified lines
144144 });
145145 });
146146
147+describe("ClientSettings panel animations", () => {
148+ it("defaults to instant changes", () => {
149+ expect(decodeClientSettings({}).panelAnimationDurationMs).toBe(0);
150+ });
151+
152+ it.each([0, 400])("accepts a panel animation duration: %s", (value) => {
153+ expect(decodeClientSettingsPatch({ panelAnimationDurationMs: value })).toEqual({
154+ panelAnimationDurationMs: value,
155+ });
156+ });
157+
158+ it.each([-1, 401, 150.5])("rejects an invalid panel animation duration: %s", (value) => {
159+ expect(() => decodeClientSettingsPatch({ panelAnimationDurationMs: value })).toThrow();
160+ });
161+});
162+
147163 describe("ClientSettings environment identification", () => {
148164 it("defaults to artwork and accepts each presentation mode", () => {
149165 expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");
packages/contracts/src/settings.ts+16−0
@@ -84,6 +84,16 @@ export const AppearanceContrast = Schema.Int.check( · 83 unmodified lines
8484 );
8585 export type AppearanceContrast = typeof AppearanceContrast.Type;
8686 export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100;
87+export const MIN_PANEL_ANIMATION_DURATION_MS = 0;
88+export const MAX_PANEL_ANIMATION_DURATION_MS = 400;
89+export const PanelAnimationDurationMs = Schema.Int.check(
90+ Schema.isBetween({
91+ minimum: MIN_PANEL_ANIMATION_DURATION_MS,
92+ maximum: MAX_PANEL_ANIMATION_DURATION_MS,
93+ }),
94+);
95+export type PanelAnimationDurationMs = typeof PanelAnimationDurationMs.Type;
96+export const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0;
8797 /**
8898 * Font size preferences, in CSS pixels. The ranges are deliberately narrow:
8999 * the interface size scales every rem-based dimension in the app, so the
@@ -179,6 +189,11 @@ export const ClientSettingsSchema = Schema.Struct({ · 89 unmodified lines
179189 appearanceContrast: AppearanceContrast.pipe(
180190 Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),
181191 ),
192+ // Panel motion defaults to zero because width and height transitions cause
193+ // layout work on every frame, which is noticeable on lower-power clients.
194+ panelAnimationDurationMs: PanelAnimationDurationMs.pipe(
195+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_PANEL_ANIMATION_DURATION_MS)),
196+ ),
182197 browserDefaultViewport: PreviewViewportSetting.pipe(
183198 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),
184199 ),
@@ -966,6 +981,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; · 781 unmodified lines
966981
967982 export const ClientSettingsPatch = Schema.Struct({
968983 appearanceContrast: Schema.optionalKey(AppearanceContrast),
984+ panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs),
969985 browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),
970986 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
971987 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),