| author | |
| committer | |
| log | c5a0c78b7c0f913610b118234117747065050a5a |
| tree | b8a6e5b3b43cc659e7e2e10119937186d1721a3e |
| parent | f870c419fc21f7ee23c122e60fb94adb8bf73a40 |
| signature | Signed by PGP key B5690EEEBB952194 |
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>() |
| 30 | 30 | ||
| 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 true | 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 | ||
| 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 in | 58 | 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") } | ||
| 153 | 155 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { parseActiveThreadPath } from "./hardwareKeyboardCommands"; | 3 | import { nextEnvironmentId, parseActiveThreadPath } from "./hardwareKeyboardCommands"; |
| 4 | 4 | ||
| 5 | describe("parseActiveThreadPath", () => { | 5 | describe("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 | |||
| 30 | describe("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"; | ||
| 18 | 19 | ||
| 19 | type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void; | 20 | type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void; |
| 20 | 21 | ||
| ... | @@ -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 | */ | ||
| 100 | export 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 @@ |
| 1 | import { MaterialListRow } from "../../components/MaterialListRow"; | 1 | import { MaterialListRow } from "../../components/MaterialListRow"; |
| 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 3 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; | 3 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 4 | import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; | 4 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 5 | import { LegendList } from "@legendapp/list/react-native"; | 5 | import { LegendList } from "@legendapp/list/react-native"; |
| 6 | import { | 6 | import { |
| 7 | isAtomCommandInterrupted, | 7 | isAtomCommandInterrupted, |
| ... | @@ -30,7 +30,6 @@ import { ThemedSwitch } from "../../components/ThemedSwitch"; | ... | @@ -30,7 +30,6 @@ import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 30 | import { cn } from "../../lib/cn"; | 30 | import { cn } from "../../lib/cn"; |
| 31 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 31 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 32 | import { useServerConfigs } from "../../state/entities"; | 32 | import { useServerConfigs } from "../../state/entities"; |
| 33 | import { projectEnvironment } from "../../state/projects"; | ||
| 34 | import { useAtomCommand } from "../../state/use-atom-command"; | 33 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 35 | import { vcsEnvironment } from "../../state/vcs"; | 34 | import { vcsEnvironment } from "../../state/vcs"; |
| 36 | import { | 35 | import { |
| ... | @@ -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 | <NativeStackScreenOptions | 210 | <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 |
| 117 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; | 117 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; |
| 118 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | 118 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 119 | import { useNewTaskFlow } from "./new-task-flow-provider"; | 119 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 120 | import { | ||
| 121 | nextEnvironmentId, | ||
| 122 | useHardwareKeyboardCommand, | ||
| 123 | } from "../keyboard/hardwareKeyboardCommands"; | ||
| 120 | import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; | 124 | import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; |
| 121 | import { resolveDraftProjectSelection } from "./new-task-project-selection"; | 125 | import { resolveDraftProjectSelection } from "./new-task-project-selection"; |
| 122 | import { | 126 | import { |
| ... | @@ -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 iOS | 461 | // 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 @@ |
| 1 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { Alert } from "react-native"; | ||
| 2 | 3 | ||
| 3 | import type { | 4 | import type { |
| 4 | EnvironmentId, | 5 | EnvironmentId, |
| ... | @@ -44,6 +45,7 @@ import { scopedProjectKey } from "../../lib/scopedEntities"; | ... | @@ -44,6 +45,7 @@ import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 44 | import { appAtomRegistry } from "../../state/atom-registry"; | 45 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 45 | import { projectEnvironment } from "../../state/projects"; | 46 | import { projectEnvironment } from "../../state/projects"; |
| 46 | import { useEnvironmentQuery } from "../../state/query"; | 47 | import { useEnvironmentQuery } from "../../state/query"; |
| 48 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 47 | import { | 49 | import { |
| 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"; |
| 92 | import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; | 94 | import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; |
| 95 | import { | ||
| 96 | isAtomCommandInterrupted, | ||
| 97 | squashAtomCommandFailure, | ||
| 98 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 93 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | 99 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 94 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 100 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 95 | import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; | 101 | import { 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 | ); |
| 800 | 813 | ||
| 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; |
| 4332 | 4332 | ||
| 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 | } |
| 7754 | 7755 | ||
| 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 | ]); |
| 7845 | 7867 | ||
| 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 | }); |
| 315 | 318 | ||
| 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 |
| 22 | steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings. | 22 | steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings. |
| 23 | 23 | ||
| 24 | Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. | 24 | Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. |
| 25 | To 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. | ||
| 25 | Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the | 27 | Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the |
| 26 | workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the | 28 | workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the |
| 27 | current checkout, a new worktree, and the previous worktree when available. | 29 | 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 | ... | @@ -53,6 +55,8 @@ Use the arrow keys and Return to choose a result, or `Cmd+1` through `Cmd+9` to |
| 53 | choose directly. Escape or `Cmd+K` closes the palette. Start a search with `>` | 55 | choose directly. Escape or `Cmd+K` closes the palette. Start a search with `>` |
| 54 | to show only actions. | 56 | to show only actions. |
| 55 | 57 | ||
| 58 | In a new thread, `Cmd+Shift+H` moves the draft to the next machine. | ||
| 59 | |||
| 56 | In the composer, Return sends and `Shift+Return` inserts a new line. `Cmd+Return` | 60 | In the composer, Return sends and `Shift+Return` inserts a new line. `Cmd+Return` |
| 57 | also sends. To make Return insert a new line instead, change the Return key | 61 | also sends. To make Return insert a new line instead, change the Return key |
| 58 | behavior in Settings → Keyboard. | 62 | behavior 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", |