Skip to content

Commit fad003a

Browse files
committed
Merge remote-tracking branch 'upstream/main' into yordis/chore-sync-upstream-20261004-1
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com> # Conflicts: # apps/server/src/orchestration-v2/Orchestrator.ts # apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
2 parents c5ae670 + a92c43b commit fad003a

64 files changed

Lines changed: 2905 additions & 1091 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,21 @@ class T3KeyboardCommandsView(
2929
var enabledCommands = emptySet<String>()
3030

3131
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
32-
val copiesThreadReference =
33-
event.action == KeyEvent.ACTION_DOWN &&
34-
event.repeatCount == 0 &&
35-
event.keyCode == KeyEvent.KEYCODE_C &&
36-
event.isCtrlPressed &&
37-
event.isShiftPressed &&
38-
!event.isAltPressed &&
39-
enabledCommands.contains("copyThreadReference")
40-
if (copiesThreadReference) {
41-
onCommand(mapOf("command" to "copyThreadReference"))
42-
return true
32+
val command = commandFor(event)?.takeIf { enabledCommands.contains(it) }
33+
if (command != null) onCommand(mapOf("command" to command))
34+
return command != null || super.dispatchKeyEvent(event)
35+
}
36+
37+
private fun commandFor(event: KeyEvent): String? {
38+
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0 || !event.isCtrlPressed) {
39+
return null
40+
}
41+
return when {
42+
event.keyCode == KeyEvent.KEYCODE_C && event.isShiftPressed && !event.isAltPressed ->
43+
"copyThreadReference"
44+
event.keyCode == KeyEvent.KEYCODE_H && event.isShiftPressed && !event.isAltPressed ->
45+
"cycleHost"
46+
else -> null
4347
}
44-
return super.dispatchKeyEvent(event)
4548
}
4649
}

