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
47 changes: 19 additions & 28 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, InteractionManager, Keyboard, Platform, View, useColorScheme } from "react-native";
import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
Expand All@@ -26,6 +26,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ComposerSurface } from "./ThreadComposer";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
Expand DownExpand Up@@ -99,9 +100,10 @@ export function NewTaskDraftScreen(props: {
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const isSettingsSheetVisibleRef = useRef(false);
isSettingsSheetVisibleRef.current = isSettingsSheetVisible;
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: promptInputRef,
isEditorFocused: isComposerFocused,
});
const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
Expand DownExpand Up@@ -523,8 +525,10 @@ export function NewTaskDraftScreen(props: {
focusFrame = requestAnimationFrame(() => {
// The delayed focus can land after the settings sheet opened, which
// would pop the keyboard underneath its modal.
if (!isSettingsSheetVisibleRef.current) {
if (!settingsSheetPresentation.isActiveRef.current) {
promptInputRef.current?.focus();
} else {
settingsSheetPresentation.restoreFocusAfterSave();
}
});
});
Expand All@@ -535,7 +539,11 @@ export function NewTaskDraftScreen(props: {
cancelAnimationFrame(focusFrame);
}
};
}, [selectedProject]);
}, [
selectedProject,
settingsSheetPresentation.isActiveRef,
settingsSheetPresentation.restoreFocusAfterSave,
]);

const environmentMenuActions = useMemo(
() =>
Expand DownExpand Up@@ -643,24 +651,6 @@ export function NewTaskDraftScreen(props: {
}),
[currentBranchName, flow.selectedBranchName, flow.workspaceMode],
);
// Order matters: mark the sheet open before dismissing the keyboard so the
// Android draft layout (expanded only while focused) doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isComposerFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isComposerFocused]);
const closeSettingsSheet = useCallback((reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
promptInputRef.current?.focus();
}
}, []);

function handleEnvironmentMenuAction(event: string) {
if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
return;
Expand DownExpand Up@@ -876,7 +866,7 @@ export function NewTaskDraftScreen(props: {
// the touch gesture that opens the keyboard.
// The settings sheet dismisses the keyboard, so its flag keeps the Android
// draft composer expanded through the blur (mirrors ThreadComposer).
const isExpanded = !isAndroid || isComposerFocused || isSettingsSheetVisible;
const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
Expand DownExpand Up@@ -936,7 +926,7 @@ export function NewTaskDraftScreen(props: {
iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
<ControlPillMenu
actions={environmentMenuActions}
Expand DownExpand Up@@ -965,8 +955,9 @@ export function NewTaskDraftScreen(props: {

const settingsSheet = (
<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={flow.providerGroups}
selectedModel={flow.selectedModel}
onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
Expand Down
41 changes: 12 additions & 29 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject
import {
ActivityIndicator,
Image,
Keyboard,
Platform,
Pressable,
StyleSheet,
Expand DownExpand Up@@ -67,6 +66,7 @@ import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
Expand DownExpand Up@@ -270,16 +270,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: inputRef,
isEditorFocused: isFocused,
});
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set<string>());
const { onExpandedChange } = props;

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
// The settings sheet dismisses the keyboard (it would cover the sheet), so
// the sheet flag keeps the composer expanded through that blur.
const isExpanded = isFocused || isSettingsSheetVisible;
// Opening and closing count as active so the composer stays expanded while
// focus moves between its native editor and the settings modal.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;

// Notify the parent from the derived value, not focus events: the parent
Expand DownExpand Up@@ -620,27 +623,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
interactionMode: currentInteractionMode,
});

// Order matters: mark the sheet open before dismissing the keyboard so
// isExpanded stays true through the blur and the composer doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isFocused]);
const closeSettingsSheet = useCallback(
(reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
inputRef.current?.focus();
}
},
[inputRef],
);

return (
<Animated.View
className="px-4"
Expand DownExpand Up@@ -817,7 +799,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
{showStopAction ? (
<ComposerToolbarButton
Expand DownExpand Up@@ -853,8 +835,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</Animated.View>

<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={threadProviderGroups}
selectedModel={currentModelSelection}
onSelectModel={(option) => props.onUpdateModelSelection(option.selection)}
Expand Down
43 changes: 37 additions & 6 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,16 @@ import {
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Modal, Pressable, ScrollView, Switch, useWindowDimensions, View } from "react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
Switch,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
Expand All@@ -22,6 +30,8 @@ import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
import { useThemeColor } from "../../lib/useThemeColor";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";
import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";

/**
* The everyday harnesses stay expanded; every other provider (OpenRouter
Expand DownExpand Up@@ -293,7 +303,8 @@ export function ThreadSettingsSheet(props: {
* "dismiss" = backdrop, grabber, or system back. Hosts only restore the
* keyboard for "save" so a stray tap outside a control never pops it.
*/
readonly onClose: (reason: "save" | "dismiss") => void;
readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
readonly onDismissed: () => void;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
Expand All@@ -308,18 +319,31 @@ export function ThreadSettingsSheet(props: {
const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());
const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
const wasPresentedRef = useRef(false);
const notifyDismissed = useCallback(() => {
if (!wasPresentedRef.current) {
return;
}
wasPresentedRef.current = false;
props.onDismissed();
}, [props.onDismissed]);

// Every open starts fresh: no staged model, no submenu, legacy hidden,
// secondary providers folded. The sheet stays mounted between opens, so
// state would otherwise stick around.
useEffect(() => {
if (props.visible) {
wasPresentedRef.current = true;
setShowLegacyToggle(false);
setExpandedProviders(new Set());
setPendingModel(null);
setSubmenu(null);
} else if (Platform.OS === "android" && wasPresentedRef.current) {
// React Native only emits Modal.onDismiss on iOS. Android uses no exit
// animation below, so the post-commit effect is its dismissal boundary.
notifyDismissed();
}
}, [props.visible]);
}, [notifyDismissed, props.visible]);

const isApplied = (option: ModelOption) =>
option.selection.instanceId === props.selectedModel?.instanceId &&
Expand DownExpand Up@@ -455,8 +479,9 @@ export function ThreadSettingsSheet(props: {
transparent
statusBarTranslucent
navigationBarTranslucent
animationType="fade"
animationType={Platform.OS === "ios" ? "fade" : "none"}
visible={props.visible}
onDismiss={notifyDismissed}
onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
>
<View className="flex-1 justify-end">
Expand DownExpand Up@@ -538,7 +563,13 @@ export function ThreadSettingsSheet(props: {
onPress={() => {
void Haptics.selectionAsync();
// Re-tapping the applied model cancels staging.
setPendingModel(isApplied(option) ? null : option);
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
}}
/>
))}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";

import type { ModelOption } from "../../lib/modelOptions";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";

function modelOption(
model: string,
options: ReadonlyArray<ProviderOptionSelection> = [],
): ModelOption {
return {
key: `codex:${model}`,
label: model,
subtitle: "Codex",
providerKey: "codex",
providerLabel: "Codex",
providerDriver: "codex",
isDefault: false,
isLegacy: false,
capabilities: null,
selection: {
instanceId: ProviderInstanceId.make("codex"),
model,
options,
},
};
}

describe("thread settings sheet state", () => {
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed: modelOption("gpt-current"),
pressedIsApplied: true,
}),
).toBeNull();
});

it("preserves staged options when the highlighted model is pressed again", () => {
const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);

expect(
pendingModelAfterPress({
current: pending,
pressed: modelOption("gpt-next"),
pressedIsApplied: false,
}),
).toBe(pending);
});

it("stages a different model", () => {
const pressed = modelOption("gpt-other");

expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed,
pressedIsApplied: false,
}),
).toBe(pressed);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-settings-sheet-state.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
import type { ModelOption } from "../../lib/modelOptions";

/** Preserve staged provider options when the highlighted model is tapped again. */
export function pendingModelAfterPress(input: {
readonly current: ModelOption | null;
readonly pressed: ModelOption;
readonly pressedIsApplied: boolean;
}): ModelOption | null {
if (input.pressedIsApplied) {
return null;
}
return input.current?.key === input.pressed.key ? input.current : input.pressed;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
47 changes: 19 additions & 28 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, InteractionManager, Keyboard, Platform, View, useColorScheme } from "react-native";
import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
Expand All@@ -26,6 +26,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ComposerSurface } from "./ThreadComposer";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
Expand DownExpand Up@@ -99,9 +100,10 @@ export function NewTaskDraftScreen(props: {
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const isSettingsSheetVisibleRef = useRef(false);
isSettingsSheetVisibleRef.current = isSettingsSheetVisible;
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: promptInputRef,
isEditorFocused: isComposerFocused,
});
const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
Expand DownExpand Up@@ -523,8 +525,10 @@ export function NewTaskDraftScreen(props: {
focusFrame = requestAnimationFrame(() => {
// The delayed focus can land after the settings sheet opened, which
// would pop the keyboard underneath its modal.
if (!isSettingsSheetVisibleRef.current) {
if (!settingsSheetPresentation.isActiveRef.current) {
promptInputRef.current?.focus();
} else {
settingsSheetPresentation.restoreFocusAfterSave();
}
});
});
Expand All@@ -535,7 +539,11 @@ export function NewTaskDraftScreen(props: {
cancelAnimationFrame(focusFrame);
}
};
}, [selectedProject]);
}, [
selectedProject,
settingsSheetPresentation.isActiveRef,
settingsSheetPresentation.restoreFocusAfterSave,
]);

