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 ->
"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
28 changes: 16 additions & 12 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Linking from "expo-linking";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { StatusBar, View } from "react-native";
import { StatusBar } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
Expand All @@ -23,6 +23,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal";
import { shouldHandleAppLink } from "./lib/appLinking";
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";

import "../global.css";

Expand Down Expand Up @@ -76,22 +78,24 @@ function AppContent() {
<GestureHandlerRootView className="flex-1">
<KeyboardProvider statusBarTranslucent>
<SafeAreaProvider>
<StatusBar barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} />
{/* The navigation theme drives the NATIVE header appearance: native-stack
<VoiceInputProvider>
<StatusBar barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} />
{/* The navigation theme drives the NATIVE header appearance: native-stack
forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
this, React Navigation defaults to its light theme and every native
header (glass buttons, title, materials) is forced light even when
the system is in dark mode. */}
<View style={{ flex: 1 }}>
<IncomingShareProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
</IncomingShareProvider>
<ConfirmDialogHost />
<ThreadArrangementHost />
</View>
{/* Anchored-menu overlays render here — in-window, so the
<GlobalVoiceInputControl>
<IncomingShareProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
</IncomingShareProvider>
<ConfirmDialogHost />
<ThreadArrangementHost />
</GlobalVoiceInputControl>
{/* Anchored-menu overlays render here — in-window, so the
keyboard stays up while a dropdown is open. */}
<OverlayPortalHost />
<OverlayPortalHost />
</VoiceInputProvider>
</SafeAreaProvider>
</KeyboardProvider>
</GestureHandlerRootView>
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
Expand Down Expand Up @@ -63,6 +62,8 @@ import {
} from "./scheduledTaskDraft";
import { settingsTargetsForProject } from "./settings-environment-filter.logic";
import { useScheduledTaskEditor } from "./scheduled-task-editor";
import { scheduledTaskEditorSessionAtom } from "./scheduled-task-editor-state";
import { appAtomRegistry } from "../../state/atom-registry";
import {
formatNextScheduledTaskRun,
formatScheduledTaskInterval,
Expand Down Expand Up @@ -372,14 +373,21 @@ export function SettingsScheduledTaskEditRouteScreen() {
}

function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }) {
const { editor, setEditor, hasChanges, draftForEnvironment } = useScheduledTaskEditor();
const {
editor,
voiceOwnerKey,
readEditor,
setEditor,
startEditor,
hasChanges,
draftForEnvironment,
} = useScheduledTaskEditor();
const { availableTargets } = useSettingsEnvironmentFilter();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const voiceOwnerId = useId();
const ownerKey = editor ? `${voiceOwnerId}:${editor.environmentId}` : null;
const ownerKey = editor ? `${voiceOwnerKey}:${editor.environmentId}` : null;
const prompt = editor?.draft.prompt ?? "";
const [selectionState, setSelectionState] = useState<{
readonly ownerKey: string | null;
Expand All @@ -402,32 +410,40 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }
);
const voiceInput = useVoiceInputController({
ownerKey,
draftMessage: prompt,
label: editor?.draft.title.trim() || title,
subscribeToDraftChanges: (onChange) =>
appAtomRegistry.subscribe(scheduledTaskEditorSessionAtom, onChange),
selection,
readDraftMessage: () => {
const current = readEditor();
return current?.environmentId === editor?.environmentId
? (current?.draft.prompt ?? null)
: null;
},
onChangeSelection: setSelection,
onChangeDraftMessage: setPrompt,
onChangeDraftMessage: (text) => {
if (readEditor()?.environmentId === editor?.environmentId) setPrompt(text);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dictation survives an invalid task target

Medium Severity

Changing the scheduled-task environment or starting a new editor session leaves the global dictation running against a target that readDraftMessage now treats as gone, so the mic stays on and the transcript is dropped. The controller no longer cancels when ownerKey changes, and startScheduledTaskEditor only bumps the session id.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fad003a. Configure here.

disabled: saving,
});
const preventRemove = !saved && (hasChanges || saving || voiceInput.isBusy);
const preventRemove = !saved && (hasChanges || saving);
usePreventRemove(preventRemove, ({ data }) => {
if (saving) {
Alert.alert("Saving task", "Wait for the task to finish saving before leaving.");
return;
}
Alert.alert(
"Discard changes?",
voiceInput.isBusy
? "Your dictation and unsaved changes will be lost."
: "Your unsaved changes will be lost.",
[
{ text: "Keep editing", style: "cancel" },
{
text: "Discard changes",
style: "destructive",
onPress: () => navigation.dispatch(data.action),
Alert.alert("Discard changes?", "Your unsaved changes will be lost.", [
{ text: "Keep editing", style: "cancel" },
{
text: "Discard changes",
style: "destructive",
onPress: () => {
if (voiceInput.isBusy) voiceInput.cancel();
startEditor(null);
navigation.dispatch(data.action);
},
],
);
},
]);
});
useEffect(() => {
if (!saved) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { EnvironmentId } from "@t3tools/contracts";
import { afterEach, describe, expect, it } from "vite-plus/test";

import { appAtomRegistry } from "../../state/atom-registry";
import { createDraft } from "./scheduledTaskDraft";
import {
readScheduledTaskEditor,
scheduledTaskEditorSessionAtom,
startScheduledTaskEditor,
updateScheduledTaskEditor,
} from "./scheduled-task-editor-state";

afterEach(() => appAtomRegistry.set(scheduledTaskEditorSessionAtom, null));

describe("scheduled task voice draft", () => {
const editor = {
environmentId: EnvironmentId.make("environment"),
environmentLabel: "Environment",
draft: createDraft(null, null),
};

it("keeps the prompt outside the screen and retains its original change baseline", () => {
startScheduledTaskEditor(editor);
const original = appAtomRegistry.get(scheduledTaskEditorSessionAtom)!;
const ownerKey = `scheduled-task:${original.id}`;
updateScheduledTaskEditor(
(current) =>
current && {
...current,
draft: { ...current.draft, prompt: "spoken prompt" },
},
null,
);

expect(readScheduledTaskEditor(ownerKey, null)?.draft.prompt).toBe("spoken prompt");
expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.initial).toBe(editor);
});

it("rejects an old voice target when another task opens in the same environment", () => {
startScheduledTaskEditor(editor);
const original = appAtomRegistry.get(scheduledTaskEditorSessionAtom)!;
startScheduledTaskEditor({ ...editor, draft: { ...editor.draft, prompt: "other task" } });

expect(readScheduledTaskEditor(`scheduled-task:${original.id}`, editor)).toBeNull();
expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current?.draft.prompt).toBe(
"other task",
);
});

it("does not restore the default prompt after the editor is discarded", () => {
startScheduledTaskEditor(null);
const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom)!;
expect(readScheduledTaskEditor(`scheduled-task:${session.id}`, editor)).toBeNull();
updateScheduledTaskEditor((current) => current, editor);
expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBeNull();
});
});
55 changes: 55 additions & 0 deletions apps/mobile/src/features/settings/scheduled-task-editor-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";

import { appAtomRegistry } from "../../state/atom-registry";
import type { ScheduledTaskDraft } from "./scheduledTaskDraft";

export type ScheduledTaskEditor = {
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
readonly draft: ScheduledTaskDraft;
};

type ScheduledTaskEditorSession = {
readonly id: number;
readonly initial: ScheduledTaskEditor | null;
readonly current: ScheduledTaskEditor | null;
};

export const scheduledTaskEditorSessionAtom = Atom.make<ScheduledTaskEditorSession | null>(
null,
).pipe(Atom.keepAlive);
let nextEditorId = 0;

export function startScheduledTaskEditor(next: ScheduledTaskEditor | null): void {
appAtomRegistry.set(scheduledTaskEditorSessionAtom, {
id: ++nextEditorId,
initial: next,
current: next,
});
}

export function updateScheduledTaskEditor(
update:
| ScheduledTaskEditor
| null
| ((current: ScheduledTaskEditor | null) => ScheduledTaskEditor | null),
defaultEditor: ScheduledTaskEditor | null,
): void {
const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom);
const previous = session ? session.current : defaultEditor;
appAtomRegistry.set(scheduledTaskEditorSessionAtom, {
id: session?.id ?? 0,
initial: session ? session.initial : previous,
current: typeof update === "function" ? update(previous) : update,
});
}

export function readScheduledTaskEditor(
voiceOwnerKey: string,
defaultEditor: ScheduledTaskEditor | null,
): ScheduledTaskEditor | null {
const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom);
if (`scheduled-task:${session?.id ?? 0}` !== voiceOwnerKey) return null;
return session ? session.current : defaultEditor;
}
Loading
Loading