authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 18:24:52-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 18:24:52-04:00
log91c8d4771ccb503a9dde65190b87db642df0a6ea
treed435f3623d3add72127828d6768aefca2597f969
parentca63d42d670837b918081d1fc1ebada553814b4c
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

20 files changed, 598 insertions(+), 106 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
......@@ -41,6 +41,7 @@ const clientSettings: ClientSettings = {
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"
1717import { cn, isMacPlatform } from "../lib/utils";
1818import { primaryServerKeybindingsAtom } from "../state/server";
1919import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
20import { usePanelAnimationSettings } from "../panelAnimations";
2021import LegacyThreadSidebar from "./LegacySidebar";
2122import ThreadSidebar from "./Sidebar";
2223import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
......@@ -148,6 +149,8 @@ function ProjectProjectionRetention() {
148149export 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 }) {
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 }) {
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 {
217217 useEnvironmentSettings,
218218} from "../hooks/useSettings";
219219import { useNowMinute } from "../hooks/useNowMinute";
220import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
220221import { useNewThreadHandler } from "../hooks/useHandleNewThread";
221222import { useThreadActions } from "../hooks/useThreadActions";
222223import { resolveAppModelSelectionForInstance } from "../modelSelection";
......@@ -741,7 +742,7 @@ function serverTerminalIdsStrictSubsetOfClient(
741742interface 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 {
755756const 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
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
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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
......@@ -4,6 +4,7 @@ import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout";
44import { Sheet, SheetPopup } from "./ui/sheet";
55
66export function RightPanelSheet(props: {
7 animationDurationMs: number;
78 children: ReactNode;
89 open: boolean;
910 onClose: () => void;
......@@ -18,6 +19,7 @@ export function RightPanelSheet(props: {
1819 }}
1920 >
2021 <SheetPopup
22 transitionDurationMs={props.animationDurationMs}
2123 side="right"
2224 showCloseButton={false}
2325 keepMounted
apps/web/src/components/RightPanelTabs.tsx+2
......@@ -55,6 +55,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon";
5555interface 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) {
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({
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
135135import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";
136136import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards";
137137import {
138 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
139 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
138140 shouldUseCompactComposerPrimaryActions,
139141 shouldUseCompactComposerFooter,
140142} from "../composerFooterLayout";
143import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
141144import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
142145import { ProviderModelPicker } from "./ProviderModelPicker";
143146import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
......@@ -1283,6 +1286,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
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)
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)
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)
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)
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
......@@ -37,6 +37,7 @@ import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
3737import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
3838import { threadEnvironment } from "../../state/threads";
3939import { useAtomCommand } from "../../state/use-atom-command";
40import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
4041import { ProjectFavicon } from "../ProjectFavicon";
4142import {
4243 WorkspaceBreadcrumb,
......@@ -94,6 +95,8 @@ export function resolveRenameCommit(input: {
9495// events (the second click dismisses it and dblclick still fires), so it
9596// opens immediately.
9697const TITLE_MENU_OPEN_DELAY_MS = 500;
98// Matches the @3xl/header-actions container breakpoint owned by this header.
99const HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM = 48;
97100
98101export function shouldShowOpenInPicker(input: {
99102 readonly activeProjectName: string | undefined;
......@@ -137,6 +140,21 @@ export const ChatHeader = memo(function ChatHeader({
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({
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?:
5252export 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: {
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: {
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: {
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 created+52
......@@ -0,0 +1,52 @@
1import { type CSSProperties, useState } from "react";
2
3import { cn } from "~/lib/utils";
4
5export 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
......@@ -24,6 +24,7 @@ import {
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 {
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 {
152154} from "./settingsLayout";
153155import { searchableSetting } from "./settingsSearch";
154156import { ProjectFavicon } from "../ProjectFavicon";
157import { PanelAnimationsPreview } from "./PanelAnimationsPreview";
155158
156159const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
157160 artwork: "Artwork",
......@@ -493,6 +496,9 @@ export function useSettingsRestore(onRestored?: () => void) {
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) {
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) {
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() {
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() {
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
......@@ -51,6 +51,7 @@ describe("searchSettings", () => {
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
......@@ -94,6 +94,11 @@ export const SETTINGS_SEARCH_ITEMS = [
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 @@
22
33import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
44import { XIcon } from "lucide-react";
5import type { CSSProperties } from "react";
56import { cn } from "~/lib/utils";
67import { Button } from "~/components/ui/button";
78import { ScrollArea } from "~/components/ui/scroll-area";
......@@ -62,18 +63,34 @@ function SheetPopup({
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({
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({
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({
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({
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 created+88
......@@ -0,0 +1,88 @@
1import { useEffect, useRef, useState } from "react";
2import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings";
3
4import { useMediaQuery } from "./hooks/useMediaQuery";
5import { useClientSettings } from "./hooks/useSettings";
6
7export 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
44export 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. */
54export 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 "../
106106import { SidebarInset } from "../components/ui/sidebar";
107107import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
108108import { useLiveRefresh } from "../hooks/useLiveRefresh";
109import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
109110import { toSortableTimestamp } from "../lib/threadSort";
110111import {
111112 selectActiveRightPanelSurface,
......@@ -449,10 +450,29 @@ function PullRequestsRouteView() {
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() {
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() {
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() {
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
......@@ -42,6 +42,13 @@ If reordering is unavailable for one environment, update the T3 Code server runn
4242environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
4343their pinned threads keep the default newest-first order below the ones you have arranged.
4444
45## Panel motion
46
47The 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.
49The duration can be set up to 400 ms. Clicking the preview replays all three panel transitions; at
500 ms, it snaps between the same open and closed states.
51
4552## Environment artwork
4653
4754Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in
packages/contracts/src/settings.test.ts+16
......@@ -144,6 +144,22 @@ describe("ClientSettings appearance contrast", () => {
144144 });
145145});
146146
147describe("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
147163describe("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
......@@ -84,6 +84,16 @@ export const AppearanceContrast = Schema.Int.check(
8484);
8585export type AppearanceContrast = typeof AppearanceContrast.Type;
8686export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100;
87export const MIN_PANEL_ANIMATION_DURATION_MS = 0;
88export const MAX_PANEL_ANIMATION_DURATION_MS = 400;
89export const PanelAnimationDurationMs = Schema.Int.check(
90 Schema.isBetween({
91 minimum: MIN_PANEL_ANIMATION_DURATION_MS,
92 maximum: MAX_PANEL_ANIMATION_DURATION_MS,
93 }),
94);
95export type PanelAnimationDurationMs = typeof PanelAnimationDurationMs.Type;
96export 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({
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;
966981
967982export 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),