const environmentMenuActions = useMemo(
() =>
Expand DownExpand Up@@ -643,24 +651,6 @@ export function NewTaskDraftScreen(props: {
}),
[currentBranchName, flow.selectedBranchName, flow.workspaceMode],
);
// Order matters: mark the sheet open before dismissing the keyboard so the
// Android draft layout (expanded only while focused) doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isComposerFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isComposerFocused]);
const closeSettingsSheet = useCallback((reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
promptInputRef.current?.focus();
}
}, []);

function handleEnvironmentMenuAction(event: string) {
if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
return;
Expand DownExpand Up@@ -876,7 +866,7 @@ export function NewTaskDraftScreen(props: {
// the touch gesture that opens the keyboard.
// The settings sheet dismisses the keyboard, so its flag keeps the Android
// draft composer expanded through the blur (mirrors ThreadComposer).
const isExpanded = !isAndroid || isComposerFocused || isSettingsSheetVisible;
const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
Expand DownExpand Up@@ -936,7 +926,7 @@ export function NewTaskDraftScreen(props: {
iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
<ControlPillMenu
actions={environmentMenuActions}
Expand DownExpand Up@@ -965,8 +955,9 @@ export function NewTaskDraftScreen(props: {

const settingsSheet = (
<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={flow.providerGroups}
selectedModel={flow.selectedModel}
onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
Expand Down
41 changes: 12 additions & 29 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject
import {
ActivityIndicator,
Image,
Keyboard,
Platform,
Pressable,
StyleSheet,
Expand DownExpand Up@@ -67,6 +66,7 @@ import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
Expand DownExpand Up@@ -270,16 +270,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: inputRef,
isEditorFocused: isFocused,
});
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set<string>());
const { onExpandedChange } = props;

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
// The settings sheet dismisses the keyboard (it would cover the sheet), so
// the sheet flag keeps the composer expanded through that blur.
const isExpanded = isFocused || isSettingsSheetVisible;
// Opening and closing count as active so the composer stays expanded while
// focus moves between its native editor and the settings modal.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;

// Notify the parent from the derived value, not focus events: the parent
Expand DownExpand Up@@ -620,27 +623,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
interactionMode: currentInteractionMode,
});

// Order matters: mark the sheet open before dismissing the keyboard so
// isExpanded stays true through the blur and the composer doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isFocused]);
const closeSettingsSheet = useCallback(
(reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
inputRef.current?.focus();
}
},
[inputRef],
);

return (
<Animated.View
className="px-4"
Expand DownExpand Up@@ -817,7 +799,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
{showStopAction ? (
<ComposerToolbarButton
Expand DownExpand Up@@ -853,8 +835,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</Animated.View>

<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={threadProviderGroups}
selectedModel={currentModelSelection}
onSelectModel={(option) => props.onUpdateModelSelection(option.selection)}
Expand Down
43 changes: 37 additions & 6 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,16 @@ import {
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Modal, Pressable, ScrollView, Switch, useWindowDimensions, View } from "react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
Switch,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
Expand All@@ -22,6 +30,8 @@ import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
import { useThemeColor } from "../../lib/useThemeColor";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";
import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";

/**
* The everyday harnesses stay expanded; every other provider (OpenRouter
Expand DownExpand Up@@ -293,7 +303,8 @@ export function ThreadSettingsSheet(props: {
* "dismiss" = backdrop, grabber, or system back. Hosts only restore the
* keyboard for "save" so a stray tap outside a control never pops it.
*/
readonly onClose: (reason: "save" | "dismiss") => void;
readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
readonly onDismissed: () => void;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
Expand All@@ -308,18 +319,31 @@ export function ThreadSettingsSheet(props: {
const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());
const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
const wasPresentedRef = useRef(false);
const notifyDismissed = useCallback(() => {
if (!wasPresentedRef.current) {
return;
}
wasPresentedRef.current = false;
props.onDismissed();
}, [props.onDismissed]);

// Every open starts fresh: no staged model, no submenu, legacy hidden,
// secondary providers folded. The sheet stays mounted between opens, so
// state would otherwise stick around.
useEffect(() => {
if (props.visible) {
wasPresentedRef.current = true;
setShowLegacyToggle(false);
setExpandedProviders(new Set());
setPendingModel(null);
setSubmenu(null);
} else if (Platform.OS === "android" && wasPresentedRef.current) {
// React Native only emits Modal.onDismiss on iOS. Android uses no exit
// animation below, so the post-commit effect is its dismissal boundary.
notifyDismissed();
}
}, [props.visible]);
}, [notifyDismissed, props.visible]);

const isApplied = (option: ModelOption) =>
option.selection.instanceId === props.selectedModel?.instanceId &&
Expand DownExpand Up@@ -455,8 +479,9 @@ export function ThreadSettingsSheet(props: {
transparent
statusBarTranslucent
navigationBarTranslucent
animationType="fade"
animationType={Platform.OS === "ios" ? "fade" : "none"}
visible={props.visible}
onDismiss={notifyDismissed}
onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
>
<View className="flex-1 justify-end">
Expand DownExpand Up@@ -538,7 +563,13 @@ export function ThreadSettingsSheet(props: {
onPress={() => {
void Haptics.selectionAsync();
// Re-tapping the applied model cancels staging.
setPendingModel(isApplied(option) ? null : option);
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
}}
/>
))}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";

import type { ModelOption } from "../../lib/modelOptions";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";

function modelOption(
model: string,
options: ReadonlyArray<ProviderOptionSelection> = [],
): ModelOption {
return {
key: `codex:${model}`,
label: model,
subtitle: "Codex",
providerKey: "codex",
providerLabel: "Codex",
providerDriver: "codex",
isDefault: false,
isLegacy: false,
capabilities: null,
selection: {
instanceId: ProviderInstanceId.make("codex"),
model,
options,
},
};
}

describe("thread settings sheet state", () => {
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed: modelOption("gpt-current"),
pressedIsApplied: true,
}),
).toBeNull();
});

it("preserves staged options when the highlighted model is pressed again", () => {
const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);

expect(
pendingModelAfterPress({
current: pending,
pressed: modelOption("gpt-next"),
pressedIsApplied: false,
}),
).toBe(pending);
});

it("stages a different model", () => {
const pressed = modelOption("gpt-other");

expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed,
pressedIsApplied: false,
}),
).toBe(pressed);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-settings-sheet-state.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
import type { ModelOption } from "../../lib/modelOptions";

/** Preserve staged provider options when the highlighted model is tapped again. */
export function pendingModelAfterPress(input: {
readonly current: ModelOption | null;
readonly pressed: ModelOption;
readonly pressedIsApplied: boolean;
}): ModelOption | null {
if (input.pressedIsApplied) {
return null;
}
return input.current?.key === input.pressed.key ? input.current : input.pressed;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
47 changes: 19 additions & 28 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, InteractionManager, Keyboard, Platform, View, useColorScheme } from "react-native";
import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
Expand All@@ -26,6 +26,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ComposerSurface } from "./ThreadComposer";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
Expand DownExpand Up@@ -99,9 +100,10 @@ export function NewTaskDraftScreen(props: {
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const isSettingsSheetVisibleRef = useRef(false);
isSettingsSheetVisibleRef.current = isSettingsSheetVisible;
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: promptInputRef,
isEditorFocused: isComposerFocused,
});
const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
Expand DownExpand Up@@ -523,8 +525,10 @@ export function NewTaskDraftScreen(props: {
focusFrame = requestAnimationFrame(() => {
// The delayed focus can land after the settings sheet opened, which
// would pop the keyboard underneath its modal.
if (!isSettingsSheetVisibleRef.current) {
if (!settingsSheetPresentation.isActiveRef.current) {
promptInputRef.current?.focus();
} else {
settingsSheetPresentation.restoreFocusAfterSave();
}
});
});
Expand All@@ -535,7 +539,11 @@ export function NewTaskDraftScreen(props: {
cancelAnimationFrame(focusFrame);
}
};
}, [selectedProject]);
}, [
selectedProject,
settingsSheetPresentation.isActiveRef,
settingsSheetPresentation.restoreFocusAfterSave,
]);

