Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,21 @@ class T3KeyboardCommandsView(
var enabledCommands = emptySet<String>()

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 ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃煛 Medium t3nativecontrols/T3KeyboardCommandsModule.kt:44

Ctrl+Alt+H does not cycle hosts because this branch only matches Ctrl+Shift+H; the documented shortcut falls through, while the unintended shortcut is consumed. Match event.isAltPressed instead of requiring Shift for cycleHost.

Suggested change
event.keyCode == KeyEvent.KEYCODE_H && event.isShiftPressed && !event.isAltPressed ->
event.keyCode == KeyEvent.KEYCODE_H && event.isAltPressed ->
馃殌 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt around line 44:

`Ctrl+Alt+H` does not cycle hosts because this branch only matches `Ctrl+Shift+H`; the documented shortcut falls through, while the unintended shortcut is consumed. Match `event.isAltPressed` instead of requiring Shift for `cycleHost`.

"cycleHost"
else -> null
}
return super.dispatchKeyEvent(event)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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();
});
});
16 changes: 15 additions & 1 deletion apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export type HardwareKeyboardCommand =
| "terminal"
| "review"
| "copyThreadReference"
| "toggleSidebar";
| "toggleSidebar"
| "cycleHost";

type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void;

Expand Down Expand Up @@ -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<T extends { readonly environmentId: string }>(
environments: ReadonlyArray<T>,
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;
}
47 changes: 5 additions & 42 deletions apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<EnvironmentId | null>(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<void> {
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 (
<View className="flex-1 bg-sheet" collapsable={false}>
<NativeStackScreenOptions
Expand Down Expand Up @@ -275,18 +244,12 @@ export function NewTaskEnvironmentPickerRouteScreen() {
/>
}
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}
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert } from "react-native";

import type {
EnvironmentId,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<boolean>;
/** The machine a switch in progress is heading to. */
readonly switchingToEnvironmentId: EnvironmentId | null;
readonly setSelectedModelKey: (
key: string | null,
options?: ReadonlyArray<ProviderOptionSelection>,
Expand Down Expand Up @@ -798,6 +811,53 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
[projects, selectedProject, carryDraftContentTo],
);

const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
const [switchingToEnvironmentId, setSwitchingToEnvironmentId] = useState<EnvironmentId | null>(
null,
);
// The latest switch wins: a slower, earlier one must not retarget the draft.
const latestSwitchRef = useRef<object | null>(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃煛 Medium threads/new-task-flow-provider.tsx:819

A completed openScratch request can retarget the draft to its original scratch destination after the user has selected a different project while the request is pending. The success path only checks latestSwitchRef, which project selection does not invalidate, so setProject(result.value) overwrites the user's newer choice; verify that the environment and project selection are unchanged before applying the result.

馃殌 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around line 819:

A completed `openScratch` request can retarget the draft to its original scratch destination after the user has selected a different project while the request is pending. The success path only checks `latestSwitchRef`, which project selection does not invalidate, so `setProject(result.value)` overwrites the user's newer choice; verify that the environment and project selection are unchanged before applying the result.

const switchEnvironment = useCallback(
async (environmentId: EnvironmentId): Promise<boolean> => {
if (environmentId === selectedEnvironmentId) {
latestSwitchRef.current = null;
setSwitchingToEnvironmentId(null);
return true;
}
if (!isScratchDraft) {
selectEnvironment(environmentId);
return true;
}
const request = {};
latestSwitchRef.current = request;
setSwitchingToEnvironmentId(environmentId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃煛 Medium threads/new-task-flow-provider.tsx:833

Rapid cycleHost presses can select the same host twice, so two presses advance the draft only one host instead of two. Because setSwitchingToEnvironmentId is asynchronous, both keyboard events read the same render-time switchingToEnvironmentId before this request updates it; the second call then supersedes the first through latestSwitchRef. Track and expose the pending destination synchronously so each press computes the next host from the latest request.

馃殌 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around line 833:

Rapid `cycleHost` presses can select the same host twice, so two presses advance the draft only one host instead of two. Because `setSwitchingToEnvironmentId` is asynchronous, both keyboard events read the same render-time `switchingToEnvironmentId` before this request updates it; the second call then supersedes the first through `latestSwitchRef`. Track and expose the pending destination synchronously so each press computes the next host from the latest request.

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) {
Expand Down Expand Up @@ -1244,6 +1304,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
setProject,
openDraft,
selectEnvironment,
switchEnvironment,
switchingToEnvironmentId,
setSelectedModelKey,
setWorkspaceMode,
selectBranch,
Expand Down Expand Up @@ -1310,6 +1372,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
openDraft,
selectBranch,
selectEnvironment,
switchEnvironment,
switchingToEnvironmentId,
setInteractionMode,
setPrompt,
setRuntimeMode,
Expand Down
26 changes: 24 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4330,7 +4330,8 @@ export default function ChatView(props: ChatViewProps) {
: "Auto balance"
: undefined;

const environmentChangeRef = useRef<symbol | null>(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 () => {
Expand All @@ -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]) => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading