authorgravatar for 45952064+bmdavis419@users.noreply.github.comBen Davis <45952064+bmdavis419@users.noreply.github.com> 2026-09-14 22:12:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 05:12:54+00:00
log26b8f985d85773e41a2ee637785f7c87d5c1a01e
tree19f59dc67df2f111fcbf789528f379959df95a5f
parentb20d29dc438bcabb2c25e85976dc108169bb4c6d
signature Signed by PGP key B5690EEEBB952194

feat(mobile): add iPad keyboard shortcuts and command palette (#11679)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

36 files changed, 1303 insertions(+), 122 deletions(-)

apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift+3
......@@ -87,6 +87,9 @@ public class T3ComposerEditorModule: Module {
8787 Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in
8888 view.setSpellCheck(spellCheck)
8989 }
90 Prop("enterBehavior") { (view: T3ComposerEditorView, behavior: String) in
91 view.setEnterBehavior(behavior)
92 }
9093 Prop("textPasteThresholdBytes") { (view: T3ComposerEditorView, threshold: Int) in
9194 view.setTextPasteThresholdBytes(threshold)
9295 }
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+39
......@@ -45,6 +45,11 @@ private struct ComposerChipStyle {
4545 let textColor: UIColor
4646}
4747
48private enum ComposerEnterBehavior: String {
49 case send
50 case newline
51}
52
4853private final class ComposerTextAttachment: NSTextAttachment {
4954 let source: String
5055 let label: String
......@@ -93,10 +98,12 @@ private final class ComposerTextView: UITextView {
9398 var isReadOnly = false
9499 var textPasteThresholdBytes = 0
95100 var maxInputChars = Int.max
101 var enterBehavior: ComposerEnterBehavior = .send
96102 private var bypassTextPasteInterception = false
97103
98104 override var keyCommands: [UIKeyCommand]? {
99105 var commands = super.keyCommands ?? []
106 guard !isReadOnly, markedTextRange == nil else { return commands }
100107 let submit = UIKeyCommand(
101108 input: "\r",
102109 modifierFlags: .command,
......@@ -105,6 +112,25 @@ private final class ComposerTextView: UITextView {
105112 submit.discoverabilityTitle = "Send Message"
106113 submit.wantsPriorityOverSystemBehavior = true
107114 commands.append(submit)
115 if enterBehavior == .send {
116 let submitOnReturn = UIKeyCommand(
117 input: "\r",
118 modifierFlags: [],
119 action: #selector(submitMessage(_:))
120 )
121 submitOnReturn.discoverabilityTitle = "Send Message"
122 submitOnReturn.wantsPriorityOverSystemBehavior = true
123 commands.append(submitOnReturn)
124
125 let newline = UIKeyCommand(
126 input: "\r",
127 modifierFlags: .shift,
128 action: #selector(insertNewline(_:))
129 )
130 newline.discoverabilityTitle = "New Line"
131 newline.wantsPriorityOverSystemBehavior = true
132 commands.append(newline)
133 }
108134 if textPasteThresholdBytes > 0 {
109135 let pasteAsText = UIKeyCommand(
110136 input: "v",
......@@ -119,9 +145,15 @@ private final class ComposerTextView: UITextView {
119145 }
120146
121147 @objc private func submitMessage(_ sender: UIKeyCommand) {
148 guard !isReadOnly, markedTextRange == nil else { return }
122149 onSubmit?()
123150 }
124151
152 @objc private func insertNewline(_ sender: UIKeyCommand) {
153 guard !isReadOnly, markedTextRange == nil else { return }
154 insertText("\n")
155 }
156
125157 @objc private func pasteInline(_ sender: UIKeyCommand) {
126158 guard !isReadOnly else {
127159 return
......@@ -132,6 +164,9 @@ private final class ComposerTextView: UITextView {
132164 }
133165
134166 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
167 if action == #selector(submitMessage(_:)) || action == #selector(insertNewline(_:)) {
168 return isEditable && !isReadOnly && markedTextRange == nil
169 }
135170 if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) {
136171 return false
137172 }
......@@ -657,6 +692,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
657692 textView.spellCheckingType = spellCheck ? .yes : .no
658693 }
659694
695 func setEnterBehavior(_ behavior: String) {
696 textView.enterBehavior = ComposerEnterBehavior(rawValue: behavior) ?? .send
697 }
698
660699 func setTextPasteThresholdBytes(_ threshold: Int) {
661700 textView.textPasteThresholdBytes = threshold
662701 }
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+37-2
......@@ -20,11 +20,26 @@ public final class T3KeyboardCommandsView: ExpoView {
2020
2121 public override var canBecomeFirstResponder: Bool { true }
2222
23 public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
24 if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss),
25 let input = window?.t3FirstResponder as? UITextInput,
26 input.markedTextRange != nil {
27 return false
28 }
29 return super.canPerformAction(action, withSender: sender)
30 }
31
2332 public override var keyCommands: [UIKeyCommand]? {
24 [
33 let isPad = UIDevice.current.userInterfaceIdiom == .pad
34 var commands = [
2535 enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"),
2636 enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"),
27 enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"),
37 isPad
38 ? enabledCommand("commandPalette", input: "k", modifiers: .command, action: #selector(openCommandPalette), title: "Command Palette")
39 : enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"),
40 enabledCommand("paletteNext", input: UIKeyCommand.inputDownArrow, modifiers: [], action: #selector(paletteNext), title: "Next Result"),
41 enabledCommand("palettePrevious", input: UIKeyCommand.inputUpArrow, modifiers: [], action: #selector(palettePrevious), title: "Previous Result"),
42 enabledCommand("paletteDismiss", input: UIKeyCommand.inputEscape, modifiers: [], action: #selector(paletteDismiss), title: "Close Command Palette"),
2843 enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"),
2944 enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
3045 enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),
......@@ -38,6 +53,18 @@ public final class T3KeyboardCommandsView: ExpoView {
3853 ),
3954 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
4055 ].compactMap { $0 }
56 if isPad {
57 commands += (1...9).compactMap { index in
58 enabledCommand(
59 "thread.jump.\(index)",
60 input: String(index),
61 modifiers: .command,
62 action: #selector(jumpToThread(_:)),
63 title: "Go to Thread \(index)"
64 )
65 }
66 }
67 return commands
4168 }
4269
4370 func setEnabledCommands(_ commands: [String]) {
......@@ -108,6 +135,14 @@ public final class T3KeyboardCommandsView: ExpoView {
108135 }
109136
110137 @objc private func newTask() { emit("newTask") }
138 @objc private func openCommandPalette() { emit("commandPalette") }
139 @objc private func paletteNext() { emit("paletteNext") }
140 @objc private func palettePrevious() { emit("palettePrevious") }
141 @objc private func paletteDismiss() { emit("paletteDismiss") }
142 @objc private func jumpToThread(_ sender: UIKeyCommand) {
143 guard let input = sender.input else { return }
144 emit("thread.jump.\(input)")
145 }
111146 @objc private func focusSearch() { emit("focusSearch") }
112147 @objc private func goBack() { emit("back") }
113148 @objc private func openFiles() { emit("files") }
apps/mobile/src/App.tsx+2
......@@ -38,6 +38,8 @@ void SplashScreen.preventAutoHideAsync().catch(() => {
3838
3939const appLinking = {
4040 prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],
41 // Keep the compact thread list available beneath a directly opened thread.
42 config: { initialRouteName: "Home" },
4143 // The Expo dev client launches the app via
4244 // <scheme>://expo-development-client/?url=<packager> — that URL addresses
4345 // the launcher, not app navigation. Without this filter it falls through
apps/mobile/src/Stack.tsx+25-7
......@@ -23,7 +23,10 @@ import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardi
2323import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen";
2424import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
2525import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
26import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
26import {
27 HardwareKeyboardCommandOverlay,
28 HardwareKeyboardCommandProvider,
29} from "./features/keyboard/HardwareKeyboardCommandProvider";
2730import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
2831import { ReviewSheet } from "./features/review/ReviewSheet";
2932import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
......@@ -56,6 +59,7 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl
5659import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
5760import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
5861import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
62import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen";
5963import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
6064import {
6165 SettingsOpenSourceLicenseRouteScreen,
......@@ -192,6 +196,13 @@ const SettingsContentStack = createNativeStackNavigator({
192196 title: "Project Grouping",
193197 },
194198 }),
199 SettingsKeyboard: createNativeStackScreen({
200 screen: SettingsKeyboardRouteScreen,
201 linking: "keyboard",
202 options: {
203 title: "Keyboard",
204 },
205 }),
195206 SettingsClientStorage: createNativeStackScreen({
196207 screen: SettingsClientStorageRouteScreen,
197208 linking: "client-storage",
......@@ -381,17 +392,20 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
381392]);
382393
383394/**
384 * Pathname of the topmost NON-overlay route — the screen the workspace is
385 * actually "on", regardless of any sheets floating above it.
395 * Location of the topmost non-overlay route, including its key so thread
396 * selection can dismiss sheets without replacing the wrong destination.
386397 */
387function workspacePathFromState(state: NavigationState): string {
398function workspaceLocationFromState(state: NavigationState) {
388399 const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name));
389400 const effectiveState =
390401 routes.length > 0 && routes.length !== state.routes.length
391402 ? ({ ...state, routes, index: routes.length - 1 } as NavigationState)
392403 : state;
393404 const path = getPathFromState(effectiveState, navigationPathConfig);
394 return path.startsWith("/") ? path : `/${path}`;
405 return {
406 pathname: path.startsWith("/") ? path : `/${path}`,
407 routeKey: effectiveState.routes[effectiveState.index]?.key,
408 };
395409}
396410
397411// The drain hook subscribes to the outbox, all thread shells, projects, and
......@@ -435,15 +449,19 @@ function RootStackLayout(props: {
435449 // workspace layout only reacts to the underlying non-overlay route.
436450 const path = getPathFromState(props.state, navigationPathConfig);
437451 const pathname = path.startsWith("/") ? path : `/${path}`;
438 const workspacePathname = workspacePathFromState(props.state);
452 const workspaceLocation = workspaceLocationFromState(props.state);
439453
440454 return (
441455 <HardwareKeyboardCommandProvider pathname={pathname}>
442456 <ThreadOutboxDrainWorker />
443457 <ShowcaseCaptureCoordinator pathname={pathname} />
444458 <ExistingThreadSettingsRouteProvider>
445 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
459 <AdaptiveWorkspaceLayout
460 pathname={workspaceLocation.pathname}
461 workspaceRouteKey={workspaceLocation.routeKey}
462 >
446463 {props.children}
464 <HardwareKeyboardCommandOverlay />
447465 </AdaptiveWorkspaceLayout>
448466 </ExistingThreadSettingsRouteProvider>
449467 </HardwareKeyboardCommandProvider>
apps/mobile/src/components/ComposerEditor.tsx+8
......@@ -1,4 +1,6 @@
11import { ComposerContextId } from "@t3tools/contracts";
2import { useAtomValue } from "@effect/atom-react";
3import { AsyncResult } from "effect/unstable/reactivity";
24import { useEffect, useMemo, useRef, useState } from "react";
35import { Alert } from "react-native";
46import type { EnvironmentId } from "@t3tools/contracts";
......@@ -19,6 +21,7 @@ import {
1921 useComposerDraft,
2022} from "../state/use-composer-drafts";
2123import { importComposerContextClipboard } from "../lib/composerContextClipboard";
24import { mobilePreferencesAtom } from "../state/preferences";
2225import { ComposerContextSheet } from "./ComposerContextSheet";
2326import { AppText as Text } from "./AppText";
2427import {
......@@ -53,6 +56,10 @@ export function ComposerEditor({
5356 ...props
5457}: ComposerEditorProps) {
5558 const draft = useComposerDraft(draftKey ?? null);
59 const preferencesResult = useAtomValue(mobilePreferencesAtom);
60 const preferredEnterBehavior = AsyncResult.isSuccess(preferencesResult)
61 ? preferencesResult.value.composerEnterBehavior
62 : undefined;
5663 const contextHistory = useMemo(() => createComposerDraftContextHistory(), [draftKey]);
5764 useEffect(() => () => contextHistory.dispose(), [contextHistory]);
5865 const changeText = (text: string) => {
......@@ -158,6 +165,7 @@ export function ComposerEditor({
158165 <>
159166 <NativeComposerEditor
160167 {...props}
168 enterBehavior={props.enterBehavior ?? preferredEnterBehavior}
161169 onChangeText={changeText}
162170 readOnly={props.readOnly || importing}
163171 onSubmit={importing ? undefined : props.onSubmit}
apps/mobile/src/components/RowPressable.tsx created+35
......@@ -0,0 +1,35 @@
1import type { ComponentProps, ReactNode } from "react";
2import { Pressable, View } from "react-native";
3import { GestureDetector } from "react-native-gesture-handler";
4
5import { cn } from "../lib/cn";
6import { useHoverGesture } from "../lib/useHoverGesture";
7
8/** Shared row feedback, layered over the background so selection stays visible. */
9export function RowPressable({
10 children,
11 className,
12 interactionClassName = "bg-primary",
13 ...props
14}: Omit<ComponentProps<typeof Pressable>, "children"> & {
15 readonly children: ReactNode;
16 readonly interactionClassName?: string;
17}) {
18 const { hovered, hoverGesture } = useHoverGesture(props.disabled ?? false);
19 return (
20 <GestureDetector gesture={hoverGesture}>
21 <Pressable {...props} className={cn("relative overflow-hidden", className)}>
22 {({ pressed }) => (
23 <>
24 <View
25 pointerEvents="none"
26 className={cn("absolute inset-0", interactionClassName)}
27 style={{ opacity: props.disabled ? 0 : pressed ? 0.2 : hovered ? 0.1 : 0 }}
28 />
29 {children}
30 </>
31 )}
32 </Pressable>
33 </GestureDetector>
34 );
35}
apps/mobile/src/features/home/HomeScreen.tsx+6
......@@ -37,6 +37,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3737import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3838import { useThreadSearch } from "../../state/queries";
3939import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
40import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
4041import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
4142import { usePendingThreadOrder } from "../../state/thread-order";
4243import { environmentServerConfigsAtom } from "../../state/server";
......@@ -775,6 +776,11 @@ export function HomeScreen(props: HomeScreenProps) {
775776 [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks],
776777 );
777778
779 useThreadJumpShortcuts(
780 threadListV2Enabled ? threadListV2Items : listLayout.items,
781 props.onSelectThread,
782 );
783
778784 const renderV2Item = useCallback(
779785 ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => {
780786 const nextItem = threadListV2Items[index + 1];
apps/mobile/src/features/home/homeListItems.test.ts+23
......@@ -15,6 +15,7 @@ import {
1515 type HomeListItem,
1616} from "./homeListItems";
1717import type { HomeThreadGroup } from "./homeThreadList";
18import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
1819
1920const environmentId = EnvironmentId.make("environment-1");
2021
......@@ -87,6 +88,28 @@ function displayStates(
8788 return new Map(Object.entries(entries));
8889}
8990
91describe("threadJumpTarget", () => {
92 it("numbers only displayed threads across groups, skipping collapsed groups and pagination rows", () => {
93 const layout = buildHomeListLayout({
94 groups: [makeGroup("collapsed", 3), makeGroup("alpha", 8), makeGroup("beta", 3)],
95 displayStates: displayStates({ collapsed: { collapsed: true, visibleCount: 6 } }),
96 });
97 expect(threadJumpTarget(layout.items, "thread.jump.1")?.id).toBe("alpha-thread-0");
98 expect(threadJumpTarget(layout.items, "thread.jump.7")?.id).toBe("beta-thread-0");
99 expect(threadJumpTarget(layout.items, "thread.jump.9")?.id).toBe("beta-thread-2");
100 });
101
102 it("ignores missing positions and unrelated commands", () => {
103 const layout = buildHomeListLayout({
104 groups: [makeGroup("alpha", 1)],
105 displayStates: displayStates({}),
106 });
107 expect(threadJumpTarget(layout.items, "thread.jump.2")).toBeNull();
108 expect(threadJumpTarget([], "thread.jump.1")).toBeNull();
109 expect(threadJumpTarget(layout.items, "commandPalette")).toBeNull();
110 });
111});
112
90113describe("buildHomeListLayout", () => {
91114 it("renders a header plus all threads for a small group without a show-more row", () => {
92115 const layout = buildHomeListLayout({
apps/mobile/src/features/keyboard/CommandPalette.tsx created+472
......@@ -0,0 +1,472 @@
1import { useNavigation } from "@react-navigation/native";
2import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
3import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
4import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
5import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6import {
7 FlatList,
8 KeyboardAvoidingView,
9 Modal,
10 Pressable,
11 TextInput,
12 useWindowDimensions,
13 View,
14} from "react-native";
15
16import { GestureHandlerRootView } from "react-native-gesture-handler";
17
18import { RowPressable } from "../../components/RowPressable";
19import { AppText as Text } from "../../components/AppText";
20import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
21import { GlassSurface } from "../../components/GlassSurface";
22import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
23import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
24import { useProjects, useThreadShell, useThreadShells } from "../../state/entities";
25import { useThreadSearch } from "../../state/queries";
26import { useWorkspaceState } from "../../state/workspace";
27import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
28import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
29import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match";
30import {
31 filterCommandPaletteItems,
32 nextPaletteIndex,
33 type CommandPaletteItem,
34} from "./commandPaletteItems";
35import { parseActiveThreadPath, type HardwareKeyboardCommand } from "./hardwareKeyboardCommands";
36import { threadJumpIndex } from "./threadKeyboardShortcuts";
37
38const PALETTE_COMMANDS: ReadonlyArray<HardwareKeyboardCommand> = [
39 "commandPalette",
40 "paletteDismiss",
41 "paletteNext",
42 "palettePrevious",
43 ...THREAD_JUMP_KEYBINDING_COMMANDS,
44];
45const ROW_HEIGHT = 50;
46
47const ACTION_ICONS: Record<string, AppSymbolName> = {
48 newTask: "square.and.pencil",
49 newThread: "square.and.pencil",
50 addProject: "folder.badge.plus",
51 settings: "gearshape",
52 appearance: "paintbrush",
53 environments: "desktopcomputer",
54 usage: "chart.bar.xaxis",
55 archive: "archivebox",
56 files: "doc.text",
57 terminal: "terminal",
58 review: "arrow.triangle.pull",
59 copyThreadReference: "link",
60};
61
62function itemIcon(item: CommandPaletteItem): AppSymbolName {
63 if (item.kind === "project") return "folder";
64 if (item.kind === "thread") return "text.bubble";
65 return ACTION_ICONS[item.key] ?? "ellipsis";
66}
67
68function PaletteRow(props: {
69 readonly item: CommandPaletteItem;
70 readonly index: number;
71 readonly selected: boolean;
72 readonly searchMatch?: EnvironmentThreadSearchMatch;
73 readonly searchQuery: string;
74 readonly onSelect: () => void;
75}) {
76 return (
77 <RowPressable
78 accessibilityRole="button"
79 accessibilityState={{ selected: props.selected }}
80 onPress={props.onSelect}
81 className={
82 props.selected
83 ? "mx-2 flex-row items-center gap-3 rounded-xl bg-primary/10 px-3"
84 : "mx-2 flex-row items-center gap-3 rounded-xl px-3"
85 }
86 style={{ height: ROW_HEIGHT }}
87 >
88 <View className="w-7 items-center">
89 <SymbolView name={itemIcon(props.item)} size={20} tintColorClassName="accent-icon" />
90 </View>
91 <View className="flex-1">
92 <Text numberOfLines={1} className="text-base">
93 {props.item.title}
94 </Text>
95 {props.searchMatch ? (
96 <ThreadSearchMatchExcerpt match={props.searchMatch} query={props.searchQuery} compact />
97 ) : props.item.detail ? (
98 <Text numberOfLines={1} className="text-sm text-foreground-muted">
99 {props.item.detail}
100 </Text>
101 ) : null}
102 </View>
103 {props.index < 9 ? (
104 <Text className="text-sm text-foreground-muted">⌘{props.index + 1}</Text>
105 ) : null}
106 </RowPressable>
107 );
108}
109
110/** Mounted only while open, so the app root does not subscribe to the full thread catalog. */
111export function CommandPalette(props: {
112 readonly pathname: string;
113 readonly onClose: () => void;
114 readonly onCommand: (command: HardwareKeyboardCommand) => void;
115}) {
116 const navigation = useNavigation();
117 const { selectThread } = useAdaptiveWorkspaceLayout();
118 const runCommand = props.onCommand;
119 const projects = useProjects();
120 const threads = useThreadShells();
121 const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]);
122 const activeThread = useThreadShell(activeThreadRef);
123 const { environments } = useWorkspaceState();
124 const { savedConnectionsById } = useSavedRemoteConnections();
125 const [query, setQuery] = useState("");
126 const [selection, setSelection] = useState<string | null>(null);
127 const [visible, setVisible] = useState(true);
128 const pendingAction = useRef<(() => void) | null>(null);
129 const closing = useRef(false);
130 const inputRef = useRef<TextInput>(null);
131 const listRef = useRef<FlatList<CommandPaletteItem>>(null);
132 const { width, height } = useWindowDimensions();
133 const searchEnvironmentIds = useMemo(
134 () =>
135 environments
136 .filter((environment) => environment.connectionState === "connected")
137 .map((environment) => environment.environmentId),
138 [environments],
139 );
140 const search = useThreadSearch(searchEnvironmentIds, query.startsWith(">") ? "" : query);
141 const matchedThreadKeys = useMemo(
142 () =>
143 new Set(search.matches.map((match) => scopedThreadKey(match.environmentId, match.threadId))),
144 [search.matches],
145 );
146 const contentMatchByKey = useMemo(
147 () =>
148 new Map(
149 search.matches
150 .filter((match) => match.source === "user" || match.source === "assistant")
151 .map((match) => [scopedThreadKey(match.environmentId, match.threadId), match]),
152 ),
153 [search.matches],
154 );
155 const items = useMemo(() => {
156 const actions: CommandPaletteItem[] = [
157 {
158 key: "newTask",
159 kind: "action",
160 title: "New thread in…",
161 searchTerms: ["new task", "chat", "create", "project"],
162 run: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
163 },
164 {
165 key: "addProject",
166 kind: "action",
167 title: "Add project",
168 searchTerms: ["folder", "clone", "repository", "git"],
169 run: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }),
170 },
171 {
172 key: "settings",
173 kind: "action",
174 title: "Open settings",
175 searchTerms: ["preferences", "configuration"],
176 run: () =>
177 navigation.navigate("SettingsSheet", {
178 screen: "SettingsContent",
179 params: { screen: "Settings" },
180 }),
181 },
182 {
183 key: "appearance",
184 kind: "action",
185 title: "Appearance",
186 searchTerms: ["theme", "colors", "dark", "light"],
187 run: () =>
188 navigation.navigate("SettingsSheet", {
189 screen: "SettingsContent",
190 params: { screen: "SettingsAppearance" },
191 }),
192 },
193 {
194 key: "environments",
195 kind: "action",
196 title: "Manage environments",
197 searchTerms: ["connections", "server", "remote"],
198 run: () =>
199 navigation.navigate("SettingsSheet", {
200 screen: "SettingsContent",
201 params: { screen: "SettingsEnvironments" },
202 }),
203 },
204 {
205 key: "usage",
206 kind: "action",
207 title: "Usage",
208 searchTerms: ["limits", "accounts", "quota"],
209 run: () =>
210 navigation.navigate("SettingsSheet", {
211 screen: "SettingsContent",
212 params: { screen: "SettingsUsage" },
213 }),
214 },
215 {
216 key: "archive",
217 kind: "action",
218 title: "Archived threads",
219 searchTerms: ["restore", "history"],
220 run: () =>
221 navigation.navigate("SettingsSheet", {
222 screen: "SettingsContent",
223 params: { screen: "SettingsArchive" },
224 }),
225 },
226 ];
227 const projectByKey = new Map(
228 projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]),
229 );
230 const activeProject = activeThread
231 ? projectByKey.get(scopedProjectKey(activeThread.environmentId, activeThread.projectId))
232 : null;
233 if (activeProject) {
234 actions.unshift({
235 key: "newThread",
236 kind: "action",
237 title: `New thread in ${activeProject.title}`,
238 searchTerms: ["new task", "chat", "create"],
239 run: () =>
240 navigation.navigate("NewTaskSheet", {
241 screen: "NewTaskDraft",
242 params: {
243 environmentId: activeProject.environmentId,
244 projectId: activeProject.id,
245 title: activeProject.title,
246 },
247 }),
248 });
249 }
250 if (activeThreadRef) {
251 const threadActions = [
252 ["files", "Go to file", ["open", "files", "browse", "search"]],
253 ["terminal", "Open terminal", ["shell", "console"]],
254 ["review", "Review changes", ["diff", "git", "pull request"]],
255 ["copyThreadReference", "Copy PR link or thread ID", ["reference", "clipboard"]],
256 ] as const;
257 actions.push(
258 ...threadActions.map(([command, title, searchTerms]) => ({
259 key: command,
260 kind: "action" as const,
261 title,
262 searchTerms,
263 run: () => runCommand(command),
264 })),
265 );
266 }
267 const projectItems: CommandPaletteItem[] = projects.map((project) => ({
268 key: `project:${scopedProjectKey(project.environmentId, project.id)}`,
269 kind: "project",
270 title: project.title,
271 detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`,
272 searchTerms: [project.workspaceRoot, "new thread", "project"],
273 run: () =>
274 navigation.navigate("NewTaskSheet", {
275 screen: "NewTaskDraft",
276 params: {
277 environmentId: project.environmentId,
278 projectId: project.id,
279 title: project.title,
280 },
281 }),
282 }));
283 const threadItems: CommandPaletteItem[] = threads
284 .filter((thread) => thread.archivedAt === null)
285 .sort((left, right) =>
286 (right.latestUserMessageAt ?? right.updatedAt).localeCompare(
287 left.latestUserMessageAt ?? left.updatedAt,
288 ),
289 )
290 .map((thread) => {
291 const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId));
292 const environment =
293 savedConnectionsById[thread.environmentId]?.environmentLabel ?? thread.environmentId;
294 return {
295 key: scopedThreadKey(thread.environmentId, thread.id),
296 kind: "thread",
297 title: thread.title || "Untitled thread",
298 detail: [project?.title, environment].filter(Boolean).join(" · "),
299 searchTerms: [
300 project?.title ?? "",
301 environment,
302 thread.branch ?? "",
303 ...threadPullRequestSearchTerms(thread),
304 ],
305 run: () => selectThread(thread),
306 };
307 });
308 return [...actions, ...projectItems, ...threadItems];
309 }, [
310 activeThread,
311 activeThreadRef,
312 navigation,
313 projects,
314 runCommand,
315 savedConnectionsById,
316 selectThread,
317 threads,
318 ]);
319 const results = useMemo(
320 () => filterCommandPaletteItems(items, query, matchedThreadKeys),
321 [items, matchedThreadKeys, query],
322 );
323 const selectedIndex = Math.max(
324 0,
325 results.findIndex((item) => item.key === selection),
326 );
327 const selectedKey = results[selectedIndex]?.key;
328 useEffect(() => {
329 if (selectedIndex === 0) {
330 // Centering before the list measures its height scrolls half the first row out of view.
331 listRef.current?.scrollToOffset({ offset: 0, animated: false });
332 } else if (selectedKey !== undefined) {
333 listRef.current?.scrollToIndex({ index: selectedIndex, animated: false, viewPosition: 0.5 });
334 }
335 }, [selectedIndex, selectedKey]);
336
337 const dismissed = useRef(false);
338 const handleDismissed = useCallback(() => {
339 if (dismissed.current) return;
340 dismissed.current = true;
341 // Present navigation sheets only after UIKit has dismissed this modal.
342 props.onClose();
343 pendingAction.current?.();
344 }, [props]);
345
346 // iOS drops Modal onDismiss when the VC is dismissed mid-presentation (e.g.
347 // ⌘K during the fade-in) or raced by another sheet — without a fallback the
348 // palette stays mounted-but-invisible and ⌘K dead-ends on a stale open state.
349 useEffect(() => {
350 if (visible) return;
351 const fallback = setTimeout(handleDismissed, 400);
352 return () => clearTimeout(fallback);
353 }, [visible, handleDismissed]);
354
355 function close(run?: () => void) {
356 if (closing.current) return;
357 closing.current = true;
358 pendingAction.current = run ?? null;
359 setVisible(false);
360 }
361
362 function onCommand(command: HardwareKeyboardCommand) {
363 if (command === "commandPalette" || command === "paletteDismiss") {
364 close();
365 } else if (command === "paletteNext" || command === "palettePrevious") {
366 setSelection(
367 results[nextPaletteIndex(selectedIndex, command === "paletteNext" ? 1 : -1, results.length)]
368 ?.key ?? null,
369 );
370 } else {
371 const item = results[threadJumpIndex(command)];
372 if (item) close(item.run);
373 }
374 }
375
376 return (
377 <Modal
378 visible={visible}
379 transparent
380 animationType="fade"
381 onShow={() => inputRef.current?.focus()}
382 onRequestClose={() => close()}
383 onDismiss={handleDismissed}
384 >
385 <GestureHandlerRootView className="flex-1">
386 <T3KeyboardCommands enabledCommands={PALETTE_COMMANDS} onCommand={onCommand}>
387 <KeyboardAvoidingView
388 behavior="padding"
389 className="flex-1 items-center justify-center p-4"
390 >
391 <Pressable
392 className="absolute inset-0 bg-black/15"
393 accessibilityLabel="Close command palette"
394 onPress={() => close()}
395 />
396 <GlassSurface
397 accessibilityViewIsModal
398 style={{
399 width: Math.min(600, width - 32),
400 height: Math.min(520, height - 80),
401 borderRadius: 20,
402 }}
403 >
404 <View className="px-3 pb-2.5 pt-3.5">
405 <View className="h-[38px] flex-row items-center gap-1.5 pr-2.5 pl-[11px]">
406 <SymbolView
407 name="magnifyingglass"
408 size={15}
409 tintColorClassName="accent-foreground-muted"
410 type="monochrome"
411 />
412 <TextInput
413 ref={inputRef}
414 accessibilityLabel="Search commands, projects, and threads"
415 placeholder="Search commands, projects, and threads…"
416 placeholderTextColorClassName="accent-placeholder"
417 autoCorrect={false}
418 autoCapitalize="none"
419 clearButtonMode="while-editing"
420 className="h-[34px] flex-1 px-0 py-0 font-sans text-base text-foreground"
421 value={query}
422 onChangeText={(value) => {
423 setQuery(value);
424 setSelection(null);
425 listRef.current?.scrollToOffset({ offset: 0, animated: false });
426 }}
427 returnKeyType="go"
428 submitBehavior="submit"
429 onSubmitEditing={() => {
430 const item = results[selectedIndex];
431 if (item) close(item.run);
432 }}
433 />
434 </View>
435 </View>
436 <FlatList
437 ref={listRef}
438 data={results}
439 extraData={selectedKey}
440 keyboardShouldPersistTaps="handled"
441 keyExtractor={(item) => item.key}
442 getItemLayout={(_, index) => ({
443 length: ROW_HEIGHT,
444 offset: ROW_HEIGHT * index,
445 index,
446 })}
447 contentContainerClassName="pb-2"
448 ListEmptyComponent={
449 <Text className="p-5 text-center text-foreground-muted">
450 {search.isPending ? "Searching…" : "No results"}
451 </Text>
452 }
453 renderItem={({ item, index }) => (
454 <PaletteRow
455 item={item}
456 index={index}
457 selected={item.key === selectedKey}
458 searchMatch={
459 item.kind === "thread" ? contentMatchByKey.get(item.key) : undefined
460 }
461 searchQuery={query}
462 onSelect={() => close(item.run)}
463 />
464 )}
465 />
466 </GlassSurface>
467 </KeyboardAvoidingView>
468 </T3KeyboardCommands>
469 </GestureHandlerRootView>
470 </Modal>
471 );
472}
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+34-3
......@@ -1,6 +1,8 @@
11import { StackActions, useNavigation } from "@react-navigation/native";
22import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
33import {
4 createContext,
5 use,
46 useCallback,
57 useEffect,
68 useMemo,
......@@ -8,6 +10,7 @@ import {
810 useState,
911 useSyncExternalStore,
1012 type PropsWithChildren,
13 type ReactNode,
1114} from "react";
1215
1316import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
......@@ -15,6 +18,7 @@ import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
1518import { useThreadShell } from "../../state/entities";
1619import type { GitActionProgress } from "../../state/use-vcs-action-state";
1720import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
21import { CommandPalette } from "./CommandPalette";
1822import {
1923 dispatchHardwareKeyboardCommand,
2024 getHardwareKeyboardCommandRegistrationVersion,
......@@ -31,11 +35,20 @@ const EMPTY_COPY_FEEDBACK: GitActionProgress = {
3135};
3236const COPY_FEEDBACK_DISMISS_MS = 3_000;
3337
38const CommandPaletteContext = createContext<ReactNode>(null);
39
40/** Render inside the workspace so palette actions share its navigation and pane state. */
41export function HardwareKeyboardCommandOverlay() {
42 return use(CommandPaletteContext);
43}
44
3445export function HardwareKeyboardCommandProvider({
3546 children,
3647 pathname,
3748}: PropsWithChildren<{ readonly pathname: string }>) {
3849 const navigation = useNavigation();
50 const [paletteOpen, setPaletteOpen] = useState(false);
51 const closePalette = useCallback(() => setPaletteOpen(false), []);
3952 const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);
4053 const activeThread = useThreadShell(activeThreadRef);
4154 const copyTarget = useMemo(
......@@ -86,6 +99,12 @@ export function HardwareKeyboardCommandProvider({
8699 const enabledCommands = useMemo(() => {
87100 const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
88101 commands.add("newTask");
102 commands.add("commandPalette");
103 if (pathname !== "/" && !pathname.startsWith("/threads/")) {
104 for (const command of commands) {
105 if (command.startsWith("thread.jump.")) commands.delete(command);
106 }
107 }
89108 if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
90109 if (activeThreadRef !== null) {
91110 commands.add("files");
......@@ -94,10 +113,14 @@ export function HardwareKeyboardCommandProvider({
94113 if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference");
95114 }
96115 return [...commands];
97 }, [pathname, registrationVersion, navigation]);
116 }, [activeThreadRef, pathname, registrationVersion, navigation]);
98117
99118 const onCommand = useCallback(
100119 (command: HardwareKeyboardCommand) => {
120 if (command === "commandPalette") {
121 setPaletteOpen(true);
122 return;
123 }
101124 if (dispatchHardwareKeyboardCommand(command)) return;
102125
103126 if (command === "copyThreadReference") {
......@@ -152,12 +175,20 @@ export function HardwareKeyboardCommandProvider({
152175 [copyTarget, navigation, pathname, showCopyFeedback],
153176 );
154177
178 const palette = useMemo(
179 () =>
180 paletteOpen ? (
181 <CommandPalette pathname={pathname} onClose={closePalette} onCommand={onCommand} />
182 ) : null,
183 [closePalette, onCommand, paletteOpen, pathname],
184 );
185
155186 return (
156 <>
187 <CommandPaletteContext value={palette}>
157188 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
158189 {children}
159190 </T3KeyboardCommands>
160191 <GitActionProgressOverlay progress={copyFeedback} onDismiss={dismissCopyFeedback} />
161 </>
192 </CommandPaletteContext>
162193 );
163194}
apps/mobile/src/features/keyboard/commandPaletteItems.test.ts created+76
......@@ -0,0 +1,76 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 filterCommandPaletteItems,
5 nextPaletteIndex,
6 type CommandPaletteItem,
7} from "./commandPaletteItems";
8
9function item(
10 key: string,
11 title: string,
12 kind: CommandPaletteItem["kind"],
13 searchTerms: string[] = [],
14): CommandPaletteItem {
15 return { key, title, kind, searchTerms, run: () => {} };
16}
17
18const items = [
19 item("new", "New thread in…", "action", ["project", "create"]),
20 item("settings", "Open settings", "action", ["preferences"]),
21 item("project", "Mobile app", "project", ["/workspaces/mobile", "new thread"]),
22 item("siva:one", "Keyboard shortcuts", "thread", ["Mobile app", "Siva"]),
23 item("mac:one", "Mobile app", "thread", ["Mac"]),
24];
25const emptyMatches = new Set<string>();
26
27describe("filterCommandPaletteItems", () => {
28 it("shows actions and recent threads in their original order when the query is empty", () => {
29 expect(filterCommandPaletteItems(items, "", emptyMatches).map((item) => item.key)).toEqual([
30 "new",
31 "settings",
32 "siva:one",
33 "mac:one",
34 ]);
35 });
36
37 it("matches query tokens across titles and metadata and ranks exact titles first", () => {
38 expect(
39 filterCommandPaletteItems(items, " MOBILE app ", emptyMatches).map((item) => item.key),
40 ).toEqual(["project", "mac:one", "siva:one"]);
41 expect(
42 filterCommandPaletteItems(items, "siva keyboard", emptyMatches).map((item) => item.key),
43 ).toEqual(["siva:one"]);
44 });
45
46 it("supports the desktop actions-only prefix and action aliases", () => {
47 expect(filterCommandPaletteItems(items, ">", emptyMatches).map((item) => item.key)).toEqual([
48 "new",
49 "settings",
50 ]);
51 expect(
52 filterCommandPaletteItems(items, "> preferences", emptyMatches).map((item) => item.key),
53 ).toEqual(["settings"]);
54 expect(
55 filterCommandPaletteItems(items, "> new thread", emptyMatches).map((item) => item.key),
56 ).toEqual(["new"]);
57 });
58
59 it("includes server content matches scoped to the correct environment, except in actions-only mode", () => {
60 const matches = new Set(["siva:one", "project"]);
61 expect(
62 filterCommandPaletteItems(items, "message content", matches).map((item) => item.key),
63 ).toEqual(["siva:one"]);
64 expect(filterCommandPaletteItems(items, "> message content", matches)).toEqual([]);
65 });
66});
67
68describe("nextPaletteIndex", () => {
69 it("wraps arrow navigation in both directions and handles empty results", () => {
70 expect(nextPaletteIndex(0, -1, 3)).toBe(2);
71 expect(nextPaletteIndex(2, 1, 3)).toBe(0);
72 expect(nextPaletteIndex(0, 1, 3)).toBe(1);
73 expect(nextPaletteIndex(0, -1, 0)).toBe(0);
74 expect(nextPaletteIndex(0, 1, 0)).toBe(0);
75 });
76});
apps/mobile/src/features/keyboard/commandPaletteItems.ts created+46
......@@ -0,0 +1,46 @@
1export interface CommandPaletteItem {
2 readonly key: string;
3 readonly kind: "action" | "project" | "thread";
4 readonly title: string;
5 readonly detail?: string;
6 readonly searchTerms: ReadonlyArray<string>;
7 readonly run: () => void;
8}
9
10/** `>` narrows to actions, matching the desktop palette. Stable ties retain recent-thread order. */
11export function filterCommandPaletteItems(
12 items: ReadonlyArray<CommandPaletteItem>,
13 query: string,
14 matchedThreadKeys: ReadonlySet<string>,
15) {
16 const actionsOnly = query.startsWith(">");
17 const normalized = (actionsOnly ? query.slice(1) : query).trim().toLocaleLowerCase();
18 const tokens = normalized.split(/\s+/);
19 return items
20 .flatMap((item, index) => {
21 if (actionsOnly && item.kind !== "action") return [];
22 if (!normalized) return item.kind === "project" ? [] : [{ item, rank: 0, index }];
23 const title = item.title.toLocaleLowerCase();
24 const haystack = [title, ...item.searchTerms].join(" ").toLocaleLowerCase();
25 if (
26 !tokens.every((token) => haystack.includes(token)) &&
27 !(item.kind === "thread" && matchedThreadKeys.has(item.key))
28 )
29 return [];
30 const rank =
31 title === normalized
32 ? 3
33 : title.startsWith(normalized)
34 ? 2
35 : title.includes(normalized)
36 ? 1
37 : 0;
38 return [{ item, rank, index }];
39 })
40 .sort((left, right) => right.rank - left.rank || left.index - right.index)
41 .map(({ item }) => item);
42}
43
44export function nextPaletteIndex(index: number, direction: -1 | 1, count: number) {
45 return count === 0 ? 0 : (index + direction + count) % count;
46}
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts+20-9
......@@ -1,7 +1,12 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1import { EnvironmentId, ThreadId, type ThreadJumpKeybindingCommand } from "@t3tools/contracts";
22import { useEffect } from "react";
33
44export type HardwareKeyboardCommand =
5 | ThreadJumpKeybindingCommand
6 | "commandPalette"
7 | "paletteNext"
8 | "palettePrevious"
9 | "paletteDismiss"
510 | "newTask"
611 | "focusSearch"
712 | "back"
......@@ -11,7 +16,7 @@ export type HardwareKeyboardCommand =
1116 | "copyThreadReference"
1217 | "toggleSidebar";
1318
14type CommandHandler = () => boolean | void;
19type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void;
1520
1621const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>();
1722const registrationListeners = new Set<() => void>();
......@@ -22,18 +27,24 @@ let registrationVersion = 0;
2227 * the first chance to consume the command, allowing focused screens to override app defaults.
2328 */
2429export function useHardwareKeyboardCommand(
25 command: HardwareKeyboardCommand,
30 command: HardwareKeyboardCommand | ReadonlyArray<HardwareKeyboardCommand>,
2631 handler: CommandHandler,
2732): void {
2833 useEffect(() => {
29 const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
30 commandHandlers.add(handler);
31 handlers.set(command, commandHandlers);
34 const commands = typeof command === "string" ? [command] : command;
35 for (const command of commands) {
36 const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
37 commandHandlers.add(handler);
38 handlers.set(command, commandHandlers);
39 }
3240 registrationVersion += 1;
3341 registrationListeners.forEach((listener) => listener());
3442 return () => {
35 commandHandlers.delete(handler);
36 if (commandHandlers.size === 0) handlers.delete(command);
43 for (const command of commands) {
44 const commandHandlers = handlers.get(command);
45 commandHandlers?.delete(handler);
46 if (commandHandlers?.size === 0) handlers.delete(command);
47 }
3748 registrationVersion += 1;
3849 registrationListeners.forEach((listener) => listener());
3950 };
......@@ -58,7 +69,7 @@ export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand
5869 if (!commandHandlers) return false;
5970 // `.reverse()` on a copy, not `.toReversed()`: Hermes has no ES2023 array methods.
6071 for (const handler of [...commandHandlers].reverse()) {
61 if (handler() !== false) return true;
72 if (handler(command) !== false) return true;
6273 }
6374 return false;
6475}
apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts created+49
......@@ -0,0 +1,49 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
3import { useCallback } from "react";
4
5import type { HomeListItem } from "../home/homeListItems";
6import type { ThreadListV2ListItem } from "../threads/threadListV2";
7import {
8 useHardwareKeyboardCommand,
9 type HardwareKeyboardCommand,
10} from "./hardwareKeyboardCommands";
11
12type ThreadShortcutListItem =
13 | HomeListItem
14 | ThreadListV2ListItem
15 | { readonly type: "v2-show-more" };
16
17export function threadJumpIndex(command: HardwareKeyboardCommand) {
18 return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command);
19}
20
21/** Uses the rendered list so filters, collapsed groups and shelves keep their order. */
22export function threadJumpTarget(
23 items: ReadonlyArray<ThreadShortcutListItem>,
24 command: HardwareKeyboardCommand,
25) {
26 let index = threadJumpIndex(command);
27 if (index < 0) return null;
28 for (const item of items) {
29 const thread =
30 item.type === "thread" ? item.thread : item.type === "v2-thread" ? item.item.thread : null;
31 if (thread !== null && index-- === 0) return thread;
32 }
33 return null;
34}
35
36export function useThreadJumpShortcuts(
37 items: ReadonlyArray<ThreadShortcutListItem>,
38 onSelectThread: (thread: EnvironmentThreadShell) => void,
39) {
40 const jumpToThread = useCallback(
41 (command: HardwareKeyboardCommand) => {
42 const thread = threadJumpTarget(items, command);
43 if (thread !== null) onSelectThread(thread);
44 return true;
45 },
46 [items, onSelectThread],
47 );
48 useHardwareKeyboardCommand(THREAD_JUMP_KEYBINDING_COMMANDS, jumpToThread);
49}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+52-31
......@@ -6,6 +6,7 @@ import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3too
66import { useAtomValue } from "@effect/atom-react";
77import { useFocusEffect } from "@react-navigation/native";
88import {
9 CommonActions,
910 NavigationContext,
1011 NavigationRouteContext,
1112 StackActions,
......@@ -39,7 +40,10 @@ import {
3940 type WorkspaceAuxiliaryPaneRole,
4041 type WorkspacePaneLayout,
4142} from "../../lib/layout";
42import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation";
43import {
44 resolveThreadSelectionNavigationAction,
45 resolveThreadSelectionOverlayState,
46} from "../../lib/adaptive-navigation";
4347import { scopedThreadKey } from "../../lib/scopedEntities";
4448import { mobilePreferencesAtom } from "../../state/preferences";
4549import {
......@@ -63,6 +67,7 @@ interface AdaptiveWorkspaceContextValue {
6367 readonly panes: WorkspacePaneLayout;
6468 readonly fileInspector: FileInspectorPaneLayout;
6569 readonly primarySidebarSearchQuery: string;
70 readonly selectThread: (thread: EnvironmentThreadShell) => void;
6671 readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void;
6772 /**
6873 * Route screens hand their inspector pane content to the workspace so it
......@@ -96,6 +101,7 @@ const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({
96101 panes: compactPanes,
97102 fileInspector: compactFileInspector,
98103 primarySidebarSearchQuery: "",
104 selectThread: () => undefined,
99105 activateAuxiliaryPaneRole: () => () => undefined,
100106 registerWorkspaceInspector: () => () => undefined,
101107 setPrimarySidebarSearchQuery: () => undefined,
......@@ -198,6 +204,7 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi
198204export function AdaptiveWorkspaceLayout(props: {
199205 readonly children: ReactNode;
200206 readonly pathname: string;
207 readonly workspaceRouteKey: string | undefined;
201208}) {
202209 const preferencesResult = useAtomValue(mobilePreferencesAtom);
203210 if (!AsyncResult.isSuccess(preferencesResult)) {
......@@ -221,6 +228,7 @@ function AdaptiveWorkspaceLayoutContent(
221228 props: {
222229 readonly children: ReactNode;
223230 readonly pathname: string;
231 readonly workspaceRouteKey: string | undefined;
224232 } & {
225233 readonly projectGroupingMode: SidebarProjectGroupingMode;
226234 },
......@@ -408,35 +416,6 @@ function AdaptiveWorkspaceLayoutContent(
408416 },
409417 [auxiliaryPaneRole],
410418 );
411 const contextValue = useMemo(
412 () => ({
413 layout,
414 panes,
415 fileInspector,
416 primarySidebarSearchQuery,
417 activateAuxiliaryPaneRole,
418 registerWorkspaceInspector,
419 setPrimarySidebarSearchQuery,
420 showAuxiliaryPane,
421 toggleAuxiliaryPane,
422 togglePrimarySidebar,
423 setAuxiliaryPaneWidth,
424 }),
425 [
426 activateAuxiliaryPaneRole,
427 fileInspector,
428 layout,
429 panes,
430 primarySidebarSearchQuery,
431 registerWorkspaceInspector,
432 showAuxiliaryPane,
433 setPrimarySidebarSearchQuery,
434 setAuxiliaryPaneWidth,
435 toggleAuxiliaryPane,
436 togglePrimarySidebar,
437 ],
438 );
439
440419 const handleOpenSettings = useCallback(() => {
441420 navigation.navigate("SettingsSheet", {
442421 screen: "SettingsContent",
......@@ -526,6 +505,17 @@ function AdaptiveWorkspaceLayoutContent(
526505 usesSplitView: layout.usesSplitView,
527506 pathname,
528507 });
508 const overlayState = resolveThreadSelectionOverlayState({
509 state: navigation.getState(),
510 workspaceRouteKey: props.workspaceRouteKey,
511 action: navigationAction,
512 params,
513 });
514 if (overlayState !== null) {
515 setFileInspectorPreferredVisible(false);
516 navigation.dispatch(CommonActions.reset(overlayState));
517 return;
518 }
529519 if (navigationAction === "set-params") {
530520 const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id);
531521 if (nextThreadKey === selectedThreadKey) {
......@@ -542,7 +532,38 @@ function AdaptiveWorkspaceLayoutContent(
542532 }
543533 navigation.navigate("Thread", params);
544534 },
545 [layout.usesSplitView, pathname, navigation, selectedThreadKey],
535 [layout.usesSplitView, pathname, navigation, selectedThreadKey, props.workspaceRouteKey],
536 );
537
538 const contextValue = useMemo(
539 () => ({
540 layout,
541 panes,
542 fileInspector,
543 primarySidebarSearchQuery,
544 selectThread: handleSelectThread,
545 activateAuxiliaryPaneRole,
546 registerWorkspaceInspector,
547 setPrimarySidebarSearchQuery,
548 showAuxiliaryPane,
549 toggleAuxiliaryPane,
550 togglePrimarySidebar,
551 setAuxiliaryPaneWidth,
552 }),
553 [
554 activateAuxiliaryPaneRole,
555 fileInspector,
556 handleSelectThread,
557 layout,
558 panes,
559 primarySidebarSearchQuery,
560 registerWorkspaceInspector,
561 showAuxiliaryPane,
562 setPrimarySidebarSearchQuery,
563 setAuxiliaryPaneWidth,
564 toggleAuxiliaryPane,
565 togglePrimarySidebar,
566 ],
546567 );
547568
548569 return (
apps/mobile/src/features/layout/workspace-pane-divider.tsx+3-6
......@@ -20,7 +20,6 @@ interface WorkspacePaneDividerProps {
2020export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
2121 const latestProps = useRef(props);
2222 latestProps.current = props;
23 const [hovered, setHovered] = useState(false);
2423 const [dragging, setDragging] = useState(false);
2524 const handleResizeStart = useCallback(() => {
2625 setDragging(true);
......@@ -63,7 +62,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
6362 return (
6463 <GestureDetector gesture={resizeGesture}>
6564 <Pressable
66 className="relative z-[100] -mx-[22px] w-11 self-stretch cursor-pointer justify-center"
65 className="relative z-[100] -mx-[22px] w-11 self-stretch justify-center"
6766 accessibilityActions={[
6867 { name: "increment", label: "Make pane wider" },
6968 { name: "decrement", label: "Make pane narrower" },
......@@ -75,15 +74,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
7574 text: `${Math.round(props.currentWidth)} points wide`,
7675 }}
7776 onAccessibilityAction={handleAccessibilityAction}
78 onHoverIn={() => setHovered(true)}
79 onHoverOut={() => setHovered(false)}
8077 >
8178 <View
8279 className={cn(
8380 "h-full self-center bg-border opacity-70",
84 hovered || dragging ? "w-0.5 bg-primary opacity-100" : "w-px",
81 dragging ? "w-0.5 bg-primary opacity-100" : "w-px",
8582 )}
86 style={[styles.line, (hovered || dragging) && styles.activeLine]}
83 style={[styles.line, dragging && styles.activeLine]}
8784 />
8885 </Pressable>
8986 </GestureDetector>
apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx created+101
......@@ -0,0 +1,101 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import { AsyncResult } from "effect/unstable/reactivity";
4import { Platform, Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6
7import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
8import { AppText as Text } from "../../components/AppText";
9import { SymbolView } from "../../components/AppSymbol";
10import { NativeStackScreenOptions } from "../../native/StackHeader";
11import {
12 DEFAULT_COMPOSER_ENTER_BEHAVIOR,
13 type ComposerEnterBehavior,
14} from "../../lib/composerEnterBehavior";
15import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
16import { SettingsSection } from "./components/SettingsSection";
17
18const ENTER_BEHAVIOR_OPTIONS: ReadonlyArray<{
19 readonly behavior: ComposerEnterBehavior;
20 readonly label: string;
21 readonly description: string;
22}> = [
23 {
24 behavior: "send",
25 label: "Send message",
26 description: "Return sends the message. Shift-Return inserts a new line.",
27 },
28 {
29 behavior: "newline",
30 label: "Insert new line",
31 description: "Return inserts a new line. Command-Return sends the message.",
32 },
33];
34
35export function SettingsKeyboardRouteScreen() {
36 const navigation = useNavigation();
37 const insets = useSafeAreaInsets();
38 const preferencesResult = useAtomValue(mobilePreferencesAtom);
39 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
40 const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
41 const selectedBehavior = AsyncResult.isSuccess(preferencesResult)
42 ? (preferencesResult.value.composerEnterBehavior ?? DEFAULT_COMPOSER_ENTER_BEHAVIOR)
43 : null;
44
45 return (
46 <View collapsable={false} className="flex-1 bg-sheet">
47 {Platform.OS === "android" ? (
48 <>
49 <NativeStackScreenOptions options={{ headerShown: false }} />
50 <AndroidScreenHeader title="Keyboard" onBack={() => navigation.goBack()} />
51 </>
52 ) : null}
53 <ScrollView
54 contentInsetAdjustmentBehavior="automatic"
55 showsVerticalScrollIndicator={false}
56 className="flex-1"
57 contentContainerClassName="gap-3 px-5 pt-4"
58 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
59 >
60 <SettingsSection title="Return key">
61 {ENTER_BEHAVIOR_OPTIONS.map((option, index) => (
62 <Pressable
63 key={option.behavior}
64 accessibilityRole="radio"
65 accessibilityState={{
66 checked: selectedBehavior === option.behavior,
67 disabled: !preferencesReady,
68 }}
69 disabled={!preferencesReady}
70 onPress={() => savePreferences({ composerEnterBehavior: option.behavior })}
71 className={
72 index === 0
73 ? "flex-row items-center gap-4 p-4"
74 : "flex-row items-center gap-4 border-t border-border-subtle p-4"
75 }
76 >
77 <View className="min-w-0 flex-1 gap-1">
78 <Text className="text-lg text-foreground">{option.label}</Text>
79 <Text className="text-sm leading-normal text-foreground-muted">
80 {option.description}
81 </Text>
82 </View>
83 {selectedBehavior === option.behavior ? (
84 <SymbolView
85 name="checkmark"
86 size={18}
87 tintColorClassName={"accent-icon"}
88 type="monochrome"
89 weight="semibold"
90 />
91 ) : null}
92 </Pressable>
93 ))}
94 </SettingsSection>
95 <Text className="px-2 text-sm text-foreground-muted">
96 Applies to the composer when a hardware keyboard is connected.
97 </Text>
98 </ScrollView>
99 </View>
100 );
101}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+3
......@@ -598,6 +598,9 @@ function GeneralSettingsSection() {
598598 return (
599599 <SettingsSection title="General">
600600 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
601 {Platform.OS === "ios" ? (
602 <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" />
603 ) : null}
601604 <AutoSettleSettingsRows />
602605 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
603606 </SettingsSection>
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1
......@@ -2,6 +2,7 @@ export type SettingsSheetTarget =
22 | "SettingsEnvironments"
33 | "SettingsArchive"
44 | "SettingsAppearance"
5 | "SettingsKeyboard"
56 | "SettingsProjectGrouping"
67 | "SettingsClientStorage"
78 | "SettingsDiagnostics"
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+3
......@@ -1396,6 +1396,9 @@ export function NewTaskDraftScreen(props: {
13961396 skills={composerMenu.skills}
13971397 selection={composerMenu.selection}
13981398 onChangeText={flow.setPrompt}
1399 onSubmit={() => {
1400 if (canStart) void handleStart();
1401 }}
13991402 onSelectionChange={composerMenu.onSelectionChange}
14001403 onFocus={() => setIsComposerFocused(true)}
14011404 onBlur={() => setIsComposerFocused(false)}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+2
......@@ -38,6 +38,7 @@ import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
3838import { useWorkspaceState } from "../../state/workspace";
3939import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
4040import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
41import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
4142import {
4243 hasCustomHomeListOptions,
4344 PROJECT_SORT_OPTIONS,
......@@ -801,6 +802,7 @@ function ThreadNavigationSidebarPane(
801802 threadSearchMatchByKey,
802803 ],
803804 );
805 useThreadJumpShortcuts(listItems, handleSelectThread);
804806 const sidebarItemsAreEqual = useCallback(
805807 (previous: SidebarListItem, item: SidebarListItem): boolean => {
806808 if (previous.type === "v2-thread" && item.type === "v2-thread") {
apps/mobile/src/features/threads/thread-list-items.tsx+32-32
......@@ -1,4 +1,3 @@
1import { useRecyclingState } from "@legendapp/list/react-native";
21import type {
32 EnvironmentProject,
43 EnvironmentThreadShell,
......@@ -13,6 +12,7 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw
1312import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1413import Svg, { Circle, Path } from "react-native-svg";
1514
15import { RowPressable } from "../../components/RowPressable";
1616import { AppText as Text } from "../../components/AppText";
1717import { ControlPillMenu } from "../../components/ControlPill";
1818import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
......@@ -20,7 +20,6 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
2020import { cn } from "../../lib/cn";
2121import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
2222import { relativeTime } from "../../lib/time";
23import { themeColorWithAlpha } from "../../lib/mobileTheme";
2423import { useUniwindTheme } from "../../lib/useUniwindTheme";
2524import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2625import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr";
......@@ -358,11 +357,12 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
358357 : "Sends when the environment reconnects. Opens the task for editing";
359358
360359 const rowContent = compact ? (
361 <Pressable
360 <RowPressable
361 key={pendingTask.key}
362362 accessibilityHint={accessibilityHint}
363363 accessibilityLabel={pendingTask.title}
364364 accessibilityRole="button"
365 className="bg-screen active:opacity-70"
365 className="bg-screen"
366366 onPress={() => onSelectPendingTask(pendingTask)}
367367 >
368368 <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}>
......@@ -387,17 +387,16 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
387387 {subtitleRow}
388388 </View>
389389 </View>
390 </Pressable>
390 </RowPressable>
391391 ) : (
392 <Pressable
392 <RowPressable
393 key={pendingTask.key}
393394 accessibilityHint={accessibilityHint}
394395 accessibilityLabel={pendingTask.title}
395396 accessibilityRole="button"
396 className="active:bg-subtle"
397397 onPress={() => onSelectPendingTask(pendingTask)}
398398 style={{
399399 borderRadius: SIDEBAR_ROW_RADIUS,
400 cursor: "pointer",
401400 minHeight: 64,
402401 justifyContent: "center",
403402 paddingHorizontal: 12,
......@@ -420,7 +419,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
420419 </View>
421420 {subtitleRow}
422421 </View>
423 </Pressable>
422 </RowPressable>
424423 );
425424
426425 return (
......@@ -472,14 +471,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
472471 const compact = props.variant === "compact";
473472 const selected = props.selected === true;
474473 const visuallySelected = selected && (!compact || materialYouStyleLayoutActive);
475 // Recycling-safe: resets when the list container is reused for another
476 // thread, so a hover highlight can't leak across rows.
477 const [hovered, setHovered] = useRecyclingState(false);
478
479474 const theme = useUniwindTheme();
480475 const screenColor = theme["--color-screen"];
481476 const drawerColor = theme["--color-drawer"];
482 const pressedBackgroundColor = theme["--color-subtle"];
483477 const selectedBackgroundColor = theme["--color-user-bubble"];
484478 const materialSelectedBackgroundColor = theme["--color-thread-selected"];
485479 const materialSelectedForegroundColor = theme["--color-thread-selected-foreground"];
......@@ -516,9 +510,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
516510 const effectiveSelectedForeground = materialYouStyleLayoutActive
517511 ? materialSelectedForegroundColor
518512 : selectedForegroundColor;
519 const effectivePressedBackground = visuallySelected
520 ? themeColorWithAlpha(String(effectiveSelectedForeground), 0.16)
521 : pressedBackgroundColor;
522513 const effectiveStatus =
523514 visuallySelected && status
524515 ? {
......@@ -665,11 +656,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
665656
666657 const rowContent = (close: () => void) =>
667658 compact ? (
668 <Pressable
659 <RowPressable
660 key={`${thread.environmentId}:${thread.id}`}
661 interactionClassName={
662 visuallySelected
663 ? materialYouStyleLayoutActive
664 ? "bg-thread-selected-foreground"
665 : "bg-user-bubble-foreground"
666 : "bg-primary"
667 }
669668 accessibilityHint="Swipe left for archive and delete actions"
670669 accessibilityLabel={threadAccessibilityLabel}
671670 accessibilityRole="button"
672 className="bg-screen active:opacity-70"
671 className="bg-screen"
673672 style={
674673 materialYouStyleLayoutActive
675674 ? {
......@@ -735,32 +734,33 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
735734 {subtitleRow}
736735 </View>
737736 </View>
738 </Pressable>
737 </RowPressable>
739738 ) : (
740 <Pressable
739 <RowPressable
740 key={`${thread.environmentId}:${thread.id}`}
741 interactionClassName={
742 visuallySelected
743 ? materialYouStyleLayoutActive
744 ? "bg-thread-selected-foreground"
745 : "bg-user-bubble-foreground"
746 : "bg-primary"
747 }
741748 accessibilityHint="Opens the thread"
742749 accessibilityLabel={threadAccessibilityLabel}
743750 accessibilityRole="button"
744751 accessibilityState={{ selected }}
745 onHoverIn={() => setHovered(true)}
746 onHoverOut={() => setHovered(false)}
747752 onPress={() => {
748753 close();
749754 onSelectThread(thread);
750755 }}
751 style={({ pressed }) => ({
752 backgroundColor: visuallySelected
753 ? effectiveSelectedBackground
754 : pressed || hovered
755 ? effectivePressedBackground
756 : backgroundColor,
756 style={{
757 backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor,
757758 borderRadius: SIDEBAR_ROW_RADIUS,
758 cursor: "pointer",
759759 minHeight: 64,
760760 justifyContent: "center",
761761 paddingHorizontal: 12,
762762 paddingVertical: 10,
763 })}
763 }}
764764 >
765765 <View className="gap-[3px]">
766766 <View className="flex-row items-center justify-between gap-2">
......@@ -806,7 +806,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
806806 ) : null}
807807 {subtitleRow}
808808 </View>
809 </Pressable>
809 </RowPressable>
810810 );
811811
812812 return (
apps/mobile/src/features/threads/thread-list-v2-items.tsx+41-27
......@@ -1,3 +1,4 @@
1import { RowPressable } from "../../components/RowPressable";
12import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
34import { appAtomRegistry } from "../../state/atom-registry";
......@@ -302,7 +303,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
302303 onPressAction={handleMenuAction}
303304 shouldOpenOnLongPress
304305 >
305 <Pressable
306 <RowPressable
306307 accessibilityHint={
307308 isDraft
308309 ? "Opens the draft in the new task composer"
......@@ -310,7 +311,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
310311 }
311312 accessibilityLabel={pendingTask.title}
312313 accessibilityRole="button"
313 className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined}
314 key={pendingTask.key}
315 className={sidebarPane ? "bg-drawer" : "bg-screen"}
314316 onPress={() => onSelectPendingTask(pendingTask)}
315317 style={
316318 sidebarPane
......@@ -319,20 +321,20 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
319321 paddingHorizontal: 12,
320322 paddingVertical: 10,
321323 }
322 : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })
324 : undefined
323325 }
324326 >
325327 {sidebarPane ? (
326328 rowContent
327329 ) : (
328 <View className="bg-screen">
330 <View>
329331 <View className="px-5 py-2.5">{rowContent}</View>
330332 {props.showTrailingDivider !== false ? (
331333 <View className="ml-5 h-px bg-border-subtle" />
332334 ) : null}
333335 </View>
334336 )}
335 </Pressable>
337 </RowPressable>
336338 </ControlPillMenu>
337339 </>
338340 );
......@@ -441,7 +443,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
441443 const theme = useUniwindTheme();
442444 const screenColor = theme["--color-screen"];
443445 const drawerColor = theme["--color-drawer"];
444 const pressedBackgroundColor = theme["--color-subtle"];
445446 const selectedBackgroundColor =
446447 theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"];
447448 const sidebarPane = props.pane === "sidebar";
......@@ -930,7 +931,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
930931
931932 const rowContent = (close: () => void) =>
932933 variant === "card" ? (
933 <Pressable
934 <RowPressable
935 key={`${thread.environmentId}:${thread.id}`}
936 interactionClassName={
937 selected && (sidebarPane || materialYouStyleLayoutActive)
938 ? materialYouStyleLayoutActive
939 ? "bg-thread-selected-foreground"
940 : "bg-user-bubble-foreground"
941 : "bg-primary"
942 }
943 className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"}
934944 accessibilityHint={swipeAccessibilityHint}
935945 accessibilityLabel={
936946 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
......@@ -943,18 +953,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
943953 }}
944954 style={
945955 sidebarPane || materialYouStyleLayoutActive
946 ? ({ pressed }) => ({
956 ? {
947957 backgroundColor: selected
948958 ? selectedBackgroundColor
949 : pressed
950 ? pressedBackgroundColor
951 : sidebarPane
952 ? drawerColor
953 : screenColor,
959 : sidebarPane
960 ? drawerColor
961 : screenColor,
954962 borderRadius: SIDEBAR_V2_ROW_RADIUS,
955963 ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null),
956 })
957 : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })
964 }
965 : undefined
958966 }
959967 >
960968 {sidebarPane ? (
......@@ -964,16 +972,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
964972 labels and text hierarchy carry state, an inset hairline
965973 separates rows. The opaque screen background stays so swipe
966974 actions reveal behind the row. */
967 <View className={materialYouStyleLayoutActive ? undefined : "bg-screen"}>
975 <View>
968976 <View className="px-5 py-2.5">{cardContent}</View>
969977 {props.showTrailingDivider !== false ? (
970978 <View className="ml-5 h-px bg-border-subtle" />
971979 ) : null}
972980 </View>
973981 )}
974 </Pressable>
982 </RowPressable>
975983 ) : (
976 <Pressable
984 <RowPressable
985 key={`${thread.environmentId}:${thread.id}`}
986 interactionClassName={
987 selected && (sidebarPane || materialYouStyleLayoutActive)
988 ? materialYouStyleLayoutActive
989 ? "bg-thread-selected-foreground"
990 : "bg-user-bubble-foreground"
991 : "bg-primary"
992 }
977993 accessibilityHint={swipeAccessibilityHint}
978994 accessibilityLabel={
979995 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
......@@ -987,17 +1003,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
9871003 }}
9881004 style={
9891005 sidebarPane || materialYouStyleLayoutActive
990 ? ({ pressed }) => ({
1006 ? {
9911007 backgroundColor: selected
9921008 ? selectedBackgroundColor
993 : pressed
994 ? pressedBackgroundColor
995 : sidebarPane
996 ? drawerColor
997 : screenColor,
1009 : sidebarPane
1010 ? drawerColor
1011 : screenColor,
9981012 borderRadius: SIDEBAR_V2_ROW_RADIUS,
999 })
1000 : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })
1013 }
1014 : undefined
10011015 }
10021016 >
10031017 {/* Settled history recedes: dimmed favicon + muted title. */}
......@@ -1059,7 +1073,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
10591073 : timeLabel}
10601074 </Text>
10611075 </View>
1062 </Pressable>
1076 </RowPressable>
10631077 );
10641078
10651079 return (
apps/mobile/src/features/threads/threadListV2.test.ts+4
......@@ -22,6 +22,7 @@ import {
2222import { describe, expect, it } from "vite-plus/test";
2323
2424import type { PendingNewTask } from "../../state/use-pending-new-tasks";
25import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
2526import {
2627 buildThreadListV2Items,
2728 buildThreadListV2ListItems,
......@@ -1050,6 +1051,9 @@ describe("buildThreadListV2ListItems", () => {
10501051 "v2-settled-shelf",
10511052 "v2-thread",
10521053 ]);
1054 expect(threadJumpTarget(items, "thread.jump.1")?.id).toBe("active");
1055 expect(threadJumpTarget(items, "thread.jump.2")?.id).toBe("settled");
1056 expect(threadJumpTarget(items, "thread.jump.3")).toBeNull();
10531057 });
10541058});
10551059
apps/mobile/src/lib/adaptive-navigation.test.ts+70
......@@ -4,6 +4,7 @@ import {
44 isBaseThreadRoute,
55 resolveFileSelectionNavigationAction,
66 resolveThreadSelectionNavigationAction,
7 resolveThreadSelectionOverlayState,
78} from "./adaptive-navigation";
89
910describe("isBaseThreadRoute", () => {
......@@ -66,3 +67,72 @@ describe("resolveFileSelectionNavigationAction", () => {
6667 );
6768 });
6869});
70
71describe("resolveThreadSelectionOverlayState", () => {
72 const stack = {
73 key: "workspace",
74 type: "stack",
75 stale: false as const,
76 routeNames: ["Home", "Thread", "ThreadFiles", "SettingsSheet", "SettingsLegal"],
77 };
78 const home = { key: "home", name: "Home" };
79 const thread = {
80 key: "thread",
81 name: "Thread",
82 params: { environmentId: "environment", threadId: "old-thread" },
83 };
84 const files = { key: "files", name: "ThreadFiles", params: thread.params };
85 const settings = { key: "settings", name: "SettingsSheet" };
86 const params = { environmentId: "environment", threadId: "new-thread" };
87
88 it("replaces the underlying file route and dismisses every overlay above it", () => {
89 expect(
90 resolveThreadSelectionOverlayState({
91 state: {
92 ...stack,
93 index: 4,
94 routes: [home, thread, files, settings, { key: "legal", name: "SettingsLegal" }],
95 },
96 workspaceRouteKey: files.key,
97 action: "replace",
98 params,
99 }),
100 ).toEqual({ ...stack, index: 2, routes: [home, thread, { name: "Thread", params }] });
101 });
102
103 it("dismisses an overlay when selecting the current thread without replacing its route key", () => {
104 expect(
105 resolveThreadSelectionOverlayState({
106 state: { ...stack, index: 2, routes: [home, thread, settings] },
107 workspaceRouteKey: thread.key,
108 action: "set-params",
109 params: thread.params,
110 }),
111 ).toEqual({ ...stack, index: 1, routes: [home, thread] });
112 });
113
114 it.each([home, files])(
115 "keeps $name in the back stack when pushing from beneath a sheet",
116 (route) => {
117 expect(
118 resolveThreadSelectionOverlayState({
119 state: { ...stack, index: 1, routes: [route, settings] },
120 workspaceRouteKey: route.key,
121 action: "push",
122 params,
123 }),
124 ).toEqual({ ...stack, index: 1, routes: [route, { name: "Thread", params }] });
125 },
126 );
127
128 it("leaves ordinary thread selection alone when no overlay is present", () => {
129 expect(
130 resolveThreadSelectionOverlayState({
131 state: { ...stack, index: 2, routes: [home, thread, files] },
132 workspaceRouteKey: files.key,
133 action: "replace",
134 params,
135 }),
136 ).toBeNull();
137 });
138});
apps/mobile/src/lib/adaptive-navigation.ts+29
......@@ -1,3 +1,5 @@
1import type { NavigationState } from "@react-navigation/native";
2
13export type AdaptiveNavigationAction = "push" | "replace" | "set-params";
24
35const BASE_THREAD_ROUTE_PATTERN = /^\/threads\/[^/]+\/[^/]+\/?$/;
......@@ -23,6 +25,33 @@ export function resolveThreadSelectionNavigationAction(input: {
2325 return isBaseThreadRoute(input.pathname) ? "set-params" : "replace";
2426}
2527
28/** Dismiss sheets and select their underlying workspace destination in one stack update. */
29export function resolveThreadSelectionOverlayState(input: {
30 readonly state: NavigationState | undefined;
31 readonly workspaceRouteKey: string | undefined;
32 readonly action: AdaptiveNavigationAction;
33 readonly params: ReactNavigation.RootParamList["Thread"];
34}) {
35 if (input.state === undefined) return null;
36 const workspaceIndex = input.state.routes.findIndex(
37 (route) => route.key === input.workspaceRouteKey,
38 );
39 if (workspaceIndex < 0 || workspaceIndex >= input.state.index) return null;
40
41 const workspaceRoute = input.state.routes[workspaceIndex];
42 const routes = input.state.routes.slice(0, workspaceIndex + (input.action === "push" ? 1 : 0));
43 return {
44 ...input.state,
45 index: routes.length,
46 routes: [
47 ...routes,
48 input.action === "set-params" && workspaceRoute?.name === "Thread"
49 ? { ...workspaceRoute, params: { ...workspaceRoute.params, ...input.params } }
50 : { name: "Thread", params: input.params },
51 ],
52 };
53}
54
2655/**
2756 * On regular-width layouts, the file browser and preview occupy one workspace
2857 * destination. Replacing the browser route keeps a single back step to chat.
apps/mobile/src/lib/composerEnterBehavior.ts created+9
......@@ -0,0 +1,9 @@
1/**
2 * What the Return key does in the composer on a hardware keyboard. `send`
3 * submits the draft and Shift-Return inserts a newline; `newline` inserts a
4 * newline and Command-Return submits. Applies on iOS only — Android's composer
5 * has no hardware Return handling.
6 */
7export type ComposerEnterBehavior = "send" | "newline";
8
9export const DEFAULT_COMPOSER_ENTER_BEHAVIOR: ComposerEnterBehavior = "send";
apps/mobile/src/lib/useHoverGesture.ts created+24
......@@ -0,0 +1,24 @@
1import { useMemo, useState } from "react";
2import { Gesture } from "react-native-gesture-handler";
3
4/** Uses native hover recognition without React Native's optional pointer-event flags. */
5export function useHoverGesture(disabled = false) {
6 const [hovered, setHovered] = useState(false);
7 const hoverGesture = useMemo(
8 () =>
9 Gesture.Hover()
10 .manualActivation(true)
11 .enabled(!disabled)
12 // Observe hover without competing with row taps, scrolling, or swipe actions.
13 .cancelsTouchesInView(false)
14 .runOnJS(true)
15 .onBegin(() => {
16 setHovered(true);
17 })
18 .onFinalize(() => {
19 setHovered(false);
20 }),
21 [disabled],
22 );
23 return { hovered: !disabled && hovered, hoverGesture };
24}
apps/mobile/src/native/T3ComposerEditor.ios.tsx+3
......@@ -33,6 +33,7 @@ import {
3333 resolveComposerControlledEventCount,
3434 type ComposerNativeEventSnapshot,
3535} from "./composerEditorRevision";
36import { DEFAULT_COMPOSER_ENTER_BEHAVIOR } from "../lib/composerEnterBehavior";
3637import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types";
3738
3839const NATIVE_MODULE_NAME = "T3ComposerEditor";
......@@ -79,6 +80,7 @@ interface NativeComposerEditorProps extends ViewProps {
7980 readonly contentInsetVertical: number;
8081 readonly editable: boolean;
8182 readonly readOnly: boolean;
83 readonly enterBehavior: string;
8284 readonly scrollEnabled: boolean;
8385 readonly autoFocus: boolean;
8486 readonly autoCorrect: boolean;
......@@ -291,6 +293,7 @@ export function ComposerEditor({
291293 contentInsetVertical={contentInsetVertical}
292294 editable={props.editable ?? true}
293295 readOnly={props.readOnly ?? false}
296 enterBehavior={props.enterBehavior ?? DEFAULT_COMPOSER_ENTER_BEHAVIOR}
294297 scrollEnabled={props.scrollEnabled ?? true}
295298 autoFocus={props.autoFocus ?? false}
296299 autoCorrect={props.autoCorrect ?? true}
apps/mobile/src/native/T3ComposerEditor.tsx+1
......@@ -17,6 +17,7 @@ export function ComposerEditor({
1717 textStyle,
1818 contentInsetVertical = 0,
1919 singleLineCentered: _singleLineCentered,
20 enterBehavior: _enterBehavior,
2021 readOnly = false,
2122 ...props
2223}: ComposerEditorProps) {
apps/mobile/src/native/T3ComposerEditor.types.ts+10-1
......@@ -2,6 +2,10 @@ import type { OrchestrationMessageContext, ServerProviderSkill } from "@t3tools/
22import type { Ref } from "react";
33import type { StyleProp, TextStyle, ViewStyle } from "react-native";
44
5import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
6
7export type { ComposerEnterBehavior };
8
59export type ComposerEditorSelection = {
610 readonly start: number;
711 readonly end: number;
......@@ -61,6 +65,11 @@ export interface ComposerEditorProps {
6165 readonly onPasteText?: (paste: ComposerTextPaste) => void;
6266 readonly onFocus?: () => void;
6367 readonly onBlur?: () => void;
64 /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */
68 /**
69 * Hardware-keyboard Return behavior on iOS. No-op on Android, which has no
70 * hardware Return handling.
71 */
72 readonly enterBehavior?: ComposerEnterBehavior;
73 /** Hardware keyboard submission: Command-Return, or Return when `enterBehavior` is "send". */
6574 readonly onSubmit?: () => void;
6675}
apps/mobile/src/persistence/mobile-preferences.ts+7
......@@ -6,6 +6,7 @@ import * as Ref from "effect/Ref";
66import * as Schema from "effect/Schema";
77import * as Semaphore from "effect/Semaphore";
88import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
9import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
910import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
1011
1112import * as MobileDatabase from "./mobile-database";
......@@ -29,6 +30,8 @@ export interface Preferences {
2930 readonly codeWordBreak?: boolean;
3031 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
3132 readonly collapsedProjectGroups?: readonly string[];
33 /** What the Return key does in the composer on a hardware keyboard. iOS only. */
34 readonly composerEnterBehavior?: ComposerEnterBehavior;
3235 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
3336 readonly projectGroupingEnabled?: boolean;
3437 readonly projectGroupingMode?: SidebarProjectGroupingMode;
......@@ -99,6 +102,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
99102 codeWordBreak?: boolean;
100103 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
101104 collapsedProjectGroups?: readonly string[];
105 composerEnterBehavior?: ComposerEnterBehavior;
102106 projectGroupingEnabled?: boolean;
103107 projectGroupingMode?: SidebarProjectGroupingMode;
104108 legacyThreadListEnabled?: boolean;
......@@ -159,6 +163,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
159163 (key): key is string => typeof key === "string",
160164 );
161165 }
166 if (parsed.composerEnterBehavior === "send" || parsed.composerEnterBehavior === "newline") {
167 preferences.composerEnterBehavior = parsed.composerEnterBehavior;
168 }
162169 if (typeof parsed.projectGroupingEnabled === "boolean") {
163170 preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
164171 }
docs/user/keybindings.md+13
......@@ -28,6 +28,19 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix.
2828Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID”
2929or “Copy Number”. They copy the selected PR and leave terminal input alone.
3030
31## iPad
32
33With a hardware keyboard, use `Cmd+1` through `Cmd+9` to open the first nine
34displayed threads. The shortcuts follow the current list filters and order.
35`Cmd+K` opens the command palette to search commands, projects, and threads.
36Use the arrow keys and Return to choose a result, or `Cmd+1` through `Cmd+9` to
37choose directly. Escape or `Cmd+K` closes the palette. Start a search with `>`
38to show only actions.
39
40In the composer, Return sends and `Shift+Return` inserts a new line. `Cmd+Return`
41also sends. To make Return insert a new line instead, change the Return key
42behavior in Settings → Keyboard.
43
3144## Edit the configuration file
3245
3346Keybindings live on the environment's machine, in
patches/react-native-gesture-handler@2.32.0.patch+17-1
......@@ -1,3 +1,19 @@
1diff --git a/apple/Handlers/RNHoverHandler.m b/apple/Handlers/RNHoverHandler.m
2index 320cb8cb2652bfcfe1ecc65ebd81a8d208e4260f..a88ac5f04a9663fd7a62ee89e42d85ec41dcaf55 100644
3--- a/apple/Handlers/RNHoverHandler.m
4+++ b/apple/Handlers/RNHoverHandler.m
5@@ -132,8 +132,10 @@ - (void)unbindFromView
6 {
7 #if CHECK_TARGET(13_4)
8 if (@available(iOS 13.4, *)) {
9+ // The superclass detaches the recognizer, clearing recognizer.view. Remove
10+ // the interaction from its own view before it can be recycled by Fabric.
11+ [_pointerInteraction.view removeInteraction:_pointerInteraction];
12 [super unbindFromView];
13- [self.recognizer.view removeInteraction:_pointerInteraction];
14 }
15 #endif
16 }
117diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
218index 551ab92bf58db0b79d428dcd2f2df898ef686493..13db26d88bff975bc5d46bb6432cbf9fda3d489a 100644
319--- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
......@@ -97,7 +113,7 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..055afce410d6eb655532e7a09371dfc2
97113 const animatedStyle = useAnimatedStyle(() => ({
98114 transform: [{
99115diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
100index ac8b76830d468edfbc29052b452a36221323c3de..6985d54d9d359e825a5a0e6078bb113204e2807b 100644
116index ac8b76830d468edfbc29052b452a36221323c3de..cac72ebaafaf746af7640783a16e99c93ecad2a4 100644
101117--- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
102118+++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
103119@@ -64,6 +64,13 @@ export interface SwipeableProps {
pnpm-lock.yaml+3-3
......@@ -99,7 +99,7 @@ patchedDependencies:
9999 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a
100100 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45
101101 expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6
102 react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398
102 react-native-gesture-handler@2.32.0: 0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2
103103 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787
104104 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
105105 react-native-reanimated@4.5.1: a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c
......@@ -426,7 +426,7 @@ importers:
426426 version: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
427427 react-native-gesture-handler:
428428 specifier: ~2.32.0
429 version: 2.32.0(patch_hash=96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
429 version: 2.32.0(patch_hash=0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
430430 react-native-image-viewing:
431431 specifier: ^0.2.2
432432 version: 0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
......@@ -20977,7 +20977,7 @@ snapshots:
2097720977 transitivePeerDependencies:
2097820978 - supports-color
2097920979
20980 react-native-gesture-handler@2.32.0(patch_hash=96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
20980 react-native-gesture-handler@2.32.0(patch_hash=0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
2098120981 dependencies:
2098220982 '@egjs/hammerjs': 2.0.17
2098320983 '@types/react-test-renderer': 19.1.0