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