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 {...@@ -87,6 +87,9 @@ public class T3ComposerEditorModule: Module {
87 Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in87 Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in
88 view.setSpellCheck(spellCheck)88 view.setSpellCheck(spellCheck)
89 }89 }
90 Prop("enterBehavior") { (view: T3ComposerEditorView, behavior: String) in
91 view.setEnterBehavior(behavior)
92 }
90 Prop("textPasteThresholdBytes") { (view: T3ComposerEditorView, threshold: Int) in93 Prop("textPasteThresholdBytes") { (view: T3ComposerEditorView, threshold: Int) in
91 view.setTextPasteThresholdBytes(threshold)94 view.setTextPasteThresholdBytes(threshold)
92 }95 }
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+39
...@@ -45,6 +45,11 @@ private struct ComposerChipStyle {...@@ -45,6 +45,11 @@ private struct ComposerChipStyle {
45 let textColor: UIColor45 let textColor: UIColor
46}46}
4747
48private enum ComposerEnterBehavior: String {
49 case send
50 case newline
51}
52
48private final class ComposerTextAttachment: NSTextAttachment {53private final class ComposerTextAttachment: NSTextAttachment {
49 let source: String54 let source: String
50 let label: String55 let label: String
...@@ -93,10 +98,12 @@ private final class ComposerTextView: UITextView {...@@ -93,10 +98,12 @@ private final class ComposerTextView: UITextView {
93 var isReadOnly = false98 var isReadOnly = false
94 var textPasteThresholdBytes = 099 var textPasteThresholdBytes = 0
95 var maxInputChars = Int.max100 var maxInputChars = Int.max
101 var enterBehavior: ComposerEnterBehavior = .send
96 private var bypassTextPasteInterception = false102 private var bypassTextPasteInterception = false
97103
98 override var keyCommands: [UIKeyCommand]? {104 override var keyCommands: [UIKeyCommand]? {
99 var commands = super.keyCommands ?? []105 var commands = super.keyCommands ?? []
106 guard !isReadOnly, markedTextRange == nil else { return commands }
100 let submit = UIKeyCommand(107 let submit = UIKeyCommand(
101 input: "\r",108 input: "\r",
102 modifierFlags: .command,109 modifierFlags: .command,
...@@ -105,6 +112,25 @@ private final class ComposerTextView: UITextView {...@@ -105,6 +112,25 @@ private final class ComposerTextView: UITextView {
105 submit.discoverabilityTitle = "Send Message"112 submit.discoverabilityTitle = "Send Message"
106 submit.wantsPriorityOverSystemBehavior = true113 submit.wantsPriorityOverSystemBehavior = true
107 commands.append(submit)114 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 }
108 if textPasteThresholdBytes > 0 {134 if textPasteThresholdBytes > 0 {
109 let pasteAsText = UIKeyCommand(135 let pasteAsText = UIKeyCommand(
110 input: "v",136 input: "v",
...@@ -119,9 +145,15 @@ private final class ComposerTextView: UITextView {...@@ -119,9 +145,15 @@ private final class ComposerTextView: UITextView {
119 }145 }
120146
121 @objc private func submitMessage(_ sender: UIKeyCommand) {147 @objc private func submitMessage(_ sender: UIKeyCommand) {
148 guard !isReadOnly, markedTextRange == nil else { return }
122 onSubmit?()149 onSubmit?()
123 }150 }
124151
152 @objc private func insertNewline(_ sender: UIKeyCommand) {
153 guard !isReadOnly, markedTextRange == nil else { return }
154 insertText("\n")
155 }
156
125 @objc private func pasteInline(_ sender: UIKeyCommand) {157 @objc private func pasteInline(_ sender: UIKeyCommand) {
126 guard !isReadOnly else {158 guard !isReadOnly else {
127 return159 return
...@@ -132,6 +164,9 @@ private final class ComposerTextView: UITextView {...@@ -132,6 +164,9 @@ private final class ComposerTextView: UITextView {
132 }164 }
133165
134 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {166 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
167 if action == #selector(submitMessage(_:)) || action == #selector(insertNewline(_:)) {
168 return isEditable && !isReadOnly && markedTextRange == nil
169 }
135 if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) {170 if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) {
136 return false171 return false
137 }172 }
...@@ -657,6 +692,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro...@@ -657,6 +692,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
657 textView.spellCheckingType = spellCheck ? .yes : .no692 textView.spellCheckingType = spellCheck ? .yes : .no
658 }693 }
659694
695 func setEnterBehavior(_ behavior: String) {
696 textView.enterBehavior = ComposerEnterBehavior(rawValue: behavior) ?? .send
697 }
698
660 func setTextPasteThresholdBytes(_ threshold: Int) {699 func setTextPasteThresholdBytes(_ threshold: Int) {
661 textView.textPasteThresholdBytes = threshold700 textView.textPasteThresholdBytes = threshold
662 }701 }
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+37-2
...@@ -20,11 +20,26 @@ public final class T3KeyboardCommandsView: ExpoView {...@@ -20,11 +20,26 @@ public final class T3KeyboardCommandsView: ExpoView {
2020
21 public override var canBecomeFirstResponder: Bool { true }21 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
23 public override var keyCommands: [UIKeyCommand]? {32 public override var keyCommands: [UIKeyCommand]? {
24 [33 let isPad = UIDevice.current.userInterfaceIdiom == .pad
34 var commands = [
25 enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"),35 enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"),
26 enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"),36 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"),
28 enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"),43 enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"),
29 enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),44 enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
30 enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),45 enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),
...@@ -38,6 +53,18 @@ public final class T3KeyboardCommandsView: ExpoView {...@@ -38,6 +53,18 @@ public final class T3KeyboardCommandsView: ExpoView {
38 ),53 ),
39 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),54 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
40 ].compactMap { $0 }55 ].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
41 }68 }
4269
43 func setEnabledCommands(_ commands: [String]) {70 func setEnabledCommands(_ commands: [String]) {
...@@ -108,6 +135,14 @@ public final class T3KeyboardCommandsView: ExpoView {...@@ -108,6 +135,14 @@ public final class T3KeyboardCommandsView: ExpoView {
108 }135 }
109136
110 @objc private func newTask() { emit("newTask") }137 @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 }
111 @objc private func focusSearch() { emit("focusSearch") }146 @objc private func focusSearch() { emit("focusSearch") }
112 @objc private func goBack() { emit("back") }147 @objc private func goBack() { emit("back") }
113 @objc private func openFiles() { emit("files") }148 @objc private func openFiles() { emit("files") }
apps/mobile/src/App.tsx+2
...@@ -38,6 +38,8 @@ void SplashScreen.preventAutoHideAsync().catch(() => {...@@ -38,6 +38,8 @@ void SplashScreen.preventAutoHideAsync().catch(() => {
3838
39const appLinking = {39const appLinking = {
40 prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],40 prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],
41 // Keep the compact thread list available beneath a directly opened thread.
42 config: { initialRouteName: "Home" },
41 // The Expo dev client launches the app via43 // The Expo dev client launches the app via
42 // <scheme>://expo-development-client/?url=<packager> — that URL addresses44 // <scheme>://expo-development-client/?url=<packager> — that URL addresses
43 // the launcher, not app navigation. Without this filter it falls through45 // 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...@@ -23,7 +23,10 @@ import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardi
23import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen";23import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen";
24import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";24import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
25import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";25import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
26import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";26import {
27 HardwareKeyboardCommandOverlay,
28 HardwareKeyboardCommandProvider,
29} from "./features/keyboard/HardwareKeyboardCommandProvider";
27import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";30import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
28import { ReviewSheet } from "./features/review/ReviewSheet";31import { ReviewSheet } from "./features/review/ReviewSheet";
29import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";32import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
...@@ -56,6 +59,7 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl...@@ -56,6 +59,7 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl
56import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";59import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
57import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";60import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
58import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";61import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
62import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen";
59import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";63import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
60import {64import {
61 SettingsOpenSourceLicenseRouteScreen,65 SettingsOpenSourceLicenseRouteScreen,
...@@ -192,6 +196,13 @@ const SettingsContentStack = createNativeStackNavigator({...@@ -192,6 +196,13 @@ const SettingsContentStack = createNativeStackNavigator({
192 title: "Project Grouping",196 title: "Project Grouping",
193 },197 },
194 }),198 }),
199 SettingsKeyboard: createNativeStackScreen({
200 screen: SettingsKeyboardRouteScreen,
201 linking: "keyboard",
202 options: {
203 title: "Keyboard",
204 },
205 }),
195 SettingsClientStorage: createNativeStackScreen({206 SettingsClientStorage: createNativeStackScreen({
196 screen: SettingsClientStorageRouteScreen,207 screen: SettingsClientStorageRouteScreen,
197 linking: "client-storage",208 linking: "client-storage",
...@@ -381,17 +392,20 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([...@@ -381,17 +392,20 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
381]);392]);
382393
383/**394/**
384 * Pathname of the topmost NON-overlay route — the screen the workspace is395 * Location of the topmost non-overlay route, including its key so thread
385 * actually "on", regardless of any sheets floating above it.396 * selection can dismiss sheets without replacing the wrong destination.
386 */397 */
387function workspacePathFromState(state: NavigationState): string {398function workspaceLocationFromState(state: NavigationState) {
388 const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name));399 const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name));
389 const effectiveState =400 const effectiveState =
390 routes.length > 0 && routes.length !== state.routes.length401 routes.length > 0 && routes.length !== state.routes.length
391 ? ({ ...state, routes, index: routes.length - 1 } as NavigationState)402 ? ({ ...state, routes, index: routes.length - 1 } as NavigationState)
392 : state;403 : state;
393 const path = getPathFromState(effectiveState, navigationPathConfig);404 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 };
395}409}
396410
397// The drain hook subscribes to the outbox, all thread shells, projects, and411// The drain hook subscribes to the outbox, all thread shells, projects, and
...@@ -435,15 +449,19 @@ function RootStackLayout(props: {...@@ -435,15 +449,19 @@ function RootStackLayout(props: {
435 // workspace layout only reacts to the underlying non-overlay route.449 // workspace layout only reacts to the underlying non-overlay route.
436 const path = getPathFromState(props.state, navigationPathConfig);450 const path = getPathFromState(props.state, navigationPathConfig);
437 const pathname = path.startsWith("/") ? path : `/${path}`;451 const pathname = path.startsWith("/") ? path : `/${path}`;
438 const workspacePathname = workspacePathFromState(props.state);452 const workspaceLocation = workspaceLocationFromState(props.state);
439453
440 return (454 return (
441 <HardwareKeyboardCommandProvider pathname={pathname}>455 <HardwareKeyboardCommandProvider pathname={pathname}>
442 <ThreadOutboxDrainWorker />456 <ThreadOutboxDrainWorker />
443 <ShowcaseCaptureCoordinator pathname={pathname} />457 <ShowcaseCaptureCoordinator pathname={pathname} />
444 <ExistingThreadSettingsRouteProvider>458 <ExistingThreadSettingsRouteProvider>
445 <AdaptiveWorkspaceLayout pathname={workspacePathname}>459 <AdaptiveWorkspaceLayout
460 pathname={workspaceLocation.pathname}
461 workspaceRouteKey={workspaceLocation.routeKey}
462 >
446 {props.children}463 {props.children}
464 <HardwareKeyboardCommandOverlay />
447 </AdaptiveWorkspaceLayout>465 </AdaptiveWorkspaceLayout>
448 </ExistingThreadSettingsRouteProvider>466 </ExistingThreadSettingsRouteProvider>
449 </HardwareKeyboardCommandProvider>467 </HardwareKeyboardCommandProvider>
apps/mobile/src/components/ComposerEditor.tsx+8
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { ComposerContextId } from "@t3tools/contracts";1import { ComposerContextId } from "@t3tools/contracts";
2import { useAtomValue } from "@effect/atom-react";
3import { AsyncResult } from "effect/unstable/reactivity";
2import { useEffect, useMemo, useRef, useState } from "react";4import { useEffect, useMemo, useRef, useState } from "react";
3import { Alert } from "react-native";5import { Alert } from "react-native";
4import type { EnvironmentId } from "@t3tools/contracts";6import type { EnvironmentId } from "@t3tools/contracts";
...@@ -19,6 +21,7 @@ import {...@@ -19,6 +21,7 @@ import {
19 useComposerDraft,21 useComposerDraft,
20} from "../state/use-composer-drafts";22} from "../state/use-composer-drafts";
21import { importComposerContextClipboard } from "../lib/composerContextClipboard";23import { importComposerContextClipboard } from "../lib/composerContextClipboard";
24import { mobilePreferencesAtom } from "../state/preferences";
22import { ComposerContextSheet } from "./ComposerContextSheet";25import { ComposerContextSheet } from "./ComposerContextSheet";
23import { AppText as Text } from "./AppText";26import { AppText as Text } from "./AppText";
24import {27import {
...@@ -53,6 +56,10 @@ export function ComposerEditor({...@@ -53,6 +56,10 @@ export function ComposerEditor({
53 ...props56 ...props
54}: ComposerEditorProps) {57}: ComposerEditorProps) {
55 const draft = useComposerDraft(draftKey ?? null);58 const draft = useComposerDraft(draftKey ?? null);
59 const preferencesResult = useAtomValue(mobilePreferencesAtom);
60 const preferredEnterBehavior = AsyncResult.isSuccess(preferencesResult)
61 ? preferencesResult.value.composerEnterBehavior
62 : undefined;
56 const contextHistory = useMemo(() => createComposerDraftContextHistory(), [draftKey]);63 const contextHistory = useMemo(() => createComposerDraftContextHistory(), [draftKey]);
57 useEffect(() => () => contextHistory.dispose(), [contextHistory]);64 useEffect(() => () => contextHistory.dispose(), [contextHistory]);
58 const changeText = (text: string) => {65 const changeText = (text: string) => {
...@@ -158,6 +165,7 @@ export function ComposerEditor({...@@ -158,6 +165,7 @@ export function ComposerEditor({
158 <>165 <>
159 <NativeComposerEditor166 <NativeComposerEditor
160 {...props}167 {...props}
168 enterBehavior={props.enterBehavior ?? preferredEnterBehavior}
161 onChangeText={changeText}169 onChangeText={changeText}
162 readOnly={props.readOnly || importing}170 readOnly={props.readOnly || importing}
163 onSubmit={importing ? undefined : props.onSubmit}171 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";...@@ -37,6 +37,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
37import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";37import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
38import { useThreadSearch } from "../../state/queries";38import { useThreadSearch } from "../../state/queries";
39import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";39import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
40import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
40import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";41import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
41import { usePendingThreadOrder } from "../../state/thread-order";42import { usePendingThreadOrder } from "../../state/thread-order";
42import { environmentServerConfigsAtom } from "../../state/server";43import { environmentServerConfigsAtom } from "../../state/server";
...@@ -775,6 +776,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -775,6 +776,11 @@ export function HomeScreen(props: HomeScreenProps) {
775 [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks],776 [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks],
776 );777 );
777778
779 useThreadJumpShortcuts(
780 threadListV2Enabled ? threadListV2Items : listLayout.items,
781 props.onSelectThread,
782 );
783
778 const renderV2Item = useCallback(784 const renderV2Item = useCallback(
779 ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => {785 ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => {
780 const nextItem = threadListV2Items[index + 1];786 const nextItem = threadListV2Items[index + 1];
apps/mobile/src/features/home/homeListItems.test.ts+23
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 type HomeListItem,15 type HomeListItem,
16} from "./homeListItems";16} from "./homeListItems";
17import type { HomeThreadGroup } from "./homeThreadList";17import type { HomeThreadGroup } from "./homeThreadList";
18import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
1819
19const environmentId = EnvironmentId.make("environment-1");20const environmentId = EnvironmentId.make("environment-1");
2021
...@@ -87,6 +88,28 @@ function displayStates(...@@ -87,6 +88,28 @@ function displayStates(
87 return new Map(Object.entries(entries));88 return new Map(Object.entries(entries));
88}89}
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
90describe("buildHomeListLayout", () => {113describe("buildHomeListLayout", () => {
91 it("renders a header plus all threads for a small group without a show-more row", () => {114 it("renders a header plus all threads for a small group without a show-more row", () => {
92 const layout = buildHomeListLayout({115 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 @@...@@ -1,6 +1,8 @@
1import { StackActions, useNavigation } from "@react-navigation/native";1import { StackActions, useNavigation } from "@react-navigation/native";
2import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";2import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
3import {3import {
4 createContext,
5 use,
4 useCallback,6 useCallback,
5 useEffect,7 useEffect,
6 useMemo,8 useMemo,
...@@ -8,6 +10,7 @@ import {...@@ -8,6 +10,7 @@ import {
8 useState,10 useState,
9 useSyncExternalStore,11 useSyncExternalStore,
10 type PropsWithChildren,12 type PropsWithChildren,
13 type ReactNode,
11} from "react";14} from "react";
1215
13import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";16import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
...@@ -15,6 +18,7 @@ import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";...@@ -15,6 +18,7 @@ import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
15import { useThreadShell } from "../../state/entities";18import { useThreadShell } from "../../state/entities";
16import type { GitActionProgress } from "../../state/use-vcs-action-state";19import type { GitActionProgress } from "../../state/use-vcs-action-state";
17import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";20import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
21import { CommandPalette } from "./CommandPalette";
18import {22import {
19 dispatchHardwareKeyboardCommand,23 dispatchHardwareKeyboardCommand,
20 getHardwareKeyboardCommandRegistrationVersion,24 getHardwareKeyboardCommandRegistrationVersion,
...@@ -31,11 +35,20 @@ const EMPTY_COPY_FEEDBACK: GitActionProgress = {...@@ -31,11 +35,20 @@ const EMPTY_COPY_FEEDBACK: GitActionProgress = {
31};35};
32const COPY_FEEDBACK_DISMISS_MS = 3_000;36const 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
34export function HardwareKeyboardCommandProvider({45export function HardwareKeyboardCommandProvider({
35 children,46 children,
36 pathname,47 pathname,
37}: PropsWithChildren<{ readonly pathname: string }>) {48}: PropsWithChildren<{ readonly pathname: string }>) {
38 const navigation = useNavigation();49 const navigation = useNavigation();
50 const [paletteOpen, setPaletteOpen] = useState(false);
51 const closePalette = useCallback(() => setPaletteOpen(false), []);
39 const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);52 const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);
40 const activeThread = useThreadShell(activeThreadRef);53 const activeThread = useThreadShell(activeThreadRef);
41 const copyTarget = useMemo(54 const copyTarget = useMemo(
...@@ -86,6 +99,12 @@ export function HardwareKeyboardCommandProvider({...@@ -86,6 +99,12 @@ export function HardwareKeyboardCommandProvider({
86 const enabledCommands = useMemo(() => {99 const enabledCommands = useMemo(() => {
87 const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());100 const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
88 commands.add("newTask");101 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 }
89 if (pathname !== "/" || navigation.canGoBack()) commands.add("back");108 if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
90 if (activeThreadRef !== null) {109 if (activeThreadRef !== null) {
91 commands.add("files");110 commands.add("files");
...@@ -94,10 +113,14 @@ export function HardwareKeyboardCommandProvider({...@@ -94,10 +113,14 @@ export function HardwareKeyboardCommandProvider({
94 if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference");113 if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference");
95 }114 }
96 return [...commands];115 return [...commands];
97 }, [pathname, registrationVersion, navigation]);116 }, [activeThreadRef, pathname, registrationVersion, navigation]);
98117
99 const onCommand = useCallback(118 const onCommand = useCallback(
100 (command: HardwareKeyboardCommand) => {119 (command: HardwareKeyboardCommand) => {
120 if (command === "commandPalette") {
121 setPaletteOpen(true);
122 return;
123 }
101 if (dispatchHardwareKeyboardCommand(command)) return;124 if (dispatchHardwareKeyboardCommand(command)) return;
102125
103 if (command === "copyThreadReference") {126 if (command === "copyThreadReference") {
...@@ -152,12 +175,20 @@ export function HardwareKeyboardCommandProvider({...@@ -152,12 +175,20 @@ export function HardwareKeyboardCommandProvider({
152 [copyTarget, navigation, pathname, showCopyFeedback],175 [copyTarget, navigation, pathname, showCopyFeedback],
153 );176 );
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
155 return (186 return (
156 <>187 <CommandPaletteContext value={palette}>
157 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>188 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
158 {children}189 {children}
159 </T3KeyboardCommands>190 </T3KeyboardCommands>
160 <GitActionProgressOverlay progress={copyFeedback} onDismiss={dismissCopyFeedback} />191 <GitActionProgressOverlay progress={copyFeedback} onDismiss={dismissCopyFeedback} />
161 </>192 </CommandPaletteContext>
162 );193 );
163}194}
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 @@...@@ -1,7 +1,12 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, ThreadId, type ThreadJumpKeybindingCommand } from "@t3tools/contracts";
2import { useEffect } from "react";2import { useEffect } from "react";
33
4export type HardwareKeyboardCommand =4export type HardwareKeyboardCommand =
5 | ThreadJumpKeybindingCommand
6 | "commandPalette"
7 | "paletteNext"
8 | "palettePrevious"
9 | "paletteDismiss"
5 | "newTask"10 | "newTask"
6 | "focusSearch"11 | "focusSearch"
7 | "back"12 | "back"
...@@ -11,7 +16,7 @@ export type HardwareKeyboardCommand =...@@ -11,7 +16,7 @@ export type HardwareKeyboardCommand =
11 | "copyThreadReference"16 | "copyThreadReference"
12 | "toggleSidebar";17 | "toggleSidebar";
1318
14type CommandHandler = () => boolean | void;19type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void;
1520
16const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>();21const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>();
17const registrationListeners = new Set<() => void>();22const registrationListeners = new Set<() => void>();
...@@ -22,18 +27,24 @@ let registrationVersion = 0;...@@ -22,18 +27,24 @@ let registrationVersion = 0;
22 * the first chance to consume the command, allowing focused screens to override app defaults.27 * the first chance to consume the command, allowing focused screens to override app defaults.
23 */28 */
24export function useHardwareKeyboardCommand(29export function useHardwareKeyboardCommand(
25 command: HardwareKeyboardCommand,30 command: HardwareKeyboardCommand | ReadonlyArray<HardwareKeyboardCommand>,
26 handler: CommandHandler,31 handler: CommandHandler,
27): void {32): void {
28 useEffect(() => {33 useEffect(() => {
29 const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();34 const commands = typeof command === "string" ? [command] : command;
30 commandHandlers.add(handler);35 for (const command of commands) {
31 handlers.set(command, commandHandlers);36 const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
37 commandHandlers.add(handler);
38 handlers.set(command, commandHandlers);
39 }
32 registrationVersion += 1;40 registrationVersion += 1;
33 registrationListeners.forEach((listener) => listener());41 registrationListeners.forEach((listener) => listener());
34 return () => {42 return () => {
35 commandHandlers.delete(handler);43 for (const command of commands) {
36 if (commandHandlers.size === 0) handlers.delete(command);44 const commandHandlers = handlers.get(command);
45 commandHandlers?.delete(handler);
46 if (commandHandlers?.size === 0) handlers.delete(command);
47 }
37 registrationVersion += 1;48 registrationVersion += 1;
38 registrationListeners.forEach((listener) => listener());49 registrationListeners.forEach((listener) => listener());
39 };50 };
...@@ -58,7 +69,7 @@ export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand...@@ -58,7 +69,7 @@ export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand
58 if (!commandHandlers) return false;69 if (!commandHandlers) return false;
59 // `.reverse()` on a copy, not `.toReversed()`: Hermes has no ES2023 array methods.70 // `.reverse()` on a copy, not `.toReversed()`: Hermes has no ES2023 array methods.
60 for (const handler of [...commandHandlers].reverse()) {71 for (const handler of [...commandHandlers].reverse()) {
61 if (handler() !== false) return true;72 if (handler(command) !== false) return true;
62 }73 }
63 return false;74 return false;
64}75}
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...@@ -6,6 +6,7 @@ import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3too
6import { useAtomValue } from "@effect/atom-react";6import { useAtomValue } from "@effect/atom-react";
7import { useFocusEffect } from "@react-navigation/native";7import { useFocusEffect } from "@react-navigation/native";
8import {8import {
9 CommonActions,
9 NavigationContext,10 NavigationContext,
10 NavigationRouteContext,11 NavigationRouteContext,
11 StackActions,12 StackActions,
...@@ -39,7 +40,10 @@ import {...@@ -39,7 +40,10 @@ import {
39 type WorkspaceAuxiliaryPaneRole,40 type WorkspaceAuxiliaryPaneRole,
40 type WorkspacePaneLayout,41 type WorkspacePaneLayout,
41} from "../../lib/layout";42} from "../../lib/layout";
42import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation";43import {
44 resolveThreadSelectionNavigationAction,
45 resolveThreadSelectionOverlayState,
46} from "../../lib/adaptive-navigation";
43import { scopedThreadKey } from "../../lib/scopedEntities";47import { scopedThreadKey } from "../../lib/scopedEntities";
44import { mobilePreferencesAtom } from "../../state/preferences";48import { mobilePreferencesAtom } from "../../state/preferences";
45import {49import {
...@@ -63,6 +67,7 @@ interface AdaptiveWorkspaceContextValue {...@@ -63,6 +67,7 @@ interface AdaptiveWorkspaceContextValue {
63 readonly panes: WorkspacePaneLayout;67 readonly panes: WorkspacePaneLayout;
64 readonly fileInspector: FileInspectorPaneLayout;68 readonly fileInspector: FileInspectorPaneLayout;
65 readonly primarySidebarSearchQuery: string;69 readonly primarySidebarSearchQuery: string;
70 readonly selectThread: (thread: EnvironmentThreadShell) => void;
66 readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void;71 readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void;
67 /**72 /**
68 * Route screens hand their inspector pane content to the workspace so it73 * Route screens hand their inspector pane content to the workspace so it
...@@ -96,6 +101,7 @@ const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({...@@ -96,6 +101,7 @@ const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({
96 panes: compactPanes,101 panes: compactPanes,
97 fileInspector: compactFileInspector,102 fileInspector: compactFileInspector,
98 primarySidebarSearchQuery: "",103 primarySidebarSearchQuery: "",
104 selectThread: () => undefined,
99 activateAuxiliaryPaneRole: () => () => undefined,105 activateAuxiliaryPaneRole: () => () => undefined,
100 registerWorkspaceInspector: () => () => undefined,106 registerWorkspaceInspector: () => () => undefined,
101 setPrimarySidebarSearchQuery: () => undefined,107 setPrimarySidebarSearchQuery: () => undefined,
...@@ -198,6 +204,7 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi...@@ -198,6 +204,7 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi
198export function AdaptiveWorkspaceLayout(props: {204export function AdaptiveWorkspaceLayout(props: {
199 readonly children: ReactNode;205 readonly children: ReactNode;
200 readonly pathname: string;206 readonly pathname: string;
207 readonly workspaceRouteKey: string | undefined;
201}) {208}) {
202 const preferencesResult = useAtomValue(mobilePreferencesAtom);209 const preferencesResult = useAtomValue(mobilePreferencesAtom);
203 if (!AsyncResult.isSuccess(preferencesResult)) {210 if (!AsyncResult.isSuccess(preferencesResult)) {
...@@ -221,6 +228,7 @@ function AdaptiveWorkspaceLayoutContent(...@@ -221,6 +228,7 @@ function AdaptiveWorkspaceLayoutContent(
221 props: {228 props: {
222 readonly children: ReactNode;229 readonly children: ReactNode;
223 readonly pathname: string;230 readonly pathname: string;
231 readonly workspaceRouteKey: string | undefined;
224 } & {232 } & {
225 readonly projectGroupingMode: SidebarProjectGroupingMode;233 readonly projectGroupingMode: SidebarProjectGroupingMode;
226 },234 },
...@@ -408,35 +416,6 @@ function AdaptiveWorkspaceLayoutContent(...@@ -408,35 +416,6 @@ function AdaptiveWorkspaceLayoutContent(
408 },416 },
409 [auxiliaryPaneRole],417 [auxiliaryPaneRole],
410 );418 );
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
440 const handleOpenSettings = useCallback(() => {419 const handleOpenSettings = useCallback(() => {
441 navigation.navigate("SettingsSheet", {420 navigation.navigate("SettingsSheet", {
442 screen: "SettingsContent",421 screen: "SettingsContent",
...@@ -526,6 +505,17 @@ function AdaptiveWorkspaceLayoutContent(...@@ -526,6 +505,17 @@ function AdaptiveWorkspaceLayoutContent(
526 usesSplitView: layout.usesSplitView,505 usesSplitView: layout.usesSplitView,
527 pathname,506 pathname,
528 });507 });
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 }
529 if (navigationAction === "set-params") {519 if (navigationAction === "set-params") {
530 const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id);520 const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id);
531 if (nextThreadKey === selectedThreadKey) {521 if (nextThreadKey === selectedThreadKey) {
...@@ -542,7 +532,38 @@ function AdaptiveWorkspaceLayoutContent(...@@ -542,7 +532,38 @@ function AdaptiveWorkspaceLayoutContent(
542 }532 }
543 navigation.navigate("Thread", params);533 navigation.navigate("Thread", params);
544 },534 },
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 ],
546 );567 );
547568
548 return (569 return (
apps/mobile/src/features/layout/workspace-pane-divider.tsx+3-6
...@@ -20,7 +20,6 @@ interface WorkspacePaneDividerProps {...@@ -20,7 +20,6 @@ interface WorkspacePaneDividerProps {
20export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {20export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
21 const latestProps = useRef(props);21 const latestProps = useRef(props);
22 latestProps.current = props;22 latestProps.current = props;
23 const [hovered, setHovered] = useState(false);
24 const [dragging, setDragging] = useState(false);23 const [dragging, setDragging] = useState(false);
25 const handleResizeStart = useCallback(() => {24 const handleResizeStart = useCallback(() => {
26 setDragging(true);25 setDragging(true);
...@@ -63,7 +62,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {...@@ -63,7 +62,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
63 return (62 return (
64 <GestureDetector gesture={resizeGesture}>63 <GestureDetector gesture={resizeGesture}>
65 <Pressable64 <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"
67 accessibilityActions={[66 accessibilityActions={[
68 { name: "increment", label: "Make pane wider" },67 { name: "increment", label: "Make pane wider" },
69 { name: "decrement", label: "Make pane narrower" },68 { name: "decrement", label: "Make pane narrower" },
...@@ -75,15 +74,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {...@@ -75,15 +74,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
75 text: `${Math.round(props.currentWidth)} points wide`,74 text: `${Math.round(props.currentWidth)} points wide`,
76 }}75 }}
77 onAccessibilityAction={handleAccessibilityAction}76 onAccessibilityAction={handleAccessibilityAction}
78 onHoverIn={() => setHovered(true)}
79 onHoverOut={() => setHovered(false)}
80 >77 >
81 <View78 <View
82 className={cn(79 className={cn(
83 "h-full self-center bg-border opacity-70",80 "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",
85 )}82 )}
86 style={[styles.line, (hovered || dragging) && styles.activeLine]}83 style={[styles.line, dragging && styles.activeLine]}
87 />84 />
88 </Pressable>85 </Pressable>
89 </GestureDetector>86 </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() {...@@ -598,6 +598,9 @@ function GeneralSettingsSection() {
598 return (598 return (
599 <SettingsSection title="General">599 <SettingsSection title="General">
600 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />600 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
601 {Platform.OS === "ios" ? (
602 <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" />
603 ) : null}
601 <AutoSettleSettingsRows />604 <AutoSettleSettingsRows />
602 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />605 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
603 </SettingsSection>606 </SettingsSection>
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1
...@@ -2,6 +2,7 @@ export type SettingsSheetTarget =...@@ -2,6 +2,7 @@ export type SettingsSheetTarget =
2 | "SettingsEnvironments"2 | "SettingsEnvironments"
3 | "SettingsArchive"3 | "SettingsArchive"
4 | "SettingsAppearance"4 | "SettingsAppearance"
5 | "SettingsKeyboard"
5 | "SettingsProjectGrouping"6 | "SettingsProjectGrouping"
6 | "SettingsClientStorage"7 | "SettingsClientStorage"
7 | "SettingsDiagnostics"8 | "SettingsDiagnostics"
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+3
...@@ -1396,6 +1396,9 @@ export function NewTaskDraftScreen(props: {...@@ -1396,6 +1396,9 @@ export function NewTaskDraftScreen(props: {
1396 skills={composerMenu.skills}1396 skills={composerMenu.skills}
1397 selection={composerMenu.selection}1397 selection={composerMenu.selection}
1398 onChangeText={flow.setPrompt}1398 onChangeText={flow.setPrompt}
1399 onSubmit={() => {
1400 if (canStart) void handleStart();
1401 }}
1399 onSelectionChange={composerMenu.onSelectionChange}1402 onSelectionChange={composerMenu.onSelectionChange}
1400 onFocus={() => setIsComposerFocused(true)}1403 onFocus={() => setIsComposerFocused(true)}
1401 onBlur={() => setIsComposerFocused(false)}1404 onBlur={() => setIsComposerFocused(false)}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+2
...@@ -38,6 +38,7 @@ import { useQueuedThreadKeys } from "../../state/use-thread-outbox";...@@ -38,6 +38,7 @@ import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
38import { useWorkspaceState } from "../../state/workspace";38import { useWorkspaceState } from "../../state/workspace";
39import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";39import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
40import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";40import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
41import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
41import {42import {
42 hasCustomHomeListOptions,43 hasCustomHomeListOptions,
43 PROJECT_SORT_OPTIONS,44 PROJECT_SORT_OPTIONS,
...@@ -801,6 +802,7 @@ function ThreadNavigationSidebarPane(...@@ -801,6 +802,7 @@ function ThreadNavigationSidebarPane(
801 threadSearchMatchByKey,802 threadSearchMatchByKey,
802 ],803 ],
803 );804 );
805 useThreadJumpShortcuts(listItems, handleSelectThread);
804 const sidebarItemsAreEqual = useCallback(806 const sidebarItemsAreEqual = useCallback(
805 (previous: SidebarListItem, item: SidebarListItem): boolean => {807 (previous: SidebarListItem, item: SidebarListItem): boolean => {
806 if (previous.type === "v2-thread" && item.type === "v2-thread") {808 if (previous.type === "v2-thread" && item.type === "v2-thread") {
apps/mobile/src/features/threads/thread-list-items.tsx+32-32
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { useRecyclingState } from "@legendapp/list/react-native";
2import type {1import type {
3 EnvironmentProject,2 EnvironmentProject,
4 EnvironmentThreadShell,3 EnvironmentThreadShell,
...@@ -13,6 +12,7 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw...@@ -13,6 +12,7 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw
13import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";12import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
14import Svg, { Circle, Path } from "react-native-svg";13import Svg, { Circle, Path } from "react-native-svg";
1514
15import { RowPressable } from "../../components/RowPressable";
16import { AppText as Text } from "../../components/AppText";16import { AppText as Text } from "../../components/AppText";
17import { ControlPillMenu } from "../../components/ControlPill";17import { ControlPillMenu } from "../../components/ControlPill";
18import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";18import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
...@@ -20,7 +20,6 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";...@@ -20,7 +20,6 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
20import { cn } from "../../lib/cn";20import { cn } from "../../lib/cn";
21import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";21import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
22import { relativeTime } from "../../lib/time";22import { relativeTime } from "../../lib/time";
23import { themeColorWithAlpha } from "../../lib/mobileTheme";
24import { useUniwindTheme } from "../../lib/useUniwindTheme";23import { useUniwindTheme } from "../../lib/useUniwindTheme";
25import type { PendingNewTask } from "../../state/use-pending-new-tasks";24import type { PendingNewTask } from "../../state/use-pending-new-tasks";
26import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr";25import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr";
...@@ -358,11 +357,12 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -358,11 +357,12 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
358 : "Sends when the environment reconnects. Opens the task for editing";357 : "Sends when the environment reconnects. Opens the task for editing";
359358
360 const rowContent = compact ? (359 const rowContent = compact ? (
361 <Pressable360 <RowPressable
361 key={pendingTask.key}
362 accessibilityHint={accessibilityHint}362 accessibilityHint={accessibilityHint}
363 accessibilityLabel={pendingTask.title}363 accessibilityLabel={pendingTask.title}
364 accessibilityRole="button"364 accessibilityRole="button"
365 className="bg-screen active:opacity-70"365 className="bg-screen"
366 onPress={() => onSelectPendingTask(pendingTask)}366 onPress={() => onSelectPendingTask(pendingTask)}
367 >367 >
368 <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}>368 <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}>
...@@ -387,17 +387,16 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -387,17 +387,16 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
387 {subtitleRow}387 {subtitleRow}
388 </View>388 </View>
389 </View>389 </View>
390 </Pressable>390 </RowPressable>
391 ) : (391 ) : (
392 <Pressable392 <RowPressable
393 key={pendingTask.key}
393 accessibilityHint={accessibilityHint}394 accessibilityHint={accessibilityHint}
394 accessibilityLabel={pendingTask.title}395 accessibilityLabel={pendingTask.title}
395 accessibilityRole="button"396 accessibilityRole="button"
396 className="active:bg-subtle"
397 onPress={() => onSelectPendingTask(pendingTask)}397 onPress={() => onSelectPendingTask(pendingTask)}
398 style={{398 style={{
399 borderRadius: SIDEBAR_ROW_RADIUS,399 borderRadius: SIDEBAR_ROW_RADIUS,
400 cursor: "pointer",
401 minHeight: 64,400 minHeight: 64,
402 justifyContent: "center",401 justifyContent: "center",
403 paddingHorizontal: 12,402 paddingHorizontal: 12,
...@@ -420,7 +419,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -420,7 +419,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
420 </View>419 </View>
421 {subtitleRow}420 {subtitleRow}
422 </View>421 </View>
423 </Pressable>422 </RowPressable>
424 );423 );
425424
426 return (425 return (
...@@ -472,14 +471,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -472,14 +471,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
472 const compact = props.variant === "compact";471 const compact = props.variant === "compact";
473 const selected = props.selected === true;472 const selected = props.selected === true;
474 const visuallySelected = selected && (!compact || materialYouStyleLayoutActive);473 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
479 const theme = useUniwindTheme();474 const theme = useUniwindTheme();
480 const screenColor = theme["--color-screen"];475 const screenColor = theme["--color-screen"];
481 const drawerColor = theme["--color-drawer"];476 const drawerColor = theme["--color-drawer"];
482 const pressedBackgroundColor = theme["--color-subtle"];
483 const selectedBackgroundColor = theme["--color-user-bubble"];477 const selectedBackgroundColor = theme["--color-user-bubble"];
484 const materialSelectedBackgroundColor = theme["--color-thread-selected"];478 const materialSelectedBackgroundColor = theme["--color-thread-selected"];
485 const materialSelectedForegroundColor = theme["--color-thread-selected-foreground"];479 const materialSelectedForegroundColor = theme["--color-thread-selected-foreground"];
...@@ -516,9 +510,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -516,9 +510,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
516 const effectiveSelectedForeground = materialYouStyleLayoutActive510 const effectiveSelectedForeground = materialYouStyleLayoutActive
517 ? materialSelectedForegroundColor511 ? materialSelectedForegroundColor
518 : selectedForegroundColor;512 : selectedForegroundColor;
519 const effectivePressedBackground = visuallySelected
520 ? themeColorWithAlpha(String(effectiveSelectedForeground), 0.16)
521 : pressedBackgroundColor;
522 const effectiveStatus =513 const effectiveStatus =
523 visuallySelected && status514 visuallySelected && status
524 ? {515 ? {
...@@ -665,11 +656,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -665,11 +656,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
665656
666 const rowContent = (close: () => void) =>657 const rowContent = (close: () => void) =>
667 compact ? (658 compact ? (
668 <Pressable659 <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 }
669 accessibilityHint="Swipe left for archive and delete actions"668 accessibilityHint="Swipe left for archive and delete actions"
670 accessibilityLabel={threadAccessibilityLabel}669 accessibilityLabel={threadAccessibilityLabel}
671 accessibilityRole="button"670 accessibilityRole="button"
672 className="bg-screen active:opacity-70"671 className="bg-screen"
673 style={672 style={
674 materialYouStyleLayoutActive673 materialYouStyleLayoutActive
675 ? {674 ? {
...@@ -735,32 +734,33 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -735,32 +734,33 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
735 {subtitleRow}734 {subtitleRow}
736 </View>735 </View>
737 </View>736 </View>
738 </Pressable>737 </RowPressable>
739 ) : (738 ) : (
740 <Pressable739 <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 }
741 accessibilityHint="Opens the thread"748 accessibilityHint="Opens the thread"
742 accessibilityLabel={threadAccessibilityLabel}749 accessibilityLabel={threadAccessibilityLabel}
743 accessibilityRole="button"750 accessibilityRole="button"
744 accessibilityState={{ selected }}751 accessibilityState={{ selected }}
745 onHoverIn={() => setHovered(true)}
746 onHoverOut={() => setHovered(false)}
747 onPress={() => {752 onPress={() => {
748 close();753 close();
749 onSelectThread(thread);754 onSelectThread(thread);
750 }}755 }}
751 style={({ pressed }) => ({756 style={{
752 backgroundColor: visuallySelected757 backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor,
753 ? effectiveSelectedBackground
754 : pressed || hovered
755 ? effectivePressedBackground
756 : backgroundColor,
757 borderRadius: SIDEBAR_ROW_RADIUS,758 borderRadius: SIDEBAR_ROW_RADIUS,
758 cursor: "pointer",
759 minHeight: 64,759 minHeight: 64,
760 justifyContent: "center",760 justifyContent: "center",
761 paddingHorizontal: 12,761 paddingHorizontal: 12,
762 paddingVertical: 10,762 paddingVertical: 10,
763 })}763 }}
764 >764 >
765 <View className="gap-[3px]">765 <View className="gap-[3px]">
766 <View className="flex-row items-center justify-between gap-2">766 <View className="flex-row items-center justify-between gap-2">
...@@ -806,7 +806,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -806,7 +806,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
806 ) : null}806 ) : null}
807 {subtitleRow}807 {subtitleRow}
808 </View>808 </View>
809 </Pressable>809 </RowPressable>
810 );810 );
811811
812 return (812 return (
apps/mobile/src/features/threads/thread-list-v2-items.tsx+41-27
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { RowPressable } from "../../components/RowPressable";
1import { CustomSnoozeSheet } from "./CustomSnoozeSheet";2import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
2import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";3import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3import { appAtomRegistry } from "../../state/atom-registry";4import { appAtomRegistry } from "../../state/atom-registry";
...@@ -302,7 +303,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props...@@ -302,7 +303,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
302 onPressAction={handleMenuAction}303 onPressAction={handleMenuAction}
303 shouldOpenOnLongPress304 shouldOpenOnLongPress
304 >305 >
305 <Pressable306 <RowPressable
306 accessibilityHint={307 accessibilityHint={
307 isDraft308 isDraft
308 ? "Opens the draft in the new task composer"309 ? "Opens the draft in the new task composer"
...@@ -310,7 +311,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props...@@ -310,7 +311,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
310 }311 }
311 accessibilityLabel={pendingTask.title}312 accessibilityLabel={pendingTask.title}
312 accessibilityRole="button"313 accessibilityRole="button"
313 className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined}314 key={pendingTask.key}
315 className={sidebarPane ? "bg-drawer" : "bg-screen"}
314 onPress={() => onSelectPendingTask(pendingTask)}316 onPress={() => onSelectPendingTask(pendingTask)}
315 style={317 style={
316 sidebarPane318 sidebarPane
...@@ -319,20 +321,20 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props...@@ -319,20 +321,20 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
319 paddingHorizontal: 12,321 paddingHorizontal: 12,
320 paddingVertical: 10,322 paddingVertical: 10,
321 }323 }
322 : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })324 : undefined
323 }325 }
324 >326 >
325 {sidebarPane ? (327 {sidebarPane ? (
326 rowContent328 rowContent
327 ) : (329 ) : (
328 <View className="bg-screen">330 <View>
329 <View className="px-5 py-2.5">{rowContent}</View>331 <View className="px-5 py-2.5">{rowContent}</View>
330 {props.showTrailingDivider !== false ? (332 {props.showTrailingDivider !== false ? (
331 <View className="ml-5 h-px bg-border-subtle" />333 <View className="ml-5 h-px bg-border-subtle" />
332 ) : null}334 ) : null}
333 </View>335 </View>
334 )}336 )}
335 </Pressable>337 </RowPressable>
336 </ControlPillMenu>338 </ControlPillMenu>
337 </>339 </>
338 );340 );
...@@ -441,7 +443,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -441,7 +443,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
441 const theme = useUniwindTheme();443 const theme = useUniwindTheme();
442 const screenColor = theme["--color-screen"];444 const screenColor = theme["--color-screen"];
443 const drawerColor = theme["--color-drawer"];445 const drawerColor = theme["--color-drawer"];
444 const pressedBackgroundColor = theme["--color-subtle"];
445 const selectedBackgroundColor =446 const selectedBackgroundColor =
446 theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"];447 theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"];
447 const sidebarPane = props.pane === "sidebar";448 const sidebarPane = props.pane === "sidebar";
...@@ -930,7 +931,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -930,7 +931,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
930931
931 const rowContent = (close: () => void) =>932 const rowContent = (close: () => void) =>
932 variant === "card" ? (933 variant === "card" ? (
933 <Pressable934 <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"}
934 accessibilityHint={swipeAccessibilityHint}944 accessibilityHint={swipeAccessibilityHint}
935 accessibilityLabel={945 accessibilityLabel={
936 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title946 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
...@@ -943,18 +953,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -943,18 +953,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
943 }}953 }}
944 style={954 style={
945 sidebarPane || materialYouStyleLayoutActive955 sidebarPane || materialYouStyleLayoutActive
946 ? ({ pressed }) => ({956 ? {
947 backgroundColor: selected957 backgroundColor: selected
948 ? selectedBackgroundColor958 ? selectedBackgroundColor
949 : pressed959 : sidebarPane
950 ? pressedBackgroundColor960 ? drawerColor
951 : sidebarPane961 : screenColor,
952 ? drawerColor
953 : screenColor,
954 borderRadius: SIDEBAR_V2_ROW_RADIUS,962 borderRadius: SIDEBAR_V2_ROW_RADIUS,
955 ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null),963 ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null),
956 })964 }
957 : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })965 : undefined
958 }966 }
959 >967 >
960 {sidebarPane ? (968 {sidebarPane ? (
...@@ -964,16 +972,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -964,16 +972,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
964 labels and text hierarchy carry state, an inset hairline972 labels and text hierarchy carry state, an inset hairline
965 separates rows. The opaque screen background stays so swipe973 separates rows. The opaque screen background stays so swipe
966 actions reveal behind the row. */974 actions reveal behind the row. */
967 <View className={materialYouStyleLayoutActive ? undefined : "bg-screen"}>975 <View>
968 <View className="px-5 py-2.5">{cardContent}</View>976 <View className="px-5 py-2.5">{cardContent}</View>
969 {props.showTrailingDivider !== false ? (977 {props.showTrailingDivider !== false ? (
970 <View className="ml-5 h-px bg-border-subtle" />978 <View className="ml-5 h-px bg-border-subtle" />
971 ) : null}979 ) : null}
972 </View>980 </View>
973 )}981 )}
974 </Pressable>982 </RowPressable>
975 ) : (983 ) : (
976 <Pressable984 <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 }
977 accessibilityHint={swipeAccessibilityHint}993 accessibilityHint={swipeAccessibilityHint}
978 accessibilityLabel={994 accessibilityLabel={
979 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title995 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
...@@ -987,17 +1003,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -987,17 +1003,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
987 }}1003 }}
988 style={1004 style={
989 sidebarPane || materialYouStyleLayoutActive1005 sidebarPane || materialYouStyleLayoutActive
990 ? ({ pressed }) => ({1006 ? {
991 backgroundColor: selected1007 backgroundColor: selected
992 ? selectedBackgroundColor1008 ? selectedBackgroundColor
993 : pressed1009 : sidebarPane
994 ? pressedBackgroundColor1010 ? drawerColor
995 : sidebarPane1011 : screenColor,
996 ? drawerColor
997 : screenColor,
998 borderRadius: SIDEBAR_V2_ROW_RADIUS,1012 borderRadius: SIDEBAR_V2_ROW_RADIUS,
999 })1013 }
1000 : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })1014 : undefined
1001 }1015 }
1002 >1016 >
1003 {/* Settled history recedes: dimmed favicon + muted title. */}1017 {/* Settled history recedes: dimmed favicon + muted title. */}
...@@ -1059,7 +1073,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1059,7 +1073,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1059 : timeLabel}1073 : timeLabel}
1060 </Text>1074 </Text>
1061 </View>1075 </View>
1062 </Pressable>1076 </RowPressable>
1063 );1077 );
10641078
1065 return (1079 return (
apps/mobile/src/features/threads/threadListV2.test.ts+4
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22import { describe, expect, it } from "vite-plus/test";22import { describe, expect, it } from "vite-plus/test";
2323
24import type { PendingNewTask } from "../../state/use-pending-new-tasks";24import type { PendingNewTask } from "../../state/use-pending-new-tasks";
25import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
25import {26import {
26 buildThreadListV2Items,27 buildThreadListV2Items,
27 buildThreadListV2ListItems,28 buildThreadListV2ListItems,
...@@ -1050,6 +1051,9 @@ describe("buildThreadListV2ListItems", () => {...@@ -1050,6 +1051,9 @@ describe("buildThreadListV2ListItems", () => {
1050 "v2-settled-shelf",1051 "v2-settled-shelf",
1051 "v2-thread",1052 "v2-thread",
1052 ]);1053 ]);
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();
1053 });1057 });
1054});1058});
10551059
apps/mobile/src/lib/adaptive-navigation.test.ts+70
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 isBaseThreadRoute,4 isBaseThreadRoute,
5 resolveFileSelectionNavigationAction,5 resolveFileSelectionNavigationAction,
6 resolveThreadSelectionNavigationAction,6 resolveThreadSelectionNavigationAction,
7 resolveThreadSelectionOverlayState,
7} from "./adaptive-navigation";8} from "./adaptive-navigation";
89
9describe("isBaseThreadRoute", () => {10describe("isBaseThreadRoute", () => {
...@@ -66,3 +67,72 @@ describe("resolveFileSelectionNavigationAction", () => {...@@ -66,3 +67,72 @@ describe("resolveFileSelectionNavigationAction", () => {
66 );67 );
67 });68 });
68});69});
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 @@...@@ -1,3 +1,5 @@
1import type { NavigationState } from "@react-navigation/native";
2
1export type AdaptiveNavigationAction = "push" | "replace" | "set-params";3export type AdaptiveNavigationAction = "push" | "replace" | "set-params";
24
3const BASE_THREAD_ROUTE_PATTERN = /^\/threads\/[^/]+\/[^/]+\/?$/;5const BASE_THREAD_ROUTE_PATTERN = /^\/threads\/[^/]+\/[^/]+\/?$/;
...@@ -23,6 +25,33 @@ export function resolveThreadSelectionNavigationAction(input: {...@@ -23,6 +25,33 @@ export function resolveThreadSelectionNavigationAction(input: {
23 return isBaseThreadRoute(input.pathname) ? "set-params" : "replace";25 return isBaseThreadRoute(input.pathname) ? "set-params" : "replace";
24}26}
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
26/**55/**
27 * On regular-width layouts, the file browser and preview occupy one workspace56 * On regular-width layouts, the file browser and preview occupy one workspace
28 * destination. Replacing the browser route keeps a single back step to chat.57 * 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 {...@@ -33,6 +33,7 @@ import {
33 resolveComposerControlledEventCount,33 resolveComposerControlledEventCount,
34 type ComposerNativeEventSnapshot,34 type ComposerNativeEventSnapshot,
35} from "./composerEditorRevision";35} from "./composerEditorRevision";
36import { DEFAULT_COMPOSER_ENTER_BEHAVIOR } from "../lib/composerEnterBehavior";
36import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types";37import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types";
3738
38const NATIVE_MODULE_NAME = "T3ComposerEditor";39const NATIVE_MODULE_NAME = "T3ComposerEditor";
...@@ -79,6 +80,7 @@ interface NativeComposerEditorProps extends ViewProps {...@@ -79,6 +80,7 @@ interface NativeComposerEditorProps extends ViewProps {
79 readonly contentInsetVertical: number;80 readonly contentInsetVertical: number;
80 readonly editable: boolean;81 readonly editable: boolean;
81 readonly readOnly: boolean;82 readonly readOnly: boolean;
83 readonly enterBehavior: string;
82 readonly scrollEnabled: boolean;84 readonly scrollEnabled: boolean;
83 readonly autoFocus: boolean;85 readonly autoFocus: boolean;
84 readonly autoCorrect: boolean;86 readonly autoCorrect: boolean;
...@@ -291,6 +293,7 @@ export function ComposerEditor({...@@ -291,6 +293,7 @@ export function ComposerEditor({
291 contentInsetVertical={contentInsetVertical}293 contentInsetVertical={contentInsetVertical}
292 editable={props.editable ?? true}294 editable={props.editable ?? true}
293 readOnly={props.readOnly ?? false}295 readOnly={props.readOnly ?? false}
296 enterBehavior={props.enterBehavior ?? DEFAULT_COMPOSER_ENTER_BEHAVIOR}
294 scrollEnabled={props.scrollEnabled ?? true}297 scrollEnabled={props.scrollEnabled ?? true}
295 autoFocus={props.autoFocus ?? false}298 autoFocus={props.autoFocus ?? false}
296 autoCorrect={props.autoCorrect ?? true}299 autoCorrect={props.autoCorrect ?? true}
apps/mobile/src/native/T3ComposerEditor.tsx+1
...@@ -17,6 +17,7 @@ export function ComposerEditor({...@@ -17,6 +17,7 @@ export function ComposerEditor({
17 textStyle,17 textStyle,
18 contentInsetVertical = 0,18 contentInsetVertical = 0,
19 singleLineCentered: _singleLineCentered,19 singleLineCentered: _singleLineCentered,
20 enterBehavior: _enterBehavior,
20 readOnly = false,21 readOnly = false,
21 ...props22 ...props
22}: ComposerEditorProps) {23}: ComposerEditorProps) {
apps/mobile/src/native/T3ComposerEditor.types.ts+10-1
...@@ -2,6 +2,10 @@ import type { OrchestrationMessageContext, ServerProviderSkill } from "@t3tools/...@@ -2,6 +2,10 @@ import type { OrchestrationMessageContext, ServerProviderSkill } from "@t3tools/
2import type { Ref } from "react";2import type { Ref } from "react";
3import type { StyleProp, TextStyle, ViewStyle } from "react-native";3import type { StyleProp, TextStyle, ViewStyle } from "react-native";
44
5import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
6
7export type { ComposerEnterBehavior };
8
5export type ComposerEditorSelection = {9export type ComposerEditorSelection = {
6 readonly start: number;10 readonly start: number;
7 readonly end: number;11 readonly end: number;
...@@ -61,6 +65,11 @@ export interface ComposerEditorProps {...@@ -61,6 +65,11 @@ export interface ComposerEditorProps {
61 readonly onPasteText?: (paste: ComposerTextPaste) => void;65 readonly onPasteText?: (paste: ComposerTextPaste) => void;
62 readonly onFocus?: () => void;66 readonly onFocus?: () => void;
63 readonly onBlur?: () => void;67 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". */
65 readonly onSubmit?: () => void;74 readonly onSubmit?: () => void;
66}75}
apps/mobile/src/persistence/mobile-preferences.ts+7
...@@ -6,6 +6,7 @@ import * as Ref from "effect/Ref";...@@ -6,6 +6,7 @@ import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";7import * as Semaphore from "effect/Semaphore";
8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
9import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
9import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";10import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
1011
11import * as MobileDatabase from "./mobile-database";12import * as MobileDatabase from "./mobile-database";
...@@ -29,6 +30,8 @@ export interface Preferences {...@@ -29,6 +30,8 @@ export interface Preferences {
29 readonly codeWordBreak?: boolean;30 readonly codeWordBreak?: boolean;
30 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;31 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
31 readonly collapsedProjectGroups?: readonly string[];32 readonly collapsedProjectGroups?: readonly string[];
33 /** What the Return key does in the composer on a hardware keyboard. iOS only. */
34 readonly composerEnterBehavior?: ComposerEnterBehavior;
32 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */35 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
33 readonly projectGroupingEnabled?: boolean;36 readonly projectGroupingEnabled?: boolean;
34 readonly projectGroupingMode?: SidebarProjectGroupingMode;37 readonly projectGroupingMode?: SidebarProjectGroupingMode;
...@@ -99,6 +102,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -99,6 +102,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
99 codeWordBreak?: boolean;102 codeWordBreak?: boolean;
100 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;103 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
101 collapsedProjectGroups?: readonly string[];104 collapsedProjectGroups?: readonly string[];
105 composerEnterBehavior?: ComposerEnterBehavior;
102 projectGroupingEnabled?: boolean;106 projectGroupingEnabled?: boolean;
103 projectGroupingMode?: SidebarProjectGroupingMode;107 projectGroupingMode?: SidebarProjectGroupingMode;
104 legacyThreadListEnabled?: boolean;108 legacyThreadListEnabled?: boolean;
...@@ -159,6 +163,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -159,6 +163,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
159 (key): key is string => typeof key === "string",163 (key): key is string => typeof key === "string",
160 );164 );
161 }165 }
166 if (parsed.composerEnterBehavior === "send" || parsed.composerEnterBehavior === "newline") {
167 preferences.composerEnterBehavior = parsed.composerEnterBehavior;
168 }
162 if (typeof parsed.projectGroupingEnabled === "boolean") {169 if (typeof parsed.projectGroupingEnabled === "boolean") {
163 preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;170 preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
164 }171 }
docs/user/keybindings.md+13
...@@ -28,6 +28,19 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix....@@ -28,6 +28,19 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix.
28Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID”28Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID”
29or “Copy Number”. They copy the selected PR and leave terminal input alone.29or “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
31## Edit the configuration file44## Edit the configuration file
3245
33Keybindings live on the environment's machine, in46Keybindings live on the environment's machine, in
patches/react-native-gesture-handler@2.32.0.patch+17-1
...@@ -1,3 +1,19 @@...@@ -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 }
1diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js17diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
2index 551ab92bf58db0b79d428dcd2f2df898ef686493..13db26d88bff975bc5d46bb6432cbf9fda3d489a 10064418index 551ab92bf58db0b79d428dcd2f2df898ef686493..13db26d88bff975bc5d46bb6432cbf9fda3d489a 100644
3--- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js19--- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
...@@ -97,7 +113,7 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..055afce410d6eb655532e7a09371dfc2...@@ -97,7 +113,7 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..055afce410d6eb655532e7a09371dfc2
97 const animatedStyle = useAnimatedStyle(() => ({113 const animatedStyle = useAnimatedStyle(() => ({
98 transform: [{114 transform: [{
99diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts115diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
100index ac8b76830d468edfbc29052b452a36221323c3de..6985d54d9d359e825a5a0e6078bb113204e2807b 100644116index ac8b76830d468edfbc29052b452a36221323c3de..cac72ebaafaf746af7640783a16e99c93ecad2a4 100644
101--- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts117--- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
102+++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts118+++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
103@@ -64,6 +64,13 @@ export interface SwipeableProps {119@@ -64,6 +64,13 @@ export interface SwipeableProps {
pnpm-lock.yaml+3-3
...@@ -99,7 +99,7 @@ patchedDependencies:...@@ -99,7 +99,7 @@ patchedDependencies:
99 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a99 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a
100 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45100 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45
101 expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6101 expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6
102 react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398102 react-native-gesture-handler@2.32.0: 0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2
103 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787103 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787
104 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675104 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
105 react-native-reanimated@4.5.1: a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c105 react-native-reanimated@4.5.1: a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c
...@@ -426,7 +426,7 @@ importers:...@@ -426,7 +426,7 @@ importers:
426 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)426 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)
427 react-native-gesture-handler:427 react-native-gesture-handler:
428 specifier: ~2.32.0428 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)
430 react-native-image-viewing:430 react-native-image-viewing:
431 specifier: ^0.2.2431 specifier: ^0.2.2
432 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)432 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:...@@ -20977,7 +20977,7 @@ snapshots:
20977 transitivePeerDependencies:20977 transitivePeerDependencies:
20978 - supports-color20978 - 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):
20981 dependencies:20981 dependencies:
20982 '@egjs/hammerjs': 2.0.1720982 '@egjs/hammerjs': 2.0.17
20983 '@types/react-test-renderer': 19.1.020983 '@types/react-test-renderer': 19.1.0