Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions apps/mobile/src/components/GlassSurface.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { BlurView } from "expo-blur";
import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
import type { ReactNode, Ref } from "react";
import { useContext, type ReactNode, type Ref, type RefObject } from "react";
import {
Platform,
StyleSheet,
useColorScheme,
View,
type ColorValue,
type StyleProp,
type ViewProps,
type ViewStyle,
} from "react-native";
import { withUniwind } from "uniwind";

import { cn } from "../lib/cn";
import { GlassBlurTargetContext } from "../lib/glassBlurTarget";
import { themeColorWithAlpha } from "../lib/mobileTheme";

// Explicit mappings keep the native glassEffectStyle enum out of style-array conversion.
const ThemedGlassView = withUniwind(GlassView, {
Expand All @@ -26,8 +29,9 @@ interface GlassSurfaceProps extends ViewProps {
readonly tintColor?: ColorValue;
readonly tintColorClassName?: string;
readonly chrome?: "default" | "none";
/** Styling used only when native Liquid Glass is unavailable. */
readonly fallbackStyle?: StyleProp<ViewStyle>;
/** Base color for the frosted tint, or solid fill when blur is unavailable. */
readonly fallbackColor?: ColorValue;
readonly blurTarget?: RefObject<View | null>;
/** Uniwind styling used only when native Liquid Glass is unavailable. */
readonly fallbackClassName?: string;
}
Expand All @@ -39,13 +43,21 @@ export function GlassSurface({
chrome = "default",
tintColor,
tintColorClassName,
fallbackStyle,
fallbackColor,
blurTarget,
fallbackClassName,
className,
style,
...props
}: GlassSurfaceProps) {
const isDarkMode = useColorScheme() === "dark";
const inheritedBlurTarget = useContext(GlassBlurTargetContext);
const target = blurTarget ?? inheritedBlurTarget;
const supportsBlur =
Platform.OS === "ios" ||
(Platform.OS === "android" && Platform.Version >= 31 && target !== undefined);
const backgroundColor =
fallbackColor === undefined ? undefined : themeColorWithAlpha(String(fallbackColor), 1);
const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();
const surfaceStyle: ViewStyle = {
borderRadius: 32,
Expand Down Expand Up @@ -95,14 +107,27 @@ export function GlassSurface({
{...props}
ref={ref}
className={cn(
chrome === "none"
? "border-0 border-transparent bg-transparent"
: "border border-border bg-glass-surface",
chrome === "none" ? "border-0 border-transparent" : "border border-border",
fallbackClassName,
className,
)}
style={[surfaceStyle, fallbackStyle, style]}
style={[surfaceStyle, style]}
>
{supportsBlur ? (
<BlurView
pointerEvents="none"
blurTarget={target}
blurMethod="dimezisBlurViewSdk31Plus"
intensity={80}
tint={isDarkMode ? "dark" : "default"}
style={StyleSheet.absoluteFill}
/>
) : null}
<View
pointerEvents="none"
className="absolute inset-0 bg-card"
style={{ backgroundColor, opacity: supportsBlur ? (isDarkMode ? 0.25 : 0.55) : 1 }}
/>
{children}
</View>
);
Expand Down
22 changes: 20 additions & 2 deletions apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BlurTargetView } from "expo-blur";
import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
import type { MenuAction } from "@react-native-menu/menu";
Expand Down Expand Up @@ -154,6 +155,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{
}>;

export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
const terminalBlurTarget = useRef<View>(null);
const navigation = useNavigation();
const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
Expand Down Expand Up @@ -1260,7 +1262,21 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
/>
) : (
<>
<View className="flex-1" style={{ paddingBottom: terminalBottomInset }}>
<BlurTargetView
ref={terminalBlurTarget}
style={{
flex: 1,
paddingBottom: terminalBottomInset,
}}
>
<View
pointerEvents="none"
style={{
position: "absolute",
inset: 0,
backgroundColor: terminalTheme.background,
}}
/>
<TerminalSurface
autoFocus={!SHOWCASE_ENABLED}
buffer={terminalSurfaceBuffer}
Expand All @@ -1273,7 +1289,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
terminalKey={terminalKey}
theme={terminalTheme}
/>
</View>
</BlurTargetView>

{isAccessoryVisible ? (
<KeyboardStickyView
Expand Down Expand Up @@ -1340,6 +1356,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
>
<GlassSurface
chrome="none"
blurTarget={terminalBlurTarget}
fallbackColor={terminalTheme.background}
glassEffectStyle="regular"
tintColor="transparent"
style={{
Expand Down
10 changes: 6 additions & 4 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export interface ThreadComposerProps {

/**
* The pill / card container — renders with Expo's native GlassView on supported
* iOS 26+ devices and keeps the existing opaque fallback elsewhere.
* iOS 26+ devices, with a frosted blur fallback where supported.
* Exported so NewTaskDraftScreen can render the same composer chrome.
*/
// The bottom-anchored dock position and clipped surface height use the same
Expand Down Expand Up @@ -172,6 +172,7 @@ export function ComposerSurface(props: {
readonly animateLayout?: boolean;
}) {
const { materialYouStyleLayoutActive } = useAppearancePreferences();
const colors = useUniwindTheme();
const targetBorderRadius =
typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
const animatedBorderRadius = useSharedValue(targetBorderRadius);
Expand Down Expand Up @@ -210,10 +211,11 @@ export function ComposerSurface(props: {
>
<AnimatedGlassSurface
chrome="none"
fallbackColor={
materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"]
}
fallbackClassName={
materialYouStyleLayoutActive
? "border border-composer-border bg-composer-surface"
: "border border-border bg-card-translucent"
materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border"
}
glassEffectStyle="regular"
// The composer is a passive material containing interactive controls.
Expand Down
92 changes: 53 additions & 39 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import type {
UserInputQuestion,
} from "@t3tools/contracts";
import * as Haptics from "expo-haptics";
import { BlurTargetView } from "expo-blur";
import { GlassBlurTargetContext } from "../../lib/glassBlurTarget";
import {
memo,
useCallback,
Expand Down Expand Up @@ -807,7 +809,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}, [freeze, scrollMessageToEnd]);

const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
const { themeAppearance } = useAppearancePreferences();
const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences();
const isDarkMode = themeAppearance === "dark";

const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
Expand Down Expand Up @@ -839,17 +841,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const handleFeedTouchCancel = useCallback(() => {
feedTouchStartRef.current = null;
}, []);
const feedBlurTarget = useRef<View>(null);

return (
<View className="flex-1">
{showContent ? (
<View
className="flex-1"
<BlurTargetView
ref={feedBlurTarget}
style={{ flex: 1 }}
onTouchStart={handleFeedTouchStart}
onTouchMove={handleFeedTouchMove}
onTouchEnd={handleFeedTouchEnd}
onTouchCancel={handleFeedTouchCancel}
>
<View
pointerEvents="none"
className={
materialYouStyleLayoutActive
? "absolute inset-0 bg-thread-canvas"
: "absolute inset-0 bg-screen"
}
/>
<ThreadFeed
key={selectedThreadKey}
environmentId={props.environmentId}
Expand Down Expand Up @@ -881,7 +893,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onUseArtifactTemplate={handleUseArtifactTemplate}
loadEarlier={props.loadEarlier ?? null}
/>
</View>
</BlurTargetView>
) : (
<View className="flex-1" />
)}
Expand Down Expand Up @@ -1003,41 +1015,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
: undefined
}
>
<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
contentMaxWidth={contentMaxWidth}
connectionState={props.connectionStateLabel}
environmentLabel={props.environmentLabel}
selectedThread={props.selectedThread}
hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
serverConfig={props.serverConfig}
queueCount={props.selectedThreadQueueCount}
environmentId={props.environmentId}
projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
// Follow-ups typed during setup wait in the draft: queueing
// them against a thread id the server may still reject
// would strand them in the outbox.
sendBlockedReason={
props.creationState?.kind === "preparing" ? "Starting the task…" : null
}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftMedia={props.onPickDraftMedia}
onPickDraftFiles={props.onPickDraftFiles}
onNativePasteImages={props.onNativePasteImages}
onRemoveDraftImage={props.onRemoveDraftImage}
onStopThread={props.onStopThread}
onSendMessage={handleSendMessage}
onShowUsageLimits={showUsageLimits}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
onEditorFocusChange={handleComposerFocusChange}
/>
<GlassBlurTargetContext value={feedBlurTarget}>
<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
contentMaxWidth={contentMaxWidth}
connectionState={props.connectionStateLabel}
environmentLabel={props.environmentLabel}
selectedThread={props.selectedThread}
hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
serverConfig={props.serverConfig}
queueCount={props.selectedThreadQueueCount}
environmentId={props.environmentId}
projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
// Follow-ups typed during setup wait in the draft: queueing
// them against a thread id the server may still reject
// would strand them in the outbox.
sendBlockedReason={
props.creationState?.kind === "preparing" ? "Starting the task…" : null
}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftMedia={props.onPickDraftMedia}
onPickDraftFiles={props.onPickDraftFiles}
onNativePasteImages={props.onNativePasteImages}
onRemoveDraftImage={props.onRemoveDraftImage}
onStopThread={props.onStopThread}
onSendMessage={handleSendMessage}
onShowUsageLimits={showUsageLimits}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
onEditorFocusChange={handleComposerFocusChange}
/>
</GlassBlurTargetContext>
</View>
</View>
</Animated.View>
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/lib/glassBlurTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createContext, type RefObject } from "react";
import type { View } from "react-native";

// Android cannot sample a target that contains the BlurView itself. Keep the
// feed in a separate target, shared by the composer and its popovers.
export const GlassBlurTargetContext = createContext<RefObject<View | null> | undefined>(undefined);
19 changes: 18 additions & 1 deletion apps/web/src/components/settings/ProviderModelsSection.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
import type { ServerProviderModel } from "@t3tools/contracts";

import { groupModelsForDisplay } from "./ProviderModelsSection";
import { groupModelsForDisplay, nextHiddenModelsForBulkToggle } from "./ProviderModelsSection";

function model(slug: string, isCustom = false): ServerProviderModel {
return { slug, name: slug, isCustom, capabilities: null };
Expand All @@ -21,3 +21,20 @@ describe("groupModelsForDisplay", () => {
expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]);
});
});

describe("nextHiddenModelsForBulkToggle", () => {
it("hides every built-in model without hiding custom models", () => {
const models = [model("a"), model("b"), model("custom", true)];

expect(nextHiddenModelsForBulkToggle(models, ["a"])).toEqual(["a", "b"]);
});

it("shows every built-in model while preserving unrelated hidden entries", () => {
const models = [model("a"), model("b"), model("custom", true)];

expect(nextHiddenModelsForBulkToggle(models, ["a", "b", "legacy", "custom"])).toEqual([
"legacy",
"custom",
]);
});
});
Loading
Loading