diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt index 68608d9eb3f9..6349bc811dd7 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt @@ -29,18 +29,21 @@ class T3KeyboardCommandsView( var enabledCommands = emptySet() 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) } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index 8626a7091567..3be5df2eb547 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -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 @@ -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]) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 001c9d597cc8..645bc0b8a2fb 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -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"; @@ -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"; @@ -76,22 +78,24 @@ function AppContent() { - - {/* The navigation theme drives the NATIVE header appearance: native-stack + + + {/* 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. */} - - - - - - - - {/* Anchored-menu overlays render here — in-window, so the + + + + + + + + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} - + + diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts index 55f1037c0109..44507100fc81 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts @@ -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", () => { @@ -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(); + }); +}); diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index 8083d85cfaea..679790f59cf7 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -14,7 +14,8 @@ export type HardwareKeyboardCommand = | "terminal" | "review" | "copyThreadReference" - | "toggleSidebar"; + | "toggleSidebar" + | "cycleHost"; type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void; @@ -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( + environments: ReadonlyArray, + 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; +} diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 91b1d0923e6c..a3219888241a 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -15,7 +15,6 @@ import { import { useCallback, useEffect, - useId, useLayoutEffect, useMemo, useRef, @@ -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, @@ -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; @@ -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); + }, 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; diff --git a/apps/mobile/src/features/settings/scheduled-task-editor-state.test.ts b/apps/mobile/src/features/settings/scheduled-task-editor-state.test.ts new file mode 100644 index 000000000000..97a7d38a44bf --- /dev/null +++ b/apps/mobile/src/features/settings/scheduled-task-editor-state.test.ts @@ -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(); + }); +}); diff --git a/apps/mobile/src/features/settings/scheduled-task-editor-state.ts b/apps/mobile/src/features/settings/scheduled-task-editor-state.ts new file mode 100644 index 000000000000..3ac81c08b13d --- /dev/null +++ b/apps/mobile/src/features/settings/scheduled-task-editor-state.ts @@ -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( + 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; +} diff --git a/apps/mobile/src/features/settings/scheduled-task-editor.tsx b/apps/mobile/src/features/settings/scheduled-task-editor.tsx index f5d8337beeb4..265f42e0059d 100644 --- a/apps/mobile/src/features/settings/scheduled-task-editor.tsx +++ b/apps/mobile/src/features/settings/scheduled-task-editor.tsx @@ -1,10 +1,10 @@ +import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; import { createContext, use, useMemo, useCallback, - useState, type Dispatch, type ReactNode, type SetStateAction, @@ -20,14 +20,18 @@ import { import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; -type ScheduledTaskEditor = { - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - readonly draft: ScheduledTaskDraft; -}; +import { + scheduledTaskEditorSessionAtom, + startScheduledTaskEditor, + updateScheduledTaskEditor, + readScheduledTaskEditor, + type ScheduledTaskEditor, +} from "./scheduled-task-editor-state"; const ScheduledTaskEditorContext = createContext<{ readonly editor: ScheduledTaskEditor | null; + readonly voiceOwnerKey: string; + readonly readEditor: () => ScheduledTaskEditor | null; readonly setEditor: Dispatch>; readonly startEditor: (editor: ScheduledTaskEditor | null) => void; readonly hasChanges: boolean; @@ -76,12 +80,10 @@ export function ScheduledTaskEditorProvider({ children }: { readonly children: R : null, [defaultTarget, draftForEnvironment], ); - const [session, setSession] = useState<{ - readonly initial: ScheduledTaskEditor | null; - readonly current: ScheduledTaskEditor | null; - } | null>(null); - const editor = session?.current ?? defaultEditor; - const initial = session?.initial ?? defaultEditor; + const session = useAtomValue(scheduledTaskEditorSessionAtom); + const voiceOwnerKey = `scheduled-task:${session?.id ?? 0}`; + const editor = session ? session.current : defaultEditor; + const initial = session ? session.initial : defaultEditor; const hasChanges = Boolean( initial && editor && @@ -90,23 +92,37 @@ export function ScheduledTaskEditorProvider({ children }: { readonly children: R ); const setEditor = useCallback>>( (update) => { - setSession((current) => { - const previous = current?.current ?? defaultEditor; - return { - initial: current?.initial ?? previous, - current: typeof update === "function" ? update(previous) : update, - }; - }); + updateScheduledTaskEditor(update, defaultEditor); }, [defaultEditor], ); - const startEditor = useCallback((next: ScheduledTaskEditor | null) => { - setSession({ initial: next, current: next }); - }, []); + const startEditor = startScheduledTaskEditor; const resetEditor = useCallback(() => startEditor(defaultEditor), [defaultEditor, startEditor]); + const readEditor = useCallback( + () => readScheduledTaskEditor(voiceOwnerKey, defaultEditor), + [defaultEditor, voiceOwnerKey], + ); const value = useMemo( - () => ({ editor, setEditor, startEditor, hasChanges, resetEditor, draftForEnvironment }), - [editor, setEditor, startEditor, hasChanges, resetEditor, draftForEnvironment], + () => ({ + editor, + voiceOwnerKey, + readEditor, + setEditor, + startEditor, + hasChanges, + resetEditor, + draftForEnvironment, + }), + [ + editor, + voiceOwnerKey, + readEditor, + setEditor, + startEditor, + hasChanges, + resetEditor, + draftForEnvironment, + ], ); return {children}; } diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index a5c0d71325a7..f9ba9cf82c2f 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -1,9 +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 * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/unstable/reactivity"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { LegendList } from "@legendapp/list/react-native"; import { isAtomCommandInterrupted, @@ -31,8 +29,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useServerConfigs, waitForProject } from "../../state/entities"; -import { projectEnvironment } from "../../state/projects"; +import { useServerConfigs } from "../../state/entities"; import { useAtomCommand } from "../../state/use-atom-command"; import { vcsEnvironment } from "../../state/vcs"; import { @@ -208,39 +205,6 @@ export function NewTaskEnvironmentPickerRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const serverConfigs = useServerConfigs(); - const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { - reportFailure: false, - }); - const [movingToEnvironmentId, setMovingToEnvironmentId] = useState(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 { - setMovingToEnvironmentId(environmentId); - try { - const result = await ensureScratch({ environmentId, input: {} }); - if (AsyncResult.isFailure(result)) { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not switch machine", - error instanceof Error - ? error.message - : "The folder for threads without a project could not be created.", - ); - return; - } - const project = await waitForProject({ environmentId, projectId: result.value.projectId }); - if (project === null) { - Alert.alert("Could not switch machine", "It has not reached this device yet. Try again."); - return; - } - flow.setProject(project); - navigation.goBack(); - } finally { - setMovingToEnvironmentId(null); - } - } - return ( } 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} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 1ee4e9bc4e85..d0da6e2f03d0 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -96,6 +96,7 @@ import { captureComposerDraftInsertion, countComposerDraftAttachmentsAfterSelection, getComposerDraftSnapshot, + composerDraftsAtom, mergeComposerDraftContent, restoreComposerDraftSnapshot, updateComposerDraftSettings, @@ -117,6 +118,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 { @@ -434,6 +439,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. @@ -473,7 +498,9 @@ export function NewTaskDraftScreen(props: { }); const voiceInput = useVoiceInputController({ ownerKey: flow.draftKey, - draftMessage: flow.prompt, + label: selectedProject ? `New task in ${selectedProject.title}` : "New task", + readDraftMessage: () => (flow.draftKey ? getComposerDraftSnapshot(flow.draftKey).text : null), + subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange), selection: composerMenu.selection, disabled: isIncomingShareTransferPending || isImportingShare || flow.submitting, onChangeDraftMessage: flow.setPrompt, diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 1ba1409d7976..1a5323281c47 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -7,11 +7,13 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/unstable/reactivity"; import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -21,7 +23,7 @@ import { MaterialButton } from "../../components/MaterialButton"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; +import { useProjects, useServerConfigs } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { useAtomCommand } from "../../state/use-atom-command"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; @@ -173,7 +175,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - canCreateProjectInEnvironment(environment.connectionState) && - serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, ); const scratchEnvironment = scratchEnvironments.find( @@ -230,26 +234,20 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps getComposerDraftSnapshot(composerDraftKey).text, + subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange), selection: composerMenu.selection, - onChangeDraftMessage: props.onChangeDraftMessage, + onChangeDraftMessage: (text) => setComposerDraftText(composerDraftKey, text), onChangeSelection: composerMenu.onSelectionChange, }); const voicePresentation = resolveVoiceComposerPresentation( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 8b9e061f39a2..013bf900912d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -132,6 +132,7 @@ import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; import { useThreadQueuedCount } from "./ThreadQueueControl"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; +import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider"; export interface ThreadDetailScreenProps { readonly worktreeSetup?: WorktreeSetupCardProps | null; @@ -304,6 +305,7 @@ const USER_INPUT_TOGGLE_TIMING = { export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const navigation = useNavigation(); + const { session: voiceInputSession } = useGlobalVoiceInput(); const reportedModelSelection = useThreadReportedModelSelection({ environmentId: props.environmentId, threadId: props.selectedThread.id, @@ -1169,7 +1171,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread > { + voiceInputSession.cancel(props.composerDraftKey); + props.onCancelQueuedRunEdit(); + }} /> ) : null} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 18c023b45ee1..1ffa7696250e 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert } from "react-native"; import type { EnvironmentId, @@ -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, @@ -89,7 +91,11 @@ import { useRemoteConnectionStatus, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +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"; @@ -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; + /** The machine a switch in progress is heading to. */ + readonly switchingToEnvironmentId: EnvironmentId | null; readonly setSelectedModelKey: ( key: string | null, options?: ReadonlyArray, @@ -367,8 +380,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { () => connectedEnvironments.filter( (environment) => - canCreateProjectInEnvironment(environment.connectionState) && - serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, ), [connectedEnvironments, serverConfigs], ); @@ -796,6 +811,53 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [projects, selectedProject, carryDraftContentTo], ); + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); + const [switchingToEnvironmentId, setSwitchingToEnvironmentId] = useState( + null, + ); + // The latest switch wins: a slower, earlier one must not retarget the draft. + const latestSwitchRef = useRef(null); + const switchEnvironment = useCallback( + async (environmentId: EnvironmentId): Promise => { + if (environmentId === selectedEnvironmentId) { + latestSwitchRef.current = null; + setSwitchingToEnvironmentId(null); + return true; + } + if (!isScratchDraft) { + selectEnvironment(environmentId); + return true; + } + const request = {}; + latestSwitchRef.current = request; + setSwitchingToEnvironmentId(environmentId); + 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) { @@ -1242,6 +1304,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setProject, openDraft, selectEnvironment, + switchEnvironment, + switchingToEnvironmentId, setSelectedModelKey, setWorkspaceMode, selectBranch, @@ -1308,6 +1372,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { openDraft, selectBranch, selectEnvironment, + switchEnvironment, + switchingToEnvironmentId, setInteractionMode, setPrompt, setRuntimeMode, diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index b9ca6d4eaa56..57287c81eda1 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -41,7 +41,17 @@ import { View, } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; -import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts"; +import { + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + type EnvironmentId, + type RunId, + type ThreadId, + type ToolActivityIcon, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; import { AppText as Text } from "../../components/AppText"; @@ -770,6 +780,44 @@ function workLogRowKey(row: ThreadFeedActivity): string { return row.id; } +/** Shown while the thread's run still ends in this failed preparation. */ +function WorkspacePreparationRetryButton(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly runId: RunId; +}) { + const retryable = useAtomValue( + environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, props.threadId)), + (thread) => { + const run = thread?.projection.runs.find((candidate) => candidate.id === props.runId); + return run?.status === "failed" && run.workspacePreparation !== undefined; + }, + ); + const retry = useAtomCommand(threadEnvironment.retryWorkspacePreparation, "retry setup"); + const [busy, setBusy] = useState(false); + if (!retryable) return null; + return ( + { + setBusy(true); + void Haptics.selectionAsync(); + void retry({ + environmentId: props.environmentId, + input: { threadId: props.threadId, runId: props.runId }, + }).finally(() => setBusy(false)); + }} + className="ml-7 mt-2 min-h-11 flex-row items-center gap-1.5 self-start rounded-full border border-border px-4" + style={{ opacity: busy ? 0.5 : 1 }} + > + + Retry + + ); +} + const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, @@ -850,6 +898,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( {failureItem.failure.message} ) : null} + {failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE && + failureItem.runId !== null ? ( + + ) : null} ); diff --git a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx index 602cbb158b54..f27d2359639a 100644 --- a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx +++ b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx @@ -157,8 +157,10 @@ export function ComposerDictationToolbar(props: { const WaveformBar = memo(function WaveformBar(props: { readonly audioLevels: SharedValue; readonly sampleIndex: number; + readonly height?: number; }) { const { audioLevels, sampleIndex } = props; + const height = props.height ?? WAVEFORM_BAR_HEIGHT; const animatedStyle = useAnimatedStyle(() => { const level = audioLevels.value[sampleIndex] ?? 0; return { @@ -166,8 +168,7 @@ const WaveformBar = memo(function WaveformBar(props: { transform: [ { scaleY: withTiming( - (WAVEFORM_MIN_BAR_HEIGHT + level * (WAVEFORM_BAR_HEIGHT - WAVEFORM_MIN_BAR_HEIGHT)) / - WAVEFORM_BAR_HEIGHT, + (WAVEFORM_MIN_BAR_HEIGHT + level * (height - WAVEFORM_MIN_BAR_HEIGHT)) / height, WAVEFORM_TIMING, ), }, @@ -178,11 +179,37 @@ const WaveformBar = memo(function WaveformBar(props: { return ( ); }); +const COMPACT_WAVEFORM_BAR_COUNT = 8; +const COMPACT_WAVEFORM_BAR_HEIGHT = 14; + +/** A fixed-size waveform of the latest samples, for tight spaces like the global dictation pill. */ +export const CompactVoiceWaveform = memo(function CompactVoiceWaveform(props: { + readonly audioLevels: SharedValue; +}) { + return ( + + {Array.from({ length: COMPACT_WAVEFORM_BAR_COUNT }, (_, index) => ( + + ))} + + ); +}); + const VoiceWaveform = memo(function VoiceWaveform(props: { readonly audioLevels: SharedValue; }) { diff --git a/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx b/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx new file mode 100644 index 000000000000..4159041e2853 --- /dev/null +++ b/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx @@ -0,0 +1,259 @@ +import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { + ActivityIndicator, + Linking, + Platform, + Pressable, + useWindowDimensions, + View, +} from "react-native"; +import Animated, { + Easing, + Extrapolation, + FadeIn, + interpolate, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { FullWindowOverlay } from "react-native-screens"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; +import { cn } from "../../lib/cn"; +import { CompactVoiceWaveform } from "./ComposerDictationControl"; +import { useGlobalVoiceInput } from "./VoiceInputProvider"; +import { resolveVoiceComposerPresentation } from "./voiceInputPresentation"; + +const PILL_HEIGHT = 36; +const COLLAPSED_WIDTH = 64; +const EXPANDED_MAX_WIDTH = 280; +// Clears Home's floating bottom toolbar (56) and a collapsed thread composer (60). +const IOS_BOTTOM_CHROME_CLEARANCE = 64; +const MORPH_TIMING = { + duration: 260, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, +} as const; +const ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); + +/** + * Keeps an off-screen dictation reachable as a pill on the screen's trailing + * edge. Wraps the app content so any touch that starts outside the pill, + * including a scroll, collapses it without claiming the touch. + */ +export function GlobalVoiceInputControl(props: { readonly children: ReactNode }) { + const voice = useGlobalVoiceInput(); + const collapseRef = useRef<(() => void) | null>(null); + const presentation = resolveVoiceComposerPresentation(voice.state, voice.elapsedSeconds); + const visible = + presentation.statusLabel !== null && + !(voice.ownerKey && voice.focusedOwners.has(voice.ownerKey)); + // Mounted only while visible, so each dictation starts collapsed. + const pill = visible ? : null; + return ( + <> + { + collapseRef.current?.(); + return false; + }} + > + {props.children} + + {pill && Platform.OS === "ios" ? {pill} : pill} + + ); +} + +function EdgeDictationPill(props: { readonly collapseRef: RefObject<(() => void) | null> }) { + const voice = useGlobalVoiceInput(); + const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); + const { fabSize } = useAndroidControlSizing(); + const [expanded, setExpanded] = useState(false); + const progress = useSharedValue(0); + useEffect(() => { + progress.value = withTiming(expanded ? 1 : 0, MORPH_TIMING); + }, [expanded, progress]); + const { collapseRef } = props; + useEffect(() => { + if (!expanded) return; + collapseRef.current = () => setExpanded(false); + return () => { + collapseRef.current = null; + }; + }, [collapseRef, expanded]); + + const expandedWidth = Math.min(width - insets.left - insets.right - 32, EXPANDED_MAX_WIDTH); + // Each face keeps its final width and stays pinned to the edge, so the + // morphing pill reveals the expanded row instead of reflowing it. + const pillStyle = useAnimatedStyle(() => ({ + width: interpolate(progress.value, [0, 1], [COLLAPSED_WIDTH, expandedWidth]), + })); + const collapsedStyle = useAnimatedStyle(() => ({ + opacity: interpolate(progress.value, [0, 0.4], [1, 0], Extrapolation.CLAMP), + })); + const expandedStyle = useAnimatedStyle(() => ({ + opacity: interpolate(progress.value, [0.3, 1], [0, 1], Extrapolation.CLAMP), + })); + + // Sit just above the bottom chrome: the toolbar or composer on iOS, the + // new-thread FAB on Android. + const bottom = + Platform.OS === "android" + ? Math.max(insets.bottom, 16) + 16 + fabSize + 12 + : Math.max(insets.bottom, 12) + IOS_BOTTOM_CHROME_CLEARANCE; + + const presentation = resolveVoiceComposerPresentation(voice.state, voice.elapsedSeconds); + const phase = voice.state.phase; + const isError = phase === "error"; + const openSettings = isError && voice.state.errorAction === "settings"; + const label = voice.label ?? "Draft"; + const elapsedLabel = `${Math.floor(voice.elapsedSeconds / 60)}:${String(voice.elapsedSeconds % 60).padStart(2, "0")}`; + + return ( + + + + + setExpanded(true)} + > + {isError ? ( + + ) : phase === "recording" ? ( + <> + + + {elapsedLabel} + + + ) : ( + + )} + + + + + + + + setExpanded(false)} + > + + {isError ? presentation.statusLabel : label} + + + {phase === "recording" ? ( + <> + + + {elapsedLabel} + + + + + + + + ) : isError ? ( + voice.isAvailable ? ( + { + if (!openSettings) { + void voice.session.retry(); + return; + } + voice.cancel(); + void Linking.openSettings(); + }} + > + + + ) : null + ) : ( + + + + )} + + + + + ); +} diff --git a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx new file mode 100644 index 000000000000..f6deefcea87b --- /dev/null +++ b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx @@ -0,0 +1,222 @@ +import { + RecordingPresets, + requestRecordingPermissionsAsync, + setAudioModeAsync, + setIsAudioActiveAsync, + useAudioRecorder, + type RecordingStatus, +} from "expo-audio"; +import { File } from "expo-file-system"; +import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; +import { + createContext, + use, + useCallback, + useEffect, + useId, + useRef, + useState, + type ReactNode, +} from "react"; +import { AppState } from "react-native"; +import { useSharedValue } from "react-native-reanimated"; + +import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; +import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; +import { + VOICE_RECORDING_LIMIT_SECONDS, + voiceInputBlocksSubmission, + type VoiceInputState, +} from "@t3tools/client-runtime/voice-input"; +import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; +import { VoiceInputSession } from "./voiceInputSession"; + +const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; +const VOICE_METERING_INTERVAL_MS = 80; +const VOICE_RECORDING_OPTIONS = { + ...RecordingPresets.HIGH_QUALITY, + isMeteringEnabled: true, +}; + +async function releaseVoiceRecordingAudio(): Promise { + try { + await setAudioModeAsync({ allowsRecording: false }); + } finally { + // Expo does not deactivate AVAudioSession when recording stops or its + // category changes. Explicit deactivation resumes interrupted app audio. + await setIsAudioActiveAsync(false); + } +} + +async function configureVoiceRecordingAudio(): Promise { + try { + await setAudioModeAsync({ + allowsRecording: true, + interruptionMode: "doNotMix", + playsInSilentMode: true, + shouldPlayInBackground: false, + }); + await setIsAudioActiveAsync(true); + } catch (error) { + try { + await releaseVoiceRecordingAudio(); + } catch { + // Keep the setup error. The controller has not started a recorder yet. + } + throw error; + } +} + +const VoiceInputContext = createContext | null>(null); + +export function VoiceInputProvider({ children }: { readonly children: ReactNode }) { + const runtime = useVoiceInputRuntime(); + return {children}; +} + +export function useGlobalVoiceInput() { + const context = use(VoiceInputContext); + if (!context) throw new Error("Voice input provider is missing."); + return context; +} + +function useVoiceInputRuntime() { + const [{ state, ownerKey, label }, setState] = useState({ + state: INITIAL_STATE, + ownerKey: null as string | null, + label: null as string | null, + }); + const [focusedOwners, setFocusedOwners] = useState>(new Set()); + const setOwnerFocused = useCallback((key: string, focused: boolean) => { + setFocusedOwners((current) => { + const next = new Set(current); + if (focused) next.add(key); + else next.delete(key); + return next; + }); + }, []); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const keepAwakeId = useId(); + const keepAwakeSessionRef = useRef(0); + const elapsedSecondsRef = useRef(0); + const audioLevelsRef = useRef(Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0)); + const audioLevels = useSharedValue(audioLevelsRef.current); + const sessionRef = useRef(null); + const handleRecorderStatus = useCallback((status: RecordingStatus) => { + sessionRef.current?.controller.handleRecorderStatus({ + isFinished: status.isFinished, + hasError: status.hasError || status.mediaServicesDidReset === true, + error: status.error, + url: status.url, + }); + }, []); + const recorder = useAudioRecorder(VOICE_RECORDING_OPTIONS, handleRecorderStatus); + + if (!sessionRef.current) { + sessionRef.current = new VoiceInputSession({ + recorder, + getTranscriber: getLocalVoiceTranscriber, + requestPermission: async () => { + const permission = await requestRecordingPermissionsAsync(); + return { granted: permission.granted, canAskAgain: permission.canAskAgain }; + }, + configureRecording: configureVoiceRecordingAudio, + releaseRecording: releaseVoiceRecordingAudio, + deleteRecording: (uri) => new File(uri).delete(), + onStateChange: (nextState) => + setState({ + state: nextState, + ownerKey: sessionRef.current?.ownerKey ?? null, + label: sessionRef.current?.label ?? null, + }), + }); + } + + const session = sessionRef.current; + const controller = session.controller; + + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextState) => { + // iOS reports `inactive` while its permission dialog is open. Only the + // real background state cancels preparation; recorder status handles + // calls and route interruptions during capture. + if (nextState === "background") controller.appMovedToBackground(); + }); + return () => subscription.remove(); + }, [controller]); + + useEffect(() => () => controller.dispose(), [controller]); + + useEffect(() => { + if (state.phase !== "recording") return; + + const tag = `voice-input:${keepAwakeId}:${++keepAwakeSessionRef.current}`; + const activation = activateKeepAwakeAsync(tag); + void activation.catch(() => {}); + return () => { + // Release after activation settles, even if the recording ends immediately. + void activation.then(() => deactivateKeepAwake(tag)).catch(() => {}); + }; + }, [keepAwakeId, state.phase]); + + useEffect(() => { + if (state.phase !== "preparing" && state.phase !== "recording") return; + + if (audioLevelsRef.current.some((level) => level !== 0)) { + audioLevelsRef.current = Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0); + audioLevels.value = audioLevelsRef.current; + } + if (elapsedSecondsRef.current !== 0) { + elapsedSecondsRef.current = 0; + setElapsedSeconds(0); + } + if (state.phase !== "recording") return; + + const sampleRecording = () => { + if (controller.currentState.phase !== "recording") return; + const status = recorder.getStatus(); + if (!status.isRecording) return; + + const level = normalizeVoiceInputDecibels(status.metering); + const history = audioLevelsRef.current; + if (level !== 0 || history.some((sample) => sample !== 0)) { + const nextLevels = [...history.slice(1), level]; + audioLevelsRef.current = nextLevels; + audioLevels.value = nextLevels; + } + + const nextElapsedSeconds = Math.min( + VOICE_RECORDING_LIMIT_SECONDS, + Math.max(0, Math.floor(status.durationMillis / 1_000)), + ); + if (nextElapsedSeconds !== elapsedSecondsRef.current) { + elapsedSecondsRef.current = nextElapsedSeconds; + setElapsedSeconds(nextElapsedSeconds); + } + }; + + sampleRecording(); + const intervalId = setInterval(sampleRecording, VOICE_METERING_INTERVAL_MS); + return () => clearInterval(intervalId); + }, [audioLevels, controller, recorder, state.phase]); + + const stop = useCallback(() => controller.stop(), [controller]); + const cancel = useCallback(() => controller.cancel(), [controller]); + + return { + // Store screenshots show the dictation button even on simulators, whose + // on-device transcription is unavailable. + isAvailable: getLocalVoiceTranscriber() !== null || getNativeShowcaseScene() !== null, + state, + audioLevels, + elapsedSeconds, + isBusy: voiceInputBlocksSubmission(state), + ownerKey, + label, + focusedOwners, + setOwnerFocused, + session, + stop, + cancel, + }; +} diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts index 5fd15482d037..d1dc234f39f5 100644 --- a/apps/mobile/src/features/voice-input/useVoiceInputController.ts +++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts @@ -1,235 +1,79 @@ -import { - RecordingPresets, - requestRecordingPermissionsAsync, - setAudioModeAsync, - setIsAudioActiveAsync, - useAudioRecorder, - type RecordingStatus, -} from "expo-audio"; -import { File } from "expo-file-system"; -import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useFocusEffect } from "@react-navigation/native"; -import { useCallback, useEffect, useId, useRef, useState } from "react"; -import { AppState } from "react-native"; -import { useSharedValue } from "react-native-reanimated"; - -import type { ComposerEditorSelection } from "../../components/ComposerEditor"; -import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; -import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; +import { useCallback, useEffect, useRef } from "react"; import { - VoiceInputController, - VOICE_RECORDING_LIMIT_SECONDS, voiceInputBlocksSubmission, - voiceInputFreezesEditor, - type VoiceDraftSnapshot, type VoiceInputState, } from "@t3tools/client-runtime/voice-input"; -import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; -const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; -const VOICE_METERING_INTERVAL_MS = 80; -const VOICE_RECORDING_OPTIONS = { - ...RecordingPresets.HIGH_QUALITY, - isMeteringEnabled: true, -}; - -async function releaseVoiceRecordingAudio(): Promise { - try { - await setAudioModeAsync({ allowsRecording: false }); - } finally { - // Expo does not deactivate AVAudioSession when recording stops or its - // category changes. Explicit deactivation resumes interrupted app audio. - await setIsAudioActiveAsync(false); - } -} +import type { ComposerEditorSelection } from "../../components/ComposerEditor"; +import { useGlobalVoiceInput } from "./VoiceInputProvider"; +import { createVoiceInputTarget } from "./voiceInputSession"; -async function configureVoiceRecordingAudio(): Promise { - try { - await setAudioModeAsync({ - allowsRecording: true, - interruptionMode: "doNotMix", - playsInSilentMode: true, - shouldPlayInBackground: false, - }); - await setIsAudioActiveAsync(true); - } catch (error) { - try { - await releaseVoiceRecordingAudio(); - } catch { - // Keep the setup error. The controller has not started a recorder yet. - } - throw error; - } -} +const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; export function useVoiceInputController(input: { readonly ownerKey: string | null; - readonly draftMessage: string; + /** Shown by the global dictation pill when this composer is off screen. */ + readonly label: string; + readonly readDraftMessage: () => string | null; + readonly subscribeToDraftChanges: (onChange: () => void) => () => void; readonly selection: ComposerEditorSelection; readonly disabled?: boolean; readonly onChangeDraftMessage: (value: string) => void; readonly onChangeSelection: (selection: ComposerEditorSelection) => void; }) { - const [state, setState] = useState(INITIAL_STATE); - const [elapsedSeconds, setElapsedSeconds] = useState(0); - const keepAwakeId = useId(); - const keepAwakeSessionRef = useRef(0); - const elapsedSecondsRef = useRef(0); - const audioLevelsRef = useRef(Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0)); - const audioLevels = useSharedValue(audioLevelsRef.current); - const controllerRef = useRef(null); - const previousDraftRef = useRef({ ownerKey: input.ownerKey, text: input.draftMessage }); - const revisionRef = useRef(0); - if ( - previousDraftRef.current.ownerKey !== input.ownerKey || - previousDraftRef.current.text !== input.draftMessage - ) { - previousDraftRef.current = { ownerKey: input.ownerKey, text: input.draftMessage }; - revisionRef.current += 1; - } - const latestInputRef = useRef(input); - latestInputRef.current = input; - - const handleRecorderStatus = useCallback((status: RecordingStatus) => { - controllerRef.current?.handleRecorderStatus({ - isFinished: status.isFinished, - hasError: status.hasError || status.mediaServicesDidReset === true, - error: status.error, - url: status.url, - }); - }, []); - const recorder = useAudioRecorder(VOICE_RECORDING_OPTIONS, handleRecorderStatus); - - if (!controllerRef.current) { - controllerRef.current = new VoiceInputController({ - recorder, - getTranscriber: getLocalVoiceTranscriber, - requestPermission: async () => { - const permission = await requestRecordingPermissionsAsync(); - return { granted: permission.granted, canAskAgain: permission.canAskAgain }; - }, - configureRecording: configureVoiceRecordingAudio, - releaseRecording: releaseVoiceRecordingAudio, - deleteRecording: (uri) => new File(uri).delete(), - readDraft: (): VoiceDraftSnapshot | null => { - const current = latestInputRef.current; - if (!current.ownerKey) return null; - return { - ownerKey: current.ownerKey, - text: current.draftMessage, - selection: current.selection, - revision: revisionRef.current, - }; - }, - commitDraft: (text, selection) => { - const current = latestInputRef.current; - current.onChangeSelection(selection); - current.onChangeDraftMessage(text); - }, - onStateChange: setState, - }); - } - - const controller = controllerRef.current; - const previousOwnerRef = useRef(input.ownerKey); - useEffect(() => { - if (previousOwnerRef.current === input.ownerKey) return; - previousOwnerRef.current = input.ownerKey; - controller.ownerChanged(); - }, [controller, input.ownerKey]); - - useFocusEffect( - useCallback( - () => () => { - controller.dispose(); - }, - [controller], - ), - ); - - useEffect(() => { - const subscription = AppState.addEventListener("change", (nextState) => { - // iOS reports `inactive` while its permission dialog is open. Only the - // real background state cancels preparation; recorder status handles - // calls and route interruptions during capture. - if (nextState === "background") controller.appMovedToBackground(); - }); - return () => subscription.remove(); - }, [controller]); - - useEffect(() => () => controller.dispose(), [controller]); - + const global = useGlobalVoiceInput(); + const { setOwnerFocused, session } = global; + const latestInput = useRef(input); + latestInput.current = input; + const mounted = useRef(true); useEffect(() => { - if (state.phase !== "recording") return; - - const tag = `voice-input:${keepAwakeId}:${++keepAwakeSessionRef.current}`; - const activation = activateKeepAwakeAsync(tag); - void activation.catch(() => {}); + mounted.current = true; return () => { - // Release after activation settles, even if the recording ends immediately. - void activation.then(() => deactivateKeepAwake(tag)).catch(() => {}); - }; - }, [keepAwakeId, state.phase]); - - useEffect(() => { - if (state.phase !== "preparing" && state.phase !== "recording") return; - - if (audioLevelsRef.current.some((level) => level !== 0)) { - audioLevelsRef.current = Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0); - audioLevels.value = audioLevelsRef.current; - } - if (elapsedSecondsRef.current !== 0) { - elapsedSecondsRef.current = 0; - setElapsedSeconds(0); - } - if (state.phase !== "recording") return; - - const sampleRecording = () => { - if (controller.currentState.phase !== "recording") return; - const status = recorder.getStatus(); - if (!status.isRecording) return; - - const level = normalizeVoiceInputDecibels(status.metering); - const history = audioLevelsRef.current; - if (level !== 0 || history.some((sample) => sample !== 0)) { - const nextLevels = [...history.slice(1), level]; - audioLevelsRef.current = nextLevels; - audioLevels.value = nextLevels; - } - - const nextElapsedSeconds = Math.min( - VOICE_RECORDING_LIMIT_SECONDS, - Math.max(0, Math.floor(status.durationMillis / 1_000)), - ); - if (nextElapsedSeconds !== elapsedSecondsRef.current) { - elapsedSecondsRef.current = nextElapsedSeconds; - setElapsedSeconds(nextElapsedSeconds); - } + mounted.current = false; }; + }, []); - sampleRecording(); - const intervalId = setInterval(sampleRecording, VOICE_METERING_INTERVAL_MS); - return () => clearInterval(intervalId); - }, [audioLevels, controller, recorder, state.phase]); + useFocusEffect( + useCallback(() => { + const ownerKey = input.ownerKey; + if (!ownerKey) return; + setOwnerFocused(ownerKey, true); + return () => setOwnerFocused(ownerKey, false); + }, [input.ownerKey, setOwnerFocused]), + ); const start = useCallback(() => { - if (!latestInputRef.current.disabled) void controller.start(); - }, [controller]); - const stop = useCallback(() => controller.stop(), [controller]); - const cancel = useCallback(() => controller.cancel(), [controller]); - + const captured = latestInput.current; + if (!captured.ownerKey || captured.disabled) return; + void session.start({ + ...createVoiceInputTarget( + captured.ownerKey, + captured.readDraftMessage, + (text, selection) => { + captured.onChangeDraftMessage(text); + if (mounted.current && latestInput.current.ownerKey === captured.ownerKey) { + latestInput.current.onChangeSelection(selection); + } + }, + captured.selection, + captured.subscribeToDraftChanges, + ), + label: captured.label, + }); + }, [session]); + const state = global.ownerKey === input.ownerKey ? global.state : IDLE_STATE; + const isBusy = voiceInputBlocksSubmission(state); return { - // Store screenshots show the dictation button even on simulators, whose - // on-device transcription is unavailable. - isAvailable: getLocalVoiceTranscriber() !== null || getNativeShowcaseScene() !== null, + isAvailable: global.isAvailable && (!global.isBusy || global.ownerKey === input.ownerKey), state, - audioLevels, - elapsedSeconds, - isBusy: voiceInputBlocksSubmission(state), - freezesEditor: voiceInputFreezesEditor(state), - blocksSubmission: voiceInputBlocksSubmission(state), + audioLevels: global.audioLevels, + elapsedSeconds: global.elapsedSeconds, + isBusy, + freezesEditor: isBusy, + blocksSubmission: isBusy, start, - stop, - cancel, + stop: global.stop, + cancel: global.cancel, }; } diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts new file mode 100644 index 000000000000..07fc6348f8af --- /dev/null +++ b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { PreparedVoiceTranscription } from "@t3tools/client-runtime/voice-input"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { resetVoiceInputGlobalsForTests } from "../../../../../packages/client-runtime/src/voice-input/controller"; + +import { + createVoiceInputTarget, + VoiceInputSession, + type VoiceInputTarget, +} from "./voiceInputSession"; + +function createTarget( + ownerKey: string, + readText: () => string | null, + commit: VoiceInputTarget["commitDraft"], + selection: { start: number; end: number }, +) { + return createVoiceInputTarget(ownerKey, readText, commit, selection, () => () => {}); +} + +function createSession() { + const recorder = { + uri: "file:///voice.m4a", + prepareToRecordAsync: vi.fn(async () => {}), + record: vi.fn(), + stop: vi.fn(async () => {}), + }; + const prepare = vi.fn(async (): Promise => ({ + locale: "en-US", + transcribe: async () => "spoken text", + })); + const session = new VoiceInputSession({ + recorder, + getTranscriber: () => ({ prepare }), + requestPermission: async () => ({ granted: true, canAskAgain: true }), + configureRecording: async () => {}, + releaseRecording: vi.fn(async () => {}), + deleteRecording: vi.fn(), + onStateChange: vi.fn(), + }); + return { session, recorder, prepare }; +} + +describe("global voice input", () => { + beforeEach(() => resetVoiceInputGlobalsForTests()); + + it.each([ + { selection: { start: 6, end: 11 }, expected: "hello spoken text", cursor: 17 }, + { selection: { start: 6, end: 6 }, expected: "hello spoken text world", cursor: 18 }, + ])( + "keeps the starting selection $selection after navigation", + async ({ selection, expected, cursor }) => { + const { session } = createSession(); + const commit = vi.fn(); + await session.start(createTarget("first", () => "hello world", commit, selection)); + await session.start( + createTarget("second", () => "other prompt", vi.fn(), { start: 12, end: 12 }), + ); + await session.controller.stop(); + expect(commit).toHaveBeenCalledWith(expected, { + start: cursor, + end: cursor, + }); + }, + ); + + it("appends to the starting draft after its screen leaves and another draft opens", async () => { + const { session, recorder } = createSession(); + const drafts = new Map([ + ["first", "original prompt"], + ["second", "other prompt"], + ]); + let visibleDraft = "first"; + const targetKey = visibleDraft; + await session.start( + createTarget( + targetKey, + () => drafts.get(targetKey) ?? null, + (text) => drafts.set(targetKey, text), + { start: 15, end: 15 }, + ), + ); + visibleDraft = "second"; + expect(session.controller.currentState.phase).toBe("recording"); + expect(recorder.stop).not.toHaveBeenCalled(); + await session.controller.stop(); + + expect(drafts.get("first")).toBe("original prompt spoken text"); + expect(drafts.get(visibleDraft)).toBe("other prompt"); + expect(session.controller.currentState.phase).toBe("idle"); + }); + + it.each(["preparing", "recording", "transcribing"] as const)( + "keeps one recorder and its original target during %s", + async (phase) => { + const preparation = Promise.withResolvers(); + const preparationEntered = Promise.withResolvers(); + const transcription = Promise.withResolvers(); + const transcriptionEntered = Promise.withResolvers(); + const { session, recorder, prepare } = createSession(); + prepare.mockImplementationOnce(() => { + preparationEntered.resolve(); + return preparation.promise; + }); + const firstCommit = vi.fn(); + const secondCommit = vi.fn(); + const starting = session.start( + createTarget("first", () => "first", firstCommit, { start: 5, end: 5 }), + ); + await preparationEntered.promise; + let stopping: Promise | null = null; + if (phase !== "preparing") { + preparation.resolve({ + locale: "en-US", + transcribe: () => { + transcriptionEntered.resolve(); + return transcription.promise; + }, + }); + await starting; + } + if (phase === "transcribing") { + stopping = session.controller.stop(); + await transcriptionEntered.promise; + } + await session.start( + createTarget("second", () => "second", secondCommit, { start: 6, end: 6 }), + ); + expect(session.ownerKey).toBe("first"); + expect(session.controller.currentState.phase).toBe(phase); + expect(prepare).toHaveBeenCalledTimes(1); + preparation.resolve({ locale: "en-US", transcribe: async () => "spoken text" }); + await starting; + transcription.resolve("spoken text"); + await (stopping ?? session.controller.stop()); + expect(recorder.record).toHaveBeenCalledTimes(1); + expect(firstCommit).toHaveBeenCalledWith("first spoken text", { start: 17, end: 17 }); + expect(secondCommit).not.toHaveBeenCalled(); + }, + ); + + it.each(["changed", "removed"] as const)( + "does not overwrite a %s starting draft", + async (change) => { + const { session } = createSession(); + let text: string | null = "first"; + const commit = vi.fn(); + await session.start(createTarget("first", () => text, commit, { start: 5, end: 5 })); + text = change === "removed" ? null : "edited prompt"; + await session.controller.stop(); + expect(commit).not.toHaveBeenCalled(); + expect(session.controller.currentState.error).toContain("draft changed"); + }, + ); + + it("finishes the original draft at the recording limit while it is off screen", async () => { + const { session, recorder } = createSession(); + const commit = vi.fn(); + await session.start(createTarget("first", () => "first", commit, { start: 5, end: 5 })); + await session.controller.handleRecorderStatus({ + isFinished: true, + hasError: false, + error: null, + url: recorder.uri, + }); + expect(commit).toHaveBeenCalledWith("first spoken text", { start: 17, end: 17 }); + expect(session.controller.currentState.phase).toBe("idle"); + }); + + it("rejects a transcript when its off-screen draft changes and returns to the original text", async () => { + const registry = AtomRegistry.make(); + const draft = Atom.make("hello world"); + const unsubscribe = vi.fn(); + const { session } = createSession(); + const commit = vi.fn(); + const target = createVoiceInputTarget( + "first", + () => registry.get(draft), + commit, + { start: 6, end: 6 }, + (onChange) => { + const stop = registry.subscribe(draft, onChange); + return () => { + stop(); + unsubscribe(); + }; + }, + ); + await session.start(target); + registry.set(draft, "changed"); + registry.set(draft, "hello world"); + await session.controller.stop(); + expect(commit).not.toHaveBeenCalled(); + expect(session.controller.currentState.error).toContain("draft changed"); + expect(unsubscribe).toHaveBeenCalledTimes(1); + registry.dispose(); + }); + + it("stops recording when its queued edit is discarded", async () => { + const { session, recorder } = createSession(); + const commit = vi.fn(); + let draft: string | null = "queued prompt"; + const ownerKey = "thread~queued-edit~run"; + await session.start(createTarget(ownerKey, () => draft, commit, { start: 13, end: 13 })); + session.cancel(ownerKey); + draft = null; + await session.controller.stop(); + expect(recorder.stop).toHaveBeenCalledTimes(1); + expect(session.controller.currentState).toEqual({ + phase: "idle", + error: null, + errorAction: null, + }); + expect(commit).not.toHaveBeenCalled(); + }); + + it.each(["complete", "cancel"] as const)( + "releases draft observation after %s", + async (finish) => { + const { session } = createSession(); + const unsubscribe = vi.fn(); + const subscribe = vi.fn(() => unsubscribe); + await session.start( + createVoiceInputTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }, subscribe), + ); + expect(subscribe).toHaveBeenCalledTimes(1); + if (finish === "complete") await session.controller.stop(); + else session.cancel("first"); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }, + ); + + it("keeps another prompt's recording when a queued edit is discarded", async () => { + const { session, recorder } = createSession(); + const commit = vi.fn(); + await session.start(createTarget("other prompt", () => "hello", commit, { start: 5, end: 5 })); + session.cancel("thread~queued-edit~run"); + expect(recorder.stop).not.toHaveBeenCalled(); + expect(session.controller.currentState.phase).toBe("recording"); + await session.controller.stop(); + expect(commit).toHaveBeenCalledWith("hello spoken text", { start: 17, end: 17 }); + }); + + it("waits for canceled native work before starting a recording for another draft", async () => { + const preparation = Promise.withResolvers(); + const preparationEntered = Promise.withResolvers(); + const { session, recorder, prepare } = createSession(); + prepare.mockImplementationOnce(() => { + preparationEntered.resolve(); + return preparation.promise; + }); + const oldCommit = vi.fn(); + const nextCommit = vi.fn(); + const firstStart = session.start( + createTarget("first", () => "first", oldCommit, { start: 5, end: 5 }), + ); + await preparationEntered.promise; + session.cancel("first"); + const nextStart = session.start( + createTarget("second", () => "second", nextCommit, { start: 6, end: 6 }), + ); + expect(prepare).toHaveBeenCalledTimes(1); + expect(recorder.record).not.toHaveBeenCalled(); + preparation.resolve({ locale: "en-US", transcribe: async () => "old transcript" }); + await firstStart; + await nextStart; + await session.controller.stop(); + expect(recorder.record).toHaveBeenCalledTimes(1); + expect(oldCommit).not.toHaveBeenCalled(); + expect(nextCommit).toHaveBeenCalledWith("second spoken text", { start: 18, end: 18 }); + }); +}); diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.ts b/apps/mobile/src/features/voice-input/voiceInputSession.ts new file mode 100644 index 000000000000..45983654601f --- /dev/null +++ b/apps/mobile/src/features/voice-input/voiceInputSession.ts @@ -0,0 +1,88 @@ +import { + VoiceInputController, + voiceInputBlocksSubmission, + type VoiceDraftSnapshot, + type VoiceInputControllerDependencies, +} from "@t3tools/client-runtime/voice-input"; + +export type VoiceInputTarget = { + readonly ownerKey: string; + /** Names the draft being dictated into while its composer is off screen. */ + readonly label?: string; + readonly readDraft: () => VoiceDraftSnapshot | null; + readonly commitDraft: VoiceInputControllerDependencies["commitDraft"]; + readonly subscribe: () => () => void; +}; + +export function createVoiceInputTarget( + ownerKey: string, + readText: () => string | null, + commitDraft: VoiceInputTarget["commitDraft"], + selection: VoiceDraftSnapshot["selection"], + subscribeToChanges: (onChange: () => void) => () => void, +): VoiceInputTarget { + let revision = 0; + return { + ownerKey, + readDraft: () => { + const text = readText(); + if (text === null) return null; + return { ownerKey, text, selection, revision }; + }, + commitDraft, + subscribe: () => { + let previousText = readText(); + return subscribeToChanges(() => { + const text = readText(); + if (text !== previousText) { + previousText = text; + revision += 1; + } + }); + }, + }; +} + +export class VoiceInputSession { + readonly controller: VoiceInputController; + private target: VoiceInputTarget | null = null; + private unsubscribe: (() => void) | null = null; + + constructor(dependencies: Omit) { + this.controller = new VoiceInputController({ + ...dependencies, + onStateChange: (state) => { + if (!voiceInputBlocksSubmission(state)) { + this.unsubscribe?.(); + this.unsubscribe = null; + } + dependencies.onStateChange(state); + }, + readDraft: () => this.target?.readDraft() ?? null, + commitDraft: (text, selection) => this.target?.commitDraft(text, selection), + }); + } + + get ownerKey(): string | null { + return this.target?.ownerKey ?? null; + } + + get label(): string | null { + return this.target?.label ?? null; + } + + cancel(ownerKey: string | null): void { + if (ownerKey !== null && this.ownerKey === ownerKey) this.controller.cancel(); + } + + retry(): Promise { + return this.target ? this.start(this.target) : Promise.resolve(); + } + + start(target: VoiceInputTarget): Promise { + if (voiceInputBlocksSubmission(this.controller.currentState)) return Promise.resolve(); + this.target = target; + this.unsubscribe = target.subscribe(); + return this.controller.start(); + } +} diff --git a/apps/mobile/src/state/projects.ts b/apps/mobile/src/state/projects.ts index 7a8799883281..aaad677ef9cd 100644 --- a/apps/mobile/src/state/projects.ts +++ b/apps/mobile/src/state/projects.ts @@ -5,8 +5,10 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; -export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, }); +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { + projectAtom: environmentProjects.projectAtom, +}); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 127121876393..9bbe9417e0bd 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -12,6 +12,7 @@ import { legacyThreadPullRequestKey, } from "@t3tools/shared/threadPullRequests"; import { + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type ChatAttachment, CommandId, isProviderNativeSubagentThread, @@ -415,6 +416,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "prepared-run.release": case "prepared-run.progress": case "prepared-run.fail": + case "prepared-run.retry": case "run.interrupt": case "queued-message.promote-to-steer": case "queue.resume": @@ -466,6 +468,14 @@ const BACKGROUND_CAPABLE_TURN_ITEM_TYPES: ReadonlySet, +) { + const { output: _output, exitCode: _exitCode, outputIndicatesFailure: _failure, ...rest } = item; + return rest; +} + function isBlockingRun(run: OrchestrationV2Run): boolean { return ( run.status === "preparing" || @@ -5174,6 +5184,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...(dispatchMode.type === "defer_start" && dispatchMode.workspaceStrategy !== undefined + ? { workspacePreparation: dispatchMode.workspaceStrategy } + : {}), ...wakeWorkStartedAt(projection.runs, command), }; const attempt: OrchestrationV2RunAttempt = { @@ -7513,7 +7526,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command: Extract< OrchestrationV2Command, { - readonly type: "prepared-run.release" | "prepared-run.progress" | "prepared-run.fail"; + readonly type: + | "prepared-run.release" + | "prepared-run.progress" + | "prepared-run.fail" + | "prepared-run.retry"; } >, projection: Pick< @@ -7536,7 +7553,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio candidate.input === WORKSPACE_PREPARATION_INPUT, ); if ( - run?.status !== "preparing" || + run?.status !== (command.type === "prepared-run.retry" ? "failed" : "preparing") || attempt === undefined || rootNode === undefined || providerThread === undefined || @@ -7754,7 +7771,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completedAt: now, updatedAt: now, type: "error", - failure: command.failure, + failure: { + ...command.failure, + code: ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + }, }, }); yield* emitEvent({ @@ -7768,6 +7788,90 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); + /** + * Returns a run whose workspace preparation failed to preparing. The failure + * item turns cancelled so clients stop offering the retry; ThreadLaunchService + * runs the recorded preparation again once this commits. + */ + const dispatchPreparedRunRetry = ( + command: Extract, + events: Ref.Ref>, + ) => + Effect.gen(function* () { + const projection = yield* loadProjectionForCommand( + command, + ["runs", "attempts", "nodes", "providerThreads", "turnItems"], + { turnItemTypes: ["command_execution", "error"], turnItemRunId: command.runId }, + ); + const state = preparedRunState(command, projection); + const failureItem = projection.turnItems.find( + (candidate): candidate is Extract => + candidate.type === "error" && + candidate.status === "failed" && + candidate.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + ); + if ( + state === null || + state.run.workspacePreparation === undefined || + failureItem === undefined || + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} has no failed workspace preparation to retry.`, + }); + } + if (projection.runs.some((run) => run.id !== state.run.id && isBlockingRun(run))) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Another run is active on this thread.", + }); + } + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + const scope = { + threadId: command.threadId, + runId: state.run.id, + nodeId: state.rootNode.id, + providerInstanceId: state.run.providerInstanceId, + occurredAt: now, + }; + yield* emitEvent({ + ...scope, + type: "turn-item.updated", + payload: { ...failureItem, status: "cancelled", updatedAt: now }, + }); + yield* emitEvent({ + ...scope, + type: "turn-item.updated", + payload: { + ...withoutPreparationResult(state.preparationItem), + status: "running", + title: WORKSPACE_PREPARATION_INPUT, + completedAt: null, + updatedAt: now, + }, + }); + yield* emitEvent({ + ...scope, + type: "run-attempt.updated", + payload: { ...state.attempt, status: "pending", completedAt: null }, + }); + yield* emitEvent({ + ...scope, + type: "node.updated", + payload: { ...state.rootNode, status: "pending", completedAt: null }, + }); + yield* emitEvent({ + ...scope, + type: "run.updated", + payload: { ...state.run, status: "preparing", completedAt: null }, + }); + }); + /** * Ends the background work a settled thread still shows that no provider * process will report on: work on the provider thread whose interrupt just @@ -9557,6 +9661,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "prepared-run.fail": yield* dispatchPreparedRunFail(command, events); break; + case "prepared-run.retry": + yield* dispatchPreparedRunRetry(command, events); + break; case "runtime-request.respond": yield* dispatchRuntimeRequestRespond(command, events, effects); break; diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 10eb4ac9485e..5e53250915f8 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -10,6 +10,7 @@ import * as ThreadMessageIntake from "./ThreadMessageIntake.ts"; import { assert, it, vi } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, ChatAttachmentId, ComposerContextId, type ChatAttachment, @@ -127,6 +128,10 @@ function makeHarness(options: HarnessOptions = {}) { const renameBranch = vi.fn( options.renameBranch ?? ((input) => Effect.succeed({ branch: input.newBranch })), ); + const removeWorktree = vi.fn( + (_input: Parameters[0]) => + Effect.void, + ); const runSetup = vi.fn( options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })), ); @@ -164,7 +169,7 @@ function makeHarness(options: HarnessOptions = {}) { fetchRemote: options.fetchRemote ?? (() => Effect.void), remoteExists: () => Effect.succeed(true), remoteBranchExists: () => Effect.succeed(true), - removeWorktree: () => Effect.void, + removeWorktree, resolveRemoteTrackingCommit: () => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), }), @@ -220,6 +225,7 @@ function makeHarness(options: HarnessOptions = {}) { externalServices, ), createWorktree, + removeWorktree, renameBranch, generateBranchName, generateThreadTitle, @@ -1293,6 +1299,172 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails }).pipe(Effect.provide(harness.layer)); }); +it.effect("retries a failed workspace preparation on the same run", () => { + let fetchFailures = 1; + const harness = makeHarness({ + fetchRemote: () => + fetchFailures-- > 0 + ? Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git", + cwd: project.workspaceRoot, + detail: "Git could not update a local reference.", + exitCode: 1, + }), + ) + : Effect.void, + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:retry", + thread: "thread:launch:retry", + message: "Retry me", + workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), + ); + const failed = yield* threads.getThreadProjection(launched.threadId); + const runId = failed.runs[0]!.id; + assert.equal( + failed.turnItems.find((item) => item.type === "error")?.failure.code, + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + ); + + const retry = { + commandId: CommandId.make("command:launch:retry:1"), + threadId: launched.threadId, + runId, + }; + yield* launches.retryPreparation(retry); + yield* waitUntil(() => + outbox + .listByCommandId(CommandId.make("command:launch:retry:1:release")) + .pipe(Effect.map((effects) => effects.length === 1)), + ); + const retried = yield* threads.getThreadProjection(launched.threadId); + assert.equal(retried.runs.length, 1); + assert.equal(retried.runs[0]?.status, "starting"); + assert.equal(retried.thread.worktreePath, "/repo-worktrees/feature"); + assert.equal(retried.turnItems.find((item) => item.type === "error")?.status, "cancelled"); + assert.equal( + retried.turnItems.find((item) => item.type === "command_execution")?.status, + "completed", + ); + assert.equal(harness.createWorktree.mock.calls.length, 1); + + // The run left preparation, so a second retry has nothing to do. + const rejected = yield* launches + .retryPreparation({ ...retry, commandId: CommandId.make("command:launch:retry:2") }) + .pipe(Effect.flip); + assert.equal(rejected._tag, "OrchestratorDispatchError"); + }).pipe(Effect.provide(harness.layer)); +}); + +it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => { + let setupFailures = 1; + const harness = makeHarness({ + runSetup: () => + setupFailures-- > 0 + ? Effect.fail(new Error("setup failed") as never) + : Effect.succeed({ status: "no-script" as const }), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:reuse", + thread: "thread:launch:reuse", + message: "Reuse the worktree", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe( + Effect.map( + (projection) => + projection.runs[0]?.status === "failed" && + projection.thread.branch === "generated-branch", + ), + ), + ); + const failed = yield* threads.getThreadProjection(launched.threadId); + assert.equal(failed.thread.worktreePath, "/repo-worktrees/feature"); + + yield* launches.retryPreparation({ + commandId: CommandId.make("command:launch:reuse:retry"), + threadId: launched.threadId, + runId: failed.runs[0]!.id, + }); + yield* waitUntil(() => + outbox + .listByCommandId(CommandId.make("command:launch:reuse:retry:release")) + .pipe(Effect.map((effects) => effects.length === 1)), + ); + const retried = yield* threads.getThreadProjection(launched.threadId); + assert.equal(retried.runs[0]?.status, "starting"); + // The retry neither checks out again nor puts back the temporary branch. + assert.equal(harness.createWorktree.mock.calls.length, 1); + assert.equal(harness.renameBranch.mock.calls.length, 1); + assert.equal(retried.thread.branch, "generated-branch"); + assert.equal(retried.thread.worktreePath, "/repo-worktrees/feature"); + // Clients see the retry's setup, not the failed one it replaced. + const snapshot = yield* tracker.get(launched.threadId); + assert.equal(snapshot?.phase, "done"); + assert.deepEqual( + snapshot?.stages.map((stage) => stage.id), + ["setup-script", "agent"], + ); + }).pipe(Effect.provide(harness.layer)); +}); + +it.effect("removes a worktree that failed before the thread recorded it", () => { + const harness = makeHarness({ + // A checkout that dies after claiming its directory. + createWorktree: (_input, options) => + (options?.progress?.onWorktreeClaimed?.("/repo-worktrees/partial") ?? Effect.void).pipe( + Effect.andThen(Effect.fail(new Error("checkout failed") as never)), + ), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:partial-worktree", + thread: "thread:launch:partial-worktree", + message: "Partial checkout", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), + ); + const projection = yield* threads.getThreadProjection(launched.threadId); + // Unrecorded, so a retry would create a second checkout beside it. + assert.equal(projection.thread.worktreePath, null); + assert.deepEqual( + harness.removeWorktree.mock.calls.map(([input]) => input.path), + ["/repo-worktrees/partial"], + ); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect.each(["worktree", "setup"] as const)( "%s failure keeps the thread and message visible and emits failure items", (failurePoint) => @@ -1799,6 +1971,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch }), ), ), + retryPreparation: launches.retryPreparation, }), Effect.flip, ); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index faf613376920..555d3266eba2 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -39,6 +39,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; +import type * as Orchestrator from "./Orchestrator.ts"; import { makeProviderFailure } from "./ProviderFailure.ts"; import { randomUuidV4 } from "./RandomUuid.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; @@ -90,6 +91,25 @@ export interface ThreadLaunchInput { readonly creationSource: OrchestrationV2CreationSource; } +/** What workspace preparation reads from a launch; a retry rebuilds it from the run. */ +type PreparationInput = Pick< + ThreadLaunchInput, + "commandId" | "projectId" | "workspaceStrategy" | "initialMessage" +> & { + /** + * Set when a retry reuses the worktree its failed attempt created and + * recorded. Its setup is tracked like a new one, but the thread already + * records the workspace, and a branch rename may still be running. + */ + readonly reusedWorktree?: { readonly baseRef: string }; +}; + +export interface ThreadLaunchRetryInput { + readonly commandId: CommandId; + readonly threadId: ThreadId; + readonly runId: RunId; +} + export interface ThreadLaunchResult { readonly threadId: ThreadId; readonly projection: OrchestrationV2ThreadProjection; @@ -128,6 +148,10 @@ export class ThreadLaunchService extends Context.Service< readonly launch: ( input: ThreadLaunchInput, ) => Effect.Effect; + /** Dispatches prepared-run.retry and prepares the run's workspace again. */ + readonly retryPreparation: ( + input: ThreadLaunchRetryInput, + ) => Effect.Effect; } >()("t3/orchestration-v2/ThreadLaunchService") {} @@ -161,7 +185,7 @@ const make = Effect.gen(function* () { yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void)); const mapError = - (input: ThreadLaunchInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) => + (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) => (cause: unknown) => new ThreadLaunchError({ operation, @@ -201,7 +225,7 @@ const make = Effect.gen(function* () { }); const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* ( - input: ThreadLaunchInput, + input: PreparationInput, threadId: ThreadId, runId: RunId | null, ) { @@ -216,10 +240,12 @@ const make = Effect.gen(function* () { ), ); - const tracked = input.workspaceStrategy.type === "worktree"; + const reused = input.reusedWorktree; + const tracked = input.workspaceStrategy.type === "worktree" || reused !== undefined; let createdWorktreePath: string | null = null; let setupTerminalId: string | null = null; - if (tracked) { + let workspaceRecorded = false; + if (input.workspaceStrategy.type === "worktree") { yield* setupTracker.begin({ threadId, branch: input.workspaceStrategy.branch ?? null, @@ -227,6 +253,14 @@ const make = Effect.gen(function* () { stages: ["fetch", "checkout", "setup-script", "agent"], fiber: yield* Effect.fiber, }); + } else if (reused !== undefined) { + yield* setupTracker.begin({ + threadId, + branch: input.workspaceStrategy.branch ?? null, + baseRef: reused.baseRef, + stages: ["setup-script", "agent"], + fiber: yield* Effect.fiber, + }); } yield* Effect.gen(function* () { const initialMessage = input.initialMessage; @@ -358,21 +392,27 @@ const make = Effect.gen(function* () { yield* setupTracker.stageStatus(threadId, "checkout", "done"); } - yield* threads - .dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`${input.commandId}:workspace`), - threadId, - branch, - worktreePath, - }) - .pipe(Effect.mapError(mapError(input, "update-thread", threadId))); + // A reused worktree is already recorded, and rewriting it could undo + // the first attempt's branch rename. + if (reused === undefined) { + yield* threads + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`${input.commandId}:workspace`), + threadId, + branch, + worktreePath, + }) + .pipe(Effect.mapError(mapError(input, "update-thread", threadId))); + } + workspaceRecorded = true; // Rename temporary branches (server-invented above, or sent by clients // that name worktrees themselves) in the background so generation latency // never delays provisioning or the provider turn. The temporary name // simply sticks if generation or the rename fails. if ( + reused === undefined && worktreePath !== null && branch !== null && initialMessage !== undefined && @@ -512,27 +552,41 @@ const make = Effect.gen(function* () { cancelled ? "cancelled" : "failed", cancelled ? null : failureDetail(Cause.squash(cause)), ); - if (cancelled && tracked && createdWorktreePath) { + // A cancelled setup leaves nothing behind. A failed one keeps a worktree + // the thread recorded, so a retry reuses it, and removes one it never + // recorded, which a retry would otherwise duplicate. + if (tracked && createdWorktreePath && (cancelled || !workspaceRecorded)) { if (setupTerminalId) yield* terminals .close({ threadId, terminalId: setupTerminalId, deleteHistory: true }) .pipe(Effect.ignore); + const removedPath = createdWorktreePath; + // The thread forgets the worktree only once it is gone; a failed + // removal leaves the directory for the user to clean up rather than + // reusing a checkout that may be half written. yield* git - .removeWorktree({ - cwd: project.workspaceRoot, - path: createdWorktreePath, - force: true, - }) - .pipe(Effect.ignore); - yield* threads - .dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`${input.commandId}:cancel-workspace`), - threadId, - worktreePath: null, - branch: null, - }) - .pipe(Effect.ignore); + .removeWorktree({ cwd: project.workspaceRoot, path: removedPath, force: true }) + .pipe( + Effect.andThen( + threads + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`${input.commandId}:cancel-workspace`), + threadId, + worktreePath: null, + branch: null, + }) + .pipe(Effect.ignore), + ), + Effect.catchCause((removeCause) => + Effect.logWarning("Failed to remove an abandoned thread worktree", { + commandId: input.commandId, + threadId, + path: removedPath, + cause: removeCause, + }), + ), + ); } }), ), @@ -540,7 +594,7 @@ const make = Effect.gen(function* () { }); const failPreparedRun = ( - input: ThreadLaunchInput, + input: Pick, threadId: ThreadId, runId: RunId | null, cause: unknown, @@ -565,7 +619,6 @@ const make = Effect.gen(function* () { }), }) .pipe( - Effect.mapError(mapError(input, "fail-run", threadId)), Effect.catchCause((persistCause) => Effect.logWarning("Failed to persist thread workspace preparation failure", { commandId: input.commandId, @@ -592,7 +645,7 @@ const make = Effect.gen(function* () { }); const schedulePreparation = Effect.fn("ThreadLaunchService.schedulePreparation")(function* ( - input: ThreadLaunchInput, + input: PreparationInput, threadId: ThreadId, runId: RunId | null, ) { @@ -763,7 +816,7 @@ const make = Effect.gen(function* () { ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}), ...(input.generateTitle === true ? { titleSeed: input.title } : {}), modelSelection: input.modelSelection, - dispatchMode: { type: "defer_start" }, + dispatchMode: { type: "defer_start", workspaceStrategy }, createdBy: input.createdBy, creationSource: input.creationSource, }) @@ -836,7 +889,78 @@ const make = Effect.gen(function* () { }, ); - return ThreadLaunchService.of({ launch }); + const retryPreparation: ThreadLaunchService["Service"]["retryPreparation"] = Effect.fn( + "ThreadLaunchService.retryPreparation", + )(function* (input) { + const dispatched = yield* threads.dispatch({ + type: "prepared-run.retry", + commandId: input.commandId, + threadId: input.threadId, + runId: input.runId, + }); + // A replayed retry finds the run already past preparation, or prepared by + // the attempt that first reserved this command. + // From here the run is preparing again; anything that stops preparation + // from being scheduled must fail it, or it would wait in preparing forever. + const scheduled = yield* Effect.gen(function* () { + const projection = yield* threads.getThreadProjection(input.threadId); + const run = projection.runs.find((candidate) => candidate.id === input.runId); + const workspacePreparation = run?.workspacePreparation; + if (run?.status !== "preparing" || workspacePreparation === undefined) return; + if (!(yield* reservePreparation(input.commandId))) return; + yield* scheduleRetriedPreparation(input, projection, run, workspacePreparation).pipe( + Effect.onError(() => releasePreparation(input.commandId)), + ); + }).pipe(Effect.exit); + if (Exit.isFailure(scheduled)) { + yield* failPreparedRun(input, input.threadId, input.runId, Cause.squash(scheduled.cause)); + } + return dispatched; + }); + + const scheduleRetriedPreparation = ( + input: ThreadLaunchRetryInput, + projection: OrchestrationV2ThreadProjection, + run: OrchestrationV2ThreadProjection["runs"][number], + workspacePreparation: ThreadLaunchWorkspaceStrategy, + ) => { + const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + // A worktree the failed attempt already created is reused, not created again. + const reuse = + workspacePreparation.type === "worktree" && + projection.thread.worktreePath !== null && + projection.thread.branch !== null + ? { + strategy: { + type: "existing_worktree" as const, + worktreePath: projection.thread.worktreePath, + branch: projection.thread.branch, + }, + reusedWorktree: { baseRef: workspacePreparation.baseRef }, + } + : null; + return schedulePreparation( + { + commandId: input.commandId, + projectId: projection.thread.projectId, + workspaceStrategy: reuse?.strategy ?? workspacePreparation, + ...(reuse === null ? {} : { reusedWorktree: reuse.reusedWorktree }), + ...(message === undefined + ? {} + : { + initialMessage: { + text: message.text, + attachments: message.attachments, + ...(message.context ? { context: message.context } : {}), + }, + }), + }, + input.threadId, + run.id, + ); + }; + + return ThreadLaunchService.of({ launch, retryPreparation }); }); export const layer = Layer.effect(ThreadLaunchService, make); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index b9cad107409a..0bb2d5053b4b 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -165,6 +165,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray {', - " const message = JSON.parse(line);", - ' if (message.type !== "control_request") return;', - ' if (message.request?.subtype === "get_usage") {', - " const marker = process.env.T3_CLAUDE_RESET_MARKER;", - " if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && existsSync(marker)) {", - " process.stdout.write(JSON.stringify({", - ' type: "control_response",', - ' response: { subtype: "error", request_id: message.request_id, error: "usage failed" },', - ' }) + "\\n");', - " return;", - " }", - " process.stdout.write(JSON.stringify({", - ' type: "control_response",', - ' response: { subtype: "success", request_id: message.request_id, response: {', - ' session: {}, subscription_type: "pro", rate_limits_available: true,', - " rate_limits: { five_hour: { utilization: marker && existsSync(marker) ? 0 : 100, resets_at: null } },", - " } },", - ' }) + "\\n");', - " return;", - " }", - ' if (message.request?.subtype !== "initialize") return;', - " process.stdout.write(JSON.stringify({", - ' type: "control_response",', - " response: {", - ' subtype: "success",', - " request_id: message.request_id,", - " response: {", - " commands: [], agents: [], models: [],", - ' output_style: "default", available_output_styles: ["default"],', - ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },', - " },", - " },", - ' }) + "\\n");', - "});", - "// Stay alive for follow-up control requests, but never outlive the", - "// parent: the probe aborts the SDK without awaiting the child, so an", - "// unconditional interval would strand this process until reboot.", - "const keepAlive = setInterval(() => {}, 1_000);", - 'lines.on("close", () => {', - " clearInterval(keepAlive);", - " process.exit(0);", - "});", - "", - ].join("\n"), ); yield* fileSystem.chmod(claudePath, 0o755); yield* fileSystem.makeDirectory(claudeHomePath); @@ -837,304 +779,3 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }).pipe(Effect.provide(testLayer)), ); }); - -describe("ProviderInstanceRegistryLive: rebuildInstanceWhen", () => { - const testLayer = ProviderOrchestrationAdapterInfrastructureLive.pipe( - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "provider-instance-registry-rebuild-test", - }).pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge( - Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "unused-managed-installation", - }), - ), - Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), - Layer.provideMerge( - Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed( - EnvironmentId.make("00000000-0000-4000-8000-000000000001"), - ), - }), - ), - Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - ), - ), - ); - - const decodeEnvironment = Schema.decodeSync(ProviderInstanceEnvironment); - - const codexDriverKind = ProviderDriverKind.make("codex"); - const firstId = ProviderInstanceId.make("codex_first"); - const secondId = ProviderInstanceId.make("codex_second"); - const firstEntry: ProviderInstanceConfig = { - driver: codexDriverKind, - displayName: "Codex (first)", - enabled: false, - environment: decodeEnvironment([ - { - name: "OP_TOKEN", - value: { - kind: "1password", - reference: "op://Vault/Item/token", - account: "my.1password.com", - }, - }, - ]), - config: makeCodexConfig({ homePath: "/home/julius/.codex_first" }), - }; - const secondEntry: ProviderInstanceConfig = { - driver: codexDriverKind, - displayName: "Codex (second)", - enabled: false, - config: makeCodexConfig({ homePath: "/home/julius/.codex_second" }), - }; - const configMap: ProviderInstanceConfigMap = { [firstId]: firstEntry, [secondId]: secondEntry }; - - /** - * A codex driver whose `create` parks until the test opens the gate, so a - * rebuild can be held exactly where a real one waits: inside the driver, - * with the old instance already gone. Arming is explicit so the registry's - * initial hydration runs unblocked. - */ - const makeCreateGate = Effect.gen(function* () { - const armed = yield* Ref.make(false); - const entered = yield* Deferred.make(); - const released = yield* Deferred.make(); - const gatedDriver: typeof CodexDriver = { - ...CodexDriver, - create: (input) => - Effect.gen(function* () { - if (yield* Ref.get(armed)) { - yield* Deferred.succeed(entered, undefined); - yield* Deferred.await(released); - } - return yield* CodexDriver.create(input); - }), - }; - return { - gatedDriver, - arm: Ref.set(armed, true), - disarm: Ref.set(armed, false), - awaitEntered: Deferred.await(entered), - release: Deferred.succeed(released, undefined), - }; - }); - - /** A codex driver that fails `create` while the returned ref says so. */ - const makeFailingCreate = Effect.gen(function* () { - const failing = yield* Ref.make(false); - const failingDriver: typeof CodexDriver = { - ...CodexDriver, - create: (input) => - Effect.gen(function* () { - if (yield* Ref.get(failing)) { - return yield* Effect.fail( - new ProviderDriverError({ - driver: codexDriverKind, - instanceId: input.instanceId, - detail: "secret store is locked", - }), - ); - } - return yield* CodexDriver.create(input); - }), - }; - return { failingDriver, setFailing: (value: boolean) => Ref.set(failing, value) }; - }); - - it.live("replaces only the instance the predicate accepts, in place", () => - Effect.gen(function* () { - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap, - }); - const before = yield* registry.listInstances; - - const rebuilt = yield* registry.rebuildInstanceWhen( - firstId, - (entry) => entry.environment !== undefined, - ); - - expect(rebuilt).toBe(true); - const after = yield* registry.listInstances; - // Order is settings-author order, not "rebuilt last". - expect(after.map((instance) => instance.instanceId)).toEqual([firstId, secondId]); - // The accepted instance is a genuinely new bundle; its neighbour is - // untouched, which is what keeps a refresh from restarting every - // provider on the machine. - expect(after[0]).not.toBe(before[0]); - expect(after[1]).toBe(before[1]); - }).pipe(Effect.provide(testLayer)), - ); - - it.live("leaves the instance alone when the predicate declines", () => - Effect.gen(function* () { - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap, - }); - const before = yield* registry.listInstances; - - const rebuilt = yield* registry.rebuildInstanceWhen(secondId, () => false); - - expect(rebuilt).toBe(false); - expect(yield* registry.listInstances).toEqual(before); - }).pipe(Effect.provide(testLayer)), - ); - - it.live("treats an unknown instance id as a no-op", () => - Effect.gen(function* () { - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap, - }); - - const rebuilt = yield* registry.rebuildInstanceWhen( - ProviderInstanceId.make("codex_missing"), - () => true, - ); - - expect(rebuilt).toBe(false); - }).pipe(Effect.provide(testLayer)), - ); - - it.live("never serves the instance it is in the middle of replacing", () => - Effect.gen(function* () { - const gate = yield* makeCreateGate; - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [gate.gatedDriver], - configMap, - }); - yield* gate.arm; - - const rebuilding = yield* registry - .rebuildInstanceWhen(firstId, () => true) - .pipe(Effect.forkScoped); - yield* gate.awaitEntered; - - // The window this covers is a real one: resolving a secret can park on - // a person at a biometric prompt. Handing out the old bundle here means - // handing out a closed scope. - expect(yield* registry.getInstance(firstId)).toBeUndefined(); - expect((yield* registry.listInstances).map((instance) => instance.instanceId)).toEqual([ - secondId, - ]); - // Not routable, but not gone either: consumers prune ids they find in - // neither list, and the provider's card must not blink out of the UI for - // as long as a secret store takes to answer. - expect((yield* registry.listUnavailable).map((provider) => provider.instanceId)).toEqual([ - firstId, - ]); - - yield* gate.release; - expect(yield* Fiber.join(rebuilding)).toBe(true); - expect((yield* registry.listInstances).map((instance) => instance.instanceId)).toEqual([ - firstId, - secondId, - ]); - expect(yield* registry.listUnavailable).toEqual([]); - }).pipe(Effect.provide(testLayer)), - ); - - it.live("does not undo a settings change that lands during a rebuild", () => - Effect.gen(function* () { - const gate = yield* makeCreateGate; - const { registry, mutator } = yield* makeProviderInstanceRegistry({ - drivers: [gate.gatedDriver], - configMap, - }); - yield* gate.arm; - - const rebuilding = yield* registry - .rebuildInstanceWhen(firstId, () => true) - .pipe(Effect.forkScoped); - yield* gate.awaitEntered; - const reconciling = yield* mutator - .reconcile({ [firstId]: firstEntry }) - .pipe(Effect.forkScoped); - - yield* gate.release; - expect(yield* Fiber.join(rebuilding)).toBe(true); - yield* Fiber.join(reconciling); - - // The removal sticks. A rebuild that wrote back the map it read before - // the removal would put the deleted instance back, pointing at a scope - // reconcile already closed. - expect((yield* registry.listInstances).map((instance) => instance.instanceId)).toEqual([ - firstId, - ]); - }).pipe(Effect.provide(testLayer)), - ); - - // Interruption is not exotic here: the request that asked for the refresh can - // go away while `op` is still waiting on a fingerprint. - it.live("keeps an interrupted rebuild retryable", () => - Effect.gen(function* () { - const gate = yield* makeCreateGate; - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [gate.gatedDriver], - configMap, - }); - yield* gate.arm; - - const rebuilding = yield* registry - .rebuildInstanceWhen(firstId, () => true) - .pipe(Effect.forkScoped); - yield* gate.awaitEntered; - yield* Fiber.interrupt(rebuilding); - - // The instance is not live and its envelope only ever existed in the - // registry, so a refresh that cannot find it here has nowhere else to - // look and the user waits on a settings edit to get it back. - yield* gate.disarm; - expect(yield* registry.rebuildInstanceWhen(firstId, () => true)).toBe(true); - expect((yield* registry.listInstances).map((instance) => instance.instanceId)).toContain( - firstId, - ); - expect(yield* registry.listUnavailable).toEqual([]); - }).pipe(Effect.provide(testLayer)), - ); - - it.live("retries an instance a previous rebuild could not bring back", () => - Effect.gen(function* () { - const driver = yield* makeFailingCreate; - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [driver.failingDriver], - configMap, - }); - - yield* driver.setFailing(true); - expect(yield* registry.rebuildInstanceWhen(firstId, () => true)).toBe(true); - expect((yield* registry.listInstances).map((instance) => instance.instanceId)).toEqual([ - secondId, - ]); - expect((yield* registry.listUnavailable).map((provider) => provider.instanceId)).toEqual([ - firstId, - ]); - - // The next refresh is a retry, not a lookup that finds nothing: a locked - // vault must not cost the user their instance until settings change. - yield* driver.setFailing(false); - expect(yield* registry.rebuildInstanceWhen(firstId, () => true)).toBe(true); - expect(yield* registry.listUnavailable).toEqual([]); - // Recovered last, where it already sat while unavailable; the next - // settings change restores settings-author order. - expect((yield* registry.listInstances).map((instance) => instance.instanceId)).toEqual([ - secondId, - firstId, - ]); - }).pipe(Effect.provide(testLayer)), - ); -}); diff --git a/apps/server/src/provider/Layers/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/Layers/testing/ProviderInstanceRegistryLive.fixture.mjs new file mode 100755 index 000000000000..054ca04703e5 --- /dev/null +++ b/apps/server/src/provider/Layers/testing/ProviderInstanceRegistryLive.fixture.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +if (process.argv.includes("--version")) { + process.stdout.write("claude 2.1.219\n"); + process.exit(0); +} +const lines = NodeReadline.createInterface({ input: process.stdin }); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.type !== "control_request") return; + if (message.request?.subtype === "get_usage") { + const marker = process.env.T3_CLAUDE_RESET_MARKER; + if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) { + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { subtype: "error", request_id: message.request_id, error: "usage failed" }, + }) + "\n", + ); + return; + } + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { + subtype: "success", + request_id: message.request_id, + response: { + session: {}, + subscription_type: "pro", + rate_limits_available: true, + rate_limits: { + five_hour: { + utilization: marker && NodeFS.existsSync(marker) ? 0 : 100, + resets_at: null, + }, + }, + }, + }, + }) + "\n", + ); + return; + } + if (message.request?.subtype !== "initialize") return; + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { + subtype: "success", + request_id: message.request_id, + response: { + commands: [], + agents: [], + models: [], + output_style: "default", + available_output_styles: ["default"], + account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + }, + }, + }) + "\n", + ); +}); +// Stay alive for follow-up control requests, but never outlive the +// parent: the probe aborts the SDK without awaiting the child, so an +// unconditional interval would strand this process until reboot. +const keepAlive = setInterval(() => {}, 1_000); +lines.on("close", () => { + clearInterval(keepAlive); + process.exit(0); +}); diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts index 1a5172d4a99d..b12b13e3a4ef 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts @@ -78,32 +78,11 @@ const makeFakeNpmToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeNpmToolc yield* fileSystem.makeDirectory(toolchainBin, { recursive: true }); yield* fileSystem.makeDirectory(globalPrefix, { recursive: true }); yield* fileSystem.writeFileString(logPath, ""); - yield* fileSystem.writeFileString( + yield* fileSystem.copyFile( + yield* path.fromFileUrl( + new URL("./testing/AcpRegistrySupport.npm.fixture.sh", import.meta.url), + ), npmPath, - [ - "#!/bin/sh", - 'printf \'%s\\n\' "$*" >> "$FAKE_NPM_LOG"', - 'prefix="${npm_config_prefix:-$FAKE_NPM_PREFIX}"', - 'if [ "$1" = "root" ] && [ "$2" = "--global" ]; then', - " printf '%s\\n' \"$prefix/lib/node_modules\"", - " exit 0", - "fi", - 'if [ "$1" = "prefix" ] && [ "$2" = "--global" ]; then', - " printf '%s\\n' \"$prefix\"", - " exit 0", - "fi", - 'if [ "$1" = "install" ] && [ "$2" = "--global" ]; then', - ' package_root="$prefix/lib/node_modules/@example/acp"', - ' executable="$prefix/bin/example-agent"', - ' mkdir -p "$package_root" "$prefix/bin"', - ' printf \'%s\' "$FAKE_NPM_MANIFEST" > "$package_root/package.json"', - " printf '#!/bin/sh\\n' > \"$executable\"", - ' chmod 755 "$executable"', - " exit 0", - "fi", - "exit 64", - "", - ].join("\n"), ); yield* fileSystem.chmod(npmPath, 0o755); return { @@ -137,33 +116,9 @@ const makeFakeUvToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeUvToolcha const logPath = path.join(rootDirectory, "uv.log"); yield* fileSystem.makeDirectory(toolchainBin, { recursive: true }); yield* fileSystem.writeFileString(logPath, ""); - yield* fileSystem.writeFileString( + yield* fileSystem.copyFile( + yield* path.fromFileUrl(new URL("./testing/AcpRegistrySupport.uv.fixture.sh", import.meta.url)), uvPath, - [ - "#!/bin/sh", - 'printf \'%s\\n\' "$*" >> "$FAKE_UV_LOG"', - 'tool_bin="${UV_TOOL_BIN_DIR:-$FAKE_UV_BIN}"', - 'executable="$tool_bin/fast-agent"', - 'printf "tool-dir=%s bin-dir=%s\\n" "$UV_TOOL_DIR" "$UV_TOOL_BIN_DIR" >> "$FAKE_UV_LOG"', - 'if [ "$1" = "tool" ] && [ "$2" = "dir" ] && [ "$3" = "--bin" ]; then', - " printf '%s\\n' \"$tool_bin\"", - " exit 0", - "fi", - 'if [ "$1" = "tool" ] && [ "$2" = "list" ]; then', - ' if [ -x "$executable" ]; then', - " printf 'fast-agent-acp v0.10.1\\n- fast-agent\\n'", - " fi", - " exit 0", - "fi", - 'if [ "$1" = "tool" ] && [ "$2" = "install" ] && [ "$3" = "--force" ]; then', - ' mkdir -p "$tool_bin"', - " printf '#!/bin/sh\\n' > \"$executable\"", - ' chmod 755 "$executable"', - " exit 0", - "fi", - "exit 64", - "", - ].join("\n"), ); yield* fileSystem.chmod(uvPath, 0o755); return { diff --git a/apps/server/src/provider/acp/testing/AcpRegistrySupport.npm.fixture.sh b/apps/server/src/provider/acp/testing/AcpRegistrySupport.npm.fixture.sh new file mode 100755 index 000000000000..4bfa5d9a8de9 --- /dev/null +++ b/apps/server/src/provider/acp/testing/AcpRegistrySupport.npm.fixture.sh @@ -0,0 +1,21 @@ +#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_NPM_LOG" +prefix="${npm_config_prefix:-$FAKE_NPM_PREFIX}" +if [ "$1" = "root" ] && [ "$2" = "--global" ]; then + printf '%s\n' "$prefix/lib/node_modules" + exit 0 +fi +if [ "$1" = "prefix" ] && [ "$2" = "--global" ]; then + printf '%s\n' "$prefix" + exit 0 +fi +if [ "$1" = "install" ] && [ "$2" = "--global" ]; then + package_root="$prefix/lib/node_modules/@example/acp" + executable="$prefix/bin/example-agent" + mkdir -p "$package_root" "$prefix/bin" + printf '%s' "$FAKE_NPM_MANIFEST" > "$package_root/package.json" + printf '#!/bin/sh\n' > "$executable" + chmod 755 "$executable" + exit 0 +fi +exit 64 diff --git a/apps/server/src/provider/acp/testing/AcpRegistrySupport.uv.fixture.sh b/apps/server/src/provider/acp/testing/AcpRegistrySupport.uv.fixture.sh new file mode 100755 index 000000000000..18a456c24d15 --- /dev/null +++ b/apps/server/src/provider/acp/testing/AcpRegistrySupport.uv.fixture.sh @@ -0,0 +1,22 @@ +#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_UV_LOG" +tool_bin="${UV_TOOL_BIN_DIR:-$FAKE_UV_BIN}" +executable="$tool_bin/fast-agent" +printf "tool-dir=%s bin-dir=%s\n" "$UV_TOOL_DIR" "$UV_TOOL_BIN_DIR" >> "$FAKE_UV_LOG" +if [ "$1" = "tool" ] && [ "$2" = "dir" ] && [ "$3" = "--bin" ]; then + printf '%s\n' "$tool_bin" + exit 0 +fi +if [ "$1" = "tool" ] && [ "$2" = "list" ]; then + if [ -x "$executable" ]; then + printf 'fast-agent-acp v0.10.1\n- fast-agent\n' + fi + exit 0 +fi +if [ "$1" = "tool" ] && [ "$2" = "install" ] && [ "$3" = "--force" ]; then + mkdir -p "$tool_bin" + printf '#!/bin/sh\n' > "$executable" + chmod 755 "$executable" + exit 0 +fi +exit 64 diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index fc51a7248ad3..42483614043f 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -34,86 +34,18 @@ const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(proces function makeFakeClaudeBinary(dir: string) { return Effect.gen(function* () { const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; const platform = yield* HostProcessPlatform; const binDir = path.join(dir, "bin"); + const fixturePath = yield* path.fromFileUrl( + new URL("./testing/ClaudeTextGeneration.fixture.mjs", import.meta.url), + ); + const source = yield* fs.readFileString(fixturePath); writeFakeCli({ directory: binDir, name: "claude", platform, - source: [ - "const argv = process.argv.slice(2);", - 'const args = argv.join(" ");', - 'const { realpathSync } = await import("node:fs");', - "", - "function fail(message, code) {", - ' process.stderr.write(message + "\\n");', - " process.exit(code);", - "}", - "", - 'const permissionIndex = argv.indexOf("--permission-mode");', - 'if (permissionIndex === -1 || argv[permissionIndex + 1] !== "dontAsk") {', - ' fail("text generation must deny permission prompts", 12);', - "}", - 'const toolsIndex = argv.indexOf("--tools");', - 'if (toolsIndex === -1 || argv[toolsIndex + 1] !== "") {', - ' fail("text generation must receive an explicit empty tool set", 6);', - "}", - 'if (argv.includes("--dangerously-skip-permissions")) {', - ' fail("text generation must not bypass permissions", 7);', - "}", - 'if (!argv.includes("--disable-slash-commands")) {', - ' fail("text generation must disable skills", 8);', - "}", - 'if (!argv.includes("--strict-mcp-config")) {', - ' fail("text generation must not load configured MCP servers", 9);', - "}", - 'const settingsIndex = argv.indexOf("--settings");', - "if (settingsIndex === -1 || JSON.parse(argv[settingsIndex + 1]).disableAllHooks !== true) {", - ' fail("text generation must disable hooks", 10);', - "}", - "const cwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE;", - "if (cwdMustNotBe && realpathSync(process.cwd()) === realpathSync(cwdMustNotBe)) {", - ' fail("text generation ran in the project directory", 11);', - "}", - "", - 'let stdinContent = "";', - "if (!process.stdin.isTTY) {", - " const chunks = [];", - " for await (const chunk of process.stdin) {", - " chunks.push(chunk);", - " }", - ' stdinContent = Buffer.concat(chunks).toString("utf8");', - "}", - "", - "const argsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;", - "if (argsMustContain && !args.includes(argsMustContain)) {", - ' fail("args missing expected content", 2);', - "}", - "", - "const argsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;", - "if (argsMustNotContain && args.includes(argsMustNotContain)) {", - ' fail("args contained forbidden content", 3);', - "}", - "", - "const stdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;", - "if (stdinMustContain && !stdinContent.includes(stdinMustContain)) {", - ' fail("stdin missing expected content", 4);', - "}", - "", - "const configDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE;", - "if (configDirMustBe && process.env.CLAUDE_CONFIG_DIR !== configDirMustBe) {", - ' fail("CLAUDE_CONFIG_DIR was " + (process.env.CLAUDE_CONFIG_DIR ?? ""), 5);', - "}", - "", - "const stderrText = process.env.T3_FAKE_CLAUDE_STDERR;", - "if (stderrText) {", - ' process.stderr.write(stderrText + "\\n");', - "}", - "", - 'process.stdout.write(process.env.T3_FAKE_CLAUDE_OUTPUT ?? "");', - "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);", - "", - ].join("\n"), + source, }); return binDir; }); diff --git a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs new file mode 100644 index 000000000000..d5ca2f185eb8 --- /dev/null +++ b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs @@ -0,0 +1,72 @@ +import * as NodeFS from "node:fs"; + +const argv = process.argv.slice(2); +const args = argv.join(" "); + +function fail(message, code) { + process.stderr.write(message + "\n"); + process.exit(code); +} + +const permissionIndex = argv.indexOf("--permission-mode"); +if (permissionIndex === -1 || argv[permissionIndex + 1] !== "dontAsk") { + fail("text generation must deny permission prompts", 12); +} +const toolsIndex = argv.indexOf("--tools"); +if (toolsIndex === -1 || argv[toolsIndex + 1] !== "") { + fail("text generation must receive an explicit empty tool set", 6); +} +if (argv.includes("--dangerously-skip-permissions")) { + fail("text generation must not bypass permissions", 7); +} +if (!argv.includes("--disable-slash-commands")) { + fail("text generation must disable skills", 8); +} +if (!argv.includes("--strict-mcp-config")) { + fail("text generation must not load configured MCP servers", 9); +} +const settingsIndex = argv.indexOf("--settings"); +if (settingsIndex === -1 || JSON.parse(argv[settingsIndex + 1]).disableAllHooks !== true) { + fail("text generation must disable hooks", 10); +} +const cwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; +if (cwdMustNotBe && NodeFS.realpathSync(process.cwd()) === NodeFS.realpathSync(cwdMustNotBe)) { + fail("text generation ran in the project directory", 11); +} + +let stdinContent = ""; +if (!process.stdin.isTTY) { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + stdinContent = Buffer.concat(chunks).toString("utf8"); +} + +const argsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; +if (argsMustContain && !args.includes(argsMustContain)) { + fail("args missing expected content", 2); +} + +const argsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; +if (argsMustNotContain && args.includes(argsMustNotContain)) { + fail("args contained forbidden content", 3); +} + +const stdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; +if (stdinMustContain && !stdinContent.includes(stdinMustContain)) { + fail("stdin missing expected content", 4); +} + +const configDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; +if (configDirMustBe && process.env.CLAUDE_CONFIG_DIR !== configDirMustBe) { + fail("CLAUDE_CONFIG_DIR was " + (process.env.CLAUDE_CONFIG_DIR ?? ""), 5); +} + +const stderrText = process.env.T3_FAKE_CLAUDE_STDERR; +if (stderrText) { + process.stderr.write(stderrText + "\n"); +} + +process.stdout.write(process.env.T3_FAKE_CLAUDE_OUTPUT ?? ""); +process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index fec77d110dbb..c720a2e4958a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2216,7 +2216,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const sshLogPath = pathService.join(tempDir, "ssh-env.txt"); - const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh"); + const sshWrapperPath = yield* pathService.fromFileUrl( + new URL("./testing/GitVcsDriverCore.fixture.sh", import.meta.url), + ); const envKeys = [ "GCM_INTERACTIVE", "GIT_ASKPASS", @@ -2228,20 +2230,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ] as const; const previousEnv = new Map(envKeys.map((key) => [key, process.env[key]])); - yield* fileSystem.writeFileString( - sshWrapperPath, - [ - "#!/bin/sh", - 'printf "GCM_INTERACTIVE=%s\\n" "${GCM_INTERACTIVE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"', - 'printf "GIT_ASKPASS=%s\\n" "${GIT_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', - 'printf "GIT_TERMINAL_PROMPT=%s\\n" "${GIT_TERMINAL_PROMPT:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', - 'printf "SSH_ASKPASS=%s\\n" "${SSH_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', - 'printf "SSH_ASKPASS_REQUIRE=%s\\n" "${SSH_ASKPASS_REQUIRE:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', - "exit 1", - "", - ].join("\n"), - ); - yield* fileSystem.chmod(sshWrapperPath, 0o755); yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]); yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]); yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]); diff --git a/apps/server/src/vcs/testing/GitVcsDriverCore.fixture.sh b/apps/server/src/vcs/testing/GitVcsDriverCore.fixture.sh new file mode 100755 index 000000000000..d461d27f3bc0 --- /dev/null +++ b/apps/server/src/vcs/testing/GitVcsDriverCore.fixture.sh @@ -0,0 +1,7 @@ +#!/bin/sh +printf "GCM_INTERACTIVE=%s\n" "${GCM_INTERACTIVE:-}" > "$T3_TEST_SSH_ASKPASS_LOG" +printf "GIT_ASKPASS=%s\n" "${GIT_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG" +printf "GIT_TERMINAL_PROMPT=%s\n" "${GIT_TERMINAL_PROMPT:-}" >> "$T3_TEST_SSH_ASKPASS_LOG" +printf "SSH_ASKPASS=%s\n" "${SSH_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG" +printf "SSH_ASKPASS_REQUIRE=%s\n" "${SSH_ASKPASS_REQUIRE:-}" >> "$T3_TEST_SSH_ASKPASS_LOG" +exit 1 diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2c6c3859fb01..b00e949458e4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1798,11 +1798,16 @@ const makeWsRpcLayer = ( ORCHESTRATION_V2_WS_METHODS.dispatchCommand, startup .enqueueCommand( - ThreadMessageIntake.dispatchCommand( - ThreadManagementService.withCreationProvenance(command, { - createdBy: "user", - creationSource: "creationSource" in command ? command.creationSource : "web", - }), + // A retry also restarts the preparation work the launch owns. + (command.type === "prepared-run.retry" + ? threadLaunch.retryPreparation(command) + : ThreadMessageIntake.dispatchCommand( + ThreadManagementService.withCreationProvenance(command, { + createdBy: "user", + creationSource: + "creationSource" in command ? command.creationSource : "web", + }), + ) ).pipe(Effect.provide(intakeContext)), ) .pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a8eb8fd64df0..8879fe8f1f38 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -395,6 +395,7 @@ import { } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; +import { workspacePreparationRetryRunIds } from "@t3tools/client-runtime/state/turn-item-presentation"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { sourceControlEnvironment } from "../state/sourceControl"; @@ -1510,6 +1511,9 @@ function releaseChatTimelineAnchor(); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -3920,6 +3924,34 @@ export default function ChatView(props: ChatViewProps) { const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { reportFailure: false, }); + const retryWorkspacePreparation = useAtomCommand(threadEnvironment.retryWorkspacePreparation); + const retryableRunIdsKey = useMemo( + () => + [ + ...workspacePreparationRetryRunIds( + serverProjection?.runs ?? [], + serverProjection?.turnItems ?? [], + ), + ].join("\n"), + [serverProjection?.runs, serverProjection?.turnItems], + ); + // Keyed by content so the timeline context only changes when a retry appears or clears. + const retryableWorkspacePreparationRunIds = useMemo( + () => new Set(retryableRunIdsKey === "" ? [] : (retryableRunIdsKey.split("\n") as RunId[])), + [retryableRunIdsKey], + ); + const onRetryWorkspacePreparation = useCallback( + (runId: RunId) => { + // One retry per failed run: a second click lands after the run is preparing again. + if (!activeThreadRef || retryingWorkspacePreparationRunIds.has(runId)) return; + retryingWorkspacePreparationRunIds.add(runId); + void retryWorkspacePreparation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, runId }, + }).finally(() => retryingWorkspacePreparationRunIds.delete(runId)); + }, + [activeThreadRef, retryWorkspacePreparation], + ); const onCancelWorktreeSetup = useCallback(() => { if (!worktreeSetup || worktreeSetup.phase !== "running") return; void cancelWorktreeSetup({ @@ -4298,7 +4330,8 @@ export default function ChatView(props: ChatViewProps) { : "Auto balance" : undefined; - const environmentChangeRef = useRef(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 () => { @@ -4316,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]) => { @@ -4364,7 +4397,7 @@ export default function ChatView(props: ChatViewProps) { } // Keep send disabled until the destination Scratch project is ready. setIsEnvironmentChanging(true); - void openScratchProject(target.environmentId) + void openScratchProject(target.environmentId, "Could not switch machine") .then((project) => { if (project) retarget(project); }) @@ -7720,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(); @@ -7809,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, @@ -10896,6 +10950,9 @@ export default function ChatView(props: ChatViewProps) { activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} onCancelWorktreeSetup={onCancelWorktreeSetup} + {...(paintOnlyDisplayedTimeline + ? {} + : { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation })} {...(draftId ? { onWorktreeSetupWorkLocally } : {})} {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5b8b7688953a..6d3432f3ddb9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -30,6 +30,7 @@ import { reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveProjectStatusIndicator, + resolveSidebarSweepKeys, resolveSidebarStageBadgeLabel, resolveSidebarThreadSection, resolveSidebarRowAccessibility, @@ -2014,6 +2015,22 @@ describe("sortPinnedThreadsForSidebar", () => { }); }); +describe("resolveSidebarSweepKeys", () => { + const ordered = ["a", "b", "c", "d", "blocked"]; + const canSettle = (key: string) => key !== "blocked"; + + it("covers every row between the pressed row and the pointer, in either direction", () => { + expect(resolveSidebarSweepKeys(ordered, "b", "b", canSettle)).toEqual(["b"]); + expect(resolveSidebarSweepKeys(ordered, "b", "d", canSettle)).toEqual(["b", "c", "d"]); + expect(resolveSidebarSweepKeys(ordered, "d", "a", canSettle)).toEqual(["a", "b", "c", "d"]); + }); + + it("leaves out rows that cannot settle and rows that left the list", () => { + expect(resolveSidebarSweepKeys(ordered, "c", "blocked", canSettle)).toEqual(["c", "d"]); + expect(resolveSidebarSweepKeys(ordered, "gone", "a", canSettle)).toEqual([]); + }); +}); + describe("navigation after parking a thread", () => { it.each([ ["settle", "settled", null, "thread", true], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5dd6444ad8fb..3bd900ac4c67 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -274,6 +274,34 @@ export function resolveSidebarDropVerb( return "wake"; } +/** Eligible rows between the pressed action and the pointer, in sidebar order. */ +export function resolveSidebarSweepKeys( + orderedKeys: readonly string[], + originKey: string, + targetKey: string, + canApply: (key: string) => boolean, +): string[] { + const origin = orderedKeys.indexOf(originKey); + const target = orderedKeys.indexOf(targetKey); + if (origin === -1 || target === -1) return []; + return orderedKeys.slice(Math.min(origin, target), Math.max(origin, target) + 1).filter(canApply); +} + +/** The thread row at a pointer height, clamped to the rows visible in the + sidebar's scroll viewport. A gap between rows resolves to the row above + it. Rows carry their key in data-thread-item, which departing motion + clones drop. */ +export function sidebarThreadKeyAtY(list: HTMLElement, y: number): string | null { + const viewport = list.closest('[data-slot="scroll-area-viewport"]')?.getBoundingClientRect(); + const visibleY = viewport ? Math.min(Math.max(y, viewport.top), viewport.bottom - 1) : y; + let key: string | null = null; + for (const row of list.querySelectorAll("li[data-thread-item]")) { + if (key !== null && row.getBoundingClientRect().top > visibleY) break; + key = row.dataset.threadItem ?? null; + } + return key; +} + export function planSidebarThreadDrop(input: { readonly activeKey: string; readonly activeSection: SidebarSection; diff --git a/apps/web/src/components/Sidebar.pointer.test.ts b/apps/web/src/components/Sidebar.pointer.test.ts index a70486673b64..3a54498c2304 100644 --- a/apps/web/src/components/Sidebar.pointer.test.ts +++ b/apps/web/src/components/Sidebar.pointer.test.ts @@ -80,6 +80,13 @@ describe("sidebar pointer lifecycle", () => { expect(drag.onFinish).toHaveBeenCalledOnce(); }); + it("moves to the pointer on the move that starts the drag", () => { + const drag = gesture(); + document.dispatchEvent(pointer("pointermove", { clientY: 90 })); + expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 }); + expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 90 }); + }); + const interruptions = { blur: () => window.dispatchEvent(new Event("blur")), hidden: () => { @@ -122,10 +129,11 @@ describe("sidebar pointer lifecycle", () => { const drag = gesture({ onMove, onDrop }); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 })); - expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 }); + expect(drag.onMove).toHaveBeenNthCalledWith(1, { x: 10, y: 20 }); + expect(drag.onMove).toHaveBeenNthCalledWith(2, { x: 30, y: 30 }); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); // Outside the list the sort no longer sees the pointer. - expect(drag.onMove).toHaveBeenCalledOnce(); + expect(drag.onMove).toHaveBeenCalledTimes(2); document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 })); expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 90, y: 30 }); expect(drag.onCancel).toHaveBeenCalledOnce(); @@ -148,7 +156,8 @@ describe("sidebar pointer lifecycle", () => { document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 })); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 100 })); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 200 })); - expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 }); + expect(drag.onMove).toHaveBeenCalledTimes(2); + expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 30 }); document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 60 })); expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 60 }); diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index e2294b37ba0c..5ae83901ed1e 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -34,7 +34,9 @@ export class SidebarPointerSensor { private readonly document: Document; private readonly window: Window; - constructor(private readonly props: SensorProps) { + // The settle sweep constructs this sensor directly, outside dnd-kit, so it + // takes only the props the gesture reads. + constructor(private readonly props: Omit, "activeNode" | "context">) { this.pointer = props.event as PointerEvent; this.document = getOwnerDocument(this.pointer.target); this.window = getWindow(this.pointer.target); @@ -89,8 +91,9 @@ export class SidebarPointerSensor { this.document.addEventListener("selectionchange", this.clearSelection); this.clearSelection(); this.props.onStart(this.coordinates()); - return; } + // The move that starts a drag also moves it, so a release before the + // next pointermove still lands where the pointer is. if (this.phase === "dragging") { if (event.cancelable) event.preventDefault(); if (this.props.options.onMove?.(coordinates) === true) return; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 493e47d6ceb6..bef96b686b81 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -92,6 +92,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -188,6 +189,7 @@ import { planSidebarThreadDrop, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, + resolveSidebarSweepKeys, resolveSidebarDropTarget, resolveSidebarDropVerb, resolveSidebarThreadSection, @@ -202,6 +204,7 @@ import { resolveWorkingStartedAt, sidebarListItemId, sidebarMarkerId, + sidebarThreadKeyAtY, sortInboxThreadsByReturn, sortPinnedThreadsForSidebar, sortSidebarV2ProjectGroups, @@ -708,6 +711,12 @@ function SidebarSectionPlaceholder(props: { ); } +// Pointer travel before a press on a row starts a drag, or a press on its +// action button starts a sweep. Shorter presses stay clicks. +const SIDEBAR_DRAG_DISTANCE = 6; + +type SidebarSweepAction = "settle" | "unsettle" | "unsnooze"; + // Zero-height markers reserve no label space at rest. During a drag the // sorting strategy opens 24px for a 16px label with 4px clearance on each side. const SIDEBAR_DRAG_LABEL_HEIGHT = 24; @@ -1066,9 +1075,8 @@ const dropVerbBadge: Record = { const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; - // Slim rows are either settled (action: un-settle) or merely quiet - // (seen Ready threads — action: settle). - variantAction: "settle" | "unsettle" | "unsnooze"; + // Settled rows un-settle, snoozed rows wake, and cards settle. + variantAction: SidebarSweepAction; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; @@ -1092,6 +1100,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // the pinned section. Any other position shows the verb badge instead, and // the badge carries its own icon. dragOverPinned: boolean; + // The action this row will take when the sweep is released. + sweepAction: SidebarSweepAction | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1117,6 +1127,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; + onActionSweepStart: ( + threadRef: ScopedThreadRef, + action: SidebarSweepAction, + event: PointerEvent, + ) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; @@ -1140,6 +1155,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onFileDropThreads, onRenameTitleChange, onSettle, + onActionSweepStart, onSnooze, onStartRename, onThreadActivate, @@ -1435,6 +1451,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onSettle, threadRef], ); + const handleActionPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (!event.isPrimary || event.button !== 0) return; + // Action buttons sweep their section rather than picking up the row. + event.stopPropagation(); + onActionSweepStart(threadRef, variantAction, event.nativeEvent); + }, + [onActionSweepStart, threadRef, variantAction], + ); const handleUnsettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -1512,7 +1537,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" - : isSelected + : isSelected || props.sweepAction !== null ? "bg-sidebar-row-selected text-sidebar-foreground" : hasUnsentDraft ? cn(draftSurfaceClassName, "text-sidebar-foreground") @@ -1552,13 +1577,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ...sortable.listeners, } : {}; + // Sweeps reuse the corresponding row-drop action badge. + const destinationVerb = sortable?.isDragging + ? props.dropVerb + : props.sweepAction === "unsnooze" + ? "wake" + : props.sweepAction; const dragDestination = - sortable?.isDragging && props.dropVerb !== null ? ( + destinationVerb !== null ? ( - {dropVerbBadge[props.dropVerb]} + {dropVerbBadge[destinationVerb]} ) : null; @@ -1703,7 +1734,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { if (variant === "slim") { return (
  • + )} + {props.sweepAction !== null ? dragDestination : null} {props.jumpLabel ? : null} {detailsTooltip} @@ -1859,7 +1899,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • + {/* Read-only status labels yield to the hover actions. Woke is itself an action, so it stays pointer-enabled and visible while the other controls appear beside it. */} @@ -2026,6 +2071,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} + onPointerDown={handleActionPointerDown} className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" /> } @@ -2040,6 +2086,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} )} + {/* A sweep hides the slot rather than unmounting it, so the + pressed action button stays connected and a cancelled sweep's + release click still fires and is consumed. */} + {props.sweepAction !== null ? dragDestination : null}
    {title} @@ -3288,6 +3338,22 @@ export default function Sidebar() { }, [planForwardNavigation, settleThread], ); + // Post-settle navigation must skip threads settling in this same batch — + // they are all leaving the card block together. Rows that are already + // explicitly settled are skipped: nothing to do on a valid mixed selection. + // Pinned rows ARE included: the decider clears the pin as part of settling, + // so they park like the rest. + const settleThreads = useCallback( + (threadKeys: readonly string[]) => { + const coSettlingKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread || thread.settledOverride === "settled") continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); + } + }, + [attemptSettle], + ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { @@ -3414,7 +3480,7 @@ export default function Sidebar() { ); const dndSensors = useSensors( useSensor(SidebarPointerSensor, { - distance: 6, + distance: SIDEBAR_DRAG_DISTANCE, onAttach: attachDragSensor, onFinish: finishThreadDrag, onMove: moveThreadContextDrag, @@ -3435,6 +3501,77 @@ export default function Sidebar() { add(settledThreads, "settled"); return map; }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]); + const sectionByThreadKeyRef = useRef(sectionByThreadKey); + sectionByThreadKeyRef.current = sectionByThreadKey; + // Drag a row action to apply it to the armed rows in the same section. + // Staying in one section keeps an overshoot from changing neighboring + // sections, whose boundaries may be hidden outside a row drag. The sweep + // runs on the row drag's sensor and ref, so unmounting the list cancels it. + const [actionSweep, setActionSweep] = useState<{ + action: SidebarSweepAction; + keys: ReadonlySet; + } | null>(null); + const startActionSweep = useCallback( + (threadRef: ScopedThreadRef, action: SidebarSweepAction, event: PointerEvent) => { + const originKey = scopedThreadKey(threadRef); + const originSection = sectionByThreadKeyRef.current.get(originKey); + const canApply = (key: string) => { + const thread = threadByKeyRef.current.get(key); + const capabilities = + thread && serverConfigs.get(thread.environmentId)?.environment.capabilities; + return ( + thread !== undefined && + sectionByThreadKeyRef.current.get(key) === originSection && + (action === "unsnooze" + ? capabilities?.threadSnooze === true + : capabilities?.threadSettlement === true) + ); + }; + let targetKey: string | null = null; + let sweptKeys: string[] = []; + const sweepTo = (key: string | null) => { + if (key === null || key === targetKey) return; + targetKey = key; + sweptKeys = resolveSidebarSweepKeys(orderedThreadKeysRef.current, originKey, key, canApply); + setActionSweep({ action, keys: new Set(sweptKeys) }); + }; + dragSensorRef.current = new SidebarPointerSensor({ + active: originKey, + event, + options: { + distance: SIDEBAR_DRAG_DISTANCE, + onAttach: () => {}, + onFinish: () => { + dragSensorRef.current = null; + setActionSweep(null); + }, + }, + onPending: () => {}, + onStart: () => sweepTo(originKey), + onMove: ({ y }) => + sweepTo(threadListRef.current && sidebarThreadKeyAtY(threadListRef.current, y)), + // Also runs after a press that never moved. Nothing is swept then, + // and the button's own click applies its action. Rows that changed + // section mid-gesture, say pinned from another device, are skipped. + onEnd: () => { + const keys = sweptKeys.filter(canApply); + if (action === "settle") { + settleThreads(keys); + return; + } + for (const key of keys) { + const ref = parseScopedThreadKey(key); + if (ref === null) continue; + if (action === "unsettle") attemptUnsettle(ref); + else attemptUnsnooze(ref); + } + }, + onCancel: () => {}, + onAbort: () => {}, + }); + }, + [attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads], + ); const pinnedKeys = useMemo( () => pinnedThreads.map((thread) => @@ -4191,17 +4328,7 @@ export default function Sidebar() { return; } if (clicked.value === "settle") { - // Post-settle navigation must skip threads settling in this same - // batch — they are all leaving the card block together. Rows that - // are already explicitly settled are skipped: nothing to do on a - // valid mixed selection. Pinned rows ARE included: the decider - // clears the pin as part of settling, so they park like the rest. - const coSettlingKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.settledOverride === "settled") continue; - attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); - } + settleThreads(threadKeys); clearSelection(); return; } @@ -4254,7 +4381,6 @@ export default function Sidebar() { ); }, [ - attemptSettle, attemptUnpin, clearSelection, confirmThreadDelete, @@ -4263,6 +4389,7 @@ export default function Sidebar() { performSnooze, removeFromSelection, serverConfigs, + settleThreads, updateThreadMetadata, timestampFormat, ], @@ -4992,6 +5119,10 @@ export default function Sidebar() { className={cn( "relative flex flex-col gap-px", sidebarListItems.length > 0 && "flex-1", + // An action sweep owns the pointer: rows it passes over + // neither show hover actions nor open tooltips, even + // controls that opt back in, like the Woke pill. + actionSweep !== null && "**:pointer-events-none", )} > {(() => { @@ -5048,6 +5179,9 @@ export default function Sidebar() { dragOverPinned={ dragState?.activeKey === threadKey && dragTargetSection === "pinned" } + sweepAction={ + actionSweep?.keys.has(threadKey) ? actionSweep.action : null + } snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null ? snoozeWakeLabel(thread.snoozedUntil, { @@ -5096,6 +5230,7 @@ export default function Sidebar() { renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} onContextMenu={handleThreadContextMenu} onSettle={attemptSettle} + onActionSweepStart={startActionSweep} onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ba6e6800db59..3b80e65bb285 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -18,6 +18,7 @@ import { } from "./timelineMinimapItems"; import { COMPOSER_CONTEXT_KINDS, + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type AssistantCitation, type EnvironmentId, type MessageId, @@ -139,6 +140,7 @@ import { WrenchIcon, XIcon, ZapIcon, + RotateCcwIcon, } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide"; import type { @@ -325,6 +327,8 @@ interface TimelineRowSharedState { onToggleWorkGroup: (groupId: string, anchorKey: string) => void; onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; onCancelWorktreeSetup: (() => void) | null; + retryableWorkspacePreparationRunIds: ReadonlySet; + onRetryWorkspacePreparation: ((runId: RunId) => void) | null; onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; workGroupViewState: WorkGroupViewState; @@ -383,6 +387,7 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { }, } as const satisfies MaintainScrollAtEndOptions; const EMPTY_TIMELINE_RUNS: ReadonlyArray = []; +const EMPTY_RUN_IDS: ReadonlySet = new Set(); // Streamed text lands a paragraph at a time. A smooth scroll to the end // turns each landing into a short glide instead of a jump. Thread switches // and layout settles keep the instant variant so nothing visibly travels. @@ -416,6 +421,9 @@ interface MessagesTimelineProps { activeTurnStartedAt?: string | null; worktreeSetup?: WorktreeSetupSnapshot | null; onCancelWorktreeSetup?: () => void; + /** Runs whose failed workspace preparation can be retried, keyed by run id. */ + retryableWorkspacePreparationRunIds?: ReadonlySet; + onRetryWorkspacePreparation?: (runId: RunId) => void; onWorktreeSetupWorkLocally?: () => void; onOpenWorktreeSetupTerminal?: (terminalId: string) => void; isPreparingWorktree?: boolean; @@ -499,6 +507,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeTurnStartedAt = null, worktreeSetup = null, onCancelWorktreeSetup, + retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS, + onRetryWorkspacePreparation, onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, isPreparingWorktree = false, @@ -1164,6 +1174,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onToggleWorkGroup, onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, + retryableWorkspacePreparationRunIds, + onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null, onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, workGroupViewState, @@ -1197,6 +1209,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onToggleWorkGroup, suspendEndScrollMaintenanceForDisclosure, onCancelWorktreeSetup, + retryableWorkspacePreparationRunIds, + onRetryWorkspacePreparation, onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, workGroupViewState, @@ -4980,6 +4994,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { const { workEntry, workspaceRoot, displayLabel } = props; const ctx = use(TimelineRowCtx); const { threadRef, onImageExpand, timestampFormat } = ctx; + const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx; const createdThread = workEntry.projectedItem?.item.type === "thread_created" ? workEntry.projectedItem.item @@ -5011,6 +5026,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { const label = warning ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}` : workEntry.label; + const retryRunId = + failureItem.runId !== null && + retryableWorkspacePreparationRunIds.has(failureItem.runId) && + failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE + ? failureItem.runId + : null; return ( ) : null} + {retryRunId !== null && onRetryWorkspacePreparation ? ( +
    + +
    + ) : null}
    ); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index cedcda74e6f9..40f24c9bf3ba 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -303,14 +303,17 @@ describe("KeybindingsSettings.logic", () => { "chat.new", "threadPanel.toggle", "rightPanel.toggleMaximized", + "composer.cycleHost", "thread.stop", "usage.open", "script.setup-db.run", ]), ); - expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === "thread.stop")).toBe( - false, - ); + for (const command of ["thread.stop", "composer.cycleHost"]) { + expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === command)).toBe( + false, + ); + } }); it("reports unknown when variables without rejecting parseable expressions", () => { diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts index 0d25f3385855..52fdf39cc522 100644 --- a/apps/web/src/hooks/useScratchProject.ts +++ b/apps/web/src/hooks/useScratchProject.ts @@ -1,4 +1,5 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -8,17 +9,16 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback } from "react"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -import { waitForProject } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { projectEnvironment } from "~/state/projects"; import { useAtomCommand } from "~/state/use-atom-command"; import { useNewThreadHandler } from "./useHandleNewThread"; -function reportScratchFailure(error: unknown) { +function reportScratchFailure(title: string, error: unknown) { toastManager.add( stackedThreadToast({ type: "error", - title: "Could not start without a project", + title, description: error instanceof Error ? error.message : "An error occurred.", }), ); @@ -32,16 +32,17 @@ function reportScratchFailure(error: unknown) { */ export function useScratchProject() { const { environments } = useEnvironments(); - const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false }); + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); const handleNewThread = useNewThreadHandler(); /** The scratch folder of a connected environment, or null when it offers none. */ const scratchWorkspaceRootFor = useCallback( (environmentId: EnvironmentId | null): string | null => { const environment = environments.find((entry) => entry.environmentId === environmentId); - return environment?.connection.phase === "connected" - ? (environment.serverConfig?.scratchWorkspaceRoot ?? null) - : null; + return availableScratchWorkspaceRoot( + environment?.connection.phase, + environment?.serverConfig, + ); }, [environments], ); @@ -62,24 +63,18 @@ export function useScratchProject() { /** Resolves to the scratch project once it is in this client's store. */ const openScratchProject = useCallback( - async (environmentId: EnvironmentId): Promise => { - const result = await ensureScratch({ environmentId, input: {} }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - reportScratchFailure(squashAtomCommandFailure(result)); - } - return null; + async ( + environmentId: EnvironmentId, + failureTitle = "Could not start without a project", + ): Promise => { + const result = await openScratch({ environmentId, input: {} }); + if (result._tag === "Success") return result.value; + if (!isAtomCommandInterrupted(result)) { + reportScratchFailure(failureTitle, squashAtomCommandFailure(result)); } - // Drafts key off the project's stored path and settings, so wait for - // the create event to reach the store before targeting one. - return waitForProject(scopeProjectRef(environmentId, result.value.projectId)).catch( - (error: unknown) => { - reportScratchFailure(error); - return null; - }, - ); + return null; }, - [ensureScratch], + [openScratch], ); const startScratchThread = useCallback( @@ -87,7 +82,7 @@ export function useScratchProject() { const project = await openScratchProject(environmentId); if (project) { await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch( - reportScratchFailure, + (error: unknown) => reportScratchFailure("Could not start without a project", error), ); } }, diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index d4e1098a364e..d5a07058a80b 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -7,7 +7,13 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; -export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +export const environmentProjects = createEnvironmentProjectAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { + projectAtom: environmentProjects.projectAtom, +}); /** * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile * surface, so the atom family lives here instead of the shared client-runtime @@ -19,7 +25,3 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti staleTimeMs: 5_000, idleTtlMs: 60_000, }); -export const environmentProjects = createEnvironmentProjectAtoms({ - catalogValueAtom: environmentCatalog.catalogValueAtom, - snapshotAtom: environmentSnapshotAtom, -}); diff --git a/docs/fork/0025-a-test-run-leaves-no-processes-behind.md b/docs/fork/0025-a-test-run-leaves-no-processes-behind.md deleted file mode 100644 index 7d348f5e0b60..000000000000 --- a/docs/fork/0025-a-test-run-leaves-no-processes-behind.md +++ /dev/null @@ -1,47 +0,0 @@ -# 0025: A test run leaves no processes behind - -- PR: [TrogonStack/t3code#57](https://github.com/TrogonStack/t3code/pull/57) -- Status: active - -## What you can do now - -- Run the suite as often as you like without the machine accumulating - stranded processes. What the tests start, the tests take with them when - they go. -- Trust your process list again. A stray provider process in it now means - something is genuinely running, not that you ran the tests last Tuesday. -- Leave a long-lived machine running the suite on a loop without it slowly - filling with residue that only a reboot clears. - -## Why - -The leak was silent in the way that matters: nothing failed, nothing was -logged, and every run reported green. The cost accrued outside the test -report entirely, one process per run, each holding its memory and its file -handles for as long as the machine stayed up. Found in the wild, the oldest -survivors were days old and their temporary directories had long since been -deleted out from under them. - -Silence is what makes it worth fixing rather than living with. A test that -fails gets attention on the spot. A test that quietly leaves something -behind gets attention weeks later, from whoever is wondering why a -workstation is sluggish, and by then the connection back to the suite is -gone. The processes are idle, so nothing in the usual places points at them. - -It also erodes a thing the suite is supposed to be good for. Some of these -tests exist to check that provider processes are started and cleaned up -correctly, and a harness that strands its own children is poorly placed to -make claims about cleanup. - -## Upstream considerations - -A clean upstream submission. This carries no fork-specific intent, fixes -upstream's own tests, and changes no product behavior, so there is nothing -here upstream would want to weigh. Once an equivalent lands there, this -entry goes. - -Worth flagging for a sync rather than a rebase: the divergence lives -entirely in test fixtures, so a sync that takes upstream's copy of either -file reintroduces the leak without anything going red. The suite passes -either way, and the symptom shows up only in the process list on whatever -machine ran it. diff --git a/docs/fork/README.md b/docs/fork/README.md index 403911e0167c..5b8030910558 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -41,7 +41,5 @@ Each entry uses these sections: active, [#28](https://github.com/TrogonStack/t3code/pull/28) - **0024** [A refused merge says why, and an administrator can merge anyway](./0024-a-refused-merge-says-why.md) active, [#38](https://github.com/TrogonStack/t3code/pull/38) -- **0025** [A test run leaves no processes behind](./0025-a-test-run-leaves-no-processes-behind.md) - active, [#57](https://github.com/TrogonStack/t3code/pull/57) - **0026** [Telemetry says which app sent it](./0026-telemetry-says-which-app-sent-it.md) active, [#68](https://github.com/TrogonStack/t3code/pull/68) diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 9f1ef0626fdd..e3773bc3adbc 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -22,6 +22,8 @@ When an active turn has queued messages, `mod+Shift+Enter` sends the first as a steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings. Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. +To step a new thread to the next machine instead of opening the menu, bind +**Composer: Cycle Host** in Keybindings. It has no default shortcut. Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the 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 choose directly. Escape or `Cmd+K` closes the palette. Start a search with `>` to show only actions. +In a new thread, `Cmd+Shift+H` moves the draft to the next machine. + In the composer, Return sends and `Shift+Return` inserts a new line. `Cmd+Return` also sends. To make Return insert a new line instead, change the Return key behavior in Settings → Keyboard. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 6ce2901f4545..a4d11342a442 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -19,7 +19,9 @@ A thread does not need a project. To start one without a project, click **or start without a project** under a new thread's heading, pick **No project** from the project menu in that heading or from **New thread in...** in the command palette, or press `mod+alt+n`. On mobile, pick **No project** from the project -list. To move a draft into a project, pick the project in the heading. +list. It starts on your current machine; before sending, pick another machine +from the machine menu to move it there. To move a draft into a project, pick the +project in the heading. Each thread without a project works in its own folder under `~/.t3/scratch` (the `scratch` folder of your T3 data directory), named after its date, the first words @@ -143,6 +145,11 @@ sending an answer or restarting the agent. Settling also closes the thread's terminals that wait at an idle prompt, and keeps their output. A terminal that runs a command, such as a dev server, stays open. +On web and desktop, press a thread's **Settle** button and drag up or down to +settle every thread in that section between it and the one you release on. +The **Un-settle** and **Wake** buttons work the same way in their sections. +Press `Escape` while dragging to cancel. + By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle thread. Work in progress, pending questions or approvals, and live background work diff --git a/packages/client-runtime/src/errors/transport.test.ts b/packages/client-runtime/src/errors/transport.test.ts index 692b3af4a51c..b60c90b2d55f 100644 --- a/packages/client-runtime/src/errors/transport.test.ts +++ b/packages/client-runtime/src/errors/transport.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; +import { NETWORK_BLOCKING_HINT } from "./network.ts"; import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transport.ts"; describe("isTransportConnectionErrorMessage", () => { @@ -30,6 +31,20 @@ describe("isTransportConnectionErrorMessage", () => { expect(isTransportConnectionErrorMessage("ClientProtocolError: socket closed")).toBe(true); }); + it("recognizes relay connection errors that carry the network hint", () => { + for (const sentence of [ + "Relay environment disconnected.", + "Relay environment could not establish a WebSocket connection.", + ]) { + expect(isTransportConnectionErrorMessage(`${sentence} ${NETWORK_BLOCKING_HINT}`)).toBe(true); + } + expect( + isTransportConnectionErrorMessage( + "Your ChatGPT connection expired or was disconnected. Sign in again.", + ), + ).toBe(false); + }); + it("returns true for the T3 server WebSocket message", () => { expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( true, diff --git a/packages/client-runtime/src/errors/transport.ts b/packages/client-runtime/src/errors/transport.ts index e21c5d4ecf57..a47c2257aca2 100644 --- a/packages/client-runtime/src/errors/transport.ts +++ b/packages/client-runtime/src/errors/transport.ts @@ -1,11 +1,18 @@ +import { NETWORK_BLOCKING_HINT } from "./network.ts"; + +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const TRANSPORT_ERROR_PATTERNS = [ /\bSocketCloseError\b/i, /\bSocketOpenError\b/i, /\bSocket is not connected\b/i, /Unable to connect to the T3 server WebSocket\./i, - /\bis not connected\.$/i, - /\bdisconnected\.$/i, - /\bcould not establish a WebSocket connection\.$/i, + // The RPC session appends the network hint for relay connections. Any other + // trailing text means a different error that the user should still see. + new RegExp( + `\\b(?:is not connected|disconnected|could not establish a WebSocket connection)\\.(?: ${escapeRegExp(NETWORK_BLOCKING_HINT)})?$`, + "i", + ), /\bClientProtocolError\b/i, /\bRpcClientError\b/i, /\bping timeout\b/i, diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index c0cb8e67504c..bedf552f9b20 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -237,6 +237,10 @@ export interface CancelQueuedRunInput extends ThreadCommandInput { readonly runId: RunId; } +export interface RetryWorkspacePreparationInput extends ThreadCommandInput { + readonly runId: RunId; +} + export interface EditQueuedRunInput extends ThreadCommandInput { readonly runId: RunId; readonly text: string; @@ -982,6 +986,17 @@ export const cancelQueuedRun = Effect.fn("EnvironmentCommands.cancelQueuedRun")( }); }); +export const retryWorkspacePreparation = Effect.fn("EnvironmentCommands.retryWorkspacePreparation")( + function* (input: RetryWorkspacePreparationInput) { + return yield* dispatch({ + type: "prepared-run.retry", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + runId: input.runId, + }); + }, +); + export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(function* ( input: EditQueuedRunInput, ) { diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 505f99ad8e25..8dd937d3a68d 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -4,6 +4,7 @@ import type { EnvironmentId, ProjectMutation, ProjectId, + ServerConfig, SourceControlDiscoveryResult, SourceControlProviderKind, SourceControlRepositoryInfo, @@ -36,6 +37,19 @@ export function canCreateProjectInEnvironment( return connectionPhase === "connected"; } +/** + * The Scratch folder an environment offers threads without a project right + * now, or null while it is not connected or has none. + */ +export function availableScratchWorkspaceRoot( + connectionPhase: EnvironmentConnectionPhase | null | undefined, + serverConfig: Pick | null | undefined, +): string | null { + return canCreateProjectInEnvironment(connectionPhase) + ? (serverConfig?.scratchWorkspaceRoot ?? null) + : null; +} + export type AddProjectRemoteSourceReadiness = Record< AddProjectRemoteSource, { readonly ready: boolean; readonly hint: string | null } diff --git a/packages/client-runtime/src/state/projectCommands.test.ts b/packages/client-runtime/src/state/projectCommands.test.ts new file mode 100644 index 000000000000..4e1bc85ca6df --- /dev/null +++ b/packages/client-runtime/src/state/projectCommands.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ProjectId, type ScopedProjectRef, WS_METHODS } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { EnvironmentProject } from "./models.ts"; +import { createProjectEnvironmentAtoms } from "./projectCommands.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("scratch"); +const PROJECT = { + id: PROJECT_ID, + environmentId: ENVIRONMENT_ID, + workspaceRoot: "/scratch", +} as EnvironmentProject; + +const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: { environmentId: ENVIRONMENT_ID }, + session: yield* SubscriptionRef.make( + Option.some({ + client: { + [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }), + }, + } as unknown as RpcSession), + ), + } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { + run: (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + ), + ); + const storedProject = Atom.make(null); + const commands = createProjectEnvironmentAtoms(runtime, { + projectAtom: (ref: ScopedProjectRef) => + ref.environmentId === ENVIRONMENT_ID && ref.projectId === PROJECT_ID + ? storedProject + : Atom.make(null), + }); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const openScratch = Effect.promise(() => + commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }), + ); + return { registry, storedProject, openScratch }; +}); + +describe("openScratch", () => { + it.effect("resolves once the created project reaches the client store", () => + Effect.gen(function* () { + const { registry, storedProject, openScratch } = yield* makeHarness(); + const opening = yield* Effect.forkChild(openScratch); + yield* Effect.yieldNow; + registry.set(storedProject, PROJECT); + const result = yield* Fiber.join(opening); + expect(result).toMatchObject({ _tag: "Success", value: PROJECT }); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 5c39113501fb..261fee9db09e 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -1,7 +1,20 @@ -import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts"; +import { + type EnvironmentId, + ProjectId, + type ProjectReadFileResult, + type ScopedProjectRef, + WS_METHODS, +} from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; -import { Atom } from "effect/unstable/reactivity"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { type EnvironmentRpcInput, request } from "../rpc/client.ts"; +import type { EnvironmentProject } from "./models.ts"; import { createAtomCommandScheduler, createEnvironmentCommand, @@ -39,8 +52,22 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); } +/** The Scratch project was created, but its event never reached this client. */ +export class ScratchProjectNotLoadedError extends Schema.TaggedError()( + "ScratchProjectNotLoadedError", + { projectId: ProjectId }, +) { + override get message(): string { + return "The folder for threads without a project has not reached this device yet. Try again."; + } +} + export function createProjectEnvironmentAtoms( runtime: Atom.AtomRuntime, + options: { + /** The client store's project; openScratch waits here for the created project. */ + readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom; + }, ) { const projectScheduler = createAtomCommandScheduler(); const fileScheduler = createAtomCommandScheduler(); @@ -92,10 +119,31 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: projectConcurrency, }), - // Finds or creates the environment's Scratch project and returns its id. - ensureScratch: createEnvironmentRpcCommand(runtime, { - label: "environment-data:projects:ensure-scratch", - tag: WS_METHODS.projectsEnsureScratch, + // Finds or creates the environment's Scratch project and resolves once the + // project is in the client store, since drafts key off its stored path. + openScratch: createEnvironmentCommand(runtime, { + label: "environment-data:projects:open-scratch", + execute: ( + input: EnvironmentRpcInput, + registry, + environmentId, + ) => + request(WS_METHODS.projectsEnsureScratch, input).pipe( + Effect.flatMap(({ projectId }) => + AtomRegistry.toStream(registry, options.projectAtom({ environmentId, projectId })).pipe( + Stream.filter(Predicate.isNotNull), + Stream.runHead, + Effect.timeoutOption("10 seconds"), + Effect.map(Option.flatten), + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => Effect.fail(new ScratchProjectNotLoadedError({ projectId })), + }), + ), + ), + ), + ), scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index e3ad5c07f583..ed8cc1ec97cc 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -21,6 +21,7 @@ import { type ThreadCommandInput, type ArchiveThreadInput, type CancelQueuedRunInput, + type RetryWorkspacePreparationInput, type CreateThreadInput, type DeleteThreadInput, type EditQueuedRunInput, @@ -65,6 +66,7 @@ import { promoteQueuedRun, reorderQueuedRun, resumeThreadQueue, + retryWorkspacePreparation, linkThreadPullRequest, respondToThreadApproval, respondToThreadUserInput, @@ -357,6 +359,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + retryWorkspacePreparation: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:retry-workspace-preparation", + execute: (input: RetryWorkspacePreparationInput) => retryWorkspacePreparation(input), + scheduler, + concurrency, + }), editQueuedRun: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:edit-queued-run", execute: (input: EditQueuedRunInput) => editQueuedRun(input), diff --git a/packages/client-runtime/src/state/turnItemPresentation.test.ts b/packages/client-runtime/src/state/turnItemPresentation.test.ts index af2b9fcbe83b..bfccbe63cf5f 100644 --- a/packages/client-runtime/src/state/turnItemPresentation.test.ts +++ b/packages/client-runtime/src/state/turnItemPresentation.test.ts @@ -1,8 +1,18 @@ -import { RunId, ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2Run, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { turnItemIsWorkspacePreparation } from "./turnItemPresentation.ts"; +import { + turnItemIsWorkspacePreparation, + workspacePreparationRetryRunIds, +} from "./turnItemPresentation.ts"; function command(input: string): OrchestrationV2TurnItem { const now = DateTime.makeUnsafe("2026-08-03T00:00:00.000Z"); @@ -28,9 +38,67 @@ function command(input: string): OrchestrationV2TurnItem { }; } +function preparationFailure( + status: OrchestrationV2TurnItem["status"], + code: string | null = ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, +): OrchestrationV2TurnItem { + const { + input: _input, + output: _output, + exitCode: _exitCode, + ...base + } = command("") as Extract; + return { + ...base, + id: TurnItemId.make("item-error"), + status, + title: "Workspace preparation failed", + type: "error", + failure: { class: "validation_error", message: "fetch failed", code, retryable: false }, + }; +} + +const worktree = { type: "worktree", baseRef: "main" } as const; +function run( + status: OrchestrationV2Run["status"], + workspacePreparation?: typeof worktree, +): Pick { + return { + id: RunId.make("run-1"), + status, + ...(workspacePreparation === undefined ? {} : { workspacePreparation }), + }; +} + describe("turnItemIsWorkspacePreparation", () => { it("identifies the synthetic workspace preparation command", () => { expect(turnItemIsWorkspacePreparation(command("Preparing workspace"))).toBe(true); expect(turnItemIsWorkspacePreparation(command("prepare workspace"))).toBe(false); }); + + it("hides a preparation failure only once a retry cancelled it", () => { + expect(turnItemIsWorkspacePreparation(preparationFailure("failed"))).toBe(false); + expect(turnItemIsWorkspacePreparation(preparationFailure("cancelled"))).toBe(true); + expect(turnItemIsWorkspacePreparation(preparationFailure("cancelled", null))).toBe(false); + }); +}); + +describe("workspacePreparationRetryRunIds", () => { + it("offers a retry while the run still ends in its failed preparation", () => { + const failure = [preparationFailure("failed")]; + expect([...workspacePreparationRetryRunIds([run("failed", worktree)], failure)]).toEqual([ + RunId.make("run-1"), + ]); + // Retried: the run is preparing again and the old failure is cancelled. + expect(workspacePreparationRetryRunIds([run("preparing", worktree)], failure).size).toBe(0); + // An older server records no preparation, so there is nothing to repeat. + expect(workspacePreparationRetryRunIds([run("failed")], failure).size).toBe(0); + // A provider error on a run that did reach the provider is not a preparation failure. + expect( + workspacePreparationRetryRunIds( + [run("failed", worktree)], + [preparationFailure("failed", "provider_crashed")], + ).size, + ).toBe(0); + }); }); diff --git a/packages/client-runtime/src/state/turnItemPresentation.ts b/packages/client-runtime/src/state/turnItemPresentation.ts index fe306c92ae34..5268aee52321 100644 --- a/packages/client-runtime/src/state/turnItemPresentation.ts +++ b/packages/client-runtime/src/state/turnItemPresentation.ts @@ -1,8 +1,49 @@ -import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + type OrchestrationV2Run, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; -/** Workspace setup is client bookkeeping; preparation failures have their own error item. */ +/** + * Workspace setup is client bookkeeping; preparation failures have their own + * error item. A retry cancels that item, which then has nothing left to say. + */ export function turnItemIsWorkspacePreparation(item: OrchestrationV2TurnItem): boolean { - return item.type === "command_execution" && item.input === WORKSPACE_PREPARATION_INPUT; + return ( + (item.type === "command_execution" && item.input === WORKSPACE_PREPARATION_INPUT) || + (item.type === "error" && + item.status === "cancelled" && + item.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE) + ); +} + +/** + * Runs a Retry can prepare again: their workspace preparation failed and the + * run still ended there. Older servers record no preparation on the run, so + * they never offer it. + */ +export function workspacePreparationRetryRunIds( + runs: ReadonlyArray>, + items: ReadonlyArray, +): ReadonlySet { + const failedRuns = new Set( + runs.flatMap((run) => + run.status === "failed" && run.workspacePreparation !== undefined ? [run.id] : [], + ), + ); + const retryable = new Set(); + if (failedRuns.size === 0) return retryable; + for (const item of items) { + if ( + item.type === "error" && + item.status === "failed" && + item.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE && + item.runId !== null && + failedRuns.has(item.runId) + ) + retryable.add(item.runId); + } + return retryable; } diff --git a/packages/client-runtime/src/voice-input/controller.test.ts b/packages/client-runtime/src/voice-input/controller.test.ts index 5f26b882b694..d73c8e7505ba 100644 --- a/packages/client-runtime/src/voice-input/controller.test.ts +++ b/packages/client-runtime/src/voice-input/controller.test.ts @@ -298,11 +298,21 @@ describe("VoiceInputController", () => { }); it.each(["cancel", "dispose", "ownerChanged"] as const)( - "holds the session after %s until non-abortable transcription settles", + "queues the next recording after %s until transcription and audio cleanup settle", async (action) => { const transcription = deferred(); const transcriptionEntered = deferred(); + const audioRelease = deferred(); + const audioReleaseEntered = deferred(); + const releaseRecording = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("busy")) + .mockImplementationOnce(() => { + audioReleaseEntered.resolve(undefined); + return audioRelease.promise; + }); const harness = createHarness({ + releaseRecording, getTranscriber: () => ({ prepare: async () => preparedTranscription((_uri, { signal }) => { @@ -321,20 +331,31 @@ describe("VoiceInputController", () => { harness.controller[action](); expect(signal.aborted).toBe(true); - const next = createHarness(); - await next.controller.start(); - expect(next.controller.currentState.error).toContain("already active"); + const prepare = vi.fn(async () => preparedTranscription()); + const next = createHarness({ getTranscriber: () => ({ prepare }) }); + const nextStart = next.controller.start(); + expect(next.controller.currentState).toEqual({ + phase: "preparing", + error: null, + errorAction: null, + }); + expect(prepare).not.toHaveBeenCalled(); expect(next.recorder.record).not.toHaveBeenCalled(); transcription.resolve("late text"); + await audioReleaseEntered.promise; + expect(prepare).not.toHaveBeenCalled(); + expect(next.recorder.prepareToRecordAsync).not.toHaveBeenCalled(); + audioRelease.resolve(undefined); await stopping; + await nextStart; expect(harness.commits).toEqual([]); expect(harness.deleted).toEqual(["file:///voice.m4a"]); expect(harness.controller.currentState.phase).toBe("idle"); - await next.controller.start(); expect(next.controller.currentState.phase).toBe("recording"); + expect(prepare).toHaveBeenCalledTimes(1); await next.controller.interruptRecording(); }, ); @@ -431,7 +452,7 @@ describe("VoiceInputController", () => { expect(harness.controller.currentState.error).toContain("draft changed"); }); - it("keeps the app-wide session locked until canceled preparation settles", async () => { + it("queues the next recording until canceled preparation settles", async () => { const preparation = deferred(); const preparationEntered = deferred(); const first = createHarness({ @@ -447,21 +468,105 @@ describe("VoiceInputController", () => { first.controller.cancel(); expect(signal.aborted).toBe(true); - const blocked = createHarness(); - await blocked.controller.start(); - expect(blocked.controller.currentState.error).toContain("already active"); + const next = createHarness(); + const nextStart = next.controller.start(); + expect(next.controller.currentState.phase).toBe("preparing"); + expect(next.recorder.prepareToRecordAsync).not.toHaveBeenCalled(); preparation.resolve(preparedTranscription()); await firstStart; expect(first.recorder.record).not.toHaveBeenCalled(); - blocked.controller.cancel(); + await nextStart; + expect(next.controller.currentState.phase).toBe("recording"); + await next.controller.interruptRecording(); + }); + + it("still rejects a second start while the first recording is active", async () => { + const first = createHarness(); + await first.controller.start(); + const next = createHarness(); + await next.controller.start(); + expect(next.controller.currentState.error).toContain("already active"); + expect(next.recorder.record).not.toHaveBeenCalled(); + await first.controller.interruptRecording(); + }); + + it("does not start a canceled waiter or let it release another recording", async () => { + const preparation = deferred(); + const preparationEntered = deferred(); + const first = createHarness({ + getTranscriber: () => ({ + prepare: () => { + preparationEntered.resolve(undefined); + return preparation.promise; + }, + }), + }); + const firstStart = first.controller.start(); + await preparationEntered.promise; + first.controller.dispose(); + + const next = createHarness(); + const canceledStart = next.controller.start(); + next.controller.cancel(); + const nextStart = next.controller.start(); + preparation.resolve(preparedTranscription()); + await Promise.all([firstStart, canceledStart, nextStart]); + + expect(next.recorder.record).toHaveBeenCalledTimes(1); + expect(next.deleted).toEqual([]); + const blocked = createHarness(); + await blocked.controller.start(); + expect(blocked.controller.currentState.error).toContain("already active"); + await next.controller.interruptRecording(); + }); + it("releases an abandoned recording even if its native recorder was already disposed", async () => { + const harness = createHarness(); + await harness.controller.start(); + harness.recorder.stop.mockRejectedValueOnce(new Error("recorder released")); + Object.defineProperty(harness.recorder, "uri", { + get() { + throw new Error("recorder released"); + }, + }); + + await expect(harness.controller.interruptRecording()).resolves.toBeUndefined(); + expect(harness.deleted).toEqual(["file:///voice.m4a"]); const next = createHarness(); await next.controller.start(); expect(next.controller.currentState.phase).toBe("recording"); await next.controller.interruptRecording(); }); + it("keeps the new abort controller when restarting the same composer after cancellation", async () => { + const transcription = deferred(); + const transcriptionEntered = deferred(); + const prepare = vi + .fn() + .mockResolvedValueOnce( + preparedTranscription(() => { + transcriptionEntered.resolve(undefined); + return transcription.promise; + }), + ) + .mockResolvedValue(preparedTranscription()); + const harness = createHarness({ getTranscriber: () => ({ prepare }) }); + await harness.controller.start(); + const stopping = harness.controller.stop(); + await transcriptionEntered.promise; + harness.controller.cancel(); + const restarting = harness.controller.start(); + transcription.resolve("late text"); + await Promise.all([stopping, restarting]); + await harness.controller.stop(); + + expect(harness.commits).toEqual([ + { text: "hello new text", selection: { start: 14, end: 14 } }, + ]); + expect(harness.controller.currentState.phase).toBe("idle"); + }); + it("does not start the microphone for an owner that changed during preparation", async () => { const preparation = deferred(); const preparationEntered = deferred(); diff --git a/packages/client-runtime/src/voice-input/controller.ts b/packages/client-runtime/src/voice-input/controller.ts index cb284ad9f85b..80a8a528f335 100644 --- a/packages/client-runtime/src/voice-input/controller.ts +++ b/packages/client-runtime/src/voice-input/controller.ts @@ -121,18 +121,28 @@ export function resolveTranscriptCommit( }; } -let activeSession: symbol | null = null; +type VoiceInputSession = { + abandoned: boolean; + readonly released: Promise; + readonly release: () => void; +}; + +let activeSession: VoiceInputSession | null = null; let activeTranscriptionOperation: Promise | null = null; -function acquireSession(): symbol | null { +function acquireSession(): VoiceInputSession | null { if (activeSession) return null; - const token = Symbol("voice-input-session"); + const { promise, resolve } = Promise.withResolvers(); + const token = { abandoned: false, released: promise, release: resolve }; activeSession = token; return token; } -function releaseSession(token: symbol | null): void { - if (token && activeSession === token) activeSession = null; +function releaseSession(token: VoiceInputSession | null): void { + if (token && activeSession === token) { + activeSession = null; + token.release(); + } } async function runTranscriptionOperation(operation: () => Promise): Promise { @@ -177,7 +187,7 @@ export class VoiceInputController { private readonly dependencies: VoiceInputControllerDependencies; private state: VoiceInputState = IDLE_STATE; private operationToken = 0; - private sessionToken: symbol | null = null; + private sessionToken: VoiceInputSession | null = null; private transcription: PreparedVoiceTranscription | null = null; private transcriptionAbortController: AbortController | null = null; private capturedDraft: VoiceDraftSnapshot | null = null; @@ -201,19 +211,28 @@ export class VoiceInputController { this.setError("This draft is no longer available.", "retry"); return; } - const sessionToken = acquireSession(); - if (!sessionToken) { - this.setError("Another voice recording is already active.", "retry"); - return; - } - - this.sessionToken = sessionToken; + let sessionToken: VoiceInputSession | null = null; const operationToken = ++this.operationToken; - const abortController = new AbortController(); - this.transcriptionAbortController = abortController; this.setState({ phase: "preparing", error: null, errorAction: null }); try { + for ( + let previousSession = activeSession; + previousSession?.abandoned; + previousSession = activeSession + ) { + await previousSession.released; + if (!this.isCurrent(operationToken)) return; + } + sessionToken = acquireSession(); + if (!sessionToken) { + this.setError("Another voice recording is already active.", "retry"); + return; + } + this.sessionToken = sessionToken; + const abortController = new AbortController(); + this.transcriptionAbortController = abortController; + const transcriber = this.dependencies.getTranscriber(); if (!transcriber) { this.setError("Voice transcription is not available.", null); @@ -260,10 +279,12 @@ export class VoiceInputController { if (this.isCurrent(operationToken)) this.setError("Could not start voice recording.", "retry"); } finally { - if (this.isCurrent(operationToken) && this.state.phase === "error") { - await this.releaseResources(); - } else if (!this.isCurrent(operationToken) && !this.finishing) { - await this.releaseResources(); + if (sessionToken && this.sessionToken === sessionToken) { + if (this.isCurrent(operationToken) && this.state.phase === "error") { + await this.releaseResources(); + } else if (!this.isCurrent(operationToken) && !this.finishing) { + await this.releaseResources(); + } } } } @@ -426,9 +447,7 @@ export class VoiceInputController { ); try { await this.dependencies.recorder.stop(); - this.rememberRecordingUri(this.dependencies.recorder.uri); } catch { - this.rememberRecordingUri(this.dependencies.recorder.uri); } finally { await this.releaseResources(); } @@ -436,7 +455,9 @@ export class VoiceInputController { private async releaseResources(): Promise { this.rememberRecordingUri(this.recordingUri); - this.rememberRecordingUri(this.dependencies.recorder.uri); + try { + this.rememberRecordingUri(this.dependencies.recorder.uri); + } catch {} this.recordingUri = null; for (const uri of this.ownedRecordingUris) { try { @@ -470,6 +491,7 @@ export class VoiceInputController { private invalidateOperation(): void { this.operationToken += 1; + if (this.sessionToken) this.sessionToken.abandoned = true; this.transcriptionAbortController?.abort(); } diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index e827ba8270fd..e42396dbc582 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -89,6 +89,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.sendBackground", "composer.sendAndNewThread", "composer.host", + "composer.cycleHost", "composer.effort", "composer.mode", "composer.workspace", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 6004ab119917..a2cdc71368eb 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -508,6 +508,29 @@ export const OrchestrationV2RunBackgroundWorkCancelled = Schema.Struct({ export type OrchestrationV2RunBackgroundWorkCancelled = typeof OrchestrationV2RunBackgroundWorkCancelled.Type; +export const OrchestrationV2ThreadLaunchWorkspaceStrategy = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("root"), + branch: Schema.optional(TrimmedNonEmptyString), + }), + Schema.Struct({ + type: Schema.Literal("existing_worktree"), + worktreePath: TrimmedNonEmptyString, + branch: Schema.optional(TrimmedNonEmptyString), + }), + Schema.Struct({ + type: Schema.Literal("worktree"), + baseRef: TrimmedNonEmptyString, + branch: Schema.optional(TrimmedNonEmptyString), + startFromOrigin: Schema.optional(Schema.Boolean), + }), +]); +export type OrchestrationV2ThreadLaunchWorkspaceStrategy = + typeof OrchestrationV2ThreadLaunchWorkspaceStrategy.Type; + +/** Failure code on the error item a failed workspace preparation leaves. */ +export const ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE = "workspace_preparation_failed"; + export const OrchestrationV2Run = Schema.Struct({ id: RunId, threadId: ThreadId, @@ -549,6 +572,8 @@ export const OrchestrationV2Run = Schema.Struct({ }), ), delegatedCompletion: Schema.optional(OrchestrationV2DelegatedCompletionCohort), + /** How a launch prepares this run's workspace; prepared-run.retry repeats it. */ + workspacePreparation: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), }); export type OrchestrationV2Run = typeof OrchestrationV2Run.Type; @@ -2691,7 +2716,10 @@ export const OrchestrationV2Command = Schema.Union([ }), ), dispatchMode: Schema.Union([ - Schema.Struct({ type: Schema.Literal("defer_start") }), + Schema.Struct({ + type: Schema.Literal("defer_start"), + workspaceStrategy: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), + }), Schema.Struct({ type: Schema.Literal("steer_active"), targetRunId: RunId }), Schema.Struct({ type: Schema.Literal("restart_active"), targetRunId: RunId }), Schema.Struct({ type: Schema.Literal("queue_after_active") }), @@ -2725,6 +2753,13 @@ export const OrchestrationV2Command = Schema.Union([ runId: RunId, failure: OrchestrationV2ProviderFailure, }), + /** Puts a run whose workspace preparation failed back into preparation. */ + Schema.Struct({ + type: Schema.Literal("prepared-run.retry"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + }), Schema.Struct({ type: Schema.Literal("run.interrupt"), commandId: CommandId, @@ -2961,26 +2996,6 @@ export const OrchestrationV2ArchivedShellStreamItem = Schema.Union([ export type OrchestrationV2ArchivedShellStreamItem = typeof OrchestrationV2ArchivedShellStreamItem.Type; -export const OrchestrationV2ThreadLaunchWorkspaceStrategy = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("root"), - branch: Schema.optional(TrimmedNonEmptyString), - }), - Schema.Struct({ - type: Schema.Literal("existing_worktree"), - worktreePath: TrimmedNonEmptyString, - branch: Schema.optional(TrimmedNonEmptyString), - }), - Schema.Struct({ - type: Schema.Literal("worktree"), - baseRef: TrimmedNonEmptyString, - branch: Schema.optional(TrimmedNonEmptyString), - startFromOrigin: Schema.optional(Schema.Boolean), - }), -]); -export type OrchestrationV2ThreadLaunchWorkspaceStrategy = - typeof OrchestrationV2ThreadLaunchWorkspaceStrategy.Type; - export const OrchestrationV2ThreadLaunchInput = Schema.Struct({ commandId: CommandId, creationSource: Schema.optional(OrchestrationV2CreationSource),