const environmentMenuActions = useMemo(
() =>
Expand DownExpand Up@@ -643,24 +651,6 @@ export function NewTaskDraftScreen(props: {
}),
[currentBranchName, flow.selectedBranchName, flow.workspaceMode],
);
// Order matters: mark the sheet open before dismissing the keyboard so the
// Android draft layout (expanded only while focused) doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isComposerFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isComposerFocused]);
const closeSettingsSheet = useCallback((reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
promptInputRef.current?.focus();
}
}, []);

function handleEnvironmentMenuAction(event: string) {
if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
return;
Expand DownExpand Up@@ -876,7 +866,7 @@ export function NewTaskDraftScreen(props: {
// the touch gesture that opens the keyboard.
// The settings sheet dismisses the keyboard, so its flag keeps the Android
// draft composer expanded through the blur (mirrors ThreadComposer).
const isExpanded = !isAndroid || isComposerFocused || isSettingsSheetVisible;
const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
Expand DownExpand Up@@ -936,7 +926,7 @@ export function NewTaskDraftScreen(props: {
iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
<ControlPillMenu
actions={environmentMenuActions}
Expand DownExpand Up@@ -965,8 +955,9 @@ export function NewTaskDraftScreen(props: {

const settingsSheet = (
<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={flow.providerGroups}
selectedModel={flow.selectedModel}
onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
Expand Down
41 changes: 12 additions & 29 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject
import {
ActivityIndicator,
Image,
Keyboard,
Platform,
Pressable,
StyleSheet,
Expand DownExpand Up@@ -67,6 +66,7 @@ import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
Expand DownExpand Up@@ -270,16 +270,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: inputRef,
isEditorFocused: isFocused,
});
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set<string>());
const { onExpandedChange } = props;

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
// The settings sheet dismisses the keyboard (it would cover the sheet), so
// the sheet flag keeps the composer expanded through that blur.
const isExpanded = isFocused || isSettingsSheetVisible;
// Opening and closing count as active so the composer stays expanded while
// focus moves between its native editor and the settings modal.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;

// Notify the parent from the derived value, not focus events: the parent
Expand DownExpand Up@@ -620,27 +623,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
interactionMode: currentInteractionMode,
});

// Order matters: mark the sheet open before dismissing the keyboard so
// isExpanded stays true through the blur and the composer doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isFocused]);
const closeSettingsSheet = useCallback(
(reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
inputRef.current?.focus();
}
},
[inputRef],
);

return (
<Animated.View
className="px-4"
Expand DownExpand Up@@ -817,7 +799,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
{showStopAction ? (
<ComposerToolbarButton
Expand DownExpand Up@@ -853,8 +835,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</Animated.View>

<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={threadProviderGroups}
selectedModel={currentModelSelection}
onSelectModel={(option) => props.onUpdateModelSelection(option.selection)}
Expand Down
43 changes: 37 additions & 6 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,16 @@ import {
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Modal, Pressable, ScrollView, Switch, useWindowDimensions, View } from "react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
Switch,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
Expand All@@ -22,6 +30,8 @@ import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
import { useThemeColor } from "../../lib/useThemeColor";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";
import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";

/**
* The everyday harnesses stay expanded; every other provider (OpenRouter
Expand DownExpand Up@@ -293,7 +303,8 @@ export function ThreadSettingsSheet(props: {
* "dismiss" = backdrop, grabber, or system back. Hosts only restore the
* keyboard for "save" so a stray tap outside a control never pops it.
*/
readonly onClose: (reason: "save" | "dismiss") => void;
readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
readonly onDismissed: () => void;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
Expand All@@ -308,18 +319,31 @@ export function ThreadSettingsSheet(props: {
const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());
const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
const wasPresentedRef = useRef(false);
const notifyDismissed = useCallback(() => {
if (!wasPresentedRef.current) {
return;
}
wasPresentedRef.current = false;
props.onDismissed();
}, [props.onDismissed]);

// Every open starts fresh: no staged model, no submenu, legacy hidden,
// secondary providers folded. The sheet stays mounted between opens, so
// state would otherwise stick around.
useEffect(() => {
if (props.visible) {
wasPresentedRef.current = true;
setShowLegacyToggle(false);
setExpandedProviders(new Set());
setPendingModel(null);
setSubmenu(null);
} else if (Platform.OS === "android" && wasPresentedRef.current) {
// React Native only emits Modal.onDismiss on iOS. Android uses no exit
// animation below, so the post-commit effect is its dismissal boundary.
notifyDismissed();
}
}, [props.visible]);
}, [notifyDismissed, props.visible]);

const isApplied = (option: ModelOption) =>
option.selection.instanceId === props.selectedModel?.instanceId &&
Expand DownExpand Up@@ -455,8 +479,9 @@ export function ThreadSettingsSheet(props: {
transparent
statusBarTranslucent
navigationBarTranslucent
animationType="fade"
animationType={Platform.OS === "ios" ? "fade" : "none"}
visible={props.visible}
onDismiss={notifyDismissed}
onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
>
<View className="flex-1 justify-end">
Expand DownExpand Up@@ -538,7 +563,13 @@ export function ThreadSettingsSheet(props: {
onPress={() => {
void Haptics.selectionAsync();
// Re-tapping the applied model cancels staging.
setPendingModel(isApplied(option) ? null : option);
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
}}
/>
))}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";

import type { ModelOption } from "../../lib/modelOptions";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";

function modelOption(
model: string,
options: ReadonlyArray<ProviderOptionSelection> = [],
): ModelOption {
return {
key: `codex:${model}`,
label: model,
subtitle: "Codex",
providerKey: "codex",
providerLabel: "Codex",
providerDriver: "codex",
isDefault: false,
isLegacy: false,
capabilities: null,
selection: {
instanceId: ProviderInstanceId.make("codex"),
model,
options,
},
};
}

describe("thread settings sheet state", () => {
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed: modelOption("gpt-current"),
pressedIsApplied: true,
}),
).toBeNull();
});

it("preserves staged options when the highlighted model is pressed again", () => {
const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);

expect(
pendingModelAfterPress({
current: pending,
pressed: modelOption("gpt-next"),
pressedIsApplied: false,
}),
).toBe(pending);
});

it("stages a different model", () => {
const pressed = modelOption("gpt-other");

expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed,
pressedIsApplied: false,
}),
).toBe(pressed);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-settings-sheet-state.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
import type { ModelOption } from "../../lib/modelOptions";

/** Preserve staged provider options when the highlighted model is tapped again. */
export function pendingModelAfterPress(input: {
readonly current: ModelOption | null;
readonly pressed: ModelOption;
readonly pressedIsApplied: boolean;
}): ModelOption | null {
if (input.pressedIsApplied) {
return null;
}
return input.current?.key === input.pressed.key ? input.current : input.pressed;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
47 changes: 19 additions & 28 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, InteractionManager, Keyboard, Platform, View, useColorScheme } from "react-native";
import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
Expand All@@ -26,6 +26,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ComposerSurface } from "./ThreadComposer";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
Expand DownExpand Up@@ -99,9 +100,10 @@ export function NewTaskDraftScreen(props: {
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const isSettingsSheetVisibleRef = useRef(false);
isSettingsSheetVisibleRef.current = isSettingsSheetVisible;
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: promptInputRef,
isEditorFocused: isComposerFocused,
});
const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
Expand DownExpand Up@@ -523,8 +525,10 @@ export function NewTaskDraftScreen(props: {
focusFrame = requestAnimationFrame(() => {
// The delayed focus can land after the settings sheet opened, which
// would pop the keyboard underneath its modal.
if (!isSettingsSheetVisibleRef.current) {
if (!settingsSheetPresentation.isActiveRef.current) {
promptInputRef.current?.focus();
} else {
settingsSheetPresentation.restoreFocusAfterSave();
}
});
});
Expand All@@ -535,7 +539,11 @@ export function NewTaskDraftScreen(props: {
cancelAnimationFrame(focusFrame);
}
};
}, [selectedProject]);
}, [
selectedProject,
settingsSheetPresentation.isActiveRef,
settingsSheetPresentation.restoreFocusAfterSave,
]);

