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