diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt index 68608d9eb3f90c353949667dc8c816f11ccfe676..6349bc811dd7efc88f1ea9e156f38a39f149bea8 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt @@ -29,18 +29,21 @@ class T3KeyboardCommandsView( var enabledCommands = emptySet() override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val copiesThreadReference = - event.action == KeyEvent.ACTION_DOWN && - event.repeatCount == 0 && - event.keyCode == KeyEvent.KEYCODE_C && - event.isCtrlPressed && - event.isShiftPressed && - !event.isAltPressed && - enabledCommands.contains("copyThreadReference") - if (copiesThreadReference) { - onCommand(mapOf("command" to "copyThreadReference")) - return true + val command = commandFor(event)?.takeIf { enabledCommands.contains(it) } + if (command != null) onCommand(mapOf("command" to command)) + return command != null || super.dispatchKeyEvent(event) + } + + private fun commandFor(event: KeyEvent): String? { + if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0 || !event.isCtrlPressed) { + return null + } + return when { + event.keyCode == KeyEvent.KEYCODE_C && event.isShiftPressed && !event.isAltPressed -> + "copyThreadReference" + event.keyCode == KeyEvent.KEYCODE_H && event.isShiftPressed && !event.isAltPressed -> + "cycleHost" + else -> null } - return super.dispatchKeyEvent(event) } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index 8626a7091567d3452d24ebbcbd7bd8a22896fa98..3be5df2eb547ea4a9e0f719b024199baab61fc7c 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -52,6 +52,7 @@ public final class T3KeyboardCommandsView: ExpoView { title: "Copy PR Link or Thread ID" ), enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), + enabledCommand("cycleHost", input: "h", modifiers: [.command, .shift], action: #selector(cycleHost), title: "Next Machine"), ].compactMap { $0 } if isPad { commands += (1...9).compactMap { index in @@ -150,6 +151,7 @@ public final class T3KeyboardCommandsView: ExpoView { @objc private func openReview() { emit("review") } @objc private func copyThreadReference() { emit("copyThreadReference") } @objc private func handleToggleSidebar() { emit("toggleSidebar") } + @objc private func cycleHost() { emit("cycleHost") } private func emit(_ command: String) { onCommand(["command": command]) diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts index 55f1037c0109fba22cce07352737dfe88bf6dcfb..44507100fc81b8478a2f59da5c745fc57e7d42e6 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { parseActiveThreadPath } from "./hardwareKeyboardCommands"; +import { nextEnvironmentId, parseActiveThreadPath } from "./hardwareKeyboardCommands"; describe("parseActiveThreadPath", () => { it("extracts the active thread from thread subroutes", () => { @@ -26,3 +26,20 @@ describe("parseActiveThreadPath", () => { expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull(); }); }); + +describe("nextEnvironmentId", () => { + const environments = [{ environmentId: "a" }, { environmentId: "b" }, { environmentId: "c" }]; + + it.each([ + ["the next machine", "a", "b"], + ["the first machine after the last", "c", "a"], + ["the first machine when the current one is not listed", "gone", "a"], + ["the first machine when there is no current one", null, "a"], + ])("returns %s", (_label, current, expected) => { + expect(nextEnvironmentId(environments, current)).toBe(expected); + }); + + it("returns null when there is nowhere else to go", () => { + expect(nextEnvironmentId([{ environmentId: "a" }], "a")).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index 8083d85cfaeaefd36db218b9795c8b4da73d4eb0..679790f59cf791ff19229718ca5f8e3bb6a04519 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -14,7 +14,8 @@ export type HardwareKeyboardCommand = | "terminal" | "review" | "copyThreadReference" - | "toggleSidebar"; + | "toggleSidebar" + | "cycleHost"; type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void; @@ -91,3 +92,16 @@ export function parseActiveThreadPath(pathname: string): { return null; } } + +/** + * The machine after `currentId` in display order, wrapping around. Starts at + * the first machine when `currentId` is not listed. + */ +export function nextEnvironmentId( + environments: ReadonlyArray, + currentId: string | null, +): T["environmentId"] | null { + if (environments.length < 2) return null; + const index = environments.findIndex((environment) => environment.environmentId === currentId); + return environments[(index + 1) % environments.length]?.environmentId ?? null; +} diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index a1a96d9dbf3dbdbef0958a3b5bf624ffeb3e0249..f9ba9cf82c2f1f4c6aa2d2116b4f90abeec42d79 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -1,7 +1,7 @@ import { MaterialListRow } from "../../components/MaterialListRow"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; -import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { LegendList } from "@legendapp/list/react-native"; import { isAtomCommandInterrupted, @@ -30,7 +30,6 @@ import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useServerConfigs } from "../../state/entities"; -import { projectEnvironment } from "../../state/projects"; import { useAtomCommand } from "../../state/use-atom-command"; import { vcsEnvironment } from "../../state/vcs"; import { @@ -206,36 +205,6 @@ export function NewTaskEnvironmentPickerRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const serverConfigs = useServerConfigs(); - const openScratch = useAtomCommand(projectEnvironment.openScratch, { - reportFailure: false, - }); - const [movingToEnvironmentId, setMovingToEnvironmentId] = useState(null); - - // A thread without a project moves to the other machine's own Scratch - // project, which is created there first if it does not exist yet. - async function moveScratchDraft(environmentId: EnvironmentId): Promise { - setMovingToEnvironmentId(environmentId); - try { - const result = await openScratch({ environmentId, input: {} }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Alert.alert( - "Could not switch machine", - error instanceof Error - ? error.message - : "The folder for threads without a project could not be created.", - ); - } - return; - } - flow.setProject(result.value); - navigation.goBack(); - } finally { - setMovingToEnvironmentId(null); - } - } - return ( } isLast={index === flow.environments.length - 1} - disabled={movingToEnvironmentId !== null} + disabled={flow.switchingToEnvironmentId !== null} onPress={() => { void Haptics.selectionAsync(); - if (flow.isScratchDraft) { - if (environment.environmentId !== flow.selectedEnvironmentId) { - void moveScratchDraft(environment.environmentId); - return; - } - } else { - flow.selectEnvironment(environment.environmentId); - } - navigation.goBack(); + void flow.switchEnvironment(environment.environmentId).then((switched) => { + if (switched) navigation.goBack(); + }); }} selected={flow.selectedEnvironmentId === environment.environmentId} title={environment.environmentLabel} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 1ee4e9bc4e852084dea001a914779d0a37d8b29d..c0b136be0eda3e8ebc70ce836f1dd322fa856e9f 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -117,6 +117,10 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import { useNewTaskFlow } from "./new-task-flow-provider"; +import { + nextEnvironmentId, + useHardwareKeyboardCommand, +} from "../keyboard/hardwareKeyboardCommands"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; import { resolveDraftProjectSelection } from "./new-task-project-selection"; import { @@ -434,6 +438,26 @@ export function NewTaskDraftScreen(props: { const isImportingContext = flow.draftKey ? contextImports[flow.draftKey] === true : false; const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting || isImportingContext; + // Hardware keyboard: step to the next machine, from the one a switch in + // progress is heading to so repeated presses keep advancing. + const { environments, selectedEnvironmentId, switchEnvironment, switchingToEnvironmentId } = flow; + const cycleEnvironment = useCallback(() => { + if (isComposerInteractionLocked) return true; + const next = nextEnvironmentId(environments, switchingToEnvironmentId ?? selectedEnvironmentId); + if (next !== null) void switchEnvironment(next); + return true; + }, [ + environments, + isComposerInteractionLocked, + selectedEnvironmentId, + switchEnvironment, + switchingToEnvironmentId, + ]); + const cycleEnvironmentCommands = useMemo( + () => (environments.length > 1 ? (["cycleHost"] as const) : []), + [environments.length], + ); + useHardwareKeyboardCommand(cycleEnvironmentCommands, cycleEnvironment); // Also guard while a submit is in flight: an Android back press or iOS // Cancel would otherwise abandon the screen while the task still starts. // T3 owns /usage-limits only where Limits has data for the selected provider. diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ccc2ebdc0e4272a2aab71f031268a4f0c55aeaad..1ffa7696250e041948df3b04d0f6f4141a4d96c9 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert } from "react-native"; import type { EnvironmentId, @@ -44,6 +45,7 @@ import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; import { appendComposerDraftAttachments, type ComposerDraftInsertion, @@ -90,6 +92,10 @@ import { useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; @@ -201,6 +207,13 @@ type NewTaskFlowContextValue = { */ readonly openDraft: (draftKey: string) => boolean; readonly selectEnvironment: (environmentId: EnvironmentId) => void; + /** + * Moves the draft to another machine. A draft without a project resolves to + * that machine's "No project" folder first, creating it when needed. + */ + readonly switchEnvironment: (environmentId: EnvironmentId) => Promise; + /** The machine a switch in progress is heading to. */ + readonly switchingToEnvironmentId: EnvironmentId | null; readonly setSelectedModelKey: ( key: string | null, options?: ReadonlyArray, @@ -798,6 +811,53 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [projects, selectedProject, carryDraftContentTo], ); + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); + const [switchingToEnvironmentId, setSwitchingToEnvironmentId] = useState( + null, + ); + // The latest switch wins: a slower, earlier one must not retarget the draft. + const latestSwitchRef = useRef(null); + const switchEnvironment = useCallback( + async (environmentId: EnvironmentId): Promise => { + if (environmentId === selectedEnvironmentId) { + latestSwitchRef.current = null; + setSwitchingToEnvironmentId(null); + return true; + } + if (!isScratchDraft) { + selectEnvironment(environmentId); + return true; + } + const request = {}; + latestSwitchRef.current = request; + setSwitchingToEnvironmentId(environmentId); + try { + const result = await openScratch({ environmentId, input: {} }); + if (latestSwitchRef.current !== request) return false; + if (result._tag === "Success") { + setProject(result.value); + return true; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not switch machine", + error instanceof Error + ? error.message + : "The folder for threads without a project could not be created.", + ); + } + return false; + } finally { + if (latestSwitchRef.current === request) { + latestSwitchRef.current = null; + setSwitchingToEnvironmentId(null); + } + } + }, + [isScratchDraft, openScratch, selectEnvironment, selectedEnvironmentId, setProject], + ); + const setWorkspaceMode = useCallback( (mode: WorkspaceMode) => { if (!selectedProjectDraftKey) { @@ -1244,6 +1304,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setProject, openDraft, selectEnvironment, + switchEnvironment, + switchingToEnvironmentId, setSelectedModelKey, setWorkspaceMode, selectBranch, @@ -1310,6 +1372,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { openDraft, selectBranch, selectEnvironment, + switchEnvironment, + switchingToEnvironmentId, setInteractionMode, setPrompt, setRuntimeMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 55d5492ae74f529cd6197bc5adfe82bad995b9b3..8879fe8f1f38e7800407167e77cde7382f0d9257 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4330,7 +4330,8 @@ export default function ChatView(props: ChatViewProps) { : "Auto balance" : undefined; - const environmentChangeRef = useRef(null); + // The machine an in-flight switch is heading to; a newer switch replaces it. + const environmentChangeRef = useRef<{ readonly environmentId: EnvironmentId } | null>(null); const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false); useLayoutEffect(() => { return () => { @@ -4348,7 +4349,7 @@ export default function ChatView(props: ChatViewProps) { (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; - const request = Symbol(); + const request = { environmentId: target.environmentId }; environmentChangeRef.current = request; setIsEnvironmentChanging(false); const retarget = (project: (typeof allProjects)[number]) => { @@ -7752,6 +7753,21 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "composer.cycleHost") { + if (envLocked || !draftId || !hasMultipleEnvironments) return; + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + // Step from where a pending switch is heading, so repeated presses keep advancing. + const currentId = environmentChangeRef.current?.environmentId ?? environmentId; + const index = logicalProjectEnvironments.findIndex( + (env) => env.environmentId === currentId, + ); + const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length]; + if (next) onEnvironmentChange(next.environmentId); + return; + } + if (command === "composer.branch") { event.preventDefault(); event.stopPropagation(); @@ -7841,6 +7857,12 @@ export default function ChatView(props: ChatViewProps) { toggleThreadPanel, toggleTerminalVisibility, composerRef, + draftId, + environmentId, + envLocked, + hasMultipleEnvironments, + logicalProjectEnvironments, + onEnvironmentChange, ]); // Paste-to-focus: the resting composer blurs on a click into the timeline, diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index cedcda74e6f9ba30ea2ca68d7e67f612963e4bc9..40f24c9bf3bae95330938c8e671db8883352a392 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -303,14 +303,17 @@ describe("KeybindingsSettings.logic", () => { "chat.new", "threadPanel.toggle", "rightPanel.toggleMaximized", + "composer.cycleHost", "thread.stop", "usage.open", "script.setup-db.run", ]), ); - expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === "thread.stop")).toBe( - false, - ); + for (const command of ["thread.stop", "composer.cycleHost"]) { + expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === command)).toBe( + false, + ); + } }); it("reports unknown when variables without rejecting parseable expressions", () => { diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 9f1ef0626fdd2d71084b37c529623696944883c1..e3773bc3adbcc2ec4235a4b531a4debb3f065ecd 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -22,6 +22,8 @@ When an active turn has queued messages, `mod+Shift+Enter` sends the first as a steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings. Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. +To step a new thread to the next machine instead of opening the menu, bind +**Composer: Cycle Host** in Keybindings. It has no default shortcut. Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. @@ -53,6 +55,8 @@ Use the arrow keys and Return to choose a result, or `Cmd+1` through `Cmd+9` to choose directly. Escape or `Cmd+K` closes the palette. Start a search with `>` to show only actions. +In a new thread, `Cmd+Shift+H` moves the draft to the next machine. + In the composer, Return sends and `Shift+Return` inserts a new line. `Cmd+Return` also sends. To make Return insert a new line instead, change the Return key behavior in Settings → Keyboard. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index e827ba8270fd6a049937955d0ccce850b5d2f007..e42396dbc582d12c6e56692860d40bc99fc0a961 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -89,6 +89,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.sendBackground", "composer.sendAndNewThread", "composer.host", + "composer.cycleHost", "composer.effort", "composer.mode", "composer.workspace",