const environmentMenuActions = useMemo(
() =>
Expand DownExpand Up@@ -643,24 +651,6 @@ export function NewTaskDraftScreen(props: {
}),
[currentBranchName, flow.selectedBranchName, flow.workspaceMode],
);
// Order matters: mark the sheet open before dismissing the keyboard so the
// Android draft layout (expanded only while focused) doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isComposerFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isComposerFocused]);
const closeSettingsSheet = useCallback((reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
promptInputRef.current?.focus();
}
}, []);

function handleEnvironmentMenuAction(event: string) {
if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
return;
Expand DownExpand Up@@ -876,7 +866,7 @@ export function NewTaskDraftScreen(props: {
// the touch gesture that opens the keyboard.
// The settings sheet dismisses the keyboard, so its flag keeps the Android
// draft composer expanded through the blur (mirrors ThreadComposer).
const isExpanded = !isAndroid || isComposerFocused || isSettingsSheetVisible;
const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
Expand DownExpand Up@@ -936,7 +926,7 @@ export function NewTaskDraftScreen(props: {
iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
<ControlPillMenu
actions={environmentMenuActions}
Expand DownExpand Up@@ -965,8 +955,9 @@ export function NewTaskDraftScreen(props: {

const settingsSheet = (
<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={flow.providerGroups}
selectedModel={flow.selectedModel}
onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
Expand Down
41 changes: 12 additions & 29 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject
import {
ActivityIndicator,
Image,
Keyboard,
Platform,
Pressable,
StyleSheet,
Expand DownExpand Up@@ -67,6 +66,7 @@ import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
Expand DownExpand Up@@ -270,16 +270,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: inputRef,
isEditorFocused: isFocused,
});
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set<string>());
const { onExpandedChange } = props;

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
// The settings sheet dismisses the keyboard (it would cover the sheet), so
// the sheet flag keeps the composer expanded through that blur.
const isExpanded = isFocused || isSettingsSheetVisible;
// Opening and closing count as active so the composer stays expanded while
// focus moves between its native editor and the settings modal.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;

// Notify the parent from the derived value, not focus events: the parent
Expand DownExpand Up@@ -620,27 +623,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
interactionMode: currentInteractionMode,
});

// Order matters: mark the sheet open before dismissing the keyboard so
// isExpanded stays true through the blur and the composer doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isFocused]);
const closeSettingsSheet = useCallback(
(reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
inputRef.current?.focus();
}
},
[inputRef],
);

return (
<Animated.View
className="px-4"
Expand DownExpand Up@@ -817,7 +799,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
{showStopAction ? (
<ComposerToolbarButton
Expand DownExpand Up@@ -853,8 +835,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</Animated.View>

<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={threadProviderGroups}
selectedModel={currentModelSelection}
onSelectModel={(option) => props.onUpdateModelSelection(option.selection)}
Expand Down
43 changes: 37 additions & 6 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,16 @@ import {
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Modal, Pressable, ScrollView, Switch, useWindowDimensions, View } from "react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
Switch,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
Expand All@@ -22,6 +30,8 @@ import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
import { useThemeColor } from "../../lib/useThemeColor";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";
import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";

/**
* The everyday harnesses stay expanded; every other provider (OpenRouter
Expand DownExpand Up@@ -293,7 +303,8 @@ export function ThreadSettingsSheet(props: {
* "dismiss" = backdrop, grabber, or system back. Hosts only restore the
* keyboard for "save" so a stray tap outside a control never pops it.
*/
readonly onClose: (reason: "save" | "dismiss") => void;
readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
readonly onDismissed: () => void;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
Expand All@@ -308,18 +319,31 @@ export function ThreadSettingsSheet(props: {
const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());
const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
const wasPresentedRef = useRef(false);
const notifyDismissed = useCallback(() => {
if (!wasPresentedRef.current) {
return;
}
wasPresentedRef.current = false;
props.onDismissed();
}, [props.onDismissed]);

// Every open starts fresh: no staged model, no submenu, legacy hidden,
// secondary providers folded. The sheet stays mounted between opens, so
// state would otherwise stick around.
useEffect(() => {
if (props.visible) {
wasPresentedRef.current = true;
setShowLegacyToggle(false);
setExpandedProviders(new Set());
setPendingModel(null);
setSubmenu(null);
} else if (Platform.OS === "android" && wasPresentedRef.current) {
// React Native only emits Modal.onDismiss on iOS. Android uses no exit
// animation below, so the post-commit effect is its dismissal boundary.
notifyDismissed();
}
}, [props.visible]);
}, [notifyDismissed, props.visible]);

const isApplied = (option: ModelOption) =>
option.selection.instanceId === props.selectedModel?.instanceId &&
Expand DownExpand Up@@ -455,8 +479,9 @@ export function ThreadSettingsSheet(props: {
transparent
statusBarTranslucent
navigationBarTranslucent
animationType="fade"
animationType={Platform.OS === "ios" ? "fade" : "none"}
visible={props.visible}
onDismiss={notifyDismissed}
onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
>
<View className="flex-1 justify-end">
Expand DownExpand Up@@ -538,7 +563,13 @@ export function ThreadSettingsSheet(props: {
onPress={() => {
void Haptics.selectionAsync();
// Re-tapping the applied model cancels staging.
setPendingModel(isApplied(option) ? null : option);
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
}}
/>
))}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";

import type { ModelOption } from "../../lib/modelOptions";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";

function modelOption(
model: string,
options: ReadonlyArray<ProviderOptionSelection> = [],
): ModelOption {
return {
key: `codex:${model}`,
label: model,
subtitle: "Codex",
providerKey: "codex",
providerLabel: "Codex",
providerDriver: "codex",
isDefault: false,
isLegacy: false,
capabilities: null,
selection: {
instanceId: ProviderInstanceId.make("codex"),
model,
options,
},
};
}

describe("thread settings sheet state", () => {
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed: modelOption("gpt-current"),
pressedIsApplied: true,
}),
).toBeNull();
});

it("preserves staged options when the highlighted model is pressed again", () => {
const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);

expect(
pendingModelAfterPress({
current: pending,
pressed: modelOption("gpt-next"),
pressedIsApplied: false,
}),
).toBe(pending);
});

it("stages a different model", () => {
const pressed = modelOption("gpt-other");

expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed,
pressedIsApplied: false,
}),
).toBe(pressed);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-settings-sheet-state.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
import type { ModelOption } from "../../lib/modelOptions";

/** Preserve staged provider options when the highlighted model is tapped again. */
export function pendingModelAfterPress(input: {
readonly current: ModelOption | null;
readonly pressed: ModelOption;
readonly pressedIsApplied: boolean;
}): ModelOption | null {
if (input.pressedIsApplied) {
return null;
}
return input.current?.key === input.pressed.key ? input.current : input.pressed;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
47 changes: 19 additions & 28 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, InteractionManager, Keyboard, Platform, View, useColorScheme } from "react-native";
import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
Expand All@@ -26,6 +26,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ComposerSurface } from "./ThreadComposer";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
Expand DownExpand Up@@ -99,9 +100,10 @@ export function NewTaskDraftScreen(props: {
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const isSettingsSheetVisibleRef = useRef(false);
isSettingsSheetVisibleRef.current = isSettingsSheetVisible;
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: promptInputRef,
isEditorFocused: isComposerFocused,
});
const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
Expand DownExpand Up@@ -523,8 +525,10 @@ export function NewTaskDraftScreen(props: {
focusFrame = requestAnimationFrame(() => {
// The delayed focus can land after the settings sheet opened, which
// would pop the keyboard underneath its modal.
if (!isSettingsSheetVisibleRef.current) {
if (!settingsSheetPresentation.isActiveRef.current) {
promptInputRef.current?.focus();
} else {
settingsSheetPresentation.restoreFocusAfterSave();
}
});
});
Expand All@@ -535,7 +539,11 @@ export function NewTaskDraftScreen(props: {
cancelAnimationFrame(focusFrame);
}
};
}, [selectedProject]);
}, [
selectedProject,
settingsSheetPresentation.isActiveRef,
settingsSheetPresentation.restoreFocusAfterSave,
]);

