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(...@@ -29,18 +29,21 @@ class T3KeyboardCommandsView(
29 var enabledCommands = emptySet<String>()29 var enabledCommands = emptySet<String>()
3030
31 override fun dispatchKeyEvent(event: KeyEvent): Boolean {31 override fun dispatchKeyEvent(event: KeyEvent): Boolean {
32 val copiesThreadReference =32 val command = commandFor(event)?.takeIf { enabledCommands.contains(it) }
33 event.action == KeyEvent.ACTION_DOWN &&33 if (command != null) onCommand(mapOf("command" to command))
34 event.repeatCount == 0 &&34 return command != null || super.dispatchKeyEvent(event)
35 event.keyCode == KeyEvent.KEYCODE_C &&35 }
36 event.isCtrlPressed &&36
37 event.isShiftPressed &&37 private fun commandFor(event: KeyEvent): String? {
38 !event.isAltPressed &&38 if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0 || !event.isCtrlPressed) {
39 enabledCommands.contains("copyThreadReference")39 return null
40 if (copiesThreadReference) {40 }
41 onCommand(mapOf("command" to "copyThreadReference"))41 return when {
42 return true42 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
43 }47 }
44 return super.dispatchKeyEvent(event)
45 }48 }
46}49}
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+2
...@@ -52,6 +52,7 @@ public final class T3KeyboardCommandsView: ExpoView {...@@ -52,6 +52,7 @@ public final class T3KeyboardCommandsView: ExpoView {
52 title: "Copy PR Link or Thread ID"52 title: "Copy PR Link or Thread ID"
53 ),53 ),
54 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),54 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"),
55 ].compactMap { $0 }56 ].compactMap { $0 }
56 if isPad {57 if isPad {
57 commands += (1...9).compactMap { index in58 commands += (1...9).compactMap { index in
...@@ -150,6 +151,7 @@ public final class T3KeyboardCommandsView: ExpoView {...@@ -150,6 +151,7 @@ public final class T3KeyboardCommandsView: ExpoView {
150 @objc private func openReview() { emit("review") }151 @objc private func openReview() { emit("review") }
151 @objc private func copyThreadReference() { emit("copyThreadReference") }152 @objc private func copyThreadReference() { emit("copyThreadReference") }
152 @objc private func handleToggleSidebar() { emit("toggleSidebar") }153 @objc private func handleToggleSidebar() { emit("toggleSidebar") }
154 @objc private func cycleHost() { emit("cycleHost") }
153155
154 private func emit(_ command: String) {156 private func emit(_ command: String) {
155 onCommand(["command": command])157 onCommand(["command": command])
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts+18-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { parseActiveThreadPath } from "./hardwareKeyboardCommands";3import { nextEnvironmentId, parseActiveThreadPath } from "./hardwareKeyboardCommands";
44
5describe("parseActiveThreadPath", () => {5describe("parseActiveThreadPath", () => {
6 it("extracts the active thread from thread subroutes", () => {6 it("extracts the active thread from thread subroutes", () => {
...@@ -26,3 +26,20 @@ describe("parseActiveThreadPath", () => {...@@ -26,3 +26,20 @@ describe("parseActiveThreadPath", () => {
26 expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull();26 expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull();
27 });27 });
28});28});
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 =...@@ -14,7 +14,8 @@ export type HardwareKeyboardCommand =
14 | "terminal"14 | "terminal"
15 | "review"15 | "review"
16 | "copyThreadReference"16 | "copyThreadReference"
17 | "toggleSidebar";17 | "toggleSidebar"
18 | "cycleHost";
1819
19type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void;20type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void;
2021
...@@ -91,3 +92,16 @@ export function parseActiveThreadPath(pathname: string): {...@@ -91,3 +92,16 @@ export function parseActiveThreadPath(pathname: string): {
91 return null;92 return null;
92 }93 }
93}94}
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 @@...@@ -1,7 +1,7 @@
1import { MaterialListRow } from "../../components/MaterialListRow";1import { MaterialListRow } from "../../components/MaterialListRow";
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
3import type { VcsRef } from "@t3tools/client-runtime/state/vcs";3import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
4import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";4import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
5import { LegendList } from "@legendapp/list/react-native";5import { LegendList } from "@legendapp/list/react-native";
6import {6import {
7 isAtomCommandInterrupted,7 isAtomCommandInterrupted,
...@@ -30,7 +30,6 @@ import { ThemedSwitch } from "../../components/ThemedSwitch";...@@ -30,7 +30,6 @@ import { ThemedSwitch } from "../../components/ThemedSwitch";
30import { cn } from "../../lib/cn";30import { cn } from "../../lib/cn";
31import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";31import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
32import { useServerConfigs } from "../../state/entities";32import { useServerConfigs } from "../../state/entities";
33import { projectEnvironment } from "../../state/projects";
34import { useAtomCommand } from "../../state/use-atom-command";33import { useAtomCommand } from "../../state/use-atom-command";
35import { vcsEnvironment } from "../../state/vcs";34import { vcsEnvironment } from "../../state/vcs";
36import {35import {
...@@ -206,36 +205,6 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -206,36 +205,6 @@ export function NewTaskEnvironmentPickerRouteScreen() {
206 const navigation = useNavigation();205 const navigation = useNavigation();
207 const insets = useSafeAreaInsets();206 const insets = useSafeAreaInsets();
208 const serverConfigs = useServerConfigs();207 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
239 return (208 return (
240 <View className="flex-1 bg-sheet" collapsable={false}>209 <View className="flex-1 bg-sheet" collapsable={false}>
241 <NativeStackScreenOptions210 <NativeStackScreenOptions
...@@ -275,18 +244,12 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -275,18 +244,12 @@ export function NewTaskEnvironmentPickerRouteScreen() {
275 />244 />
276 }245 }
277 isLast={index === flow.environments.length - 1}246 isLast={index === flow.environments.length - 1}
278 disabled={movingToEnvironmentId !== null}247 disabled={flow.switchingToEnvironmentId !== null}
279 onPress={() => {248 onPress={() => {
280 void Haptics.selectionAsync();249 void Haptics.selectionAsync();
281 if (flow.isScratchDraft) {250 void flow.switchEnvironment(environment.environmentId).then((switched) => {
282 if (environment.environmentId !== flow.selectedEnvironmentId) {251 if (switched) navigation.goBack();
283 void moveScratchDraft(environment.environmentId);252 });
284 return;
285 }
286 } else {
287 flow.selectEnvironment(environment.environmentId);
288 }
289 navigation.goBack();
290 }}253 }}
291 selected={flow.selectedEnvironmentId === environment.environmentId}254 selected={flow.selectedEnvironmentId === environment.environmentId}
292 title={environment.environmentLabel}255 title={environment.environmentLabel}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+24
...@@ -117,6 +117,10 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re...@@ -117,6 +117,10 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re
117import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";117import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
118import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";118import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
119import { useNewTaskFlow } from "./new-task-flow-provider";119import { useNewTaskFlow } from "./new-task-flow-provider";
120import {
121 nextEnvironmentId,
122 useHardwareKeyboardCommand,
123} from "../keyboard/hardwareKeyboardCommands";
120import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";124import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
121import { resolveDraftProjectSelection } from "./new-task-project-selection";125import { resolveDraftProjectSelection } from "./new-task-project-selection";
122import {126import {
...@@ -434,6 +438,26 @@ export function NewTaskDraftScreen(props: {...@@ -434,6 +438,26 @@ export function NewTaskDraftScreen(props: {
434 const isImportingContext = flow.draftKey ? contextImports[flow.draftKey] === true : false;438 const isImportingContext = flow.draftKey ? contextImports[flow.draftKey] === true : false;
435 const isComposerInteractionLocked =439 const isComposerInteractionLocked =
436 isIncomingShareTransferPending || flow.submitting || isImportingContext;440 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);
437 // Also guard while a submit is in flight: an Android back press or iOS461 // Also guard while a submit is in flight: an Android back press or iOS
438 // Cancel would otherwise abandon the screen while the task still starts.462 // Cancel would otherwise abandon the screen while the task still starts.
439 // T3 owns /usage-limits only where Limits has data for the selected provider.463 // 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 @@...@@ -1,4 +1,5 @@
1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { Alert } from "react-native";
23
3import type {4import type {
4 EnvironmentId,5 EnvironmentId,
...@@ -44,6 +45,7 @@ import { scopedProjectKey } from "../../lib/scopedEntities";...@@ -44,6 +45,7 @@ import { scopedProjectKey } from "../../lib/scopedEntities";
44import { appAtomRegistry } from "../../state/atom-registry";45import { appAtomRegistry } from "../../state/atom-registry";
45import { projectEnvironment } from "../../state/projects";46import { projectEnvironment } from "../../state/projects";
46import { useEnvironmentQuery } from "../../state/query";47import { useEnvironmentQuery } from "../../state/query";
48import { useAtomCommand } from "../../state/use-atom-command";
47import {49import {
48 appendComposerDraftAttachments,50 appendComposerDraftAttachments,
49 type ComposerDraftInsertion,51 type ComposerDraftInsertion,
...@@ -90,6 +92,10 @@ import {...@@ -90,6 +92,10 @@ import {
90 useSavedRemoteConnections,92 useSavedRemoteConnections,
91} from "../../state/use-remote-environment-registry";93} from "../../state/use-remote-environment-registry";
92import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";94import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
95import {
96 isAtomCommandInterrupted,
97 squashAtomCommandFailure,
98} from "@t3tools/client-runtime/state/runtime";
93import { isScratchProject } from "@t3tools/client-runtime/state/projects";99import { isScratchProject } from "@t3tools/client-runtime/state/projects";
94import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";100import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
95import { type VcsRef } from "@t3tools/client-runtime/state/vcs";101import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
...@@ -201,6 +207,13 @@ type NewTaskFlowContextValue = {...@@ -201,6 +207,13 @@ type NewTaskFlowContextValue = {
201 */207 */
202 readonly openDraft: (draftKey: string) => boolean;208 readonly openDraft: (draftKey: string) => boolean;
203 readonly selectEnvironment: (environmentId: EnvironmentId) => void;209 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;
204 readonly setSelectedModelKey: (217 readonly setSelectedModelKey: (
205 key: string | null,218 key: string | null,
206 options?: ReadonlyArray<ProviderOptionSelection>,219 options?: ReadonlyArray<ProviderOptionSelection>,
...@@ -798,6 +811,53 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -798,6 +811,53 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
798 [projects, selectedProject, carryDraftContentTo],811 [projects, selectedProject, carryDraftContentTo],
799 );812 );
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
801 const setWorkspaceMode = useCallback(861 const setWorkspaceMode = useCallback(
802 (mode: WorkspaceMode) => {862 (mode: WorkspaceMode) => {
803 if (!selectedProjectDraftKey) {863 if (!selectedProjectDraftKey) {
...@@ -1244,6 +1304,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1244,6 +1304,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1244 setProject,1304 setProject,
1245 openDraft,1305 openDraft,
1246 selectEnvironment,1306 selectEnvironment,
1307 switchEnvironment,
1308 switchingToEnvironmentId,
1247 setSelectedModelKey,1309 setSelectedModelKey,
1248 setWorkspaceMode,1310 setWorkspaceMode,
1249 selectBranch,1311 selectBranch,
...@@ -1310,6 +1372,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1310,6 +1372,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1310 openDraft,1372 openDraft,
1311 selectBranch,1373 selectBranch,
1312 selectEnvironment,1374 selectEnvironment,
1375 switchEnvironment,
1376 switchingToEnvironmentId,
1313 setInteractionMode,1377 setInteractionMode,
1314 setPrompt,1378 setPrompt,
1315 setRuntimeMode,1379 setRuntimeMode,
apps/web/src/components/ChatView.tsx+24-2
...@@ -4330,7 +4330,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -4330,7 +4330,8 @@ export default function ChatView(props: ChatViewProps) {
4330 : "Auto balance"4330 : "Auto balance"
4331 : undefined;4331 : 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);
4334 const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false);4335 const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false);
4335 useLayoutEffect(() => {4336 useLayoutEffect(() => {
4336 return () => {4337 return () => {
...@@ -4348,7 +4349,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4348,7 +4349,7 @@ export default function ChatView(props: ChatViewProps) {
4348 (env) => env.environmentId === nextEnvironmentId,4349 (env) => env.environmentId === nextEnvironmentId,
4349 );4350 );
4350 if (!target) return;4351 if (!target) return;
4351 const request = Symbol();4352 const request = { environmentId: target.environmentId };
4352 environmentChangeRef.current = request;4353 environmentChangeRef.current = request;
4353 setIsEnvironmentChanging(false);4354 setIsEnvironmentChanging(false);
4354 const retarget = (project: (typeof allProjects)[number]) => {4355 const retarget = (project: (typeof allProjects)[number]) => {
...@@ -7752,6 +7753,21 @@ export default function ChatView(props: ChatViewProps) {...@@ -7752,6 +7753,21 @@ export default function ChatView(props: ChatViewProps) {
7752 return;7753 return;
7753 }7754 }
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
7755 if (command === "composer.branch") {7771 if (command === "composer.branch") {
7756 event.preventDefault();7772 event.preventDefault();
7757 event.stopPropagation();7773 event.stopPropagation();
...@@ -7841,6 +7857,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -7841,6 +7857,12 @@ export default function ChatView(props: ChatViewProps) {
7841 toggleThreadPanel,7857 toggleThreadPanel,
7842 toggleTerminalVisibility,7858 toggleTerminalVisibility,
7843 composerRef,7859 composerRef,
7860 draftId,
7861 environmentId,
7862 envLocked,
7863 hasMultipleEnvironments,
7864 logicalProjectEnvironments,
7865 onEnvironmentChange,
7844 ]);7866 ]);
78457867
7846 // Paste-to-focus: the resting composer blurs on a click into the timeline,7868 // 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", () => {...@@ -303,14 +303,17 @@ describe("KeybindingsSettings.logic", () => {
303 "chat.new",303 "chat.new",
304 "threadPanel.toggle",304 "threadPanel.toggle",
305 "rightPanel.toggleMaximized",305 "rightPanel.toggleMaximized",
306 "composer.cycleHost",
306 "thread.stop",307 "thread.stop",
307 "usage.open",308 "usage.open",
308 "script.setup-db.run",309 "script.setup-db.run",
309 ]),310 ]),
310 );311 );
311 expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === "thread.stop")).toBe(312 for (const command of ["thread.stop", "composer.cycleHost"]) {
312 false,313 expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === command)).toBe(
313 );314 false,
315 );
316 }
314 });317 });
315318
316 it("reports unknown when variables without rejecting parseable expressions", () => {319 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...@@ -22,6 +22,8 @@ When an active turn has queued messages, `mod+Shift+Enter` sends the first as a
22steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings.22steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings.
2323
24Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host.24Use `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.
25Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the27Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the
26workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the28workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the
27current checkout, a new worktree, and the previous worktree when available.29current 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...@@ -53,6 +55,8 @@ Use the arrow keys and Return to choose a result, or `Cmd+1` through `Cmd+9` to
53choose directly. Escape or `Cmd+K` closes the palette. Start a search with `>`55choose directly. Escape or `Cmd+K` closes the palette. Start a search with `>`
54to show only actions.56to show only actions.
5557
58In a new thread, `Cmd+Shift+H` moves the draft to the next machine.
59
56In the composer, Return sends and `Shift+Return` inserts a new line. `Cmd+Return`60In the composer, Return sends and `Shift+Return` inserts a new line. `Cmd+Return`
57also sends. To make Return insert a new line instead, change the Return key61also sends. To make Return insert a new line instead, change the Return key
58behavior in Settings → Keyboard.62behavior in Settings → Keyboard.
packages/contracts/src/keybindings.ts+1
...@@ -89,6 +89,7 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -89,6 +89,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
89 "composer.sendBackground",89 "composer.sendBackground",
90 "composer.sendAndNewThread",90 "composer.sendAndNewThread",
91 "composer.host",91 "composer.host",
92 "composer.cycleHost",
92 "composer.effort",93 "composer.effort",
93 "composer.mode",94 "composer.mode",
94 "composer.workspace",95 "composer.workspace",