‎apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public final class T3KeyboardCommandsView: ExpoView {
5252
title: "Copy PR Link or Thread ID"
5353
),
5454
enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
55+
enabledCommand("cycleHost", input: "h", modifiers: [.command, .shift], action: #selector(cycleHost), title: "Next Machine"),
5556
].compactMap { $0 }
5657
if isPad {
5758
commands += (1...9).compactMap { index in
@@ -150,6 +151,7 @@ public final class T3KeyboardCommandsView: ExpoView {
150151
@objc private func openReview() { emit("review") }
151152
@objc private func copyThreadReference() { emit("copyThreadReference") }
152153
@objc private func handleToggleSidebar() { emit("toggleSidebar") }
154+
@objc private func cycleHost() { emit("cycleHost") }
153155

154156
private func emit(_ command: String) {
155157
onCommand(["command": command])

‎apps/mobile/src/App.tsx‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as Linking from "expo-linking";
22
import * as SplashScreen from "expo-splash-screen";
33
import { useEffect } from "react";
4-
import { StatusBar, View } from "react-native";
4+
import { StatusBar } from "react-native";
55
import { GestureHandlerRootView } from "react-native-gesture-handler";
66
import { KeyboardProvider } from "react-native-keyboard-controller";
77
import { SafeAreaProvider } from "react-native-safe-area-context";
@@ -23,6 +23,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal";
2323
import { shouldHandleAppLink } from "./lib/appLinking";
2424
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
2525
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
26+
import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";
27+
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
2628

2729
import "../global.css";
2830

@@ -76,22 +78,24 @@ function AppContent() {
7678
<GestureHandlerRootView className="flex-1">
7779
<KeyboardProvider statusBarTranslucent>
7880
<SafeAreaProvider>
79-
<StatusBar barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} />
80-
{/* The navigation theme drives the NATIVE header appearance: native-stack
81+
<VoiceInputProvider>
82+
<StatusBar barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} />
83+
{/* The navigation theme drives the NATIVE header appearance: native-stack
8184
forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
8285
this, React Navigation defaults to its light theme and every native
8386
header (glass buttons, title, materials) is forced light even when
8487
the system is in dark mode. */}
85-
<View style={{ flex: 1 }}>
86-
<IncomingShareProvider>
87-
<Navigation linking={appLinking} theme={navigationTheme} />
88-
</IncomingShareProvider>
89-
<ConfirmDialogHost />
90-
<ThreadArrangementHost />
91-
</View>
92-
{/* Anchored-menu overlays render here — in-window, so the
88+
<GlobalVoiceInputControl>
89+
<IncomingShareProvider>
90+
<Navigation linking={appLinking} theme={navigationTheme} />
91+
</IncomingShareProvider>
92+
<ConfirmDialogHost />
93+
<ThreadArrangementHost />
94+
</GlobalVoiceInputControl>
95+
{/* Anchored-menu overlays render here — in-window, so the
9396
keyboard stays up while a dropdown is open. */}
94-
<OverlayPortalHost />
97+
<OverlayPortalHost />
98+
</VoiceInputProvider>
9599
</SafeAreaProvider>
96100
</KeyboardProvider>
97101
</GestureHandlerRootView>

‎apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vite-plus/test";
22

3-
import { parseActiveThreadPath } from "./hardwareKeyboardCommands";
3+
import { nextEnvironmentId, parseActiveThreadPath } from "./hardwareKeyboardCommands";
44

55
describe("parseActiveThreadPath", () => {
66
it("extracts the active thread from thread subroutes", () => {
@@ -26,3 +26,20 @@ describe("parseActiveThreadPath", () => {
2626
expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull();
2727
});
2828
});
29+
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‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export type HardwareKeyboardCommand =
1414
| "terminal"
1515
| "review"
1616
| "copyThreadReference"
17-
| "toggleSidebar";
17+
| "toggleSidebar"
18+
| "cycleHost";
1819

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

@@ -91,3 +92,16 @@ export function parseActiveThreadPath(pathname: string): {
9192
return null;
9293
}
9394
}
95+
96+
/**
97+
* The machine after `currentId` in display order, wrapping around. Starts at
98+
* the first machine when `currentId` is not listed.
99+
*/
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/settings/SettingsScheduledTasksRouteScreen.tsx‎

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
import {
1616
useCallback,
1717
useEffect,
18-
useId,
1918
useLayoutEffect,
2019
useMemo,
2120
useRef,
@@ -63,6 +62,8 @@ import {
6362
} from "./scheduledTaskDraft";
6463
import { settingsTargetsForProject } from "./settings-environment-filter.logic";
6564
import { useScheduledTaskEditor } from "./scheduled-task-editor";
65+
import { scheduledTaskEditorSessionAtom } from "./scheduled-task-editor-state";
66+
import { appAtomRegistry } from "../../state/atom-registry";
6667
import {
6768
formatNextScheduledTaskRun,
6869
formatScheduledTaskInterval,
@@ -372,14 +373,21 @@ export function SettingsScheduledTaskEditRouteScreen() {
372373
}
373374

374375
function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }) {
375-
const { editor, setEditor, hasChanges, draftForEnvironment } = useScheduledTaskEditor();
376+
const {
377+
editor,
378+
voiceOwnerKey,
379+
readEditor,
380+
setEditor,
381+
startEditor,
382+
hasChanges,
383+
draftForEnvironment,
384+
} = useScheduledTaskEditor();
376385
const { availableTargets } = useSettingsEnvironmentFilter();
377386
const navigation = useNavigation();
378387
const insets = useSafeAreaInsets();
379388
const [saving, setSaving] = useState(false);
380389
const [saved, setSaved] = useState(false);
381-
const voiceOwnerId = useId();
382-
const ownerKey = editor ? `${voiceOwnerId}:${editor.environmentId}` : null;
390+
const ownerKey = editor ? `${voiceOwnerKey}:${editor.environmentId}` : null;
383391
const prompt = editor?.draft.prompt ?? "";
384392
const [selectionState, setSelectionState] = useState<{
385393
readonly ownerKey: string | null;
@@ -402,32 +410,40 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }
402410
);
403411
const voiceInput = useVoiceInputController({
404412
ownerKey,
405-
draftMessage: prompt,
413+
label: editor?.draft.title.trim() || title,
414+
subscribeToDraftChanges: (onChange) =>
415+
appAtomRegistry.subscribe(scheduledTaskEditorSessionAtom, onChange),
406416
selection,
417+
readDraftMessage: () => {
418+
const current = readEditor();
419+
return current?.environmentId === editor?.environmentId
420+
? (current?.draft.prompt ?? null)
421+
: null;
422+
},
407423
onChangeSelection: setSelection,
408-
onChangeDraftMessage: setPrompt,
424+
onChangeDraftMessage: (text) => {
425+
if (readEditor()?.environmentId === editor?.environmentId) setPrompt(text);
426+
},
409427
disabled: saving,
410428
});
411-
const preventRemove = !saved && (hasChanges || saving || voiceInput.isBusy);
429+
const preventRemove = !saved && (hasChanges || saving);
412430
usePreventRemove(preventRemove, ({ data }) => {
413431
if (saving) {
414432
Alert.alert("Saving task", "Wait for the task to finish saving before leaving.");
415433
return;
416434
}
417-
Alert.alert(
418-
"Discard changes?",
419-
voiceInput.isBusy
420-
? "Your dictation and unsaved changes will be lost."
421-
: "Your unsaved changes will be lost.",
422-
[
423-
{ text: "Keep editing", style: "cancel" },
424-
{
425-
text: "Discard changes",
426-
style: "destructive",
427-
onPress: () => navigation.dispatch(data.action),
435+
Alert.alert("Discard changes?", "Your unsaved changes will be lost.", [
436+
{ text: "Keep editing", style: "cancel" },
437+
{
438+
text: "Discard changes",
439+
style: "destructive",
440+
onPress: () => {
441+
if (voiceInput.isBusy) voiceInput.cancel();
442+
startEditor(null);
443+
navigation.dispatch(data.action);
428444
},
429-
],
430-
);
445+
},
446+
]);
431447
});
432448
useEffect(() => {
433449
if (!saved) return;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { EnvironmentId } from "@t3tools/contracts";
2+
import { afterEach, describe, expect, it } from "vite-plus/test";
3+
4+
import { appAtomRegistry } from "../../state/atom-registry";
5+
import { createDraft } from "./scheduledTaskDraft";
6+
import {
7+
readScheduledTaskEditor,
8+
scheduledTaskEditorSessionAtom,
9+
startScheduledTaskEditor,
10+
updateScheduledTaskEditor,
11+
} from "./scheduled-task-editor-state";
12+
13+
afterEach(() => appAtomRegistry.set(scheduledTaskEditorSessionAtom, null));
14+
15+
describe("scheduled task voice draft", () => {
16+
const editor = {
17+
environmentId: EnvironmentId.make("environment"),
18+
environmentLabel: "Environment",
19+
draft: createDraft(null, null),
20+
};
21+
22+
it("keeps the prompt outside the screen and retains its original change baseline", () => {
23+
startScheduledTaskEditor(editor);
24+
const original = appAtomRegistry.get(scheduledTaskEditorSessionAtom)!;
25+
const ownerKey = `scheduled-task:${original.id}`;
26+
updateScheduledTaskEditor(
27+
(current) =>
28+
current && {
29+
...current,
30+
draft: { ...current.draft, prompt: "spoken prompt" },
31+
},
32+
null,
33+
);
34+
35+
expect(readScheduledTaskEditor(ownerKey, null)?.draft.prompt).toBe("spoken prompt");
36+
expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.initial).toBe(editor);
37+
});
38+
39+
it("rejects an old voice target when another task opens in the same environment", () => {
40+
startScheduledTaskEditor(editor);
41+
const original = appAtomRegistry.get(scheduledTaskEditorSessionAtom)!;
42+
startScheduledTaskEditor({ ...editor, draft: { ...editor.draft, prompt: "other task" } });
43+
44+
expect(readScheduledTaskEditor(`scheduled-task:${original.id}`, editor)).toBeNull();
45+
expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current?.draft.prompt).toBe(
46+
"other task",
47+
);
48+
});
49+
50+
it("does not restore the default prompt after the editor is discarded", () => {
51+
startScheduledTaskEditor(null);
52+
const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom)!;
53+
expect(readScheduledTaskEditor(`scheduled-task:${session.id}`, editor)).toBeNull();
54+
updateScheduledTaskEditor((current) => current, editor);
55+
expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBeNull();
56+
});
57+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { EnvironmentId } from "@t3tools/contracts";
2+
import { Atom } from "effect/unstable/reactivity";
3+
4+
import { appAtomRegistry } from "../../state/atom-registry";
5+
import type { ScheduledTaskDraft } from "./scheduledTaskDraft";
6+
7+
export type ScheduledTaskEditor = {
8+
readonly environmentId: EnvironmentId;
9+
readonly environmentLabel: string;
10+
readonly draft: ScheduledTaskDraft;
11+
};
12+
13+
type ScheduledTaskEditorSession = {
14+
readonly id: number;
15+
readonly initial: ScheduledTaskEditor | null;
16+
readonly current: ScheduledTaskEditor | null;
17+
};
18+
19+
export const scheduledTaskEditorSessionAtom = Atom.make<ScheduledTaskEditorSession | null>(
20+
null,
21+
).pipe(Atom.keepAlive);
22+
let nextEditorId = 0;
23+
24+
export function startScheduledTaskEditor(next: ScheduledTaskEditor | null): void {
25+
appAtomRegistry.set(scheduledTaskEditorSessionAtom, {
26+
id: ++nextEditorId,
27+
initial: next,
28+
current: next,
29+
});
30+
}
31+
32+
export function updateScheduledTaskEditor(
33+
update:
34+
| ScheduledTaskEditor
35+
| null
36+
| ((current: ScheduledTaskEditor | null) => ScheduledTaskEditor | null),
37+
defaultEditor: ScheduledTaskEditor | null,
38+
): void {
39+
const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom);
40+
const previous = session ? session.current : defaultEditor;
41+
appAtomRegistry.set(scheduledTaskEditorSessionAtom, {
42+
id: session?.id ?? 0,
43+
initial: session ? session.initial : previous,
44+
current: typeof update === "function" ? update(previous) : update,
45+
});
46+
}
47+
48+
export function readScheduledTaskEditor(
49+
voiceOwnerKey: string,
50+
defaultEditor: ScheduledTaskEditor | null,
51+
): ScheduledTaskEditor | null {
52+
const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom);
53+
if (`scheduled-task:${session?.id ?? 0}` !== voiceOwnerKey) return null;
54+
return session ? session.current : defaultEditor;
55+
}

0 commit comments

Comments
 (0)