const environmentMenuActions = useMemo(
() =>
Expand DownExpand Up@@ -643,24 +651,6 @@ export function NewTaskDraftScreen(props: {
}),
[currentBranchName, flow.selectedBranchName, flow.workspaceMode],
);
// Order matters: mark the sheet open before dismissing the keyboard so the
// Android draft layout (expanded only while focused) doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isComposerFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isComposerFocused]);
const closeSettingsSheet = useCallback((reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
promptInputRef.current?.focus();
}
}, []);

function handleEnvironmentMenuAction(event: string) {
if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
return;
Expand DownExpand Up@@ -876,7 +866,7 @@ export function NewTaskDraftScreen(props: {
// the touch gesture that opens the keyboard.
// The settings sheet dismisses the keyboard, so its flag keeps the Android
// draft composer expanded through the blur (mirrors ThreadComposer).
const isExpanded = !isAndroid || isComposerFocused || isSettingsSheetVisible;
const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
Expand DownExpand Up@@ -936,7 +926,7 @@ export function NewTaskDraftScreen(props: {
iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
<ControlPillMenu
actions={environmentMenuActions}
Expand DownExpand Up@@ -965,8 +955,9 @@ export function NewTaskDraftScreen(props: {

const settingsSheet = (
<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={flow.providerGroups}
selectedModel={flow.selectedModel}
onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
Expand Down
41 changes: 12 additions & 29 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject
import {
ActivityIndicator,
Image,
Keyboard,
Platform,
Pressable,
StyleSheet,
Expand DownExpand Up@@ -67,6 +66,7 @@ import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
Expand DownExpand Up@@ -270,16 +270,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: inputRef,
isEditorFocused: isFocused,
});
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set<string>());
const { onExpandedChange } = props;

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
// The settings sheet dismisses the keyboard (it would cover the sheet), so
// the sheet flag keeps the composer expanded through that blur.
const isExpanded = isFocused || isSettingsSheetVisible;
// Opening and closing count as active so the composer stays expanded while
// focus moves between its native editor and the settings modal.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;

// Notify the parent from the derived value, not focus events: the parent
Expand DownExpand Up@@ -620,27 +623,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
interactionMode: currentInteractionMode,
});

// Order matters: mark the sheet open before dismissing the keyboard so
// isExpanded stays true through the blur and the composer doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isFocused]);
const closeSettingsSheet = useCallback(
(reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
inputRef.current?.focus();
}
},
[inputRef],
);

return (
<Animated.View
className="px-4"
Expand DownExpand Up@@ -817,7 +799,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
{showStopAction ? (
<ComposerToolbarButton
Expand DownExpand Up@@ -853,8 +835,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</Animated.View>

<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={threadProviderGroups}
selectedModel={currentModelSelection}
onSelectModel={(option) => props.onUpdateModelSelection(option.selection)}
Expand Down
43 changes: 37 additions & 6 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,16 @@ import {
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Modal, Pressable, ScrollView, Switch, useWindowDimensions, View } from "react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
Switch,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
Expand All@@ -22,6 +30,8 @@ import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
import { useThemeColor } from "../../lib/useThemeColor";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";
import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";

/**
* The everyday harnesses stay expanded; every other provider (OpenRouter
Expand DownExpand Up@@ -293,7 +303,8 @@ export function ThreadSettingsSheet(props: {
* "dismiss" = backdrop, grabber, or system back. Hosts only restore the
* keyboard for "save" so a stray tap outside a control never pops it.
*/
readonly onClose: (reason: "save" | "dismiss") => void;
readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
readonly onDismissed: () => void;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
Expand All@@ -308,18 +319,31 @@ export function ThreadSettingsSheet(props: {
const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());
const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
const wasPresentedRef = useRef(false);
const notifyDismissed = useCallback(() => {
if (!wasPresentedRef.current) {
return;
}
wasPresentedRef.current = false;
props.onDismissed();
}, [props.onDismissed]);

// Every open starts fresh: no staged model, no submenu, legacy hidden,
// secondary providers folded. The sheet stays mounted between opens, so
// state would otherwise stick around.
useEffect(() => {
if (props.visible) {
wasPresentedRef.current = true;
setShowLegacyToggle(false);
setExpandedProviders(new Set());
setPendingModel(null);
setSubmenu(null);
} else if (Platform.OS === "android" && wasPresentedRef.current) {
// React Native only emits Modal.onDismiss on iOS. Android uses no exit
// animation below, so the post-commit effect is its dismissal boundary.
notifyDismissed();
}
}, [props.visible]);
}, [notifyDismissed, props.visible]);

const isApplied = (option: ModelOption) =>
option.selection.instanceId === props.selectedModel?.instanceId &&
Expand DownExpand Up@@ -455,8 +479,9 @@ export function ThreadSettingsSheet(props: {
transparent
statusBarTranslucent
navigationBarTranslucent
animationType="fade"
animationType={Platform.OS === "ios" ? "fade" : "none"}
visible={props.visible}
onDismiss={notifyDismissed}
onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
>
<View className="flex-1 justify-end">
Expand DownExpand Up@@ -538,7 +563,13 @@ export function ThreadSettingsSheet(props: {
onPress={() => {
void Haptics.selectionAsync();
// Re-tapping the applied model cancels staging.
setPendingModel(isApplied(option) ? null : option);
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
}}
/>
))}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";

import type { ModelOption } from "../../lib/modelOptions";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";

function modelOption(
model: string,
options: ReadonlyArray<ProviderOptionSelection> = [],
): ModelOption {
return {
key: `codex:${model}`,
label: model,
subtitle: "Codex",
providerKey: "codex",
providerLabel: "Codex",
providerDriver: "codex",
isDefault: false,
isLegacy: false,
capabilities: null,
selection: {
instanceId: ProviderInstanceId.make("codex"),
model,
options,
},
};
}

describe("thread settings sheet state", () => {
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed: modelOption("gpt-current"),
pressedIsApplied: true,
}),
).toBeNull();
});

it("preserves staged options when the highlighted model is pressed again", () => {
const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);

expect(
pendingModelAfterPress({
current: pending,
pressed: modelOption("gpt-next"),
pressedIsApplied: false,
}),
).toBe(pending);
});

it("stages a different model", () => {
const pressed = modelOption("gpt-other");

expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed,
pressedIsApplied: false,
}),
).toBe(pressed);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-settings-sheet-state.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
import type { ModelOption } from "../../lib/modelOptions";

/** Preserve staged provider options when the highlighted model is tapped again. */
export function pendingModelAfterPress(input: {
readonly current: ModelOption | null;
readonly pressed: ModelOption;
readonly pressedIsApplied: boolean;
}): ModelOption | null {
if (input.pressedIsApplied) {
return null;
}
return input.current?.key === input.pressed.key ? input.current : input.pressed;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
47 changes: 19 additions & 28 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, InteractionManager, Keyboard, Platform, View, useColorScheme } from "react-native";
import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
Expand All@@ -26,6 +26,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ComposerSurface } from "./ThreadComposer";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
Expand DownExpand Up@@ -99,9 +100,10 @@ export function NewTaskDraftScreen(props: {
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const isSettingsSheetVisibleRef = useRef(false);
isSettingsSheetVisibleRef.current = isSettingsSheetVisible;
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: promptInputRef,
isEditorFocused: isComposerFocused,
});
const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
Expand DownExpand Up@@ -523,8 +525,10 @@ export function NewTaskDraftScreen(props: {
focusFrame = requestAnimationFrame(() => {
// The delayed focus can land after the settings sheet opened, which
// would pop the keyboard underneath its modal.
if (!isSettingsSheetVisibleRef.current) {
if (!settingsSheetPresentation.isActiveRef.current) {
promptInputRef.current?.focus();
} else {
settingsSheetPresentation.restoreFocusAfterSave();
}
});
});
Expand All@@ -535,7 +539,11 @@ export function NewTaskDraftScreen(props: {
cancelAnimationFrame(focusFrame);
}
};
}, [selectedProject]);
}, [
selectedProject,
settingsSheetPresentation.isActiveRef,
settingsSheetPresentation.restoreFocusAfterSave,
]);

