| author | |
| committer | |
| log | a29a7cc5853e16d4ca7e07acf364836f71b6101a |
| tree | b7a16c9cedbe4819466b5ef61adb1b09d74b3867 |
| parent | 6c583620ff7ad3235b135af7107c0543467eecfa |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 119 insertions(+), 54 deletions(-)
apps/mobile/src/components/GlassSurface.tsx+34-9| ... | ... | @@ -1,17 +1,20 @@ |
| 1 | import { BlurView } from "expo-blur"; | |
| 1 | 2 | import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; |
| 2 | import type { ReactNode, Ref } from "react"; | |
| 3 | import { useContext, type ReactNode, type Ref, type RefObject } from "react"; | |
| 3 | 4 | import { |
| 4 | 5 | Platform, |
| 6 | StyleSheet, | |
| 5 | 7 | useColorScheme, |
| 6 | 8 | View, |
| 7 | 9 | type ColorValue, |
| 8 | type StyleProp, | |
| 9 | 10 | type ViewProps, |
| 10 | 11 | type ViewStyle, |
| 11 | 12 | } from "react-native"; |
| 12 | 13 | import { withUniwind } from "uniwind"; |
| 13 | 14 | |
| 14 | 15 | import { cn } from "../lib/cn"; |
| 16 | import { GlassBlurTargetContext } from "../lib/glassBlurTarget"; | |
| 17 | import { themeColorWithAlpha } from "../lib/mobileTheme"; | |
| 15 | 18 | |
| 16 | 19 | // Explicit mappings keep the native glassEffectStyle enum out of style-array conversion. |
| 17 | 20 | const ThemedGlassView = withUniwind(GlassView, { |
| ... | ... | @@ -26,8 +29,9 @@ interface GlassSurfaceProps extends ViewProps { |
| 26 | 29 | readonly tintColor?: ColorValue; |
| 27 | 30 | readonly tintColorClassName?: string; |
| 28 | 31 | readonly chrome?: "default" | "none"; |
| 29 | /** Styling used only when native Liquid Glass is unavailable. */ | |
| 30 | readonly fallbackStyle?: StyleProp<ViewStyle>; | |
| 32 | /** Base color for the frosted tint, or solid fill when blur is unavailable. */ | |
| 33 | readonly fallbackColor?: ColorValue; | |
| 34 | readonly blurTarget?: RefObject<View | null>; | |
| 31 | 35 | /** Uniwind styling used only when native Liquid Glass is unavailable. */ |
| 32 | 36 | readonly fallbackClassName?: string; |
| 33 | 37 | } |
| ... | ... | @@ -39,13 +43,21 @@ export function GlassSurface({ |
| 39 | 43 | chrome = "default", |
| 40 | 44 | tintColor, |
| 41 | 45 | tintColorClassName, |
| 42 | fallbackStyle, | |
| 46 | fallbackColor, | |
| 47 | blurTarget, | |
| 43 | 48 | fallbackClassName, |
| 44 | 49 | className, |
| 45 | 50 | style, |
| 46 | 51 | ...props |
| 47 | 52 | }: GlassSurfaceProps) { |
| 48 | 53 | const isDarkMode = useColorScheme() === "dark"; |
| 54 | const inheritedBlurTarget = useContext(GlassBlurTargetContext); | |
| 55 | const target = blurTarget ?? inheritedBlurTarget; | |
| 56 | const supportsBlur = | |
| 57 | Platform.OS === "ios" || | |
| 58 | (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined); | |
| 59 | const backgroundColor = | |
| 60 | fallbackColor === undefined ? undefined : themeColorWithAlpha(String(fallbackColor), 1); | |
| 49 | 61 | const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); |
| 50 | 62 | const surfaceStyle: ViewStyle = { |
| 51 | 63 | borderRadius: 32, |
| ... | ... | @@ -95,14 +107,27 @@ export function GlassSurface({ |
| 95 | 107 | {...props} |
| 96 | 108 | ref={ref} |
| 97 | 109 | className={cn( |
| 98 | chrome === "none" | |
| 99 | ? "border-0 border-transparent bg-transparent" | |
| 100 | : "border border-border bg-glass-surface", | |
| 110 | chrome === "none" ? "border-0 border-transparent" : "border border-border", | |
| 101 | 111 | fallbackClassName, |
| 102 | 112 | className, |
| 103 | 113 | )} |
| 104 | style={[surfaceStyle, fallbackStyle, style]} | |
| 114 | style={[surfaceStyle, style]} | |
| 105 | 115 | > |
| 116 | {supportsBlur ? ( | |
| 117 | <BlurView | |
| 118 | pointerEvents="none" | |
| 119 | blurTarget={target} | |
| 120 | blurMethod="dimezisBlurViewSdk31Plus" | |
| 121 | intensity={80} | |
| 122 | tint={isDarkMode ? "dark" : "default"} | |
| 123 | style={StyleSheet.absoluteFill} | |
| 124 | /> | |
| 125 | ) : null} | |
| 126 | <View | |
| 127 | pointerEvents="none" | |
| 128 | className="absolute inset-0 bg-card" | |
| 129 | style={{ backgroundColor, opacity: supportsBlur ? (isDarkMode ? 0.25 : 0.55) : 1 }} | |
| 130 | /> | |
| 106 | 131 | {children} |
| 107 | 132 | </View> |
| 108 | 133 | ); |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+20-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { BlurTargetView } from "expo-blur"; | |
| 1 | 2 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | 3 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 3 | 4 | import type { MenuAction } from "@react-native-menu/menu"; |
| ... | ... | @@ -154,6 +155,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ |
| 154 | 155 | }>; |
| 155 | 156 | |
| 156 | 157 | export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { |
| 158 | const terminalBlurTarget = useRef<View>(null); | |
| 157 | 159 | const navigation = useNavigation(); |
| 158 | 160 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); |
| 159 | 161 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); |
| ... | ... | @@ -1260,7 +1262,21 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1260 | 1262 | /> |
| 1261 | 1263 | ) : ( |
| 1262 | 1264 | <> |
| 1263 | <View className="flex-1" style={{ paddingBottom: terminalBottomInset }}> | |
| 1265 | <BlurTargetView | |
| 1266 | ref={terminalBlurTarget} | |
| 1267 | style={{ | |
| 1268 | flex: 1, | |
| 1269 | paddingBottom: terminalBottomInset, | |
| 1270 | }} | |
| 1271 | > | |
| 1272 | <View | |
| 1273 | pointerEvents="none" | |
| 1274 | style={{ | |
| 1275 | position: "absolute", | |
| 1276 | inset: 0, | |
| 1277 | backgroundColor: terminalTheme.background, | |
| 1278 | }} | |
| 1279 | /> | |
| 1264 | 1280 | <TerminalSurface |
| 1265 | 1281 | autoFocus={!SHOWCASE_ENABLED} |
| 1266 | 1282 | buffer={terminalSurfaceBuffer} |
| ... | ... | @@ -1273,7 +1289,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1273 | 1289 | terminalKey={terminalKey} |
| 1274 | 1290 | theme={terminalTheme} |
| 1275 | 1291 | /> |
| 1276 | </View> | |
| 1292 | </BlurTargetView> | |
| 1277 | 1293 | |
| 1278 | 1294 | {isAccessoryVisible ? ( |
| 1279 | 1295 | <KeyboardStickyView |
| ... | ... | @@ -1340,6 +1356,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1340 | 1356 | > |
| 1341 | 1357 | <GlassSurface |
| 1342 | 1358 | chrome="none" |
| 1359 | blurTarget={terminalBlurTarget} | |
| 1360 | fallbackColor={terminalTheme.background} | |
| 1343 | 1361 | glassEffectStyle="regular" |
| 1344 | 1362 | tintColor="transparent" |
| 1345 | 1363 | style={{ |
apps/mobile/src/features/threads/ThreadComposer.tsx+6-4| ... | ... | @@ -143,7 +143,7 @@ export interface ThreadComposerProps { |
| 143 | 143 | |
| 144 | 144 | /** |
| 145 | 145 | * The pill / card container — renders with Expo's native GlassView on supported |
| 146 | * iOS 26+ devices and keeps the existing opaque fallback elsewhere. | |
| 146 | * iOS 26+ devices, with a frosted blur fallback where supported. | |
| 147 | 147 | * Exported so NewTaskDraftScreen can render the same composer chrome. |
| 148 | 148 | */ |
| 149 | 149 | // The bottom-anchored dock position and clipped surface height use the same |
| ... | ... | @@ -172,6 +172,7 @@ export function ComposerSurface(props: { |
| 172 | 172 | readonly animateLayout?: boolean; |
| 173 | 173 | }) { |
| 174 | 174 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); |
| 175 | const colors = useUniwindTheme(); | |
| 175 | 176 | const targetBorderRadius = |
| 176 | 177 | typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; |
| 177 | 178 | const animatedBorderRadius = useSharedValue(targetBorderRadius); |
| ... | ... | @@ -210,10 +211,11 @@ export function ComposerSurface(props: { |
| 210 | 211 | > |
| 211 | 212 | <AnimatedGlassSurface |
| 212 | 213 | chrome="none" |
| 214 | fallbackColor={ | |
| 215 | materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"] | |
| 216 | } | |
| 213 | 217 | fallbackClassName={ |
| 214 | materialYouStyleLayoutActive | |
| 215 | ? "border border-composer-border bg-composer-surface" | |
| 216 | : "border border-border bg-card-translucent" | |
| 218 | materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border" | |
| 217 | 219 | } |
| 218 | 220 | glassEffectStyle="regular" |
| 219 | 221 | // The composer is a passive material containing interactive controls. |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+53-39| ... | ... | @@ -26,6 +26,8 @@ import type { |
| 26 | 26 | UserInputQuestion, |
| 27 | 27 | } from "@t3tools/contracts"; |
| 28 | 28 | import * as Haptics from "expo-haptics"; |
| 29 | import { BlurTargetView } from "expo-blur"; | |
| 30 | import { GlassBlurTargetContext } from "../../lib/glassBlurTarget"; | |
| 29 | 31 | import { |
| 30 | 32 | memo, |
| 31 | 33 | useCallback, |
| ... | ... | @@ -807,7 +809,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 807 | 809 | }, [freeze, scrollMessageToEnd]); |
| 808 | 810 | |
| 809 | 811 | const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; |
| 810 | const { themeAppearance } = useAppearancePreferences(); | |
| 812 | const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 811 | 813 | const isDarkMode = themeAppearance === "dark"; |
| 812 | 814 | |
| 813 | 815 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { |
| ... | ... | @@ -839,17 +841,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 839 | 841 | const handleFeedTouchCancel = useCallback(() => { |
| 840 | 842 | feedTouchStartRef.current = null; |
| 841 | 843 | }, []); |
| 844 | const feedBlurTarget = useRef<View>(null); | |
| 842 | 845 | |
| 843 | 846 | return ( |
| 844 | 847 | <View className="flex-1"> |
| 845 | 848 | {showContent ? ( |
| 846 | <View | |
| 847 | className="flex-1" | |
| 849 | <BlurTargetView | |
| 850 | ref={feedBlurTarget} | |
| 851 | style={{ flex: 1 }} | |
| 848 | 852 | onTouchStart={handleFeedTouchStart} |
| 849 | 853 | onTouchMove={handleFeedTouchMove} |
| 850 | 854 | onTouchEnd={handleFeedTouchEnd} |
| 851 | 855 | onTouchCancel={handleFeedTouchCancel} |
| 852 | 856 | > |
| 857 | <View | |
| 858 | pointerEvents="none" | |
| 859 | className={ | |
| 860 | materialYouStyleLayoutActive | |
| 861 | ? "absolute inset-0 bg-thread-canvas" | |
| 862 | : "absolute inset-0 bg-screen" | |
| 863 | } | |
| 864 | /> | |
| 853 | 865 | <ThreadFeed |
| 854 | 866 | key={selectedThreadKey} |
| 855 | 867 | environmentId={props.environmentId} |
| ... | ... | @@ -881,7 +893,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 881 | 893 | onUseArtifactTemplate={handleUseArtifactTemplate} |
| 882 | 894 | loadEarlier={props.loadEarlier ?? null} |
| 883 | 895 | /> |
| 884 | </View> | |
| 896 | </BlurTargetView> | |
| 885 | 897 | ) : ( |
| 886 | 898 | <View className="flex-1" /> |
| 887 | 899 | )} |
| ... | ... | @@ -1003,41 +1015,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1003 | 1015 | : undefined |
| 1004 | 1016 | } |
| 1005 | 1017 | > |
| 1006 | <ThreadComposer | |
| 1007 | editorRef={composerEditorRef} | |
| 1008 | draftMessage={props.draftMessage} | |
| 1009 | draftAttachments={props.draftAttachments} | |
| 1010 | placeholder="Ask the repo agent, or run a command…" | |
| 1011 | contentMaxWidth={contentMaxWidth} | |
| 1012 | connectionState={props.connectionStateLabel} | |
| 1013 | environmentLabel={props.environmentLabel} | |
| 1014 | selectedThread={props.selectedThread} | |
| 1015 | hasCompactableConversation={hasCompactableConversation && !props.isCompacting} | |
| 1016 | serverConfig={props.serverConfig} | |
| 1017 | queueCount={props.selectedThreadQueueCount} | |
| 1018 | environmentId={props.environmentId} | |
| 1019 | projectCwd={props.threadCwd ?? props.projectWorkspaceRoot} | |
| 1020 | // Follow-ups typed during setup wait in the draft: queueing | |
| 1021 | // them against a thread id the server may still reject | |
| 1022 | // would strand them in the outbox. | |
| 1023 | sendBlockedReason={ | |
| 1024 | props.creationState?.kind === "preparing" ? "Starting the task…" : null | |
| 1025 | } | |
| 1026 | bottomInset={composerBottomInset} | |
| 1027 | onChangeDraftMessage={props.onChangeDraftMessage} | |
| 1028 | onPickDraftMedia={props.onPickDraftMedia} | |
| 1029 | onPickDraftFiles={props.onPickDraftFiles} | |
| 1030 | onNativePasteImages={props.onNativePasteImages} | |
| 1031 | onRemoveDraftImage={props.onRemoveDraftImage} | |
| 1032 | onStopThread={props.onStopThread} | |
| 1033 | onSendMessage={handleSendMessage} | |
| 1034 | onShowUsageLimits={showUsageLimits} | |
| 1035 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 1036 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 1037 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 1038 | onExpandedChange={setComposerExpanded} | |
| 1039 | onEditorFocusChange={handleComposerFocusChange} | |
| 1040 | /> | |
| 1018 | <GlassBlurTargetContext value={feedBlurTarget}> | |
| 1019 | <ThreadComposer | |
| 1020 | editorRef={composerEditorRef} | |
| 1021 | draftMessage={props.draftMessage} | |
| 1022 | draftAttachments={props.draftAttachments} | |
| 1023 | placeholder="Ask the repo agent, or run a command…" | |
| 1024 | contentMaxWidth={contentMaxWidth} | |
| 1025 | connectionState={props.connectionStateLabel} | |
| 1026 | environmentLabel={props.environmentLabel} | |
| 1027 | selectedThread={props.selectedThread} | |
| 1028 | hasCompactableConversation={hasCompactableConversation && !props.isCompacting} | |
| 1029 | serverConfig={props.serverConfig} | |
| 1030 | queueCount={props.selectedThreadQueueCount} | |
| 1031 | environmentId={props.environmentId} | |
| 1032 | projectCwd={props.threadCwd ?? props.projectWorkspaceRoot} | |
| 1033 | // Follow-ups typed during setup wait in the draft: queueing | |
| 1034 | // them against a thread id the server may still reject | |
| 1035 | // would strand them in the outbox. | |
| 1036 | sendBlockedReason={ | |
| 1037 | props.creationState?.kind === "preparing" ? "Starting the task…" : null | |
| 1038 | } | |
| 1039 | bottomInset={composerBottomInset} | |
| 1040 | onChangeDraftMessage={props.onChangeDraftMessage} | |
| 1041 | onPickDraftMedia={props.onPickDraftMedia} | |
| 1042 | onPickDraftFiles={props.onPickDraftFiles} | |
| 1043 | onNativePasteImages={props.onNativePasteImages} | |
| 1044 | onRemoveDraftImage={props.onRemoveDraftImage} | |
| 1045 | onStopThread={props.onStopThread} | |
| 1046 | onSendMessage={handleSendMessage} | |
| 1047 | onShowUsageLimits={showUsageLimits} | |
| 1048 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 1049 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 1050 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 1051 | onExpandedChange={setComposerExpanded} | |
| 1052 | onEditorFocusChange={handleComposerFocusChange} | |
| 1053 | /> | |
| 1054 | </GlassBlurTargetContext> | |
| 1041 | 1055 | </View> |
| 1042 | 1056 | </View> |
| 1043 | 1057 | </Animated.View> |
apps/mobile/src/lib/glassBlurTarget.ts created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | import { createContext, type RefObject } from "react"; | |
| 2 | import type { View } from "react-native"; | |
| 3 | ||
| 4 | // Android cannot sample a target that contains the BlurView itself. Keep the | |
| 5 | // feed in a separate target, shared by the composer and its popovers. | |
| 6 | export const GlassBlurTargetContext = createContext<RefObject<View | null> | undefined>(undefined); |