diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SegmentedControlView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SegmentedControlView.swift index 9e60a85b7d2c..d8d5abaa1407 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SegmentedControlView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SegmentedControlView.swift @@ -15,12 +15,15 @@ final class T3SegmentedControlView: ExpoView { } } var selectedIndex = -1 { didSet { updateSelection() } } - var trackColor: UIColor = .secondarySystemBackground { didSet { control.backgroundColor = trackColor } } - var selectedColor: UIColor = .secondarySystemFill { didSet { control.selectedSegmentTintColor = selectedColor } } - var textColor: UIColor = .label { didSet { updateColors() } } - var mutedColor: UIColor = .secondaryLabel { didSet { updateColors() } } + var trackColor: UIColor = .secondarySystemBackground { didSet { if trackColor != oldValue { control.backgroundColor = trackColor } } } + var selectedColor: UIColor = .secondarySystemFill { didSet { if selectedColor != oldValue { control.selectedSegmentTintColor = selectedColor } } } + var textColor: UIColor = .label { didSet { if textColor != oldValue { updateColors() } } } + var mutedColor: UIColor = .secondaryLabel { didSet { if mutedColor != oldValue { updateColors() } } } var appearance = "light" { - didSet { overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light } + didSet { + guard appearance != oldValue else { return } + overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light + } } required init(appContext: AppContext? = nil) { @@ -31,11 +34,20 @@ final class T3SegmentedControlView: ExpoView { override func layoutSubviews() { super.layoutSubviews() - control.frame = bounds + guard control.frame != bounds else { return } + UIView.performWithoutAnimation { + control.frame = bounds + } } private func updateSelection() { - control.selectedSegmentIndex = labels.indices.contains(selectedIndex) ? selectedIndex : UISegmentedControl.noSegment + let index = labels.indices.contains(selectedIndex) ? selectedIndex : UISegmentedControl.noSegment + // React echoes native selections while UIKit is still settling the glass lens. + guard control.selectedSegmentIndex != index else { return } + UIView.performWithoutAnimation { + control.selectedSegmentIndex = index + control.layoutIfNeeded() + } } private func updateColors() { diff --git a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx index d9a751664832..4862e99cdabf 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx @@ -29,14 +29,14 @@ export function SegmentedControl( ) { const { colors, colorScheme } = useControlTheme(); const trackColor = flattenThemeColor( - themeColorWithAlpha(colors["--color-primary"], colorScheme === "dark" ? 0.18 : 0.07), + themeColorWithAlpha(colors["--color-prominent-action"], colorScheme === "dark" ? 0.18 : 0.07), colors["--color-screen"], ); const selectedColor = - colorScheme === "dark" ? colors["--color-primary"] : colors["--color-secondary"]; + colorScheme === "dark" ? colors["--color-prominent-action"] : colors["--color-secondary"]; const selectedTextColor = colorScheme === "dark" - ? colors["--color-primary-foreground"] + ? colors["--color-prominent-action-foreground"] : colors["--color-secondary-foreground"]; return ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index d2ff0e482057..0d37f43d42d9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -685,7 +685,6 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps - Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { - const threadRef = parseScopedThreadKey(threadKey); - return threadRef - ? Object.values(previewState.sessions) - .filter( - (snapshot) => - snapshot.runtime !== "server" || - rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot), - ) - .map((snapshot) => ({ - threadRef, - snapshot, - runtimeTabId: previewRuntimeTabId( - threadRef, - previewState.serverEpoch, - snapshot.tabId, - ), - pictureInPicture: - previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false, - zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, - })) - : []; - }), - [previewByThreadKey, primaryEnvironmentId], - ); + const sessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef + ? Object.values(previewState.sessions) + .filter( + (snapshot) => + snapshot.runtime !== "server" || + rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot), + ) + .map((snapshot) => ({ + threadRef, + snapshot, + runtimeTabId: previewRuntimeTabId(threadRef, previewState.serverEpoch, snapshot.tabId), + pictureInPicture: + previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false, + zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, + })) + : []; + }); useEffect(() => { const preview = window.desktopBridge?.preview; @@ -86,7 +80,7 @@ export function ElectronBrowserHost() { observer.disconnect(); headObserver.disconnect(); }; - }, [resolvedTheme]); + }, []); useEffect(() => { const preview = window.desktopBridge?.preview; @@ -97,27 +91,22 @@ export function ElectronBrowserHost() { }, []); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: true }); - const sessionByRuntimeTabId = useRef(new Map()); - useEffect(() => { - sessionByRuntimeTabId.current = new Map( - sessions.map((session) => [session.runtimeTabId, session]), - ); - }, [sessions]); + const onOpenLink = useEffectEvent(({ tabId, url, background }: DesktopPreviewOpenLinkEvent) => { + const source = sessions.find((session) => session.runtimeTabId === tabId); + if (!source) return; + void openUrlInPreview({ + threadRef: source.threadRef, + url, + openPreview, + profileId: source.snapshot.profileId, + background, + }); + }); useEffect(() => { const preview = window.desktopBridge?.preview; if (!preview) return; - return preview.onOpenLink(({ tabId, url, background }) => { - const source = sessionByRuntimeTabId.current.get(tabId); - if (!source) return; - void openUrlInPreview({ - threadRef: source.threadRef, - url, - openPreview, - profileId: source.snapshot.profileId, - background, - }); - }); - }, [openPreview]); + return preview.onOpenLink(onOpenLink); + }, []); if (!isElectron) return null; return ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 7c1dd9e502ef..bf04efdf4e03 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -24,7 +24,7 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; -import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; +import React, { useEffect, useEffectEvent, memo, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { useShallow } from "zustand/react/shallow"; import { @@ -79,6 +79,7 @@ import { import { isDesktopLocalConnectionTarget, isWslConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; +import { useStableCallback } from "../hooks/useStableCallback"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; @@ -194,7 +195,6 @@ import { isSidebarNestedLinkClick, isTrailingDoubleClick, resolveProjectStatusIndicator, - resolveThreadRowClassName, resolveThreadLastVisitedAt, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -291,6 +291,13 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin } } +// Every project row keys on this map, so it gets its own cache: inline, it +// shared one with the modifier-key state and rebuilt on every key press. +function useThreadJumpLabelMap(input: Parameters[0]) { + "use memo"; + return buildThreadJumpLabelMap(input); +} + function buildThreadJumpLabelMap(input: { keybindings: ResolvedKeybindingsConfig; platform: string; @@ -376,6 +383,13 @@ function checkTaskPermission(environmentId: EnvironmentId): boolean { return false; } +// The row's handlers key on this ref; built inline, the hooks after it kept the +// compiler from caching it, so every render rebuilt all of them. +function useScopedThreadRef(environmentId: EnvironmentId, threadId: ThreadId) { + "use memo"; + return scopeThreadRef(environmentId, threadId); +} + const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, @@ -406,20 +420,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } = props; const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); const canOperatePreview = useEnvironmentScope(thread.environmentId, AuthPreviewOperateScope); - const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadRef = useScopedThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const [isFileDragOver, setIsFileDragOver] = useState(false); - const fileDropHandlers = useMemo( - () => - makeWorkspaceFileDropHandlers({ - setDragActive: setIsFileDragOver, - addFiles: (files) => { - onFileDropThreads(threadRef, files); - }, - addFolders: () => {}, - }), - [onFileDropThreads, threadRef], - ); + const fileDropHandlers = makeWorkspaceFileDropHandlers({ + setDragActive: setIsFileDragOver, + addFiles: (files) => { + onFileDropThreads(threadRef, files); + }, + addFolders: () => {}, + }); useEffect(() => { if (!isFileDragOver) return; const clearFileDrag = () => setIsFileDragOver(false); @@ -453,31 +463,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) : null; const isHighlighted = isActive || isSelected; - const handleOpenDiscoveredPort = useCallback( - (event: React.MouseEvent) => { - const port = discoveredPorts[0]; - if (!port || !canOperatePreview) return; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(threadRef); - void (async () => { - const result = await openDiscoveredPort({ threadRef, port, openPreview }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open preview", - description: - error instanceof Error ? error.message : "The preview could not be opened.", - }), - ); - })(); - }, - [canOperatePreview, discoveredPorts, navigateToThread, openPreview, threadRef], - ); + const handleOpenDiscoveredPort = (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port || !canOperatePreview) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void (async () => { + const result = await openDiscoveredPort({ threadRef, port, openPreview }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open preview", + description: error instanceof Error ? error.message : "The preview could not be opened.", + }), + ); + })(); + }; const isThreadRunning = !threadRuntimeCanArchive(thread.runtime); const threadStatus = resolveThreadStatusPill({ thread: { @@ -505,84 +511,47 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP : canOperateThread && !isThreadRunning ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" : "pointer-events-none"; - const clearConfirmingArchive = useCallback(() => { + const clearConfirmingArchive = () => { setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); - }, [setConfirmingArchiveThreadKey, threadKey]); - const handleMouseLeave = useCallback(() => { + }; + const handleMouseLeave = () => { clearConfirmingArchive(); - }, [clearConfirmingArchive]); - const handleBlurCapture = useCallback( - (event: React.FocusEvent) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - clearConfirmingArchive(); - }); - }, - [clearConfirmingArchive], - ); - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - handleThreadClick(event, threadRef, orderedProjectThreadKeys); - }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], - ); - const handleRowDoubleClick = useCallback( - (event: React.MouseEvent) => { - if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return; - if (renamingThreadKey === threadKey) return; - // On mobile the first tap navigates and closes the sidebar sheet, so the - // inline rename can't be shown. Renaming there stays on the context menu. - if (isMobile) return; - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if ((event.target as HTMLElement).closest("button, a")) return; - event.preventDefault(); - startThreadRename(threadKey, thread.title); - }, - [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.environmentId, thread.title], - ); - const handleRowKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(threadRef); - }, - [navigateToThread, threadRef], - ); - const handleRowContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - const hasSelection = useThreadSelectionStore.getState().hasSelection(); - if (hasSelection && isSelected) { - void (async () => { - const result = await settlePromise(() => - handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread action failed", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); + }; + const handleBlurCapture = (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { return; } - - if (hasSelection) { - clearSelection(); - } + clearConfirmingArchive(); + }); + }; + const handleRowClick = (event: React.MouseEvent) => { + handleThreadClick(event, threadRef, orderedProjectThreadKeys); + }; + const handleRowDoubleClick = (event: React.MouseEvent) => { + if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return; + if (renamingThreadKey === threadKey) return; + // On mobile the first tap navigates and closes the sidebar sheet, so the + // inline rename can't be shown. Renaming there stays on the context menu. + if (isMobile) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if ((event.target as HTMLElement).closest("button, a")) return; + event.preventDefault(); + startThreadRename(threadKey, thread.title); + }; + const handleRowKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }; + const handleRowContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + const hasSelection = useThreadSelectionStore.getState().hasSelection(); + if (hasSelection && isSelected) { void (async () => { const result = await settlePromise(() => - handleThreadContextMenu(threadRef, { + handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }), @@ -598,118 +567,105 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); } })(); - }, - [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], - ); - const handlePrClick = useCallback( - (event: React.MouseEvent) => { - const url = prStatus?.url ?? currentLinkedPr?.url; - if (!url) return; - const openedInRightPanel = openPrLink( - event, - url, - openPullRequestsInRightPanel ? threadRef : undefined, + return; + } + + if (hasSelection) { + clearSelection(); + } + void (async () => { + const result = await settlePromise(() => + handleThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }), ); - if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { - navigateToThread(threadRef); - } - }, - [ - isActive, - navigateToThread, - openPrLink, - openPullRequestsInRightPanel, - prStatus, - currentLinkedPr, - threadRef, - ], - ); - const handleRenameInputRef = useCallback( - (element: HTMLInputElement | null) => { - if (element && renamingInputRef.current !== element) { - renamingInputRef.current = element; - element.focus(); - element.select(); - } - }, - [renamingInputRef], - ); - const handleRenameInputChange = useCallback( - (event: React.ChangeEvent) => { - setRenamingTitle(event.target.value); - }, - [setRenamingTitle], - ); - const handleRenameInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); } - }, - [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], - ); - const handleRenameInputBlur = useCallback(() => { + })(); + }; + const handlePrClick = (event: React.MouseEvent) => { + const url = prStatus?.url ?? currentLinkedPr?.url; + if (!url) return; + const openedInRightPanel = openPrLink( + event, + url, + openPullRequestsInRightPanel ? threadRef : undefined, + ); + if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { + navigateToThread(threadRef); + } + }; + const handleRenameInputRef = (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }; + const handleRenameInputChange = (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }; + const handleRenameInputKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }; + const handleRenameInputBlur = () => { if (!renamingCommittedRef.current) { void commitRename(threadRef, renamingTitle, thread.title); } - }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + }; // Without stopping `dblclick`, double-clicking to select a word would re-fire // the row's rename handler and reset the in-progress edit back to the title. - const handleRenameInputClick = useCallback((event: React.MouseEvent) => { + const handleRenameInputClick = (event: React.MouseEvent) => { event.stopPropagation(); - }, []); - const handleConfirmArchiveRef = useCallback( - (element: HTMLButtonElement | null) => { - if (element) { - confirmArchiveButtonRefs.current.set(threadKey, element); - } else { - confirmArchiveButtonRefs.current.delete(threadKey); - } - }, - [confirmArchiveButtonRefs, threadKey], - ); - const stopPropagationOnPointerDown = useCallback( - (event: React.PointerEvent) => { - event.stopPropagation(); - }, - [], - ); - const handleConfirmArchiveClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - clearConfirmingArchive(); - void attemptArchiveThread(threadRef); - }, - [attemptArchiveThread, clearConfirmingArchive, threadRef], - ); - const handleStartArchiveConfirmation = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (!checkTaskPermission(thread.environmentId)) return; - setConfirmingArchiveThreadKey(threadKey); - requestAnimationFrame(() => { - confirmArchiveButtonRefs.current.get(threadKey)?.focus(); - }); - }, - [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey, thread.environmentId], - ); - const handleArchiveImmediateClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - void attemptArchiveThread(threadRef); - }, - [attemptArchiveThread, threadRef], - ); + }; + const handleConfirmArchiveRef = (element: HTMLButtonElement | null) => { + if (element) { + confirmArchiveButtonRefs.current.set(threadKey, element); + } else { + confirmArchiveButtonRefs.current.delete(threadKey); + } + }; + const stopPropagationOnPointerDown = (event: React.PointerEvent) => { + event.stopPropagation(); + }; + const handleConfirmArchiveClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearConfirmingArchive(); + void attemptArchiveThread(threadRef); + }; + const handleStartArchiveConfirmation = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!checkTaskPermission(thread.environmentId)) return; + setConfirmingArchiveThreadKey(threadKey); + requestAnimationFrame(() => { + confirmArchiveButtonRefs.current.get(threadKey)?.focus(); + }); + }; + const handleArchiveImmediateClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptArchiveThread(threadRef); + }; return (