const environmentMenuActions = useMemo(
() =>
Expand DownExpand Up@@ -643,24 +651,6 @@ export function NewTaskDraftScreen(props: {
}),
[currentBranchName, flow.selectedBranchName, flow.workspaceMode],
);
// Order matters: mark the sheet open before dismissing the keyboard so the
// Android draft layout (expanded only while focused) doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isComposerFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isComposerFocused]);
const closeSettingsSheet = useCallback((reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
promptInputRef.current?.focus();
}
}, []);

function handleEnvironmentMenuAction(event: string) {
if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
return;
Expand DownExpand Up@@ -876,7 +866,7 @@ export function NewTaskDraftScreen(props: {
// the touch gesture that opens the keyboard.
// The settings sheet dismisses the keyboard, so its flag keeps the Android
// draft composer expanded through the blur (mirrors ThreadComposer).
const isExpanded = !isAndroid || isComposerFocused || isSettingsSheetVisible;
const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
Expand DownExpand Up@@ -936,7 +926,7 @@ export function NewTaskDraftScreen(props: {
iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
<ControlPillMenu
actions={environmentMenuActions}
Expand DownExpand Up@@ -965,8 +955,9 @@ export function NewTaskDraftScreen(props: {

const settingsSheet = (
<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={flow.providerGroups}
selectedModel={flow.selectedModel}
onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
Expand Down
41 changes: 12 additions & 29 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject
import {
ActivityIndicator,
Image,
Keyboard,
Platform,
Pressable,
StyleSheet,
Expand DownExpand Up@@ -67,6 +66,7 @@ import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
Expand DownExpand Up@@ -270,16 +270,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: inputRef,
isEditorFocused: isFocused,
});
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set<string>());
const { onExpandedChange } = props;

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
// The settings sheet dismisses the keyboard (it would cover the sheet), so
// the sheet flag keeps the composer expanded through that blur.
const isExpanded = isFocused || isSettingsSheetVisible;
// Opening and closing count as active so the composer stays expanded while
// focus moves between its native editor and the settings modal.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;

// Notify the parent from the derived value, not focus events: the parent
Expand DownExpand Up@@ -620,27 +623,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
interactionMode: currentInteractionMode,
});

// Order matters: mark the sheet open before dismissing the keyboard so
// isExpanded stays true through the blur and the composer doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isFocused]);
const closeSettingsSheet = useCallback(
(reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
inputRef.current?.focus();
}
},
[inputRef],
);

return (
<Animated.View
className="px-4"
Expand DownExpand Up@@ -817,7 +799,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
{showStopAction ? (
<ComposerToolbarButton
Expand DownExpand Up@@ -853,8 +835,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</Animated.View>

<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={threadProviderGroups}
selectedModel={currentModelSelection}
onSelectModel={(option) => props.onUpdateModelSelection(option.selection)}
Expand Down
43 changes: 37 additions & 6 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,16 @@ import {
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Modal, Pressable, ScrollView, Switch, useWindowDimensions, View } from "react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
Switch,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
Expand All@@ -22,6 +30,8 @@ import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
import { useThemeColor } from "../../lib/useThemeColor";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";
import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";

/**
* The everyday harnesses stay expanded; every other provider (OpenRouter
Expand DownExpand Up@@ -293,7 +303,8 @@ export function ThreadSettingsSheet(props: {
* "dismiss" = backdrop, grabber, or system back. Hosts only restore the
* keyboard for "save" so a stray tap outside a control never pops it.
*/
readonly onClose: (reason: "save" | "dismiss") => void;
readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
readonly onDismissed: () => void;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
Expand All@@ -308,18 +319,31 @@ export function ThreadSettingsSheet(props: {
const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());
const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
const wasPresentedRef = useRef(false);
const notifyDismissed = useCallback(() => {
if (!wasPresentedRef.current) {
return;
}
wasPresentedRef.current = false;
props.onDismissed();
}, [props.onDismissed]);

// Every open starts fresh: no staged model, no submenu, legacy hidden,
// secondary providers folded. The sheet stays mounted between opens, so
// state would otherwise stick around.
useEffect(() => {
if (props.visible) {
wasPresentedRef.current = true;
setShowLegacyToggle(false);
setExpandedProviders(new Set());
setPendingModel(null);
setSubmenu(null);
} else if (Platform.OS === "android" && wasPresentedRef.current) {
// React Native only emits Modal.onDismiss on iOS. Android uses no exit
// animation below, so the post-commit effect is its dismissal boundary.
notifyDismissed();
}
}, [props.visible]);
}, [notifyDismissed, props.visible]);

const isApplied = (option: ModelOption) =>
option.selection.instanceId === props.selectedModel?.instanceId &&
Expand DownExpand Up@@ -455,8 +479,9 @@ export function ThreadSettingsSheet(props: {
transparent
statusBarTranslucent
navigationBarTranslucent
animationType="fade"
animationType={Platform.OS === "ios" ? "fade" : "none"}
visible={props.visible}
onDismiss={notifyDismissed}
onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
>
<View className="flex-1 justify-end">
Expand DownExpand Up@@ -538,7 +563,13 @@ export function ThreadSettingsSheet(props: {
onPress={() => {
void Haptics.selectionAsync();
// Re-tapping the applied model cancels staging.
setPendingModel(isApplied(option) ? null : option);
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
}}
/>
))}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";

import type { ModelOption } from "../../lib/modelOptions";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";

function modelOption(
model: string,
options: ReadonlyArray<ProviderOptionSelection> = [],
): ModelOption {
return {
key: `codex:${model}`,
label: model,
subtitle: "Codex",
providerKey: "codex",
providerLabel: "Codex",
providerDriver: "codex",
isDefault: false,
isLegacy: false,
capabilities: null,
selection: {
instanceId: ProviderInstanceId.make("codex"),
model,
options,
},
};
}

describe("thread settings sheet state", () => {
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed: modelOption("gpt-current"),
pressedIsApplied: true,
}),
).toBeNull();
});

it("preserves staged options when the highlighted model is pressed again", () => {
const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);

expect(
pendingModelAfterPress({
current: pending,
pressed: modelOption("gpt-next"),
pressedIsApplied: false,
}),
).toBe(pending);
});

it("stages a different model", () => {
const pressed = modelOption("gpt-other");

expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed,
pressedIsApplied: false,
}),
).toBe(pressed);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-settings-sheet-state.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
import type { ModelOption } from "../../lib/modelOptions";

/** Preserve staged provider options when the highlighted model is tapped again. */
export function pendingModelAfterPress(input: {
readonly current: ModelOption | null;
readonly pressed: ModelOption;
readonly pressedIsApplied: boolean;
}): ModelOption | null {
if (input.pressedIsApplied) {
return null;
}
return input.current?.key === input.pressed.key ? input.current : input.pressed;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
47 changes: 19 additions & 28 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, InteractionManager, Keyboard, Platform, View, useColorScheme } from "react-native";
import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
Expand All@@ -26,6 +26,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ComposerSurface } from "./ThreadComposer";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
Expand DownExpand Up@@ -99,9 +100,10 @@ export function NewTaskDraftScreen(props: {
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const isSettingsSheetVisibleRef = useRef(false);
isSettingsSheetVisibleRef.current = isSettingsSheetVisible;
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: promptInputRef,
isEditorFocused: isComposerFocused,
});
const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
Expand DownExpand Up@@ -523,8 +525,10 @@ export function NewTaskDraftScreen(props: {
focusFrame = requestAnimationFrame(() => {
// The delayed focus can land after the settings sheet opened, which
// would pop the keyboard underneath its modal.
if (!isSettingsSheetVisibleRef.current) {
if (!settingsSheetPresentation.isActiveRef.current) {
promptInputRef.current?.focus();
} else {
settingsSheetPresentation.restoreFocusAfterSave();
}
});
});
Expand All@@ -535,7 +539,11 @@ export function NewTaskDraftScreen(props: {
cancelAnimationFrame(focusFrame);
}
};
}, [selectedProject]);
}, [
selectedProject,
settingsSheetPresentation.isActiveRef,
settingsSheetPresentation.restoreFocusAfterSave,
]);

const environmentMenuActions = useMemo(
() =>
Expand DownExpand Up@@ -643,24 +651,6 @@ export function NewTaskDraftScreen(props: {
}),
[currentBranchName, flow.selectedBranchName, flow.workspaceMode],
);
// Order matters: mark the sheet open before dismissing the keyboard so the
// Android draft layout (expanded only while focused) doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isComposerFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isComposerFocused]);
const closeSettingsSheet = useCallback((reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
promptInputRef.current?.focus();
}
}, []);

function handleEnvironmentMenuAction(event: string) {
if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
return;
Expand DownExpand Up@@ -876,7 +866,7 @@ export function NewTaskDraftScreen(props: {
// the touch gesture that opens the keyboard.
// The settings sheet dismisses the keyboard, so its flag keeps the Android
// draft composer expanded through the blur (mirrors ThreadComposer).
const isExpanded = !isAndroid || isComposerFocused || isSettingsSheetVisible;
const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
Expand DownExpand Up@@ -936,7 +926,7 @@ export function NewTaskDraftScreen(props: {
iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
<ControlPillMenu
actions={environmentMenuActions}
Expand DownExpand Up@@ -965,8 +955,9 @@ export function NewTaskDraftScreen(props: {

const settingsSheet = (
<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={flow.providerGroups}
selectedModel={flow.selectedModel}
onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
Expand Down
41 changes: 12 additions & 29 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject
import {
ActivityIndicator,
Image,
Keyboard,
Platform,
Pressable,
StyleSheet,
Expand DownExpand Up@@ -67,6 +66,7 @@ import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
Expand DownExpand Up@@ -270,16 +270,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: inputRef,
isEditorFocused: isFocused,
});
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set<string>());
const { onExpandedChange } = props;

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
// The settings sheet dismisses the keyboard (it would cover the sheet), so
// the sheet flag keeps the composer expanded through that blur.
const isExpanded = isFocused || isSettingsSheetVisible;
// Opening and closing count as active so the composer stays expanded while
// focus moves between its native editor and the settings modal.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;

// Notify the parent from the derived value, not focus events: the parent
Expand DownExpand Up@@ -620,27 +623,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
interactionMode: currentInteractionMode,
});

// Order matters: mark the sheet open before dismissing the keyboard so
// isExpanded stays true through the blur and the composer doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isFocused]);
const closeSettingsSheet = useCallback(
(reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
inputRef.current?.focus();
}
},
[inputRef],
);

return (
<Animated.View
className="px-4"
Expand DownExpand Up@@ -817,7 +799,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
{showStopAction ? (
<ComposerToolbarButton
Expand DownExpand Up@@ -853,8 +835,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</Animated.View>

<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={threadProviderGroups}
selectedModel={currentModelSelection}
onSelectModel={(option) => props.onUpdateModelSelection(option.selection)}
Expand Down
43 changes: 37 additions & 6 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,16 @@ import {
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Modal, Pressable, ScrollView, Switch, useWindowDimensions, View } from "react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
Switch,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
Expand All@@ -22,6 +30,8 @@ import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
import { useThemeColor } from "../../lib/useThemeColor";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";
import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";

/**
* The everyday harnesses stay expanded; every other provider (OpenRouter
Expand DownExpand Up@@ -293,7 +303,8 @@ export function ThreadSettingsSheet(props: {
* "dismiss" = backdrop, grabber, or system back. Hosts only restore the
* keyboard for "save" so a stray tap outside a control never pops it.
*/
readonly onClose: (reason: "save" | "dismiss") => void;
readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
readonly onDismissed: () => void;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
Expand All@@ -308,18 +319,31 @@ export function ThreadSettingsSheet(props: {
const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());
const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
const wasPresentedRef = useRef(false);
const notifyDismissed = useCallback(() => {
if (!wasPresentedRef.current) {
return;
}
wasPresentedRef.current = false;
props.onDismissed();
}, [props.onDismissed]);

// Every open starts fresh: no staged model, no submenu, legacy hidden,
// secondary providers folded. The sheet stays mounted between opens, so
// state would otherwise stick around.
useEffect(() => {
if (props.visible) {
wasPresentedRef.current = true;
setShowLegacyToggle(false);
setExpandedProviders(new Set());
setPendingModel(null);
setSubmenu(null);
} else if (Platform.OS === "android" && wasPresentedRef.current) {
// React Native only emits Modal.onDismiss on iOS. Android uses no exit
// animation below, so the post-commit effect is its dismissal boundary.
notifyDismissed();
}
}, [props.visible]);
}, [notifyDismissed, props.visible]);

const isApplied = (option: ModelOption) =>
option.selection.instanceId === props.selectedModel?.instanceId &&
Expand DownExpand Up@@ -455,8 +479,9 @@ export function ThreadSettingsSheet(props: {
transparent
statusBarTranslucent
navigationBarTranslucent
animationType="fade"
animationType={Platform.OS === "ios" ? "fade" : "none"}
visible={props.visible}
onDismiss={notifyDismissed}
onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
>
<View className="flex-1 justify-end">
Expand DownExpand Up@@ -538,7 +563,13 @@ export function ThreadSettingsSheet(props: {
onPress={() => {
void Haptics.selectionAsync();
// Re-tapping the applied model cancels staging.
setPendingModel(isApplied(option) ? null : option);
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
}}
/>
))}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";

import type { ModelOption } from "../../lib/modelOptions";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";

function modelOption(
model: string,
options: ReadonlyArray<ProviderOptionSelection> = [],
): ModelOption {
return {
key: `codex:${model}`,
label: model,
subtitle: "Codex",
providerKey: "codex",
providerLabel: "Codex",
providerDriver: "codex",
isDefault: false,
isLegacy: false,
capabilities: null,
selection: {
instanceId: ProviderInstanceId.make("codex"),
model,
options,
},
};
}

describe("thread settings sheet state", () => {
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed: modelOption("gpt-current"),
pressedIsApplied: true,
}),
).toBeNull();
});

it("preserves staged options when the highlighted model is pressed again", () => {
const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);

expect(
pendingModelAfterPress({
current: pending,
pressed: modelOption("gpt-next"),
pressedIsApplied: false,
}),
).toBe(pending);
});

it("stages a different model", () => {
const pressed = modelOption("gpt-other");

expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed,
pressedIsApplied: false,
}),
).toBe(pressed);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-settings-sheet-state.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
import type { ModelOption } from "../../lib/modelOptions";

/** Preserve staged provider options when the highlighted model is tapped again. */
export function pendingModelAfterPress(input: {
readonly current: ModelOption | null;
readonly pressed: ModelOption;
readonly pressedIsApplied: boolean;
}): ModelOption | null {
if (input.pressedIsApplied) {
return null;
}
return input.current?.key === input.pressed.key ? input.current : input.pressed;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
47 changes: 19 additions & 28 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, InteractionManager, Keyboard, Platform, View, useColorScheme } from "react-native";
import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
Expand All@@ -26,6 +26,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ComposerSurface } from "./ThreadComposer";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
Expand DownExpand Up@@ -99,9 +100,10 @@ export function NewTaskDraftScreen(props: {
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const isSettingsSheetVisibleRef = useRef(false);
isSettingsSheetVisibleRef.current = isSettingsSheetVisible;
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: promptInputRef,
isEditorFocused: isComposerFocused,
});
const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
Expand DownExpand Up@@ -523,8 +525,10 @@ export function NewTaskDraftScreen(props: {
focusFrame = requestAnimationFrame(() => {
// The delayed focus can land after the settings sheet opened, which
// would pop the keyboard underneath its modal.
if (!isSettingsSheetVisibleRef.current) {
if (!settingsSheetPresentation.isActiveRef.current) {
promptInputRef.current?.focus();
} else {
settingsSheetPresentation.restoreFocusAfterSave();
}
});
});
Expand All@@ -535,7 +539,11 @@ export function NewTaskDraftScreen(props: {
cancelAnimationFrame(focusFrame);
}
};
}, [selectedProject]);
}, [
selectedProject,
settingsSheetPresentation.isActiveRef,
settingsSheetPresentation.restoreFocusAfterSave,
]);

