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 = {...@@ -41,6 +41,7 @@ const clientSettings: ClientSettings = {
41 fontSizeTerminal: 12,41 fontSizeTerminal: 12,
42 fontSmoothing: true,42 fontSmoothing: true,
43 glassOpacity: 80,43 glassOpacity: 80,
44 panelAnimationDurationMs: 0,
44 planModeEnabled: false,45 planModeEnabled: false,
45 showSkillsInSlashMenu: false,46 showSkillsInSlashMenu: false,
46 providerModelPreferences: {},47 providerModelPreferences: {},
apps/web/src/components/AppSidebarLayout.tsx+10-1
...@@ -17,6 +17,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"...@@ -17,6 +17,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"
17import { cn, isMacPlatform } from "../lib/utils";17import { cn, isMacPlatform } from "../lib/utils";
18import { primaryServerKeybindingsAtom } from "../state/server";18import { primaryServerKeybindingsAtom } from "../state/server";
19import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";19import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
20import { usePanelAnimationSettings } from "../panelAnimations";
20import LegacyThreadSidebar from "./LegacySidebar";21import LegacyThreadSidebar from "./LegacySidebar";
21import ThreadSidebar from "./Sidebar";22import ThreadSidebar from "./Sidebar";
22import { SidebarChromeHeader } from "./sidebar/SidebarChrome";23import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
...@@ -148,6 +149,8 @@ function ProjectProjectionRetention() {...@@ -148,6 +149,8 @@ function ProjectProjectionRetention() {
148export function AppSidebarLayout({ children }: { children: ReactNode }) {149export function AppSidebarLayout({ children }: { children: ReactNode }) {
149 const navigate = useNavigate();150 const navigate = useNavigate();
150 const legacySidebarEnabled = useLegacySidebarEnabled();151 const legacySidebarEnabled = useLegacySidebarEnabled();
152 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
153 usePanelAnimationSettings();
151 // Settings routes show the settings nav in place of whichever thread154 // Settings routes show the settings nav in place of whichever thread
152 // sidebar is active.155 // sidebar is active.
153 const pathname = useLocation({ select: (location) => location.pathname });156 const pathname = useLocation({ select: (location) => location.pathname });
...@@ -175,6 +178,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -175,6 +178,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
175 });178 });
176 const sidebarProviderStyle = {179 const sidebarProviderStyle = {
177 "--sidebar-width": `${sidebarWidth}px`,180 "--sidebar-width": `${sidebarWidth}px`,
181 "--panel-animation-duration": `${panelAnimationDurationMs}ms`,
178 ...(isMacosDesktop && !isWindowFullscreen182 ...(isMacosDesktop && !isWindowFullscreen
179 ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }183 ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
180 : {}),184 : {}),
...@@ -218,7 +222,12 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -218,7 +222,12 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
218 }, [navigate, pathname]);222 }, [navigate, pathname]);
219223
220 return (224 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 >
222 <ProjectProjectionRetention />231 <ProjectProjectionRetention />
223 <Sidebar232 <Sidebar
224 side="left"233 side="left"
apps/web/src/components/ChatView.tsx+147-79
...@@ -217,6 +217,7 @@ import {...@@ -217,6 +217,7 @@ import {
217 useEnvironmentSettings,217 useEnvironmentSettings,
218} from "../hooks/useSettings";218} from "../hooks/useSettings";
219import { useNowMinute } from "../hooks/useNowMinute";219import { useNowMinute } from "../hooks/useNowMinute";
220import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
220import { useNewThreadHandler } from "../hooks/useHandleNewThread";221import { useNewThreadHandler } from "../hooks/useHandleNewThread";
221import { useThreadActions } from "../hooks/useThreadActions";222import { useThreadActions } from "../hooks/useThreadActions";
222import { resolveAppModelSelectionForInstance } from "../modelSelection";223import { resolveAppModelSelectionForInstance } from "../modelSelection";
...@@ -741,7 +742,7 @@ function serverTerminalIdsStrictSubsetOfClient(...@@ -741,7 +742,7 @@ function serverTerminalIdsStrictSubsetOfClient(
741interface PersistentThreadTerminalDrawerProps {742interface PersistentThreadTerminalDrawerProps {
742 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };743 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
743 threadId: ThreadId;744 threadId: ThreadId;
744 visible: boolean;745 active: boolean;
745 launchContext: PersistentTerminalLaunchContext | null;746 launchContext: PersistentTerminalLaunchContext | null;
746 focusRequestId: number;747 focusRequestId: number;
747 splitShortcutLabel: string | undefined;748 splitShortcutLabel: string | undefined;
...@@ -755,7 +756,7 @@ interface PersistentThreadTerminalDrawerProps {...@@ -755,7 +756,7 @@ interface PersistentThreadTerminalDrawerProps {
755const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({756const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({
756 threadRef,757 threadRef,
757 threadId,758 threadId,
758 visible,759 active,
759 launchContext,760 launchContext,
760 focusRequestId,761 focusRequestId,
761 splitShortcutLabel,762 splitShortcutLabel,
...@@ -779,6 +780,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -779,6 +780,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
779 const terminalUiState = useTerminalUiStateStore((state) =>780 const terminalUiState = useTerminalUiStateStore((state) =>
780 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),781 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),
781 );782 );
783 const visible = active && terminalUiState.terminalOpen;
782 const knownTerminalSessions = useKnownTerminalSessions({784 const knownTerminalSessions = useKnownTerminalSessions({
783 environmentId: threadRef.environmentId,785 environmentId: threadRef.environmentId,
784 threadId,786 threadId,
...@@ -1063,41 +1065,51 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1063,41 +1065,51 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1063 [onAddTerminalContext, visible],1065 [onAddTerminalContext, visible],
1064 );1066 );
10651067
1066 if (!project || !terminalUiState.terminalOpen || !cwd) {1068 if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) {
1067 return null;1069 return null;
1068 }1070 }
10691071
1070 return (1072 return (
1071 <div className={visible ? undefined : "hidden"}>1073 <div
1072 <ThreadTerminalDrawer1074 className={cn(
1073 threadRef={threadRef}1075 "grid shrink-0 overflow-clip",
1074 threadId={threadId}1076 active ? (visible ? "grid-rows-[1fr]" : "grid-rows-[0fr]") : "hidden",
1075 cwd={cwd}1077 active &&
1076 worktreePath={effectiveWorktreePath}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",
1077 runtimeEnv={runtimeEnv}1079 active && visible && "[[data-panel-animations=true]_&]:starting:grid-rows-[0fr]!",
1078 visible={visible}1080 )}
1079 height={terminalUiState.terminalHeight}1081 >
1080 // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.1082 <div className="min-h-0 overflow-clip">
1081 terminalIds={terminalUiState.terminalIds}1083 <ThreadTerminalDrawer
1082 activeTerminalId={terminalUiState.activeTerminalId}1084 threadRef={threadRef}
1083 terminalGroups={terminalUiState.terminalGroups}1085 threadId={threadId}
1084 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}1086 cwd={cwd}
1085 focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}1087 worktreePath={effectiveWorktreePath}
1086 onSplitTerminal={splitTerminal}1088 runtimeEnv={runtimeEnv}
1087 onSplitTerminalVertical={splitTerminalVertical}1089 visible={visible}
1088 onNewTerminal={createNewTerminal}1090 height={terminalUiState.terminalHeight}
1089 splitShortcutLabel={visible ? splitShortcutLabel : undefined}1091 // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.
1090 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}1092 terminalIds={terminalUiState.terminalIds}
1091 newShortcutLabel={visible ? newShortcutLabel : undefined}1093 activeTerminalId={terminalUiState.activeTerminalId}
1092 closeShortcutLabel={visible ? closeShortcutLabel : undefined}1094 terminalGroups={terminalUiState.terminalGroups}
1093 keybindings={keybindings}1095 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
1094 onActiveTerminalChange={activateTerminal}1096 focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
1095 onCloseTerminal={closeTerminal}1097 onSplitTerminal={splitTerminal}
1096 onHeightChange={setTerminalHeight}1098 onSplitTerminalVertical={splitTerminalVertical}
1097 onAddTerminalContext={handleAddTerminalContext}1099 onNewTerminal={createNewTerminal}
1098 terminalLabelsById={terminalLabelsById}1100 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
1099 terminalLaunchLocationsById={terminalLaunchLocationsById}1101 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
1100 />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>
1101 </div>1113 </div>
1102 );1114 );
1103});1115});
...@@ -1791,8 +1803,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1791,8 +1803,6 @@ function ChatViewContent(props: ChatViewProps) {
1791 );1803 );
1792 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });1804 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
1793 const sidebarPrRefreshKeyRef = useRef<string | null>(null);1805 const sidebarPrRefreshKeyRef = useRef<string | null>(null);
1794 const activeFileSurface =
1795 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
1796 const activePreviewState = useThreadPreviewState(activeThreadRef);1806 const activePreviewState = useThreadPreviewState(activeThreadRef);
1797 const activePreviewServerEpoch = activePreviewState.serverEpoch;1807 const activePreviewServerEpoch = activePreviewState.serverEpoch;
1798 const resolvePreviewRuntimeTabId = useMemo(1808 const resolvePreviewRuntimeTabId = useMemo(
...@@ -1820,6 +1830,34 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1820,6 +1830,34 @@ function ChatViewContent(props: ChatViewProps) {
1820 );1830 );
1821 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();1831 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
1822 const rightPanelOpen = rightPanelState.isOpen;1832 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 ?? [];
1823 const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet;1861 const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet;
1824 const rightPanelMaximized =1862 const rightPanelMaximized =
1825 canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey;1863 canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey;
...@@ -1882,7 +1920,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1882,7 +1920,7 @@ function ChatViewContent(props: ChatViewProps) {
1882 currentThreadIds,1920 currentThreadIds,
1883 openThreadIds: existingOpenTerminalThreadKeys,1921 openThreadIds: existingOpenTerminalThreadKeys,
1884 activeThreadId: activeThreadKey,1922 activeThreadId: activeThreadKey,
1885 activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen),1923 activeThreadTerminalOpen: activeTerminalDrawerPresence.present,
1886 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,1924 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
1887 });1925 });
1888 return currentThreadIds.length === nextThreadIds.length &&1926 return currentThreadIds.length === nextThreadIds.length &&
...@@ -1890,7 +1928,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1890,7 +1928,7 @@ function ChatViewContent(props: ChatViewProps) {
1890 ? currentThreadIds1928 ? currentThreadIds
1891 : nextThreadIds;1929 : nextThreadIds;
1892 });1930 });
1893 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);1931 }, [activeTerminalDrawerPresence.present, activeThreadKey, existingOpenTerminalThreadKeys]);
1894 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);1932 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
1895 const activeProjectRef = useMemo(1933 const activeProjectRef = useMemo(
1896 () =>1934 () =>
...@@ -7095,29 +7133,39 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7095,29 +7133,39 @@ function ChatViewContent(props: ChatViewProps) {
7095 const panelLayoutControls = (7133 const panelLayoutControls = (
7096 <div7134 <div
7097 className={cn(7135 className={cn(
7098 // One inset in both states: the controls move between containers when7136 // Keep one viewport anchor inside the header's no-drag region. The
7099 // the right panel opens, and a different right offset made them jump7137 // header can shrink behind the right panel without moving the controls.
7100 // sideways on every toggle.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]",
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]",
7102 )}7139 )}
7103 data-workspace-titlebar-controls7140 data-workspace-titlebar-controls
7104 >7141 >
7105 {rightPanelOpen && !shouldUseRightPanelSheet ? (7142 {!shouldUseRightPanelSheet ? (
7106 <RightPanelMaximizeControl7143 <span
7107 maximized={rightPanelMaximized}7144 aria-hidden={!rightPanelOpen}
7108 onToggle={toggleRightPanelMaximized}7145 className={cn(
7109 />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>
7110 ) : null}7158 ) : null}
7111 {panelToggleControls}7159 <div className="pointer-events-auto flex h-full items-center">{panelToggleControls}</div>
7112 </div>7160 </div>
7113 );7161 );
7114 const rightPanelContent = activeThreadRef ? (7162 const rightPanelContent = activeThreadRef ? (
7115 activeRightPanelSurface?.kind === "preview" ? (7163 renderedRightPanelSurface?.kind === "preview" ? (
7116 <Suspense fallback={null}>7164 <Suspense fallback={null}>
7117 <PreviewPanel7165 <PreviewPanel
7118 mode="embedded"7166 mode="embedded"
7119 threadRef={activeThreadRef}7167 threadRef={activeThreadRef}
7120 tabId={activeRightPanelSurface.resourceId}7168 tabId={renderedRightPanelSurface.resourceId}
7121 configuredUrls={configuredPreviewUrls}7169 configuredUrls={configuredPreviewUrls}
7122 visible7170 visible
7123 onSendAnnotation={(annotation, image) => {7171 onSendAnnotation={(annotation, image) => {
...@@ -7125,10 +7173,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7125,10 +7173,10 @@ function ChatViewContent(props: ChatViewProps) {
7125 }}7173 }}
7126 />7174 />
7127 </Suspense>7175 </Suspense>
7128 ) : activeRightPanelSurface?.kind === "terminal" ? (7176 ) : renderedRightPanelSurface?.kind === "terminal" ? (
7129 <PersistentThreadTerminalPanel7177 <PersistentThreadTerminalPanel
7130 threadRef={activeThreadRef}7178 threadRef={activeThreadRef}
7131 surface={activeRightPanelSurface}7179 surface={renderedRightPanelSurface}
7132 launchContext={activeTerminalLaunchContext ?? null}7180 launchContext={activeTerminalLaunchContext ?? null}
7133 focusRequestId={terminalFocusRequestId}7181 focusRequestId={terminalFocusRequestId}
7134 keybindings={keybindings}7182 keybindings={keybindings}
...@@ -7143,7 +7191,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7143,7 +7191,7 @@ function ChatViewContent(props: ChatViewProps) {
7143 newShortcutLabel={newTerminalShortcutLabel ?? undefined}7191 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
7144 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}7192 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
7145 />7193 />
7146 ) : activeRightPanelSurface?.kind === "diff" ? (7194 ) : renderedRightPanelSurface?.kind === "diff" ? (
7147 <Suspense fallback={null}>7195 <Suspense fallback={null}>
7148 <DiffPanel7196 <DiffPanel
7149 key={`${activeThreadKey}:${diffPanelGitStatusResolutionKey}`}7197 key={`${activeThreadKey}:${diffPanelGitStatusResolutionKey}`}
...@@ -7153,26 +7201,26 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7153,26 +7201,26 @@ function ChatViewContent(props: ChatViewProps) {
7153 workspaceMutationId={workspaceMutationId}7201 workspaceMutationId={workspaceMutationId}
7154 />7202 />
7155 </Suspense>7203 </Suspense>
7156 ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (7204 ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
7157 <PullRequestDetailGhost />7205 <PullRequestDetailGhost />
7158 ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? (7206 ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? (
7159 <PullRequestsUnavailableState7207 <PullRequestsUnavailableState
7160 title="Pull requests unavailable"7208 title="Pull requests unavailable"
7161 error="Update this environment's T3 Code server to browse pull requests."7209 error="Update this environment's T3 Code server to browse pull requests."
7162 />7210 />
7163 ) : activeRightPanelSurface?.kind === "pull-request" ? (7211 ) : renderedRightPanelSurface?.kind === "pull-request" ? (
7164 // No onClose: the surface tab's own X owns closing here, and a second X in the header7212 // No onClose: the surface tab's own X owns closing here, and a second X in the header
7165 // would be the same action twice. The thread context also drops the checkout button, so it7213 // would be the same action twice. The thread context also drops the checkout button, so it
7166 // is only right for the thread's own pull request, whose branch is already under the7214 // is only right for the thread's own pull request, whose branch is already under the
7167 // reader's feet. A link the agent wrote can open any other one here, and that one has to be7215 // reader's feet. A link the agent wrote can open any other one here, and that one has to be
7168 // checkable out like it is anywhere else.7216 // checkable out like it is anywhere else.
7169 <PullRequestDetailPanel7217 <PullRequestDetailPanel
7170 key={`${activeRightPanelSurface.repository}#${activeRightPanelSurface.number}`}7218 key={`${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
7171 environmentId={activeThread.environmentId}7219 environmentId={activeThread.environmentId}
7172 reference={{7220 reference={{
7173 projectId: activeRightPanelSurface.projectId as ProjectId,7221 projectId: renderedRightPanelSurface.projectId as ProjectId,
7174 repository: activeRightPanelSurface.repository,7222 repository: renderedRightPanelSurface.repository,
7175 number: activeRightPanelSurface.number,7223 number: renderedRightPanelSurface.number,
7176 }}7224 }}
7177 context={7225 context={
7178 isThreadOwnPullRequest(7226 isThreadOwnPullRequest(
...@@ -7182,9 +7230,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7182,9 +7230,9 @@ function ChatViewContent(props: ChatViewProps) {
7182 number: activeThreadPr?.number ?? null,7230 number: activeThreadPr?.number ?? null,
7183 },7231 },
7184 {7232 {
7185 projectId: activeRightPanelSurface.projectId,7233 projectId: renderedRightPanelSurface.projectId,
7186 repository: activeRightPanelSurface.repository,7234 repository: renderedRightPanelSurface.repository,
7187 number: activeRightPanelSurface.number,7235 number: renderedRightPanelSurface.number,
7188 },7236 },
7189 )7237 )
7190 ? "thread"7238 ? "thread"
...@@ -7193,13 +7241,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7193,13 +7241,14 @@ function ChatViewContent(props: ChatViewProps) {
7193 composerDraftTarget={composerDraftTarget}7241 composerDraftTarget={composerDraftTarget}
7194 onStateChange={handlePullRequestTabStatusChange}7242 onStateChange={handlePullRequestTabStatusChange}
7195 />7243 />
7196 ) : activeRightPanelSurface?.kind === "agents" ? (7244 ) : renderedRightPanelSurface?.kind === "agents" ? (
7197 <AgentsPanel7245 <AgentsPanel
7198 model={agentPanelModel}7246 model={agentPanelModel}
7199 environmentId={activeThreadRef?.environmentId ?? null}7247 environmentId={activeThreadRef?.environmentId ?? null}
7200 threadId={activeThreadRef?.threadId ?? null}7248 threadId={activeThreadRef?.threadId ?? null}
7201 />7249 />
7202 ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") &&7250 ) : (renderedRightPanelSurface?.kind === "files" ||
7251 renderedRightPanelSurface?.kind === "file") &&
7203 activeProject &&7252 activeProject &&
7204 activeWorkspaceRoot ? (7253 activeWorkspaceRoot ? (
7205 <Suspense fallback={null}>7254 <Suspense fallback={null}>
...@@ -7213,14 +7262,25 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7213,14 +7262,25 @@ function ChatViewContent(props: ChatViewProps) {
7213 keybindings={keybindings}7262 keybindings={keybindings}
7214 availableEditors={availableEditors}7263 availableEditors={availableEditors}
7215 relativePath={7264 relativePath={
7216 activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null7265 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
7217 }7278 }
7218 revealLine={activeFileSurface?.revealLine ?? null}
7219 revealRequestId={activeFileSurface?.revealRequestId ?? 0}
7220 onOpenFile={openFileSurface}7279 onOpenFile={openFileSurface}
7221 onPendingChange={handleFilePendingChange}7280 onPendingChange={handleFilePendingChange}
7222 selectedFilePending={7281 selectedFilePending={
7223 activeFileSurface !== null && pendingFileSurfaceIds.has(activeFileSurface.id)7282 renderedRightPanelSurface.kind === "file" &&
7283 pendingFileSurfaceIds.has(renderedRightPanelSurface.id)
7224 }7284 }
7225 workspaceMutationId={workspaceMutationId}7285 workspaceMutationId={workspaceMutationId}
7226 />7286 />
...@@ -7235,7 +7295,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7235,7 +7295,6 @@ function ChatViewContent(props: ChatViewProps) {
72357295
7236 return (7296 return (
7237 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">7297 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
7238 {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
7239 <div7298 <div
7240 className={cn(7299 className={cn(
7241 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",7300 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",
...@@ -7250,7 +7309,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7250,7 +7309,7 @@ function ChatViewContent(props: ChatViewProps) {
7250 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}7309 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}
7251 className="relative bg-background"7310 className="relative bg-background"
7252 >7311 >
7253 {!rightPanelOpen ? panelLayoutControls : null}7312 {!shouldUseRightPanelSheet || !rightPanelControlsInPanel ? panelLayoutControls : null}
7254 <ChatHeader7313 <ChatHeader
7255 {...(!supportsPullRequests || activeProjectRepository === null7314 {...(!supportsPullRequests || activeProjectRepository === null
7256 ? {}7315 ? {}
...@@ -7639,7 +7698,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7639,7 +7698,7 @@ function ChatViewContent(props: ChatViewProps) {
7639 key={mountedThreadKey}7698 key={mountedThreadKey}
7640 threadRef={mountedThreadRef}7699 threadRef={mountedThreadRef}
7641 threadId={mountedThreadRef.threadId}7700 threadId={mountedThreadRef.threadId}
7642 visible={mountedThreadKey === activeThreadKey && terminalUiState.terminalOpen}7701 active={mountedThreadKey === activeThreadKey}
7643 launchContext={7702 launchContext={
7644 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null7703 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
7645 }7704 }
...@@ -7654,12 +7713,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7654,12 +7713,13 @@ function ChatViewContent(props: ChatViewProps) {
7654 ))}7713 ))}
7655 </div>7714 </div>
76567715
7657 {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (7716 {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? (
7658 <RightPanelTabs7717 <RightPanelTabs
7659 mode="inline"7718 mode="inline"
7719 open={rightPanelOpen}
7660 maximized={rightPanelMaximized}7720 maximized={rightPanelMaximized}
7661 surfaces={rightPanelState.surfaces}7721 surfaces={renderedRightPanelSurfaces}
7662 activeSurfaceId={activeRightPanelSurface?.id ?? null}7722 activeSurfaceId={renderedRightPanelSurface?.id ?? null}
7663 pendingSurfaceIds={pendingFileSurfaceIds}7723 pendingSurfaceIds={pendingFileSurfaceIds}
7664 previewSessions={activePreviewState.sessions}7724 previewSessions={activePreviewState.sessions}
7665 desktopByTabId={activePreviewState.desktopByTabId}7725 desktopByTabId={activePreviewState.desktopByTabId}
...@@ -7690,17 +7750,25 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7690,17 +7750,25 @@ function ChatViewContent(props: ChatViewProps) {
7690 {rightPanelContent}7750 {rightPanelContent}
7691 </RightPanelTabs>7751 </RightPanelTabs>
7692 ) : null}7752 ) : null}
7693 {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (7753 {rightPanelPresent && shouldUseRightPanelSheet && activeThreadRef ? (
7694 <RightPanelSheet open onClose={closePreviewPanel}>7754 <RightPanelSheet
7755 animationDurationMs={panelAnimationsActive ? panelAnimationDurationMs : 0}
7756 open={rightPanelOpen}
7757 onClose={closePreviewPanel}
7758 >
7695 <RightPanelTabs7759 <RightPanelTabs
7696 mode="sheet"7760 mode="sheet"
7697 // Same effective inset as the closed-state titlebar controls7761 // Same effective inset as the closed-state titlebar controls
7698 // (pr-3 in the tab bar plus this pixel equals the absolute7762 // (pr-3 in the tab bar plus this pixel equals the absolute
7699 // right inset plus mr-px), so the cluster does not creep when7763 // right inset plus mr-px), so the cluster does not creep when
7700 // the sheet opens.7764 // the sheet opens.
7701 layoutControls={<div className="mr-px flex items-center">{panelToggleControls}</div>}7765 layoutControls={
7702 surfaces={rightPanelState.surfaces}7766 rightPanelOpen ? (
7703 activeSurfaceId={activeRightPanelSurface?.id ?? null}7767 <div className="mr-px flex items-center">{panelToggleControls}</div>
7768 ) : null
7769 }
7770 surfaces={renderedRightPanelSurfaces}
7771 activeSurfaceId={renderedRightPanelSurface?.id ?? null}
7704 pendingSurfaceIds={pendingFileSurfaceIds}7772 pendingSurfaceIds={pendingFileSurfaceIds}
7705 previewSessions={activePreviewState.sessions}7773 previewSessions={activePreviewState.sessions}
7706 desktopByTabId={activePreviewState.desktopByTabId}7774 desktopByTabId={activePreviewState.desktopByTabId}
apps/web/src/components/RightPanelSheet.tsx+2
...@@ -4,6 +4,7 @@ import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout";...@@ -4,6 +4,7 @@ import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout";
4import { Sheet, SheetPopup } from "./ui/sheet";4import { Sheet, SheetPopup } from "./ui/sheet";
55
6export function RightPanelSheet(props: {6export function RightPanelSheet(props: {
7 animationDurationMs: number;
7 children: ReactNode;8 children: ReactNode;
8 open: boolean;9 open: boolean;
9 onClose: () => void;10 onClose: () => void;
...@@ -18,6 +19,7 @@ export function RightPanelSheet(props: {...@@ -18,6 +19,7 @@ export function RightPanelSheet(props: {
18 }}19 }}
19 >20 >
20 <SheetPopup21 <SheetPopup
22 transitionDurationMs={props.animationDurationMs}
21 side="right"23 side="right"
22 showCloseButton={false}24 showCloseButton={false}
23 keepMounted25 keepMounted
apps/web/src/components/RightPanelTabs.tsx+2
...@@ -55,6 +55,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon";...@@ -55,6 +55,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon";
55interface RightPanelTabsProps {55interface RightPanelTabsProps {
56 mode: PreviewPanelMode;56 mode: PreviewPanelMode;
57 maximized?: boolean;57 maximized?: boolean;
58 open?: boolean;
58 /** Forwarded to PreviewPanelShell so this surface persists its own width. */59 /** Forwarded to PreviewPanelShell so this surface persists its own width. */
59 widthStorageKey?: string;60 widthStorageKey?: string;
60 /** Forwarded to PreviewPanelShell as the initial width before a user resize. */61 /** Forwarded to PreviewPanelShell as the initial width before a user resize. */
...@@ -798,6 +799,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -798,6 +799,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
798 <PreviewPanelShell799 <PreviewPanelShell
799 mode={props.mode}800 mode={props.mode}
800 {...(props.maximized !== undefined ? { maximized: props.maximized } : {})}801 {...(props.maximized !== undefined ? { maximized: props.maximized } : {})}
802 {...(props.open !== undefined ? { open: props.open } : {})}
801 {...(props.widthStorageKey !== undefined ? { widthStorageKey: props.widthStorageKey } : {})}803 {...(props.widthStorageKey !== undefined ? { widthStorageKey: props.widthStorageKey } : {})}
802 {...(props.defaultWidth !== undefined ? { defaultWidth: props.defaultWidth } : {})}804 {...(props.defaultWidth !== undefined ? { defaultWidth: props.defaultWidth } : {})}
803 >805 >
apps/web/src/components/WorkspacePageHeader.tsx+1-1
...@@ -16,7 +16,7 @@ export function WorkspacePageHeader({...@@ -16,7 +16,7 @@ export function WorkspacePageHeader({
16 return (16 return (
17 <header17 <header
18 className={cn(18 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)]",
20 electron && "drag-region",20 electron && "drag-region",
21 reserveNativeControls && "wco:pr-[var(--workspace-native-controls-inset)]",21 reserveNativeControls && "wco:pr-[var(--workspace-native-controls-inset)]",
22 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,22 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
apps/web/src/components/chat/ChatComposer.tsx+36-2
...@@ -135,9 +135,12 @@ import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts...@@ -135,9 +135,12 @@ import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts
135import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";135import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";
136import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards";136import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards";
137import {137import {
138 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
139 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
138 shouldUseCompactComposerPrimaryActions,140 shouldUseCompactComposerPrimaryActions,
139 shouldUseCompactComposerFooter,141 shouldUseCompactComposerFooter,
140} from "../composerFooterLayout";142} from "../composerFooterLayout";
143import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
141import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";144import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
142import { ProviderModelPicker } from "./ProviderModelPicker";145import { ProviderModelPicker } from "./ProviderModelPicker";
143import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";146import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
...@@ -1283,6 +1286,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1283,6 +1286,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1283 active: false,1286 active: false,
1284 });1287 });
1285 const isMobileViewport = useMediaQuery("max-sm");1288 const isMobileViewport = useMediaQuery("max-sm");
1289 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
1290 usePanelAnimationSettings();
1286 const isComposerCollapsedMobile =1291 const isComposerCollapsedMobile =
1287 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;1292 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;
12881293
...@@ -1292,6 +1297,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1292,6 +1297,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1292 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);1297 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
1293 const attachmentInputRef = useRef<HTMLInputElement>(null);1298 const attachmentInputRef = useRef<HTMLInputElement>(null);
1294 const composerFormRef = useRef<HTMLFormElement>(null);1299 const composerFormRef = useRef<HTMLFormElement>(null);
1300 const composerFooterControlsRef = useRef<HTMLDivElement>(null);
1295 const composerSurfaceRef = useRef<HTMLDivElement>(null);1301 const composerSurfaceRef = useRef<HTMLDivElement>(null);
1296 const providerInputRejectedRef = useRef(false);1302 const providerInputRejectedRef = useRef(false);
1297 const composerSelectLockRef = useRef(false);1303 const composerSelectLockRef = useRef(false);
...@@ -1829,6 +1835,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1829,6 +1835,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1829 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);1835 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
1830 setIsComposerFooterCompact(initialCompactness.footerCompact);1836 setIsComposerFooterCompact(initialCompactness.footerCompact);
1831 if (typeof ResizeObserver === "undefined") return;1837 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
1833 const observer = new ResizeObserver(() => {1854 const observer = new ResizeObserver(() => {
1834 const nextCompactness = measureFooterCompactness();1855 const nextCompactness = measureFooterCompactness();
...@@ -1845,8 +1866,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1845,8 +1866,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1845 observer.observe(composerForm);1866 observer.observe(composerForm);
1846 return () => {1867 return () => {
1847 observer.disconnect();1868 observer.disconnect();
1869 stopFooterControlsFade?.();
1848 };1870 };
1849 }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]);1871 }, [
1872 activeThreadId,
1873 composerFooterActionLayoutKey,
1874 composerFooterHasWideActions,
1875 isComposerApprovalState,
1876 isComposerCollapsedMobile,
1877 panelAnimationDurationMs,
1878 panelAnimationsActive,
1879 ]);
18501880
1851 // ------------------------------------------------------------------1881 // ------------------------------------------------------------------
1852 // Image persist effect1882 // Image persist effect
...@@ -4223,7 +4253,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4223,7 +4253,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4223 showMobilePendingAnswerActions && "hidden sm:flex",4253 showMobilePendingAnswerActions && "hidden sm:flex",
4224 )}4254 )}
4225 >4255 >
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 >
4227 {noProviderAvailable ? (4261 {noProviderAvailable ? (
4228 <Button4262 <Button
4229 type="button"4263 type="button"
apps/web/src/components/chat/ChatHeader.tsx+20
...@@ -37,6 +37,7 @@ import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";...@@ -37,6 +37,7 @@ import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
37import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";37import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
38import { threadEnvironment } from "../../state/threads";38import { threadEnvironment } from "../../state/threads";
39import { useAtomCommand } from "../../state/use-atom-command";39import { useAtomCommand } from "../../state/use-atom-command";
40import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
40import { ProjectFavicon } from "../ProjectFavicon";41import { ProjectFavicon } from "../ProjectFavicon";
41import {42import {
42 WorkspaceBreadcrumb,43 WorkspaceBreadcrumb,
...@@ -94,6 +95,8 @@ export function resolveRenameCommit(input: {...@@ -94,6 +95,8 @@ export function resolveRenameCommit(input: {
94// events (the second click dismisses it and dblclick still fires), so it95// events (the second click dismisses it and dblclick still fires), so it
95// opens immediately.96// opens immediately.
96const TITLE_MENU_OPEN_DELAY_MS = 500;97const 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
98export function shouldShowOpenInPicker(input: {101export function shouldShowOpenInPicker(input: {
99 readonly activeProjectName: string | undefined;102 readonly activeProjectName: string | undefined;
...@@ -137,6 +140,21 @@ export const ChatHeader = memo(function ChatHeader({...@@ -137,6 +140,21 @@ export const ChatHeader = memo(function ChatHeader({
137 onUpdateProjectScript,140 onUpdateProjectScript,
138 onDeleteProjectScript,141 onDeleteProjectScript,
139}: ChatHeaderProps) {142}: 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]);
140 const primaryEnvironmentId = usePrimaryEnvironmentId();158 const primaryEnvironmentId = usePrimaryEnvironmentId();
141 const fileScripts = useT3ProjectFileScripts(159 const fileScripts = useT3ProjectFileScripts(
142 activeThreadEnvironmentId,160 activeThreadEnvironmentId,
...@@ -372,10 +390,12 @@ export const ChatHeader = memo(function ChatHeader({...@@ -372,10 +390,12 @@ export const ChatHeader = memo(function ChatHeader({
372 </WorkspaceBreadcrumbItem>390 </WorkspaceBreadcrumbItem>
373 </WorkspaceBreadcrumb>391 </WorkspaceBreadcrumb>
374 <div392 <div
393 ref={headerActionsRef}
375 data-chat-header-actions394 data-chat-header-actions
376 className={cn(395 className={cn(
377 "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",396 "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",
378 rightPanelOpen ? "pr-0" : "pr-16",397 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",
379 )}399 )}
380 >400 >
381 {activeProjectScripts && (401 {activeProjectScripts && (
apps/web/src/components/preview/PreviewPanelShell.tsx+41-4
...@@ -52,6 +52,7 @@ export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?:...@@ -52,6 +52,7 @@ export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?:
52export function PreviewPanelShell(props: {52export function PreviewPanelShell(props: {
53 mode: PreviewPanelMode;53 mode: PreviewPanelMode;
54 maximized?: boolean;54 maximized?: boolean;
55 open?: boolean;
55 /**56 /**
56 * Overrides the localStorage key used to persist the panel width. Callers57 * Overrides the localStorage key used to persist the panel width. Callers
57 * embedding this shell for a different surface (e.g. the pull requests58 * embedding this shell for a different surface (e.g. the pull requests
...@@ -65,6 +66,8 @@ export function PreviewPanelShell(props: {...@@ -65,6 +66,8 @@ export function PreviewPanelShell(props: {
65}) {66}) {
66 const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded";67 const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded";
67 const isInline = props.mode === "inline";68 const isInline = props.mode === "inline";
69 const collapsible = isInline && props.open !== undefined;
70 const open = props.open ?? true;
68 const hostRef = useRef<HTMLDivElement | null>(null);71 const hostRef = useRef<HTMLDivElement | null>(null);
69 // Only inline non-maximized mode applies `width`/`maxWidth`; skip the72 // Only inline non-maximized mode applies `width`/`maxWidth`; skip the
70 // container measurement (and its re-renders) everywhere else.73 // container measurement (and its re-renders) everywhere else.
...@@ -76,7 +79,26 @@ export function PreviewPanelShell(props: {...@@ -76,7 +79,26 @@ export function PreviewPanelShell(props: {
76 maxWidth,79 maxWidth,
77 edge: "left",80 edge: "left",
78 });81 });
7982 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]);
80 return (102 return (
81 <div103 <div
82 ref={hostRef}104 ref={hostRef}
...@@ -87,14 +109,29 @@ export function PreviewPanelShell(props: {...@@ -87,14 +109,29 @@ export function PreviewPanelShell(props: {
87 ? "flex-1 border-l border-border"109 ? "flex-1 border-l border-border"
88 : "shrink-0 border-l border-border"110 : "shrink-0 border-l border-border"
89 : "w-full",111 : "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",
90 )}116 )}
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 }
92 data-preview-panel-mode={props.mode}122 data-preview-panel-mode={props.mode}
93 data-preview-panel-maximized={props.maximized ? "true" : "false"}123 data-preview-panel-maximized={props.maximized ? "true" : "false"}
94 >124 >
95 {isInline && !props.maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}125 {isInline && !props.maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}
96 {useDragRegion ? <div className="electron-drag-region h-0 w-full" aria-hidden /> : null}126 <div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}>
97 {props.children}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>
98 </div>135 </div>
99 );136 );
100}137}
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 {...@@ -24,6 +24,7 @@ import {
24 MAX_CODE_FONT_SIZE,24 MAX_CODE_FONT_SIZE,
25 MAX_GLASS_OPACITY,25 MAX_GLASS_OPACITY,
26 MAX_INTERFACE_FONT_SIZE,26 MAX_INTERFACE_FONT_SIZE,
27 MAX_PANEL_ANIMATION_DURATION_MS,
27 MAX_PROMPT_FONT_SIZE,28 MAX_PROMPT_FONT_SIZE,
28 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,29 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
29 MAX_TERMINAL_FONT_SIZE,30 MAX_TERMINAL_FONT_SIZE,
...@@ -31,6 +32,7 @@ import {...@@ -31,6 +32,7 @@ import {
31 MIN_APPEARANCE_CONTRAST,32 MIN_APPEARANCE_CONTRAST,
32 MIN_GLASS_OPACITY,33 MIN_GLASS_OPACITY,
33 MIN_INTERFACE_FONT_SIZE,34 MIN_INTERFACE_FONT_SIZE,
35 MIN_PANEL_ANIMATION_DURATION_MS,
34 MIN_PROMPT_FONT_SIZE,36 MIN_PROMPT_FONT_SIZE,
35 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,37 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
36 MIN_TERMINAL_FONT_SIZE,38 MIN_TERMINAL_FONT_SIZE,
...@@ -152,6 +154,7 @@ import {...@@ -152,6 +154,7 @@ import {
152} from "./settingsLayout";154} from "./settingsLayout";
153import { searchableSetting } from "./settingsSearch";155import { searchableSetting } from "./settingsSearch";
154import { ProjectFavicon } from "../ProjectFavicon";156import { ProjectFavicon } from "../ProjectFavicon";
157import { PanelAnimationsPreview } from "./PanelAnimationsPreview";
155158
156const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {159const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
157 artwork: "Artwork",160 artwork: "Artwork",
...@@ -493,6 +496,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -493,6 +496,9 @@ export function useSettingsRestore(onRestored?: () => void) {
493 ? ["Contrast"]496 ? ["Contrast"]
494 : []),497 : []),
495 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),498 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
499 ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs
500 ? ["Panel animations"]
501 : []),
496 ...(settings.environmentIdentificationMode !==502 ...(settings.environmentIdentificationMode !==
497 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode503 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
498 ? ["Environment identification"]504 ? ["Environment identification"]
...@@ -593,6 +599,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -593,6 +599,7 @@ export function useSettingsRestore(onRestored?: () => void) {
593 settings.fontSizePrompt,599 settings.fontSizePrompt,
594 settings.fontSizeTerminal,600 settings.fontSizeTerminal,
595 settings.glassOpacity,601 settings.glassOpacity,
602 settings.panelAnimationDurationMs,
596 settings.enableLegacyTokenStreaming,603 settings.enableLegacyTokenStreaming,
597 settings.enableProviderUpdateChecks,604 settings.enableProviderUpdateChecks,
598 settings.continueThreadsAfterServerUpdate,605 settings.continueThreadsAfterServerUpdate,
...@@ -680,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -680,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) {
680 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,687 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
681 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,688 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
682 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,689 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
690 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
683 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,691 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
684 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,692 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
685 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,693 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
...@@ -1035,6 +1043,13 @@ export function AppearanceSettingsPanel() {...@@ -1035,6 +1043,13 @@ export function AppearanceSettingsPanel() {
1035 "--settings-slider-progress": `${appearanceContrastRatio * 100}%`,1043 "--settings-slider-progress": `${appearanceContrastRatio * 100}%`,
1036 "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`,1044 "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`,
1037 } as CSSProperties;1045 } 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
1039 return (1054 return (
1040 <SettingsPageContainer>1055 <SettingsPageContainer>
...@@ -1192,6 +1207,60 @@ export function AppearanceSettingsPanel() {...@@ -1192,6 +1207,60 @@ export function AppearanceSettingsPanel() {
1192 ) : null}1207 ) : null}
1193 </SettingsSection>1208 </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
1195 <TypographySection />1264 <TypographySection />
1196 </SettingsPageContainer>1265 </SettingsPageContainer>
1197 );1266 );
apps/web/src/components/settings/settingsSearch.test.ts+1
...@@ -51,6 +51,7 @@ describe("searchSettings", () => {...@@ -51,6 +51,7 @@ describe("searchSettings", () => {
51 it("matches normalized title substrings", () => {51 it("matches normalized title substrings", () => {
52 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);52 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
53 expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]);53 expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]);
54 expect(searchSettings("panel animations").map((item) => item.id)).toEqual(["panel-animations"]);
54 expect(searchSettings("thè\u{1ab0}mes")[0]?.id).toBe("theme");55 expect(searchSettings("thè\u{1ab0}mes")[0]?.id).toBe("theme");
55 const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt");56 const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt");
56 try {57 try {
apps/web/src/components/settings/settingsSearch.ts+5
...@@ -94,6 +94,11 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -94,6 +94,11 @@ export const SETTINGS_SEARCH_ITEMS = [
94 to: "/settings/appearance",94 to: "/settings/appearance",
95 searchTerms: ["transparent transparency solid menus dialogs composer"],95 searchTerms: ["transparent transparency solid menus dialogs composer"],
96 },96 },
97 {
98 id: "panel-animations",
99 title: "Panel animations",
100 to: "/settings/appearance",
101 },
97 {102 {
98 id: "environment-identification",103 id: "environment-identification",
99 title: "Environment identification",104 title: "Environment identification",
apps/web/src/components/ui/sheet.tsx+21-1
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
22
3import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";3import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
4import { XIcon } from "lucide-react";4import { XIcon } from "lucide-react";
5import type { CSSProperties } from "react";
5import { cn } from "~/lib/utils";6import { cn } from "~/lib/utils";
6import { Button } from "~/components/ui/button";7import { Button } from "~/components/ui/button";
7import { ScrollArea } from "~/components/ui/scroll-area";8import { ScrollArea } from "~/components/ui/scroll-area";
...@@ -62,18 +63,34 @@ function SheetPopup({...@@ -62,18 +63,34 @@ function SheetPopup({
62 children,63 children,
63 showCloseButton = true,64 showCloseButton = true,
64 keepMounted = false,65 keepMounted = false,
66 transitionDurationMs,
65 side = "right",67 side = "right",
66 variant = "default",68 variant = "default",
69 style,
67 ...props70 ...props
68}: SheetPrimitive.Popup.Props & {71}: SheetPrimitive.Popup.Props & {
69 showCloseButton?: boolean;72 showCloseButton?: boolean;
70 keepMounted?: boolean;73 keepMounted?: boolean;
74 transitionDurationMs?: number;
71 side?: "right" | "left" | "top" | "bottom";75 side?: "right" | "left" | "top" | "bottom";
72 variant?: "default" | "inset";76 variant?: "default" | "inset";
73}) {77}) {
78 const transitionStyle =
79 transitionDurationMs === undefined
80 ? undefined
81 : ({ transitionDuration: `${transitionDurationMs}ms` } satisfies CSSProperties);
82 const instant = transitionDurationMs === 0;
83
74 return (84 return (
75 <SheetPortal keepMounted={keepMounted}>85 <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 />
77 <SheetViewport side={side} variant={variant}>94 <SheetViewport side={side} variant={variant}>
78 <SheetPrimitive.Popup95 <SheetPrimitive.Popup
79 className={cn(96 className={cn(
...@@ -88,9 +105,12 @@ function SheetPopup({...@@ -88,9 +105,12 @@ function SheetPopup({
88 "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",105 "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",
89 variant === "inset" &&106 variant === "inset" &&
90 "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)]",107 "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!",
91 className,110 className,
92 )}111 )}
93 data-slot="sheet-popup"112 data-slot="sheet-popup"
113 style={{ ...transitionStyle, ...style }}
94 {...props}114 {...props}
95 >115 >
96 {children}116 {children}
apps/web/src/components/ui/sidebar.tsx+6-3
...@@ -281,7 +281,8 @@ function Sidebar({...@@ -281,7 +281,8 @@ function Sidebar({
281 {/* This is what handles the sidebar gap on desktop */}281 {/* This is what handles the sidebar gap on desktop */}
282 <div282 <div
283 className={cn(283 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",
285 "group-data-[collapsible=offcanvas]:w-0",286 "group-data-[collapsible=offcanvas]:w-0",
286 "group-data-[side=right]:rotate-180",287 "group-data-[side=right]:rotate-180",
287 variant === "floating" || variant === "inset"288 variant === "floating" || variant === "inset"
...@@ -292,7 +293,8 @@ function Sidebar({...@@ -292,7 +293,8 @@ function Sidebar({
292 />293 />
293 <div294 <div
294 className={cn(295 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",
296 side === "left"298 side === "left"
297 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"299 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
298 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",300 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
...@@ -601,7 +603,8 @@ function SidebarRail({...@@ -601,7 +603,8 @@ function SidebarRail({
601 aria-label={railLabel}603 aria-label={railLabel}
602 className={cn(604 className={cn(
603 /* 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. */605 /* 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",
605 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",608 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
606 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",609 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
607 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",610 "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 "../...@@ -106,6 +106,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../
106import { SidebarInset } from "../components/ui/sidebar";106import { SidebarInset } from "../components/ui/sidebar";
107import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";107import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
108import { useLiveRefresh } from "../hooks/useLiveRefresh";108import { useLiveRefresh } from "../hooks/useLiveRefresh";
109import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
109import { toSortableTimestamp } from "../lib/threadSort";110import { toSortableTimestamp } from "../lib/threadSort";
110import {111import {
111 selectActiveRightPanelSurface,112 selectActiveRightPanelSurface,
...@@ -449,10 +450,29 @@ function PullRequestsRouteView() {...@@ -449,10 +450,29 @@ function PullRequestsRouteView() {
449 const selectedPullRequestSurface =450 const selectedPullRequestSurface =
450 selectedRightPanelSurface?.kind === "pull-request" ? selectedRightPanelSurface : null;451 selectedRightPanelSurface?.kind === "pull-request" ? selectedRightPanelSurface : null;
451 const activePullRequestSurface = rightPanelState.isOpen ? selectedPullRequestSurface : null;452 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 ?? [];
452 // The open tab names its own server; a link that arrived before any tab was opened names it472 // The open tab names its own server; a link that arrived before any tab was opened names it
453 // through the project it selected.473 // through the project it selected.
454 const panelEnvironmentId =474 const panelEnvironmentId =
455 (activePullRequestSurface?.environmentId as EnvironmentId | undefined) ??475 (renderedPullRequestSurface?.environmentId as EnvironmentId | undefined) ??
456 selectedProject?.environmentId ??476 selectedProject?.environmentId ??
457 null;477 null;
458 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<478 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<
...@@ -1783,16 +1803,37 @@ function PullRequestsRouteView() {...@@ -1783,16 +1803,37 @@ function PullRequestsRouteView() {
1783 // mounted at the fixed titlebar inset in both states so it cannot move1803 // mounted at the fixed titlebar inset in both states so it cannot move
1784 // on toggle, and this spacer keeps refresh from sliding underneath it1804 // on toggle, and this spacer keeps refresh from sliding underneath it
1785 // (sized per header padding so refresh ends a normal gap short of it).1805 // (sized per header padding so refresh ends a normal gap short of it).
1786 !pullRequestsSupported || rightPanelState.isOpen ? null : (1806 !pullRequestsSupported ? null : (
1787 <span aria-hidden className="w-7 shrink-0 sm:w-5" />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 />
1788 ),1820 ),
1789 titlebarControls:1821 titlebarControls:
1790 // While the panel is closed the strip lives inside the header: a no-drag1822 // While the panel is closed the strip lives inside the header: a no-drag
1791 // descendant beats the header's desktop drag-region, where a floating1823 // descendant beats the header's desktop drag-region, where a floating
1792 // sibling loses (app-region hit-testing ignores z-index). Open, it moves1824 // sibling loses (app-region hit-testing ignores z-index). While the
1793 // back out to the route container, which spans the panel too, so the1825 // floating strip crosses the header during motion, the narrow extension
1794 // toggle keeps one fixed top-right anchor and never jumps sideways.1826 // keeps that overlap non-draggable without moving the toggle.
1795 pullRequestsSupported && !rightPanelState.isOpen ? openPanelControls : null,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,
1796 rightPanelOpen: rightPanelState.isOpen,1837 rightPanelOpen: rightPanelState.isOpen,
1797 listBody,1838 listBody,
1798 scrollRef,1839 scrollRef,
...@@ -1835,19 +1876,20 @@ function PullRequestsRouteView() {...@@ -1835,19 +1876,20 @@ function PullRequestsRouteView() {
1835 return (1876 return (
1836 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">1877 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
1837 <div className="relative flex min-h-0 flex-1">1878 <div className="relative flex min-h-0 flex-1">
1838 {pullRequestsSupported && rightPanelState.isOpen ? openPanelControls : null}1879 {pullRequestsSupported && rightPanelPresent ? openPanelControls : null}
1839 <PullRequestsColumn {...columnProps} />1880 <PullRequestsColumn {...columnProps} />
18401881
1841 {rightPanelState.isOpen && activePullRequestSurface && panelEnvironmentId !== null ? (1882 {rightPanelPresent && renderedPullRequestSurface && panelEnvironmentId !== null ? (
1842 <RightPanelTabs1883 <RightPanelTabs
1843 mode="inline"1884 mode="inline"
1885 open={rightPanelState.isOpen}
1844 widthStorageKey="t3code:pull-request-panel-width"1886 widthStorageKey="t3code:pull-request-panel-width"
1845 // Default to roughly half the viewport: the PR list needs more1887 // Default to roughly half the viewport: the PR list needs more
1846 // room than a chat, so the 540px chat-preview default squashes1888 // room than a chat, so the 540px chat-preview default squashes
1847 // it. SSR has no window, so fall back to a reasonable width.1889 // it. SSR has no window, so fall back to a reasonable width.
1848 defaultWidth={typeof window === "undefined" ? 640 : Math.floor(window.innerWidth / 2)}1890 defaultWidth={typeof window === "undefined" ? 640 : Math.floor(window.innerWidth / 2)}
1849 surfaces={rightPanelState.surfaces}1891 surfaces={renderedRightPanelSurfaces}
1850 activeSurfaceId={activePullRequestSurface.id}1892 activeSurfaceId={renderedPullRequestSurface.id}
1851 pendingSurfaceIds={EMPTY_PENDING_SURFACES}1893 pendingSurfaceIds={EMPTY_PENDING_SURFACES}
1852 previewSessions={EMPTY_PREVIEW_SESSIONS}1894 previewSessions={EMPTY_PREVIEW_SESSIONS}
1853 desktopByTabId={EMPTY_PREVIEW_DESKTOP_STATE}1895 desktopByTabId={EMPTY_PREVIEW_DESKTOP_STATE}
...@@ -1883,12 +1925,12 @@ function PullRequestsRouteView() {...@@ -1883,12 +1925,12 @@ function PullRequestsRouteView() {
1883 pullRequestStatuses={pullRequestTabStatuses}1925 pullRequestStatuses={pullRequestTabStatuses}
1884 >1926 >
1885 <PullRequestDetailPanel1927 <PullRequestDetailPanel
1886 key={activePullRequestSurface.id}1928 key={renderedPullRequestSurface.id}
1887 environmentId={panelEnvironmentId}1929 environmentId={panelEnvironmentId}
1888 reference={{1930 reference={{
1889 projectId: activePullRequestSurface.projectId as ProjectId,1931 projectId: renderedPullRequestSurface.projectId as ProjectId,
1890 repository: activePullRequestSurface.repository,1932 repository: renderedPullRequestSurface.repository,
1891 number: activePullRequestSurface.number,1933 number: renderedPullRequestSurface.number,
1892 }}1934 }}
1893 refreshToken={detailRefreshToken}1935 refreshToken={detailRefreshToken}
1894 // Merging, closing or reopening changes the row this panel was opened from, so1936 // 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...@@ -42,6 +42,13 @@ If reordering is unavailable for one environment, update the T3 Code server runn
42environment. Older servers can still pin and unpin threads, but do not understand synced ordering;42environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
43their pinned threads keep the default newest-first order below the ones you have arranged.43their 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
45## Environment artwork52## Environment artwork
4653
47Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in54Dev 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", () => {...@@ -144,6 +144,22 @@ describe("ClientSettings appearance contrast", () => {
144 });144 });
145});145});
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
147describe("ClientSettings environment identification", () => {163describe("ClientSettings environment identification", () => {
148 it("defaults to artwork and accepts each presentation mode", () => {164 it("defaults to artwork and accepts each presentation mode", () => {
149 expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");165 expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");
packages/contracts/src/settings.ts+16
...@@ -84,6 +84,16 @@ export const AppearanceContrast = Schema.Int.check(...@@ -84,6 +84,16 @@ export const AppearanceContrast = Schema.Int.check(
84);84);
85export type AppearanceContrast = typeof AppearanceContrast.Type;85export type AppearanceContrast = typeof AppearanceContrast.Type;
86export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100;86export 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;
87/**97/**
88 * Font size preferences, in CSS pixels. The ranges are deliberately narrow:98 * Font size preferences, in CSS pixels. The ranges are deliberately narrow:
89 * the interface size scales every rem-based dimension in the app, so the99 * the interface size scales every rem-based dimension in the app, so the
...@@ -179,6 +189,11 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -179,6 +189,11 @@ export const ClientSettingsSchema = Schema.Struct({
179 appearanceContrast: AppearanceContrast.pipe(189 appearanceContrast: AppearanceContrast.pipe(
180 Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),190 Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),
181 ),191 ),
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 ),
182 browserDefaultViewport: PreviewViewportSetting.pipe(197 browserDefaultViewport: PreviewViewportSetting.pipe(
183 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),198 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),
184 ),199 ),
...@@ -966,6 +981,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;...@@ -966,6 +981,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
966981
967export const ClientSettingsPatch = Schema.Struct({982export const ClientSettingsPatch = Schema.Struct({
968 appearanceContrast: Schema.optionalKey(AppearanceContrast),983 appearanceContrast: Schema.optionalKey(AppearanceContrast),
984 panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs),
969 browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),985 browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),
970 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),986 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
971 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),987 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),