authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-03 22:09:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 22:09:30-07:00
logc5a0c78b7c0f913610b118234117747065050a5a
treeb8a6e5b3b43cc659e7e2e10119937186d1721a3e
parentf870c419fc21f7ee23c122e60fb94adb8bf73a40
signature Signed by PGP key B5690EEEBB952194

feat(clients): step a new thread to the next machine from the keyboard (#15391)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

11 files changed, 178 insertions(+), 61 deletions(-)

apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt+15-12
......@@ -29,18 +29,21 @@ class T3KeyboardCommandsView(
2929 var enabledCommands = emptySet<String>()
3030
3131 override fun dispatchKeyEvent(event: KeyEvent): Boolean {
32 val copiesThreadReference =
33 event.action == KeyEvent.ACTION_DOWN &&
34 event.repeatCount == 0 &&
35 event.keyCode == KeyEvent.KEYCODE_C &&
36 event.isCtrlPressed &&
37 event.isShiftPressed &&
38 !event.isAltPressed &&
39 enabledCommands.contains("copyThreadReference")
40 if (copiesThreadReference) {
41 onCommand(mapOf("command" to "copyThreadReference"))
42 return true
32 val command = commandFor(event)?.takeIf { enabledCommands.contains(it) }
33 if (command != null) onCommand(mapOf("command" to command))
34 return command != null || super.dispatchKeyEvent(event)
35 }
36
37 private fun commandFor(event: KeyEvent): String? {
38 if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0 || !event.isCtrlPressed) {
39 return null
40 }
41 return when {
42 event.keyCode == KeyEvent.KEYCODE_C && event.isShiftPressed && !event.isAltPressed ->
43 "copyThreadReference"
44 event.keyCode == KeyEvent.KEYCODE_H && event.isShiftPressed && !event.isAltPressed ->
45 "cycleHost"
46 else -> null
4347 }
44 return super.dispatchKeyEvent(event)
4548 }
4649}
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+2
......@@ -52,6 +52,7 @@ public final class T3KeyboardCommandsView: ExpoView {
5252 title: "Copy PR Link or Thread ID"
5353 ),
5454 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
55 enabledCommand("cycleHost", input: "h", modifiers: [.command, .shift], action: #selector(cycleHost), title: "Next Machine"),
5556 ].compactMap { $0 }
5657 if isPad {
5758 commands += (1...9).compactMap { index in
......@@ -150,6 +151,7 @@ public final class T3KeyboardCommandsView: ExpoView {
150151 @objc private func openReview() { emit("review") }
151152 @objc private func copyThreadReference() { emit("copyThreadReference") }
152153 @objc private func handleToggleSidebar() { emit("toggleSidebar") }
154 @objc private func cycleHost() { emit("cycleHost") }
153155
154156 private func emit(_ command: String) {
155157 onCommand(["command": command])
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts+18-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { parseActiveThreadPath } from "./hardwareKeyboardCommands";
3import { nextEnvironmentId, parseActiveThreadPath } from "./hardwareKeyboardCommands";
44
55describe("parseActiveThreadPath", () => {
66 it("extracts the active thread from thread subroutes", () => {
......@@ -26,3 +26,20 @@ describe("parseActiveThreadPath", () => {
2626 expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull();
2727 });
2828});
29
30describe("nextEnvironmentId", () => {
31 const environments = [{ environmentId: "a" }, { environmentId: "b" }, { environmentId: "c" }];
32
33 it.each([
34 ["the next machine", "a", "b"],
35 ["the first machine after the last", "c", "a"],
36 ["the first machine when the current one is not listed", "gone", "a"],
37 ["the first machine when there is no current one", null, "a"],
38 ])("returns %s", (_label, current, expected) => {
39 expect(nextEnvironmentId(environments, current)).toBe(expected);
40 });
41
42 it("returns null when there is nowhere else to go", () => {
43 expect(nextEnvironmentId([{ environmentId: "a" }], "a")).toBeNull();
44 });
45});
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts+15-1
......@@ -14,7 +14,8 @@ export type HardwareKeyboardCommand =
1414 | "terminal"
1515 | "review"
1616 | "copyThreadReference"
17 | "toggleSidebar";
17 | "toggleSidebar"
18 | "cycleHost";
1819
1920type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void;
2021
......@@ -91,3 +92,16 @@ export function parseActiveThreadPath(pathname: string): {
9192 return null;
9293 }
9394}
95
96/**
97 * The machine after `currentId` in display order, wrapping around. Starts at
98 * the first machine when `currentId` is not listed.
99 */
100export function nextEnvironmentId<T extends { readonly environmentId: string }>(
101 environments: ReadonlyArray<T>,
102 currentId: string | null,
103): T["environmentId"] | null {
104 if (environments.length < 2) return null;
105 const index = environments.findIndex((environment) => environment.environmentId === currentId);
106 return environments[(index + 1) % environments.length]?.environmentId ?? null;
107}
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+5-42
......@@ -1,7 +1,7 @@
11import { MaterialListRow } from "../../components/MaterialListRow";
22import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
33import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
4import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
4import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
55import { LegendList } from "@legendapp/list/react-native";
66import {
77 isAtomCommandInterrupted,
......@@ -30,7 +30,6 @@ import { ThemedSwitch } from "../../components/ThemedSwitch";
3030import { cn } from "../../lib/cn";
3131import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
3232import { useServerConfigs } from "../../state/entities";
33import { projectEnvironment } from "../../state/projects";
3433import { useAtomCommand } from "../../state/use-atom-command";
3534import { vcsEnvironment } from "../../state/vcs";
3635import {
......@@ -206,36 +205,6 @@ export function NewTaskEnvironmentPickerRouteScreen() {
206205 const navigation = useNavigation();
207206 const insets = useSafeAreaInsets();
208207 const serverConfigs = useServerConfigs();
209 const openScratch = useAtomCommand(projectEnvironment.openScratch, {
210 reportFailure: false,
211 });
212 const [movingToEnvironmentId, setMovingToEnvironmentId] = useState<EnvironmentId | null>(null);
213
214 // A thread without a project moves to the other machine's own Scratch
215 // project, which is created there first if it does not exist yet.
216 async function moveScratchDraft(environmentId: EnvironmentId): Promise<void> {
217 setMovingToEnvironmentId(environmentId);
218 try {
219 const result = await openScratch({ environmentId, input: {} });
220 if (result._tag === "Failure") {
221 if (!isAtomCommandInterrupted(result)) {
222 const error = squashAtomCommandFailure(result);
223 Alert.alert(
224 "Could not switch machine",
225 error instanceof Error
226 ? error.message
227 : "The folder for threads without a project could not be created.",
228 );
229 }
230 return;
231 }
232 flow.setProject(result.value);
233 navigation.goBack();
234 } finally {
235 setMovingToEnvironmentId(null);
236 }
237 }
238
239208 return (
240209 <View className="flex-1 bg-sheet" collapsable={false}>
241210 <NativeStackScreenOptions
......@@ -275,18 +244,12 @@ export function NewTaskEnvironmentPickerRouteScreen() {
275244 />
276245 }
277246 isLast={index === flow.environments.length - 1}
278 disabled={movingToEnvironmentId !== null}
247 disabled={flow.switchingToEnvironmentId !== null}
279248 onPress={() => {
280249 void Haptics.selectionAsync();
281 if (flow.isScratchDraft) {
282 if (environment.environmentId !== flow.selectedEnvironmentId) {
283 void moveScratchDraft(environment.environmentId);
284 return;
285 }
286 } else {
287 flow.selectEnvironment(environment.environmentId);
288 }
289 navigation.goBack();
250 void flow.switchEnvironment(environment.environmentId).then((switched) => {
251 if (switched) navigation.goBack();
252 });
290253 }}
291254 selected={flow.selectedEnvironmentId === environment.environmentId}
292255 title={environment.environmentLabel}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+24
......@@ -117,6 +117,10 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re
117117import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
118118import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
119119import { useNewTaskFlow } from "./new-task-flow-provider";
120import {
121 nextEnvironmentId,
122 useHardwareKeyboardCommand,
123} from "../keyboard/hardwareKeyboardCommands";
120124import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
121125import { resolveDraftProjectSelection } from "./new-task-project-selection";
122126import {
......@@ -434,6 +438,26 @@ export function NewTaskDraftScreen(props: {
434438 const isImportingContext = flow.draftKey ? contextImports[flow.draftKey] === true : false;
435439 const isComposerInteractionLocked =
436440 isIncomingShareTransferPending || flow.submitting || isImportingContext;
441 // Hardware keyboard: step to the next machine, from the one a switch in
442 // progress is heading to so repeated presses keep advancing.
443 const { environments, selectedEnvironmentId, switchEnvironment, switchingToEnvironmentId } = flow;
444 const cycleEnvironment = useCallback(() => {
445 if (isComposerInteractionLocked) return true;
446 const next = nextEnvironmentId(environments, switchingToEnvironmentId ?? selectedEnvironmentId);
447 if (next !== null) void switchEnvironment(next);
448 return true;
449 }, [
450 environments,
451 isComposerInteractionLocked,
452 selectedEnvironmentId,
453 switchEnvironment,
454 switchingToEnvironmentId,
455 ]);
456 const cycleEnvironmentCommands = useMemo(
457 () => (environments.length > 1 ? (["cycleHost"] as const) : []),
458 [environments.length],
459 );
460 useHardwareKeyboardCommand(cycleEnvironmentCommands, cycleEnvironment);
437461 // Also guard while a submit is in flight: an Android back press or iOS
438462 // Cancel would otherwise abandon the screen while the task still starts.
439463 // T3 owns /usage-limits only where Limits has data for the selected provider.
apps/mobile/src/features/threads/new-task-flow-provider.tsx+64
......@@ -1,4 +1,5 @@
11import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { Alert } from "react-native";
23
34import type {
45 EnvironmentId,
......@@ -44,6 +45,7 @@ import { scopedProjectKey } from "../../lib/scopedEntities";
4445import { appAtomRegistry } from "../../state/atom-registry";
4546import { projectEnvironment } from "../../state/projects";
4647import { useEnvironmentQuery } from "../../state/query";
48import { useAtomCommand } from "../../state/use-atom-command";
4749import {
4850 appendComposerDraftAttachments,
4951 type ComposerDraftInsertion,
......@@ -90,6 +92,10 @@ import {
9092 useSavedRemoteConnections,
9193} from "../../state/use-remote-environment-registry";
9294import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
95import {
96 isAtomCommandInterrupted,
97 squashAtomCommandFailure,
98} from "@t3tools/client-runtime/state/runtime";
9399import { isScratchProject } from "@t3tools/client-runtime/state/projects";
94100import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
95101import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
......@@ -201,6 +207,13 @@ type NewTaskFlowContextValue = {
201207 */
202208 readonly openDraft: (draftKey: string) => boolean;
203209 readonly selectEnvironment: (environmentId: EnvironmentId) => void;
210 /**
211 * Moves the draft to another machine. A draft without a project resolves to
212 * that machine's "No project" folder first, creating it when needed.
213 */
214 readonly switchEnvironment: (environmentId: EnvironmentId) => Promise<boolean>;
215 /** The machine a switch in progress is heading to. */
216 readonly switchingToEnvironmentId: EnvironmentId | null;
204217 readonly setSelectedModelKey: (
205218 key: string | null,
206219 options?: ReadonlyArray<ProviderOptionSelection>,
......@@ -798,6 +811,53 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
798811 [projects, selectedProject, carryDraftContentTo],
799812 );
800813
814 const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
815 const [switchingToEnvironmentId, setSwitchingToEnvironmentId] = useState<EnvironmentId | null>(
816 null,
817 );
818 // The latest switch wins: a slower, earlier one must not retarget the draft.
819 const latestSwitchRef = useRef<object | null>(null);
820 const switchEnvironment = useCallback(
821 async (environmentId: EnvironmentId): Promise<boolean> => {
822 if (environmentId === selectedEnvironmentId) {
823 latestSwitchRef.current = null;
824 setSwitchingToEnvironmentId(null);
825 return true;
826 }
827 if (!isScratchDraft) {
828 selectEnvironment(environmentId);
829 return true;
830 }
831 const request = {};
832 latestSwitchRef.current = request;
833 setSwitchingToEnvironmentId(environmentId);
834 try {
835 const result = await openScratch({ environmentId, input: {} });
836 if (latestSwitchRef.current !== request) return false;
837 if (result._tag === "Success") {
838 setProject(result.value);
839 return true;
840 }
841 if (!isAtomCommandInterrupted(result)) {
842 const error = squashAtomCommandFailure(result);
843 Alert.alert(
844 "Could not switch machine",
845 error instanceof Error
846 ? error.message
847 : "The folder for threads without a project could not be created.",
848 );
849 }
850 return false;
851 } finally {
852 if (latestSwitchRef.current === request) {
853 latestSwitchRef.current = null;
854 setSwitchingToEnvironmentId(null);
855 }
856 }
857 },
858 [isScratchDraft, openScratch, selectEnvironment, selectedEnvironmentId, setProject],
859 );
860
801861 const setWorkspaceMode = useCallback(
802862 (mode: WorkspaceMode) => {
803863 if (!selectedProjectDraftKey) {
......@@ -1244,6 +1304,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
12441304 setProject,
12451305 openDraft,
12461306 selectEnvironment,
1307 switchEnvironment,
1308 switchingToEnvironmentId,
12471309 setSelectedModelKey,
12481310 setWorkspaceMode,
12491311 selectBranch,
......@@ -1310,6 +1372,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
13101372 openDraft,
13111373 selectBranch,
13121374 selectEnvironment,
1375 switchEnvironment,
1376 switchingToEnvironmentId,
13131377 setInteractionMode,
13141378 setPrompt,
13151379 setRuntimeMode,
apps/web/src/components/ChatView.tsx+24-2
......@@ -4330,7 +4330,8 @@ export default function ChatView(props: ChatViewProps) {
43304330 : "Auto balance"
43314331 : undefined;
43324332
4333 const environmentChangeRef = useRef<symbol | null>(null);
4333 // The machine an in-flight switch is heading to; a newer switch replaces it.
4334 const environmentChangeRef = useRef<{ readonly environmentId: EnvironmentId } | null>(null);
43344335 const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false);
43354336 useLayoutEffect(() => {
43364337 return () => {
......@@ -4348,7 +4349,7 @@ export default function ChatView(props: ChatViewProps) {
43484349 (env) => env.environmentId === nextEnvironmentId,
43494350 );
43504351 if (!target) return;
4351 const request = Symbol();
4352 const request = { environmentId: target.environmentId };
43524353 environmentChangeRef.current = request;
43534354 setIsEnvironmentChanging(false);
43544355 const retarget = (project: (typeof allProjects)[number]) => {
......@@ -7752,6 +7753,21 @@ export default function ChatView(props: ChatViewProps) {
77527753 return;
77537754 }
77547755
7756 if (command === "composer.cycleHost") {
7757 if (envLocked || !draftId || !hasMultipleEnvironments) return;
7758 event.preventDefault();
7759 event.stopPropagation();
7760 if (event.repeat) return;
7761 // Step from where a pending switch is heading, so repeated presses keep advancing.
7762 const currentId = environmentChangeRef.current?.environmentId ?? environmentId;
7763 const index = logicalProjectEnvironments.findIndex(
7764 (env) => env.environmentId === currentId,
7765 );
7766 const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length];
7767 if (next) onEnvironmentChange(next.environmentId);
7768 return;
7769 }
7770
77557771 if (command === "composer.branch") {
77567772 event.preventDefault();
77577773 event.stopPropagation();
......@@ -7841,6 +7857,12 @@ export default function ChatView(props: ChatViewProps) {
78417857 toggleThreadPanel,
78427858 toggleTerminalVisibility,
78437859 composerRef,
7860 draftId,
7861 environmentId,
7862 envLocked,
7863 hasMultipleEnvironments,
7864 logicalProjectEnvironments,
7865 onEnvironmentChange,
78447866 ]);
78457867
78467868 // Paste-to-focus: the resting composer blurs on a click into the timeline,
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+6-3
......@@ -303,14 +303,17 @@ describe("KeybindingsSettings.logic", () => {
303303 "chat.new",
304304 "threadPanel.toggle",
305305 "rightPanel.toggleMaximized",
306 "composer.cycleHost",
306307 "thread.stop",
307308 "usage.open",
308309 "script.setup-db.run",
309310 ]),
310311 );
311 expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === "thread.stop")).toBe(
312 false,
313 );
312 for (const command of ["thread.stop", "composer.cycleHost"]) {
313 expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === command)).toBe(
314 false,
315 );
316 }
314317 });
315318
316319 it("reports unknown when variables without rejecting parseable expressions", () => {
docs/user/keybindings.md+4
......@@ -22,6 +22,8 @@ When an active turn has queued messages, `mod+Shift+Enter` sends the first as a
2222steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings.
2323
2424Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host.
25To step a new thread to the next machine instead of opening the menu, bind
26**Composer: Cycle Host** in Keybindings. It has no default shortcut.
2527Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the
2628workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the
2729current 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
5355choose directly. Escape or `Cmd+K` closes the palette. Start a search with `>`
5456to show only actions.
5557
58In a new thread, `Cmd+Shift+H` moves the draft to the next machine.
59
5660In the composer, Return sends and `Shift+Return` inserts a new line. `Cmd+Return`
5761also sends. To make Return insert a new line instead, change the Return key
5862behavior in Settings → Keyboard.
packages/contracts/src/keybindings.ts+1
......@@ -89,6 +89,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
8989 "composer.sendBackground",
9090 "composer.sendAndNewThread",
9191 "composer.host",
92 "composer.cycleHost",
9293 "composer.effort",
9394 "composer.mode",
9495 "composer.workspace",