const environmentMenuActions = useMemo(
() =>
Expand DownExpand Up@@ -643,24 +651,6 @@ export function NewTaskDraftScreen(props: {
}),
[currentBranchName, flow.selectedBranchName, flow.workspaceMode],
);
// Order matters: mark the sheet open before dismissing the keyboard so the
// Android draft layout (expanded only while focused) doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isComposerFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isComposerFocused]);
const closeSettingsSheet = useCallback((reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
promptInputRef.current?.focus();
}
}, []);

function handleEnvironmentMenuAction(event: string) {
if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
return;
Expand DownExpand Up@@ -876,7 +866,7 @@ export function NewTaskDraftScreen(props: {
// the touch gesture that opens the keyboard.
// The settings sheet dismisses the keyboard, so its flag keeps the Android
// draft composer expanded through the blur (mirrors ThreadComposer).
const isExpanded = !isAndroid || isComposerFocused || isSettingsSheetVisible;
const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
Expand DownExpand Up@@ -936,7 +926,7 @@ export function NewTaskDraftScreen(props: {
iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
<ControlPillMenu
actions={environmentMenuActions}
Expand DownExpand Up@@ -965,8 +955,9 @@ export function NewTaskDraftScreen(props: {

const settingsSheet = (
<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={flow.providerGroups}
selectedModel={flow.selectedModel}
onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
Expand Down
41 changes: 12 additions & 29 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject
import {
ActivityIndicator,
Image,
Keyboard,
Platform,
Pressable,
StyleSheet,
Expand DownExpand Up@@ -67,6 +66,7 @@ import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";

/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
Expand DownExpand Up@@ -270,16 +270,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
const settingsSheetPresentation = useThreadSettingsSheetPresentation({
editorRef: inputRef,
isEditorFocused: isFocused,
});
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set<string>());
const { onExpandedChange } = props;

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [isSettingsSheetVisible, setIsSettingsSheetVisible] = useState(false);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
// The settings sheet dismisses the keyboard (it would cover the sheet), so
// the sheet flag keeps the composer expanded through that blur.
const isExpanded = isFocused || isSettingsSheetVisible;
// Opening and closing count as active so the composer stays expanded while
// focus moves between its native editor and the settings modal.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;

// Notify the parent from the derived value, not focus events: the parent
Expand DownExpand Up@@ -620,27 +623,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
interactionMode: currentInteractionMode,
});

// Order matters: mark the sheet open before dismissing the keyboard so
// isExpanded stays true through the blur and the composer doesn't collapse.
// The keyboard only comes back on close if it was up when the sheet opened.
const wasFocusedBeforeSheetRef = useRef(false);
const openSettingsSheet = useCallback(() => {
wasFocusedBeforeSheetRef.current = isFocused;
setIsSettingsSheetVisible(true);
Keyboard.dismiss();
}, [isFocused]);
const closeSettingsSheet = useCallback(
(reason: "save" | "dismiss") => {
setIsSettingsSheetVisible(false);
// Only Save/Done restores the keyboard: a dismissal (backdrop or
// grabber, including stray taps near the sheet's edge) closes quietly.
if (reason === "save" && wasFocusedBeforeSheetRef.current) {
inputRef.current?.focus();
}
},
[inputRef],
);

return (
<Animated.View
className="px-4"
Expand DownExpand Up@@ -817,7 +799,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
label={settingsSummaryLabel}
maxWidth={320}
onPress={openSettingsSheet}
onPress={settingsSheetPresentation.open}
/>
{showStopAction ? (
<ComposerToolbarButton
Expand DownExpand Up@@ -853,8 +835,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</Animated.View>

<ThreadSettingsSheet
visible={isSettingsSheetVisible}
onClose={closeSettingsSheet}
visible={settingsSheetPresentation.isVisible}
onClose={settingsSheetPresentation.close}
onDismissed={settingsSheetPresentation.onDismissed}
providerGroups={threadProviderGroups}
selectedModel={currentModelSelection}
onSelectModel={(option) => props.onUpdateModelSelection(option.selection)}
Expand Down
43 changes: 37 additions & 6 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,16 @@ import {
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Modal, Pressable, ScrollView, Switch, useWindowDimensions, View } from "react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
Switch,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
Expand All@@ -22,6 +30,8 @@ import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
import { useThemeColor } from "../../lib/useThemeColor";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";
import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";

/**
* The everyday harnesses stay expanded; every other provider (OpenRouter
Expand DownExpand Up@@ -293,7 +303,8 @@ export function ThreadSettingsSheet(props: {
* "dismiss" = backdrop, grabber, or system back. Hosts only restore the
* keyboard for "save" so a stray tap outside a control never pops it.
*/
readonly onClose: (reason: "save" | "dismiss") => void;
readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
readonly onDismissed: () => void;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
Expand All@@ -308,18 +319,31 @@ export function ThreadSettingsSheet(props: {
const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());
const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
const wasPresentedRef = useRef(false);
const notifyDismissed = useCallback(() => {
if (!wasPresentedRef.current) {
return;
}
wasPresentedRef.current = false;
props.onDismissed();
}, [props.onDismissed]);

// Every open starts fresh: no staged model, no submenu, legacy hidden,
// secondary providers folded. The sheet stays mounted between opens, so
// state would otherwise stick around.
useEffect(() => {
if (props.visible) {
wasPresentedRef.current = true;
setShowLegacyToggle(false);
setExpandedProviders(new Set());
setPendingModel(null);
setSubmenu(null);
} else if (Platform.OS === "android" && wasPresentedRef.current) {
// React Native only emits Modal.onDismiss on iOS. Android uses no exit
// animation below, so the post-commit effect is its dismissal boundary.
notifyDismissed();
}
}, [props.visible]);
}, [notifyDismissed, props.visible]);

const isApplied = (option: ModelOption) =>
option.selection.instanceId === props.selectedModel?.instanceId &&
Expand DownExpand Up@@ -455,8 +479,9 @@ export function ThreadSettingsSheet(props: {
transparent
statusBarTranslucent
navigationBarTranslucent
animationType="fade"
animationType={Platform.OS === "ios" ? "fade" : "none"}
visible={props.visible}
onDismiss={notifyDismissed}
onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
>
<View className="flex-1 justify-end">
Expand DownExpand Up@@ -538,7 +563,13 @@ export function ThreadSettingsSheet(props: {
onPress={() => {
void Haptics.selectionAsync();
// Re-tapping the applied model cancels staging.
setPendingModel(isApplied(option) ? null : option);
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
}}
/>
))}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";

import type { ModelOption } from "../../lib/modelOptions";
import { pendingModelAfterPress } from "./thread-settings-sheet-state";

function modelOption(
model: string,
options: ReadonlyArray<ProviderOptionSelection> = [],
): ModelOption {
return {
key: `codex:${model}`,
label: model,
subtitle: "Codex",
providerKey: "codex",
providerLabel: "Codex",
providerDriver: "codex",
isDefault: false,
isLegacy: false,
capabilities: null,
selection: {
instanceId: ProviderInstanceId.make("codex"),
model,
options,
},
};
}

describe("thread settings sheet state", () => {
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed: modelOption("gpt-current"),
pressedIsApplied: true,
}),
).toBeNull();
});

it("preserves staged options when the highlighted model is pressed again", () => {
const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);

expect(
pendingModelAfterPress({
current: pending,
pressed: modelOption("gpt-next"),
pressedIsApplied: false,
}),
).toBe(pending);
});

it("stages a different model", () => {
const pressed = modelOption("gpt-other");

expect(
pendingModelAfterPress({
current: modelOption("gpt-next"),
pressed,
pressedIsApplied: false,
}),
).toBe(pressed);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-settings-sheet-state.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
import type { ModelOption } from "../../lib/modelOptions";

/** Preserve staged provider options when the highlighted model is tapped again. */
export function pendingModelAfterPress(input: {
readonly current: ModelOption | null;
readonly pressed: ModelOption;
readonly pressedIsApplied: boolean;
}): ModelOption | null {
if (input.pressedIsApplied) {
return null;
}
return input.current?.key === input.pressed.key ? input.current : input.pressed;
}
Loading
Loading