diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 8b444aa08f4f8..f3c30e5aa40bb 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,16 +1,17 @@ -import { PermissionUpdateNotice } from "./features/connection/PermissionUpdateNotice"; +import { installPermissionUpdateNotice } from "./features/connection/permissionUpdateNotice"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; -import { useEffect } from "react"; 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"; import { - createNavigationContainerRef, + CommonActions, + createPathConfigForStaticNavigation, createStaticNavigation, getActionFromState, getStateFromPath, + type NavigationState, } from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; @@ -19,22 +20,59 @@ import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet import * as Dialog from "./components/ui/Dialog"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; -import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; +import { installShowcaseCapture } from "./features/showcase/ShowcaseCaptureCoordinator"; +import { + presentPendingIncomingShare, + pruneIncomingShareImports, + startIncomingShareInbox, +} from "./features/sharing/IncomingShareProvider"; +import { + installAgentNotificationNavigation, + setAgentNotificationPathname, +} from "./features/agent-awareness/notificationNavigation"; +import { + installAppShortcuts, + recordAppShortcutNavigationState, +} from "./features/shortcuts/useAppShortcuts"; import { AppearancePreferencesProvider, + installAppearanceRuntime, useAppearancePreferences, } from "./features/settings/appearance/AppearancePreferencesProvider"; -import { RootStack } from "./Stack"; +import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./Stack"; +import { pruneNewTaskFlowStates } from "./features/threads/new-task-flow-state"; +import { pruneNewTaskBranchCheckouts } from "./features/threads/NewTaskDraftRouteScreen"; import { appAtomRegistry } from "./state/atom-registry"; import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts"; import { environmentThreadShells } from "./state/threads"; import { environmentShell } from "./state/shell"; import { OverlayPortalHost } from "./components/ui/OverlayPortal"; import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; +import { hasCloudPublicConfig } from "./features/cloud/publicConfig"; +import { startDevAutoConnect } from "./features/connection/devAutoConnect"; +import { + resolvePendingNewTaskPromptLink, + takeNewTaskPromptLink, +} from "./features/prompt-links/newTaskPromptLink"; +import { takeThreadPromptLink } from "./features/prompt-links/threadPromptLink"; +import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; -import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; -import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; -import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider"; +import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; +import { + attachRootNavigation, + flushRootNavigationQueue, + rootNavigationRef, + whenRootNavigationReady, +} from "./lib/rootNavigation"; +import { + disposeOnFoundationReplace, + type FoundationHotModule, +} from "./lib/foundation-fast-refresh"; +import { installSubscriptionUsageWidget } from "./widgets/SubscriptionUsageCoordinator"; +import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery"; +import { installModelPreferencesMigration } from "./state/model-preferences-migration"; +import { installAppUpdateChecks } from "./features/updates/app-updates"; +import { installComposerAttachmentUploads } from "./state/composer-attachment-uploads"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; import { @@ -55,10 +93,42 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { void SplashScreen.preventAutoHideAsync().catch(() => {}); void hydrateWorkspaceSelections(); +const linkingConfig = { + initialRouteName: "Home", + screens: createPathConfigForStaticNavigation(RootStack, { initialRouteName: "Home" }) ?? {}, +} as const; + +function isBlankRouteParam(value: unknown): boolean { + return typeof value !== "string" || value.trim().length === 0; +} + +function getAppStateFromPath(path: string, options: Parameters[1]) { + const state = takeScheduledTaskNewLink( + takeNewTaskPromptLink( + takeThreadPromptLink( + startDevAutoConnect( + restoreSettingsLinkState( + restoreDraftLinkState(getStateFromPath(path, options), path), + settingsLinkPage(), + ), + ), + ), + ), + ); + // A device preview needs a concrete thread; a blank segment would mount it + // with nothing to stream. + const blankDevicePreview = state?.routes.some((route) => { + if (route.name !== "ThreadDevicePreview") return false; + const params = route.params as { environmentId?: unknown; threadId?: unknown } | undefined; + return isBlankRouteParam(params?.environmentId) || isBlankRouteParam(params?.threadId); + }); + return blankDevicePreview ? undefined : state; +} + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], config: { initialRouteName: "Home" }, - filter: shouldHandleAppLink, + filter: (url: string) => shouldHandleAppLink(url, hasCloudPublicConfig()), getInitialURL: async () => (await Linking.getInitialURL()) ?? (await launchWorkspaceURL()), getActionFromState: ( state: Parameters[0], @@ -67,39 +137,69 @@ const appLinking = { // Our router handles the selection; React Navigation's type only lists its own actions. (workspaceDetailLinkAction(state) as ReturnType) ?? getActionFromState(state, options), - getStateFromPath: (path: string, options: Parameters[1]) => - restoreSettingsLinkState( - restoreDraftLinkState(getStateFromPath(path, options), path), - settingsLinkPage(), - ), + getStateFromPath: getAppStateFromPath, }; +/** `useLinkTo` for app services that live outside the navigator. */ +function linkToAppPath(href: string): void { + whenRootNavigationReady(() => { + const state = getAppStateFromPath(href, linkingConfig); + if (!state) throw new Error(`Failed to parse the href to a navigation state (${href}).`); + const action = + workspaceDetailLinkAction(state) ?? + getActionFromState(state, linkingConfig) ?? + CommonActions.reset(state); + rootNavigationRef.dispatch({ target: rootNavigationRef.getRootState()?.key, ...action }); + }); +} + +function handleNavigationStateChange(state: NavigationState | undefined): void { + if (!state) return; + recordWorkspaceSelections(state); + setAgentNotificationPathname(workspaceLocationFromState(state).pathname); + recordAppShortcutNavigationState(state); + pruneNewTaskFlowStates(state); + pruneNewTaskBranchCheckouts(state); + pruneIncomingShareImports(state); + presentPendingIncomingShare(); + resolvePendingNewTaskPromptLink(state); +} + +function handleNavigationReady(): void { + handleNavigationStateChange(rootNavigationRef.getRootState()); + flushRootNavigationQueue(); +} + const Navigation = createStaticNavigation(RootStack); -const navigationRef = createNavigationContainerRef(); -function SplashScreenCoordinator() { - const { isReady } = useAppearancePreferences(); +declare const module: { readonly hot?: FoundationHotModule } | undefined; - useEffect(() => { - if (isReady) void SplashScreen.hide(); - }, [isReady]); +const appServiceDisposers = [ + installUiRuntimeMemoryWarningGc(), + subscribeComposerDraftModelReconciliation({ + registry: appAtomRegistry, + threadShellAtom: environmentThreadShells.threadShellAtom, + threadRefsAtom: environmentThreadShells.threadRefsAtom, + shellStateAtom: environmentShell.stateAtom, + }), + installAppearanceRuntime(), + installSubscriptionUsageWidget(), + installThreadOutboxDelivery(), + installModelPreferencesMigration(), + installComposerAttachmentUploads(), + installPermissionUpdateNotice(), + startIncomingShareInbox(), + installAgentNotificationNavigation(linkToAppPath), + installAppShortcuts(linkToAppPath), + installShowcaseCapture(rootPathnameFromState), + installAppUpdateChecks(), +]; - return null; -} +disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => { + for (const dispose of appServiceDisposers) dispose(); +}); export default function App() { - useUiRuntimeMemoryWarningGc(); - useEffect( - () => - subscribeComposerDraftModelReconciliation({ - registry: appAtomRegistry, - threadShellAtom: environmentThreadShells.threadShellAtom, - threadRefsAtom: environmentThreadShells.threadRefsAtom, - shellStateAtom: environmentShell.stateAtom, - }), - [], - ); - return ( @@ -117,33 +217,26 @@ function AppContent() { return ( <> - - - - - - {/* native-stack needs the theme's dark flag to select dark native headers. */} - - - - recordWorkspaceSelections(navigationRef.getRootState())} - theme={navigationTheme} - onStateChange={recordWorkspaceSelections} - /> - - - - - - - - + + {/* native-stack needs the theme's dark flag to select dark native headers. */} + + + + + + + + + diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 8a07f8b7a0867..507e031e4e8d5 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,4 +1,7 @@ -import { isDraftThreadRoute } from "./features/threads/thread-route-state"; +import { + isDraftThreadRoute, + type NewTaskDraftRouteParams, +} from "./features/threads/thread-route-state"; import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen"; import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen"; import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; @@ -15,7 +18,7 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; -import { useEffect, useRef, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { Platform, Pressable, ScrollView, StyleSheet, useWindowDimensions } from "react-native"; import { useResolveClassNames } from "uniwind"; @@ -24,6 +27,11 @@ import { androidWorkspaceDestinationAnimation } from "./lib/android-workspace-na import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { WorkspaceConnectionStatusProvider } from "./features/home/WorkspaceConnectionTitle"; import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; +import { + recordHomeFocus, + recordHomeTransitionEnd, + recordHomeTransitionStart, +} from "./features/home/home-route-visibility"; import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; import { PullRequestRouteScreen, @@ -41,9 +49,7 @@ import { type RenderFailureProps, } from "./components/RenderErrorBoundary"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; -import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; -import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; @@ -79,11 +85,11 @@ import { NewTaskEnvironmentPickerRouteScreen, } from "./features/threads/NewTaskContextPickerScreens"; import { - ExistingThreadSettingsRouteProvider, ExistingThreadSettingsRouteScreen, NewTaskThreadSettingsRouteScreen, } from "./features/threads/ThreadSettingsSheet"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; +import { newTaskFlowKey } from "./features/threads/new-task-flow-state"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsNavigationRouteScreen } from "./features/settings/SettingsNavigationRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; @@ -131,28 +137,17 @@ import { isWorkspaceOverlayRoute, registerPresentedRoutes, } from "./features/layout/workspace-overlay-routes"; -import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; +import { ShowcaseReadyMarker } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, SettingsLegalDocumentExternalHeaderButton, } from "./features/settings/components/SettingsLegalDocumentRouteScreen"; -import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; -import { useIncomingShare } from "./features/sharing/IncomingShareProvider"; -import { - EMPTY_INCOMING_SHARE_PRESENTATION_STATE, - transitionIncomingSharePresentation, -} from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; -import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; -import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; -import { - selectWorkspaceDetail, - workspaceDetailRouter, -} from "./features/workspace/workspace-detail-selection"; +import { workspaceDetailRouter } from "./features/workspace/workspace-detail-selection"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -470,7 +465,7 @@ const SETTINGS_SCREENS = { // iOS 26 header morphing requires one UINavigationBar; nested stacks make the whole screen slide. const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; -function workspaceLocationFromState(state: NavigationState) { +export function workspaceLocationFromState(state: NavigationState) { const routes = state.routes.filter((route) => !isWorkspaceOverlayRoute(route.name)); const effectiveState = routes.length > 0 && routes.length !== state.routes.length @@ -483,84 +478,61 @@ function workspaceLocationFromState(state: NavigationState) { }; } -// Isolate subscriptions so streaming updates cannot re-render RootStackLayout and every screen. -function ThreadOutboxDrainWorker() { - useThreadOutboxDrain(); - useComposerAttachmentUploadWorker(); - return null; +export function rootPathnameFromState(state: NavigationState): string { + const path = getPathFromState(state, navigationPathConfig); + return path.startsWith("/") ? path : `/${path}`; } function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; }) { - const navigation = useNavigation(); - const { pendingShare } = useIncomingShare(); - const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); - const path = getPathFromState(props.state, navigationPathConfig); - const pathname = path.startsWith("/") ? path : `/${path}`; + const pathname = rootPathnameFromState(props.state); const workspaceLocation = workspaceLocationFromState(props.state); const settingsOpen = settingsOpenIn( props.state.routes .slice(0, props.state.index + 1) .filter((route) => !isWorkspaceOverlayRoute(route.name)), ); - const draftRoute = props.state.routes.findLast((route) => route.name === "Thread"); - const draftParams = ( - draftRoute?.params as - | { draft?: { draftId?: string | string[]; pendingTaskId?: string | string[] } } - | undefined - )?.draft; - const routeDraftId = Array.isArray(draftParams?.draftId) - ? draftParams.draftId[0] - : draftParams?.draftId; - const routePendingTaskId = Array.isArray(draftParams?.pendingTaskId) - ? draftParams.pendingTaskId[0] - : draftParams?.pendingTaskId; - useAgentNotificationNavigation(workspaceLocation.pathname); - useConnectOnboardingNavigation(); - useAppShortcuts(props.state); - useEffect(() => { - const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { - isShareSheetPresented: isDraftThreadRoute(props.state.routes[props.state.index]), - pendingShareId: pendingShare?.id ?? null, - }); - sharePresentationRef.current = transition.state; - if (!transition.shareIdToPresent) { - return; - } - navigation.dispatch( - selectWorkspaceDetail({ - name: "Thread", - params: { draft: { incomingShareId: transition.shareIdToPresent } }, - }), - ); - }, [navigation, pendingShare, props.state]); + // The flow follows the draft route even while a pushed Thread covers it, so + // its state lives exactly as long as the route. + const draftRoute = props.state.routes.findLast(isDraftThreadRoute); + const draftRouteOnTop = + draftRoute !== undefined && + props.state.routes.findLast((route) => route.name === "Thread") === draftRoute; + const draftParams = (draftRoute?.params as { draft?: NewTaskDraftRouteParams } | undefined) + ?.draft; + const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value); + const routeDraftId = first(draftParams?.draftId); + const routeEnvironmentId = first(draftParams?.environmentId); + const routeProjectId = first(draftParams?.projectId); return ( - - + - - - - - {props.children} - - - - - + + + + {props.children} + + + + @@ -627,6 +599,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ Home: createNativeStackScreen({ screen: WorkspaceRootScreen, linking: "", + listeners: { + transitionStart: ({ data }) => recordHomeTransitionStart(data.closing), + transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing), + focus: recordHomeFocus, + }, options: { ...GLASS_HEADER_OPTIONS, ...FIXED_DUO_HEADER, diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 8cac0b59cfdf9..923afb87a3a1f 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -3,10 +3,13 @@ import { composerImageGallery } from "../lib/composerImageGallery"; import { SymbolView } from "./ui/AppSymbol"; import { imageMimeType } from "@t3tools/shared/image"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEffect, useMemo, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { Paths } from "expo-file-system"; import { Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./ui/AppText"; +import { MaterializedPreviewKey, materializedPreviewAtom } from "../lib/composerImagePreview"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { isFileBackedComposerAttachment, @@ -93,65 +96,31 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr ); } -const PREVIEW_CACHE_DIRECTORY = "t3-composer-previews"; - /** Fabric re-parses URLs each layout; large data URLs can stall and abort commits. */ const INLINE_PREVIEW_FALLBACK_MAX_CHARS = 256_000; -async function materializeDataUrlPreview(id: string, dataUrl: string): Promise { - const comma = dataUrl.indexOf(","); - if (comma < 0) return null; - const { Directory, File, Paths } = await import("expo-file-system"); - const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/jpeg"; - const extension = (mimeType.split("/")[1] ?? "jpg").replace("jpeg", "jpg"); - const directory = new Directory(Paths.cache, PREVIEW_CACHE_DIRECTORY); - directory.create({ idempotent: true, intermediates: true }); - const file = new File(directory, `${id}.${extension}`); - if (!file.exists) { - file.create(); - await file.write(dataUrl.slice(comma + 1), { encoding: "base64" }); - } - return file.uri; -} +const noMaterializedPreview = Atom.make(AsyncResult.initial()).pipe( + Atom.withLabel("composer-image-preview:none"), +); function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string | null { - const { id, fileUri, previewUri } = attachment; - const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null); - const [materialized, setMaterialized] = useState<{ id: string; uri: string | null } | null>(null); + const { fileUri, previewUri } = attachment; const inlinePreview = fileUri === undefined && previewUri.startsWith("data:"); - useEffect(() => { - if (fileUri === undefined) return; - let cancelled = false; - void (async () => { - const { Paths } = await import("expo-file-system"); - const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri); - if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned }); - })(); - return () => { - cancelled = true; - }; - }, [fileUri, previewUri]); - useEffect(() => { - if (!inlinePreview) return; - let cancelled = false; - void materializeDataUrlPreview(id, previewUri) - .then((uri) => { - if (!cancelled && uri !== null) setMaterialized({ id, uri }); - }) - .catch((error: unknown) => { - console.warn("[composer-attachments] could not cache an image preview", error); - if (!cancelled) setMaterialized({ id, uri: null }); - }); - return () => { - cancelled = true; - }; - }, [id, inlinePreview, previewUri]); - if (fileUri !== undefined && rebased?.fileUri === fileUri) return rebased.uri; - if (fileUri !== undefined) return previewUri.startsWith("data:") ? fileUri : previewUri; + const materialized = useAtomValue( + inlinePreview + ? materializedPreviewAtom(new MaterializedPreviewKey(attachment)) + : noMaterializedPreview, + ); + if (fileUri !== undefined) { + // Reinstalls move the app container, so a stored file URI may point at the old one. + const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri); + if (owned !== null) return owned; + return previewUri.startsWith("data:") ? fileUri : previewUri; + } if (inlinePreview) { - if (materialized?.id !== id) return null; + if (!AsyncResult.isSuccess(materialized)) return null; return ( - materialized.uri ?? + materialized.value ?? (previewUri.length <= INLINE_PREVIEW_FALLBACK_MAX_CHARS ? previewUri : null) ); } @@ -280,15 +249,12 @@ function ComposerVideoAttachment(props: { const { attachment } = props; const sourceIdentifier = `draft:${attachment.id}`; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; - const actionsSource = useMemo( - () => ({ - name: attachment.name, - mimeType: videoMimeType(attachment) ?? attachment.mimeType, - sourceIdentifier, - attachment, - }), - [attachment, sourceIdentifier], - ); + const actionsSource: MediaActionsSource = { + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + sourceIdentifier, + attachment, + }; return ( composerImageGallery(props.attachments), [props.attachments]); + const gallery = composerImageGallery(props.attachments); const size = props.imageSize ?? 72; const radius = props.imageBorderRadius ?? 16; const removeButtonPlacement = props.removeButtonPlacement ?? "overlay"; diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 8dfbb5587b621..0098605414bd2 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -3,7 +3,7 @@ import { ComposerContextId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; @@ -11,6 +11,7 @@ import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEdit import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor"; import { appendComposerDraftAttachments, + composerContextImportsAtom, createComposerDraftContextHistory, getComposerDraftAfterSelection, getComposerDraftSnapshot, @@ -22,6 +23,8 @@ import { useComposerDraft, } from "../state/use-composer-drafts"; import { importComposerContextClipboard } from "../lib/composerContextClipboard"; +import { startComposerDraftOperation } from "../state/composerDraftOperations"; +import { appAtomRegistry } from "../state/atom-registry"; import { mobilePreferencesAtom } from "../state/preferences"; import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread"; import { ComposerContextSheet } from "./ComposerContextSheet"; @@ -73,32 +76,32 @@ export function ComposerEditor({ const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( null, ); - const importRef = useRef(null); - const [importing, setImporting] = useState(false); - useEffect( - () => () => { - importRef.current?.abort(); - }, - [draftKey], - ); + const contextImports = useAtomValue(composerContextImportsAtom); + const importing = draftKey ? contextImports[draftKey] === true : false; const pasteContext = async ( clipboard: Parameters>[0], ) => { - if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; + if ( + !draftKey || + appAtomRegistry.get(composerContextImportsAtom)[draftKey] || + props.readOnly || + props.editable === false + ) + return; const insertion = { text: clipboard.value, ...clipboard.selection }; - const controller = new AbortController(); - importRef.current = controller; - setImporting(true); + const operation = startComposerDraftOperation(draftKey); + const { signal } = operation; setComposerContextImporting(draftKey, true); const settled = await settlePromise(async () => { const retained = getComposerDraftAfterSelection(draftKey, insertion); const result = await importComposerContextClipboard( clipboard, retained.attachments.length, - controller.signal, + signal, retained.context?.records.length ?? 0, environmentId, ); + if (signal.aborted) return; if (!result) { insertComposerDraftText(draftKey, clipboard.text, insertion); return; @@ -125,10 +128,9 @@ export function ComposerEditor({ [...warnings, "Those references are marked unavailable."].join("\n\n"), ); }); + operation.finish(); setComposerContextImporting(draftKey, false); - importRef.current = null; - setImporting(false); - if (AsyncResult.isFailure(settled) && !controller.signal.aborted) { + if (AsyncResult.isFailure(settled) && !signal.aborted) { const error = squashAtomCommandFailure(settled); Dialog.alert( "Could not paste context", diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index 83de9054a1e7e..7d2317e58baae 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -1,50 +1,42 @@ -import { useEffect, useLayoutEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; -import { useLinkTo } from "@react-navigation/native"; import { setAndroidThreadOnScreen } from "./androidNotifications"; import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior"; import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload"; import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; -export function useAgentNotificationNavigation(pathname: string): void { - const linkTo = useLinkTo(); - const handledResponseIds = useRef(new Set()); - const deepLinkOnScreen = useRef(null); - useLayoutEffect(() => { - const thread = threadDeepLinkOnScreen(pathname); - deepLinkOnScreen.current = thread; - setAndroidThreadOnScreen(thread); - }, [pathname]); +let deepLinkOnScreen: string | null = null; - useEffect(() => { - Notifications.setNotificationHandler({ - handleNotification: (notification) => - Promise.resolve(foregroundNotificationBehavior(notification, deepLinkOnScreen.current)), - }); - return () => { - Notifications.setNotificationHandler(null); - }; - }, []); +/** Called with the workspace pathname on every root navigation state change. */ +export function setAgentNotificationPathname(pathname: string): void { + deepLinkOnScreen = threadDeepLinkOnScreen(pathname); + setAndroidThreadOnScreen(deepLinkOnScreen); +} - useEffect(() => { - const handleResponse = (response: Notifications.NotificationResponse): void => { - routeAgentNotificationResponseOnce({ - handledResponseIds: handledResponseIds.current, - response, - navigate: linkTo, - }); - }; +/** + * App-lifetime notification routing: foreground presentation suppresses the + * thread already on screen, and taps (including the cold-start tap) open the + * notification's deep link through `navigate`. + */ +export function installAgentNotificationNavigation(navigate: (href: string) => void): () => void { + const handledResponseIds = new Set(); + Notifications.setNotificationHandler({ + handleNotification: (notification) => + Promise.resolve(foregroundNotificationBehavior(notification, deepLinkOnScreen)), + }); - const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); - void consumeLastAgentNotificationResponse({ - getLastResponse: () => Notifications.getLastNotificationResponseAsync(), - clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(), - handleResponse, - }); + const handleResponse = (response: Notifications.NotificationResponse): void => { + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }); + }; + const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); + void consumeLastAgentNotificationResponse({ + getLastResponse: () => Notifications.getLastNotificationResponseAsync(), + clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(), + handleResponse, + }); - return () => { - subscription.remove(); - }; - }, [linkTo]); + return () => { + subscription.remove(); + Notifications.setNotificationHandler(null); + }; } diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx index a458046ee2be1..46c6253501e0b 100644 --- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -7,12 +7,13 @@ import { EnvironmentId, ThreadId, type PreviewSessionSnapshot } from "@t3tools/c import { normalizePreviewUrl, previewPdfAssetUrl } from "@t3tools/shared/preview"; import * as Option from "effect/Option"; import { useCallback, useEffect, useRef, useState } from "react"; -import { AppState, Platform, TextInput, View } from "react-native"; +import { Platform, TextInput, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPill } from "../../components/ui/ControlPill"; import { AppBar } from "../../components/ui/AppBar"; +import { useAppForeground } from "../../lib/appState"; import { useThreadServerBrowserTabs } from "../../state/preview"; import { usePreparedConnection } from "../../state/session"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -79,7 +80,7 @@ function BrowserPreviewScreen({ const prepared = usePreparedConnection(environmentId); const httpBaseUrl = Option.isSome(prepared) ? prepared.value.httpBaseUrl : null; const focused = useIsFocused(); - const [foreground, setForeground] = useState(AppState.currentState !== "background"); + const foreground = useAppForeground(); const [selectedTabId, setSelectedTabId] = useState(initialTabId); const [pictureInPicture, setPictureInPicture] = useState(NO_PICTURE_IN_PICTURE); const [streaming, setStreaming] = useState(false); @@ -94,12 +95,6 @@ function BrowserPreviewScreen({ setPictureInPicture(NO_PICTURE_IN_PICTURE); setSelectedTabId(tabId); } - useEffect(() => { - const subscription = AppState.addEventListener("change", (state) => - setForeground(state !== "background"), - ); - return () => subscription.remove(); - }, []); useEffect(() => { if (focused && loaded && tabs.length === 0) onClose(); }, [focused, loaded, tabs.length, onClose]); diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index de7ecb1b0e690..4440ed712fbf4 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -12,7 +12,6 @@ import { } from "@t3tools/client-runtime/preview/server-browser-stream"; import type { EnvironmentId, PreviewStreamHostSetup } from "@t3tools/contracts"; import { - useEffect, useEffectEvent, useImperativeHandle, useLayoutEffect, @@ -52,11 +51,13 @@ export interface PreviewPictureInPictureState { type NativeStreamBridge = { readonly ref?: Ref; readonly onUnauthorized: () => void; + /** Asks for fresh access after a refusal, once `delayMs` has passed. */ + readonly onRefused: (delayMs: number) => void; readonly onGone?: () => void; readonly onViewport?: (viewport: { readonly width: number; readonly height: number }) => void; readonly onControl?: (control: PreviewStreamControl | null) => void; readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void; - /** True while frames show, so commands reach the page. Pass a stable function. */ + /** True while frames show, so commands reach the page. */ readonly onStreamingChange?: (streaming: boolean) => void; readonly compact?: boolean; readonly pdfUri?: string | null; @@ -113,14 +114,21 @@ const MAX_REFUSALS = 3; export function PreviewStreamWebView( props: Omit & - Omit & { + Omit & { readonly environmentId: EnvironmentId; readonly paused?: boolean; }, ) { - const { access, error, refresh } = usePreviewStreamAccess(props.environmentId); + const { access, error, refresh, retryAfter } = usePreviewStreamAccess(props.environmentId); if (access && !props.paused) { - return ; + return ( + + ); } return ( | null>(null); - useEffect( - () => () => { - if (refreshTimer.current !== null) clearTimeout(refreshTimer.current); - }, - [], - ); const configuration = JSON.stringify({ access: props.access, threadId: props.threadId, @@ -188,14 +189,7 @@ function AuthorizedPreviewStream({ onUnauthorized={() => { const refusals = ++unauthorized.current; if (refusals >= MAX_REFUSALS) return false; - if (refreshTimer.current !== null) clearTimeout(refreshTimer.current); - refreshTimer.current = setTimeout( - () => { - refreshTimer.current = null; - props.onUnauthorized(); - }, - refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1), - ); + props.onRefused(refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1)); return true; }} onRetry={() => { @@ -232,7 +226,7 @@ function PreviewStreamDocumentView({ onRetry, onStreaming, onRecoverProcess, -}: Omit & { +}: Omit & { readonly configuration: string; readonly background: string; readonly onUnauthorized: () => boolean; @@ -241,13 +235,13 @@ function PreviewStreamDocumentView({ readonly onRecoverProcess: () => boolean; }) { const webView = useRef>(null); - const active = useRef(true); + const active = useRef(false); const failed = useRef(false); + const bootstrapTimer = useRef | null>(null); const [status, setStatus] = useState<"connecting" | "streaming" | "error">("connecting"); const [error, setError] = useState(null); const [gone, setGone] = useState(false); const [hostSetup, setHostSetup] = useState(null); - const [started, setStarted] = useState(false); const [control, setControl] = useState(null); const dialogRef = useRef(null); const [promptText, setPromptText] = useState(""); @@ -263,7 +257,6 @@ function PreviewStreamDocumentView({ ), ); }; - const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next)); const command = (input: PreviewStreamInput) => webView.current?.injectJavaScript( `window.T3PreviewStream?.command(${JSON.stringify(input)}); true;`, @@ -277,9 +270,15 @@ function PreviewStreamDocumentView({ }, () => Dialog.alert("Could not read the clipboard"), ); + const clearBootstrapTimer = () => { + if (bootstrapTimer.current === null) return; + clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + }; const fail = (message: string) => { if (!active.current || failed.current) return; failed.current = true; + clearBootstrapTimer(); webView.current?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); onStreamingChange?.(false); setControl(null); @@ -291,11 +290,23 @@ function PreviewStreamDocumentView({ const bootstrapTimedOut = useEffectEvent(() => fail("Browser viewer could not start. Reconnect to try again."), ); - useEffect(() => { - if (started) return; - const timer = setTimeout(bootstrapTimedOut, 15_000); - return () => clearTimeout(timer); - }, [started]); + const streamEnded = useEffectEvent(() => { + onStreamingChange?.(false); + onControl?.(null); + }); + // The stream lives exactly as long as this view. + useLayoutEffect(() => { + active.current = true; + const view = webView.current; + bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000); + return () => { + if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + active.current = false; + view?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); + streamEnded(); + }; + }, []); const source = useMemo( () => ({ html: previewStreamDocument(configuration, previewStreamScript), @@ -309,16 +320,6 @@ function PreviewStreamDocumentView({ togglePictureInPicture: () => webView.current?.injectJavaScript("window.T3PreviewStream?.pictureInPicture(); true;"), })); - useLayoutEffect(() => { - active.current = true; - const view = webView.current; - return () => { - active.current = false; - view?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); - }; - }, []); - useEffect(() => () => onStreamingChange?.(false), [onStreamingChange]); - useEffect(() => () => controlChanged(null), []); const processTerminated = () => { if (!active.current || failed.current) return; if (!onRecoverProcess()) fail("Browser viewer stopped. Reconnect to try again."); @@ -407,7 +408,7 @@ function PreviewStreamDocumentView({ onPictureInPicture?.(message, message.detail); return; case "status": - setStarted(true); + clearBootstrapTimer(); if (message.status === "error") { fail(message.detail ?? "Browser stream failed."); return; diff --git a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx index 3cc5eb951b6cf..ea5f0d9c8c0ca 100644 --- a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx +++ b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx @@ -2,11 +2,12 @@ import { useIsFocused } from "@react-navigation/native"; import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; import { previewPdfAssetUrl } from "@t3tools/shared/preview"; import * as Option from "effect/Option"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { AppState, Pressable, View } from "react-native"; +import { useCallback, useState } from "react"; +import { Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; import { SymbolView } from "../../components/ui/AppSymbol"; +import { useAppForeground } from "../../lib/appState"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { browserTabTitle } from "./browserTabs"; import { PreviewStreamWebView } from "./PreviewStreamWebView"; @@ -25,29 +26,32 @@ export function ThreadBrowserFloat(props: { readonly onOpen: (tabId: string) => void; }) { const [tabId, setTabId] = useState(null); - const known = useRef | null>(null); - useEffect(() => { - if (!props.loaded) return; - const previous = known.current; - known.current = new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id])); - if (previous === null) return; - const opened = props.tabs.findLast( - (tab) => - tab.reveal === true && - (tab.revealRequest - ? previous.get(tab.tabId) !== tab.revealRequest.id - : !previous.has(tab.tabId)), - ); + // The reveal requests already seen, so only a tab revealed after the first + // list pops the player. + const [seen, setSeen] = useState<{ + readonly tabs: ReadonlyArray; + readonly reveals: ReadonlyMap; + } | null>(null); + if (props.loaded && seen?.tabs !== props.tabs) { + setSeen({ + tabs: props.tabs, + reveals: new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id])), + }); + const previous = seen?.reveals; + const opened = + previous === undefined + ? undefined + : props.tabs.findLast( + (tab) => + tab.reveal === true && + (tab.revealRequest + ? previous.get(tab.tabId) !== tab.revealRequest.id + : !previous.has(tab.tabId)), + ); if (opened !== undefined) setTabId(opened.tabId); - }, [props.loaded, props.tabs]); + } const focused = useIsFocused(); - const [foreground, setForeground] = useState(AppState.currentState !== "background"); - useEffect(() => { - const subscription = AppState.addEventListener("change", (state) => - setForeground(state !== "background"), - ); - return () => subscription.remove(); - }, []); + const foreground = useAppForeground(); const tab = props.tabs.find((entry) => entry.tabId === tabId) ?? null; const onOpen = props.onOpen; const open = useCallback(() => { diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 32ec03bf38a7e..4d0b340317c9f 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -5,8 +5,8 @@ import { Button } from "../../components/ui/Button"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useAuth } from "@clerk/expo"; -import { StackActions, useNavigation } from "@react-navigation/native"; -import { useEffect, useState } from "react"; +import { useNavigation } from "@react-navigation/native"; +import { useState } from "react"; import { Platform, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -19,21 +19,9 @@ import { useConnectionController } from "../connection/useConnectionController"; import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut"; import { hasCloudPublicConfig } from "./publicConfig"; +// Only reachable with cloud config: onboarding is requested after a T3 Connect +// sign-in, and appLinking drops the deep link otherwise. export function ConnectOnboardingRouteScreen() { - const navigation = useNavigation(); - - // Without cloud config, deep links must exit to avoid an empty, undismissable sheet. - useEffect(() => { - if (hasCloudPublicConfig()) { - return; - } - if (navigation.canGoBack()) { - navigation.goBack(); - } else { - navigation.dispatch(StackActions.replace("Home")); - } - }, [navigation]); - return hasCloudPublicConfig() ? : null; } diff --git a/apps/mobile/src/features/cloud/connectOnboarding.test.ts b/apps/mobile/src/features/cloud/connectOnboarding.test.ts new file mode 100644 index 0000000000000..a3c905b2cda1f --- /dev/null +++ b/apps/mobile/src/features/cloud/connectOnboarding.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const navigation = vi.hoisted(() => ({ navigate: vi.fn() })); +const optOut = vi.hoisted(() => ({ optedOut: new Set() })); + +vi.mock("../../lib/rootNavigation", () => ({ + rootNavigationRef: navigation, + whenRootNavigationReady: (navigate: () => void) => navigate(), +})); +vi.mock("./connectOnboardingOptOut", () => ({ + isConnectOnboardingOptedOut: (accountId: string) => + Promise.resolve(optOut.optedOut.has(accountId)), +})); + +import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; + +afterEach(() => { + clearConnectOnboardingRequest(); + optOut.optedOut.clear(); + navigation.navigate.mockReset(); + vi.useRealTimers(); +}); + +describe("requestConnectOnboarding", () => { + it("presents the sheet after the settle delay", async () => { + vi.useFakeTimers(); + requestConnectOnboarding("account-1"); + await vi.advanceTimersByTimeAsync(599); + expect(navigation.navigate).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(navigation.navigate).toHaveBeenCalledExactlyOnceWith("ConnectOnboarding"); + }); + + it("drops a request cleared by sign-out before it fires", async () => { + vi.useFakeTimers(); + requestConnectOnboarding("account-1"); + clearConnectOnboardingRequest(); + await vi.runAllTimersAsync(); + expect(navigation.navigate).not.toHaveBeenCalled(); + }); + + it("skips accounts that opted out", async () => { + vi.useFakeTimers(); + optOut.optedOut.add("account-1"); + requestConnectOnboarding("account-1"); + await vi.runAllTimersAsync(); + expect(navigation.navigate).not.toHaveBeenCalled(); + }); + + it("presents once when a newer request replaces a pending one", async () => { + vi.useFakeTimers(); + requestConnectOnboarding("account-1"); + requestConnectOnboarding("account-2"); + await vi.runAllTimersAsync(); + expect(navigation.navigate).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/features/cloud/connectOnboarding.ts b/apps/mobile/src/features/cloud/connectOnboarding.ts index c324b700942fe..c90951b127cbd 100644 --- a/apps/mobile/src/features/cloud/connectOnboarding.ts +++ b/apps/mobile/src/features/cloud/connectOnboarding.ts @@ -1,17 +1,40 @@ -import { Atom } from "effect/reactivity"; +import { rootNavigationRef, whenRootNavigationReady } from "../../lib/rootNavigation"; +import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; -import { appAtomRegistry } from "../../state/atom-registry"; +// Sign-in happens in its own modal; give its session-state transition a beat to +// settle before presenting another formSheet on top. +const PRESENT_ONBOARDING_DELAY_MS = 600; + +interface PendingRequest { + readonly accountId: string; + readonly timer: ReturnType; +} // Retain the account id so sign-out cannot present onboarding for the wrong account. -export const connectOnboardingRequestAtom = Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:connect-onboarding-request"), -); +let pending: PendingRequest | null = null; export function requestConnectOnboarding(accountId: string): void { - appAtomRegistry.set(connectOnboardingRequestAtom, accountId); + clearConnectOnboardingRequest(); + const request: PendingRequest = { + accountId, + timer: setTimeout(() => { + void (async () => { + const optedOut = await isConnectOnboardingOptedOut(accountId).catch(() => false); + // A sign-out or a newer sign-in may have replaced this request while + // the opt-out read was in flight. + if (pending !== request) return; + pending = null; + if (!optedOut) { + whenRootNavigationReady(() => rootNavigationRef.navigate("ConnectOnboarding")); + } + })(); + }, PRESENT_ONBOARDING_DELAY_MS), + }; + pending = request; } export function clearConnectOnboardingRequest(): void { - appAtomRegistry.set(connectOnboardingRequestAtom, null); + if (pending === null) return; + clearTimeout(pending.timer); + pending = null; } diff --git a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts deleted file mode 100644 index 5176dc797f65b..0000000000000 --- a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useNavigation } from "@react-navigation/native"; -import { useEffect } from "react"; - -import { appAtomRegistry } from "../../state/atom-registry"; -import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; -import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; - -// Sign-in happens in its own modal; give its session-state transition a beat to -// settle before presenting another formSheet on top. -const PRESENT_ONBOARDING_DELAY_MS = 600; - -/** Keep @react-navigation out of non-navigation consumers' import graphs. */ -export function useConnectOnboardingNavigation(): void { - const navigation = useNavigation(); - const requestedAccountId = useAtomValue(connectOnboardingRequestAtom); - - useEffect(() => { - if (requestedAccountId === null) { - return; - } - let cancelled = false; - const timer = setTimeout(() => { - void (async () => { - const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false); - // Re-check the request atom: sign-out can clear it before this render commits. - if (cancelled || appAtomRegistry.get(connectOnboardingRequestAtom) !== requestedAccountId) { - return; - } - clearConnectOnboardingRequest(); - if (!optedOut) { - navigation.navigate("ConnectOnboarding"); - } - })(); - }, PRESENT_ONBOARDING_DELAY_MS); - return () => { - cancelled = true; - clearTimeout(timer); - }; - }, [navigation, requestedAccountId]); -} diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index a91e7b69d9ea9..43ad8d1d9b990 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -12,8 +12,8 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { AsyncResult } from "effect/reactivity"; -import { useEffect, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { useState } from "react"; import { Linking, Pressable, StyleSheet, View } from "react-native"; import Svg, { Defs, RadialGradient, Rect, Stop } from "react-native-svg"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -24,13 +24,11 @@ import { SettingsScreen } from "../settings/components/SettingsScreen"; import { AppText as Text, AppTextInput, type AppTextInputProps } from "../../components/ui/AppText"; import { FrostedCutout } from "../../components/ui/FrostedCutout"; import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; -import { - setPendingConnectionError, - useRemoteConnections, -} from "../../state/use-remote-environment-registry"; +import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { SymbolView } from "../../components/ui/AppSymbol"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { CloudEnvironmentRows } from "./CloudEnvironmentRows"; +import { devAutoConnectAttemptAtom } from "./devAutoConnect"; import { splitEnvironmentSections } from "./environmentSections"; import { openSettings } from "../settings/settings-navigation"; import { leaveSettingsPage } from "../settings/settings-navigation"; @@ -46,70 +44,73 @@ type ConnectionsNewRouteParams = { export function ConnectionsNewRouteScreen({ route, }: StaticScreenProps) { - const { - connectionPairingUrl, - onChangeConnectionPairingUrl, - onConnectPress, - pairingConnectionError, - } = useRemoteConnections(); - const navigation = useNavigation(); - const routeName = useRoute().name; const params = route.params ?? {}; // Dev-only prefill prevents production links from supplying an attacker-chosen host and token. const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : ""; - const shouldAutoConnect = - __DEV__ && - routePairingUrl.length > 0 && - (params.autoConnect === "1" || params.autoConnect === "true"); + // A new prefill link replaces whatever the form held. + return ( + + ); +} + +function PairingForm({ + routePairingUrl, + routeFor, +}: { + readonly routePairingUrl: string; + readonly routeFor: EnvironmentId | undefined; +}) { + const { connectionPairingUrl, onChangeConnectionPairingUrl, onConnectPress } = + useRemoteConnections(); + const navigation = useNavigation(); + const routeName = useRoute().name; const insets = useSafeAreaInsets(); - const [hostInput, setHostInput] = useState(""); - const [codeInput, setCodeInput] = useState(""); + const [initialPairing] = useState(() => parsePairingUrl(routePairingUrl || connectionPairingUrl)); + const [hostInput, setHostInput] = useState(initialPairing.host); + const [codeInput, setCodeInput] = useState(initialPairing.code); const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const autoConnectAttempt = useAtomValue(devAutoConnectAttemptAtom); + const autoConnect = + autoConnectAttempt !== null && + routePairingUrl.length > 0 && + autoConnectAttempt.pairingUrl === routePairingUrl + ? autoConnectAttempt + : null; + // The dev auto-connect report applies until the user starts their own attempt. + const [ownAttempt, setOwnAttempt] = useState(false); + const connecting = isSubmitting || (!ownAttempt && autoConnect?.status === "connecting"); + const pairingConnectionError = + submitError ?? (!ownAttempt && autoConnect?.status === "failed" ? autoConnect.error : null); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const screenFocused = useIsFocused(); const [scannerLocked, setScannerLocked] = useState(false); // A good scan parks the camera so it can't re-read the code over later edits. const [scanComplete, setScanComplete] = useState(false); - const attemptedAutoConnectRef = useRef(null); const headerIconColor = useUniwindTheme()["--color-icon"]; - const connectDisabled = isSubmitting || hostInput.trim().length === 0; - - useEffect(() => { - const { host, code } = parsePairingUrl(connectionPairingUrl); - setHostInput(host); - setCodeInput(code); - }, [connectionPairingUrl]); + const connectDisabled = connecting || hostInput.trim().length === 0; - useEffect(() => { - if (routePairingUrl.length === 0) { - return; - } - - const { host, code } = parsePairingUrl(routePairingUrl); - setHostInput(host); - setCodeInput(code); - }, [routePairingUrl]); - - useEffect(() => { - if (pairingConnectionError) { - setIsSubmitting(false); - } - }, [pairingConnectionError]); + const clearError = () => { + setSubmitError(null); + setOwnAttempt(true); + }; const handleHostChange = (value: string) => { setHostInput(value); - setPendingConnectionError(null); + clearError(); }; const handleCodeChange = (value: string) => { setCodeInput(value); - setPendingConnectionError(null); + clearError(); }; - useEffect(() => () => setPendingConnectionError(null), []); - const handleScanPress = async () => { const permission = await requestCameraPermission(); if (permission.granted) { @@ -154,17 +155,21 @@ export function ConnectionsNewRouteScreen({ } }; - const connectAndClose = async (pairingUrl: string) => { + const handleSubmit = async () => { + const pairingUrl = buildPairingUrl(hostInput, codeInput); + setOwnAttempt(true); + setSubmitError(null); setIsSubmitting(true); onChangeConnectionPairingUrl(pairingUrl); - const result = await onConnectPress(pairingUrl, params.routeFor); + const outcome = await onConnectPress(pairingUrl, routeFor); setIsSubmitting(false); + setSubmitError(outcome.error); void Haptics.notificationAsync( - AsyncResult.isSuccess(result) + outcome.ok ? Haptics.NotificationFeedbackType.Success : Haptics.NotificationFeedbackType.Error, ); - if (AsyncResult.isSuccess(result)) { + if (outcome.ok) { // Replacing with Home would nest it inside any sheet below; return to the opener. if (!navigation.canGoBack()) { navigation.dispatch(StackActions.replace("Home")); @@ -175,27 +180,14 @@ export function ConnectionsNewRouteScreen({ } }; - const handleSubmit = async () => { - await connectAndClose(buildPairingUrl(hostInput, codeInput)); - }; - - useEffect(() => { - if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) { - return; - } - - attemptedAutoConnectRef.current = routePairingUrl; - void connectAndClose(routePairingUrl); - }, [connectAndClose, routePairingUrl, shouldAutoConnect]); - return ( (); -const shownThisLaunch = new Set(); - -export function PermissionUpdateNotice() { - const { environments } = useEnvironments(); - const [dismissed, setDismissed] = useState | null>(null); - const showing = useRef(false); - const affected = useAtomValue( - useMemo( - () => - Atom.make((get) => - environments.filter(({ environmentId }) => { - const session = get(environmentSession.sessionStateAtom(environmentId)); - return ( - session._tag === "Success" && - !session.waiting && - sessionHasLegacyPermissions(session.value) - ); - }), - ), - [environments], - ), - ); - - useEffect(() => { - let active = true; - void whenProtectedDataAvailable() - .then(() => SecureStore.getItemAsync(storageKey)) - .then((raw) => { - const value: unknown = raw === null ? [] : JSON.parse(raw); - if (active) - setDismissed( - new Set( - Array.isArray(value) - ? value.filter((id): id is string => typeof id === "string") - : [], - ), - ); - }) - .catch(() => { - if (active) setDismissed(new Set()); - }); - return () => { - active = false; - }; - }, []); - - useEffect(() => { - if (dismissed === null || showing.current) return; - const environment = affected.find( - ({ environmentId }) => !dismissed.has(environmentId) && !shownThisLaunch.has(environmentId), - ); - if (!environment) return; - showing.current = true; - shownThisLaunch.add(environment.environmentId); - const dismiss = () => { - dismissedThisLaunch.add(environment.environmentId); - const next = new Set([...dismissed, ...dismissedThisLaunch]); - showing.current = false; - setDismissed(next); - void SecureStore.setItemAsync(storageKey, JSON.stringify([...next])).catch(() => {}); - }; - Dialog.alert( - `Permissions have changed for ${environment.label}`, - "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.", - [{ text: "Got it", onPress: dismiss }], - { cancelable: false }, - ); - }, [affected, dismissed]); - return null; -} diff --git a/apps/mobile/src/features/connection/devAutoConnect.ts b/apps/mobile/src/features/connection/devAutoConnect.ts new file mode 100644 index 0000000000000..ed61d37f3429a --- /dev/null +++ b/apps/mobile/src/features/connection/devAutoConnect.ts @@ -0,0 +1,70 @@ +import type { NavigationState, PartialState } from "@react-navigation/native"; +import { StackActions } from "@react-navigation/native"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { updateLinkedRoute } from "../../lib/appLinking"; +import { rootNavigationRef, whenRootNavigationReady } from "../../lib/rootNavigation"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { pairEnvironment } from "../../state/use-remote-environment-registry"; + +export type DevAutoConnectAttempt = + | { readonly pairingUrl: string; readonly status: "connecting" } + | { readonly pairingUrl: string; readonly status: "failed"; readonly error: string | null }; + +/** The pairing the dev deep link started, so the form it opens can show its progress. */ +export const devAutoConnectAttemptAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:dev-auto-connect-attempt"), +); + +// The pairing form is mounted both as a root sheet and inside the settings sheet. +const PAIRING_FORM_ROUTES = ["ConnectionsNew", "SettingsEnvironmentNew"]; + +function isAutoConnectFlag(value: unknown): boolean { + return value === "1" || value === "true"; +} + +async function connect(pairingUrl: string, expectedEnvironmentId: EnvironmentId | undefined) { + appAtomRegistry.set(devAutoConnectAttemptAtom, { pairingUrl, status: "connecting" }); + const outcome = await pairEnvironment(pairingUrl, expectedEnvironmentId); + if (!outcome.ok) { + appAtomRegistry.set(devAutoConnectAttemptAtom, { + pairingUrl, + status: "failed", + error: outcome.error, + }); + return; + } + appAtomRegistry.set(devAutoConnectAttemptAtom, null); + whenRootNavigationReady(() => { + const current = rootNavigationRef.getCurrentRoute()?.name; + if (current === undefined || !PAIRING_FORM_ROUTES.includes(current)) return; + // Return to whatever the link opened the sheet over. + if (rootNavigationRef.canGoBack()) rootNavigationRef.goBack(); + else rootNavigationRef.dispatch(StackActions.replace("Home")); + }); +} + +/** + * `connections/new?pairingUrl=…&autoConnect=1` (or `settings/environment-new?…`) + * lets development automation pair without tapping. The link itself starts the + * pairing; the opened form only prefills and reports it. Production builds + * ignore the flag so a link can never pair with an attacker-chosen host. + */ +export function startDevAutoConnect( + state: PartialState | undefined, +): PartialState | undefined { + if (!__DEV__) return state; + return updateLinkedRoute(state, PAIRING_FORM_ROUTES, (route) => { + const { autoConnect, ...params } = (route.params ?? {}) as { + readonly autoConnect?: unknown; + readonly pairingUrl?: unknown; + readonly routeFor?: EnvironmentId; + }; + if (!isAutoConnectFlag(autoConnect)) return route; + const pairingUrl = typeof params.pairingUrl === "string" ? params.pairingUrl.trim() : ""; + if (pairingUrl.length > 0) void connect(pairingUrl, params.routeFor); + return { ...route, params }; + }); +} diff --git a/apps/mobile/src/features/connection/permissionUpdateNotice.ts b/apps/mobile/src/features/connection/permissionUpdateNotice.ts new file mode 100644 index 0000000000000..d35952f0c0e83 --- /dev/null +++ b/apps/mobile/src/features/connection/permissionUpdateNotice.ts @@ -0,0 +1,86 @@ +import * as Dialog from "../../components/ui/Dialog"; +import { sessionHasLegacyPermissions } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import * as SecureStore from "expo-secure-store"; + +import { whenProtectedDataAvailable } from "../../lib/protectedData"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { projectEnvironmentPresentation } from "../../state/environments"; +import { environmentPresentations } from "../../state/presentation"; +import { environmentSession } from "../../state/session"; + +const storageKey = "t3code.permission-update.v1"; + +const legacyPermissionEnvironmentsAtom = Atom.make((get) => + [...get(environmentPresentations.presentationsAtom).entries()].flatMap( + ([environmentId, presentation]) => { + const session = get(environmentSession.sessionStateAtom(environmentId)); + return session._tag === "Success" && + !session.waiting && + sessionHasLegacyPermissions(session.value) + ? [projectEnvironmentPresentation(environmentId, presentation)] + : []; + }, + ), +).pipe(Atom.withLabel("mobile:legacy-permission-environments")); + +async function loadDismissedEnvironmentIds(): Promise> { + try { + await whenProtectedDataAvailable(); + const raw = await SecureStore.getItemAsync(storageKey); + const value: unknown = raw === null ? [] : JSON.parse(raw); + return new Set( + Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [], + ); + } catch { + return new Set(); + } +} + +/** + * Tells the user, once per environment, that a paired session still carries + * the legacy permission set. App-lifetime; presents one dialog at a time. + */ +export function installPermissionUpdateNotice(): () => void { + let dismissed: Set | null = null; + const shownThisLaunch = new Set(); + let showing = false; + let disposed = false; + + const presentNext = () => { + if (disposed || dismissed === null || showing) return; + const known = dismissed; + const environment = appAtomRegistry + .get(legacyPermissionEnvironmentsAtom) + .find( + ({ environmentId }) => !known.has(environmentId) && !shownThisLaunch.has(environmentId), + ); + if (!environment) return; + showing = true; + shownThisLaunch.add(environment.environmentId); + const dismiss = () => { + known.add(environment.environmentId); + showing = false; + void SecureStore.setItemAsync(storageKey, JSON.stringify([...known])).catch(() => {}); + presentNext(); + }; + Dialog.alert( + `Permissions have changed for ${environment.label}`, + "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.", + [{ text: "Got it", onPress: dismiss }], + { cancelable: false }, + ); + }; + + const unsubscribe = appAtomRegistry.subscribe(legacyPermissionEnvironmentsAtom, presentNext, { + immediate: true, + }); + void loadDismissedEnvironmentIds().then((ids) => { + dismissed = ids; + presentNext(); + }); + return () => { + disposed = true; + unsubscribe(); + }; +} diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 3e5899f870a33..0402fa5752369 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -12,13 +12,14 @@ import * as Cause from "effect/Cause"; import { useEffect, useRef, useState, type RefObject } from "react"; import * as Haptics from "expo-haptics"; import { Accelerometer } from "expo-sensors"; -import { AppState, BackHandler, Pressable, StatusBar, View } from "react-native"; +import { Pressable, StatusBar, View } from "react-native"; import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppText } from "../../components/ui/AppText"; +import { EmptyState } from "../../components/ui/EmptyState"; import { ControlPill } from "../../components/ui/ControlPill"; import { Menu } from "../../components/ui/Menu"; import { GlassSurface } from "../../components/ui/GlassSurface"; @@ -27,9 +28,12 @@ import { findHeaderMenuAction, } from "../../components/ui/headerMenu.android"; import type { AppBarMenuItem } from "../../components/ui/AppBar.types"; +import { useAppForeground } from "../../lib/appState"; +import { backHandlerRef } from "../../lib/backHandler"; import { deriveBottomControlInsets } from "../../lib/reserved-regions"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; -import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; +import { uuidv4 } from "../../lib/uuid"; +import { deviceEnvironment, useDeviceHubAccess } from "../../state/device"; import { useEnvironmentQuery } from "../../state/query"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -54,10 +58,21 @@ export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProp const onClose = () => navigation.goBack(); const { environmentId, threadId } = route.params; const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0; - useEffect(() => { - if (isBlankLink) onClose(); - }, [isBlankLink, onClose]); - if (isBlankLink) return null; + // A malformed deep link has nothing to show. Navigating away from render is + // not allowed, so offer the way out instead. + if (isBlankLink) { + return ( + + + + ); + } return ( (null); const [inputConnected, setInputConnected] = useState(false); const [streamAttempt, setStreamAttempt] = useState(0); @@ -97,12 +112,6 @@ function DevicePreviewScreen({ setInputConnected(connected); if (connected) setControlsVisible(false); }; - useEffect(() => { - const subscription = AppState.addEventListener("change", (state) => - setForeground(state !== "background"), - ); - return () => subscription.remove(); - }, []); useEffect(() => { if (focused && state.data !== null && previews.length === 0) onClose(); }, [focused, state.data, previews.length, onClose]); @@ -117,14 +126,6 @@ function DevicePreviewScreen({ }); return () => subscription.remove(); }, [focused, foreground]); - useEffect(() => { - if (controlsVisible) return; - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - setControlsVisible(true); - return true; - }); - return () => subscription.remove(); - }, [controlsVisible]); const shutDownDevice = async () => { if (!preview || shuttingDown) return; @@ -317,6 +318,10 @@ function DevicePreviewScreen({ ) : ( { + setControlsVisible(true); + return true; + })} accessibilityRole="button" accessibilityLabel="Show device controls" accessibilityHint="Shaking the phone also shows them" @@ -370,12 +375,9 @@ function OpenDevicePreview({ }) { const { session } = preview; const { themeVariables } = useAppearancePreferences(); - const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId); - const onUnauthorized = async () => refreshDeviceHubAccess(environmentId); - useEffect(() => { - refreshDeviceHubAccess(environmentId); - return () => void onInputConnected(false); - }, [environmentId, onInputConnected]); + const [viewer] = useState(uuidv4); + const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId, viewer); + const onUnauthorized = async () => refresh(); return access ? ( boolean; }) { const webView = useRef>(null); - const active = useRef(true); + const active = useRef(false); const failed = useRef(false); + const bootstrapTimer = useRef | null>(null); const [status, setStatus] = useState("connecting"); const [error, setError] = useState(null); - const [started, setStarted] = useState(false); + const clearBootstrapTimer = () => { + if (bootstrapTimer.current === null) return; + clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + }; const fail = (message: string) => { if (!active.current || failed.current) return; failed.current = true; + clearBootstrapTimer(); void onInputConnected(false); webView.current?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); setError(message); @@ -111,11 +116,21 @@ function DeviceStreamDocumentView({ const bootstrapTimedOut = useEffectEvent(() => fail("Device viewer could not start. Reconnect to try again."), ); - useEffect(() => { - if (started) return; - const timer = setTimeout(bootstrapTimedOut, 15_000); - return () => clearTimeout(timer); - }, [started]); + const resetInput = useEffectEvent(() => void onInputConnected(false)); + // The stream lives exactly as long as this view. + useLayoutEffect(() => { + active.current = true; + resetInput(); + const view = webView.current; + bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000); + return () => { + if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + active.current = false; + view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + resetInput(); + }; + }, []); const source = { html: deviceStreamDocument(configuration, deviceStreamScript), baseUrl, @@ -131,16 +146,6 @@ function DeviceStreamDocumentView({ appSwitcher: () => command("appSwitcher"), rotate: () => command("rotate"), })); - const resetInput = useEffectEvent(() => void onInputConnected(false)); - useLayoutEffect(() => { - active.current = true; - resetInput(); - const view = webView.current; - return () => { - active.current = false; - view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); - }; - }, []); const processTerminated = () => { if (!active.current || failed.current) return; void onInputConnected(false); @@ -173,7 +178,7 @@ function DeviceStreamDocumentView({ else if (message?.type === "input") void onInputConnected(message.connected); else if (message?.type === "retry") onRetry(); else if (message?.type === "status") { - setStarted(true); + clearBootstrapTimer(); if (message.status === "error") fail(message.detail ?? "Device stream failed."); else { setStatus(message.status); diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 5756a140e779b..849545b7ed6ef 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -6,7 +6,7 @@ import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -22,7 +22,6 @@ import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; -import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; @@ -95,11 +94,6 @@ export function HomeRouteScreen() { [selectThreadRoute], ); - useEffect(() => { - void checkForAppUpdateOnLaunch(); - startAppUpdateForegroundRecheck(); - }, []); - const { archiveThread, confirmDeleteThread, @@ -143,16 +137,15 @@ export function HomeRouteScreen() { setSelectedProjectKey, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; - const projectScopeKey = listOptions.selectedProjectKey; - const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { - sidebarProjectGroupingMode: listOptions.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }); - useEffect(() => { - if (resolvedProjectKey !== projectScopeKey) { - setSelectedProjectKey(resolvedProjectKey); - } - }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); + // Legacy scratch keys resolve here rather than being rewritten into the saved filter. + const resolvedProjectKey = resolveScratchProjectScopeKey( + listOptions.selectedProjectKey, + projects, + { + sidebarProjectGroupingMode: listOptions.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }, + ); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 7adf6f0e8a5e7..ee8c664add2cb 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -15,7 +15,6 @@ import { } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { useFocusEffect } from "@react-navigation/native"; import { use, useEffect, useRef, useState, type ComponentProps } from "react"; import { Platform, @@ -33,6 +32,7 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; +import { useDeadlinePassed, useNowMinute } from "../../lib/clock"; import { ANDROID_READING_MAX_WIDTH, deriveCenteredContentHorizontalPadding, @@ -244,7 +244,6 @@ function renderHomeScrollView(props: ScrollViewProps) { export function HomeScreen(props: HomeScreenProps) { const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const workspace = useWorkspaceTabs(); - const [initialScrollOffset] = useState(() => workspace?.getScrollOffset("threads") ?? 0); const primaryColumn = use(NativePrimaryColumnContext); const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen"; const containerClassName = cn( @@ -319,15 +318,22 @@ export function HomeScreen(props: HomeScreenProps) { }; const onFabScroll = useFabScroll(); const listRef = useRef(null); + const [initialScrollOffset] = useState(() => workspace?.getScrollOffset("threads") ?? 0); + // Cancelling search restores the offset recorded when search opened. The + // toolbar that cancels has no handle on this list, so the list scrolls itself + // once it sees the tab come back to resting. const searchMode = workspace?.state.tabs.threads.searchMode; const restoredOffset = workspace?.state.tabs.threads.scrollOffset; + const previousSearchMode = useRef(searchMode); useEffect(() => { - if (searchMode !== "resting" || restoredOffset === undefined) return; - listRef.current?.scrollToOffset({ + const restoringSearch = previousSearchMode.current !== "resting" && searchMode === "resting"; + previousSearchMode.current = searchMode; + if (!restoringSearch || restoredOffset === undefined) return; + void listRef.current?.scrollToOffset({ offset: restoredOffset - (Platform.OS === "ios" ? insets.top : 0), animated: false, }); - }, [searchMode, insets.top, restoredOffset]); + }, [searchMode, restoredOffset, insets.top]); const swipeRowActivation = createSwipeRowActivation(); const activateVisibleRows = (rows: ReadonlyArray) => { const state = listRef.current?.getState(); @@ -338,7 +344,6 @@ export function HomeScreen(props: HomeScreenProps) { }; // Status-bar, accessibility and programmatic scrolls bypass the gate; activate visible rows after settling. const activationTimerRef = useRef | undefined>(undefined); - useEffect(() => () => clearTimeout(activationTimerRef.current), []); const handleListScroll = (event: NativeSyntheticEvent) => { onFabScroll?.(event); if (workspace) @@ -362,6 +367,7 @@ export function HomeScreen(props: HomeScreenProps) { const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ onScroll: handleListScroll, onScrollBeginDrag: handleScrollBeginDrag, + onSettle: () => activateVisibleRows(listRef.current?.getState().data ?? []), }); const projectScopes = buildHomeProjectScopes({ @@ -420,15 +426,14 @@ export function HomeScreen(props: HomeScreenProps) { toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); - // Keep time in state; the compiler would cache a render-time new Date(). - const [now, setNow] = useState(() => new Date().toISOString()); + // The list's clock comes from stores: the compiler caches anything computed from + // it on its other inputs, so a render-time `new Date()` would stop moving. The + // minute tick advances it, and so does the next snooze wake below. + const minuteTick = useNowMinute(); + const [wokenAt, setWokenAt] = useState(() => new Date().toISOString()); + const minuteNow = `${minuteTick}:00.000Z`; + const now = minuteNow > wokenAt ? minuteNow : wokenAt; const nowMinute = now.slice(0, 16); - const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - useFocusEffect(() => { - setNow(new Date().toISOString()); - const id = setInterval(() => setNow(new Date().toISOString()), 60_000); - return () => clearInterval(id); - }); const listEnvironments = useAtomValue(threadListEnvironmentsAtom); const { providersByEnvironmentId, @@ -442,7 +447,7 @@ export function HomeScreen(props: HomeScreenProps) { titleRegenerationEnvironmentIds, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); - const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const pendingOrder = usePendingThreadOrder(); const { layout: threadListV2Layout, items: threadListV2Items, @@ -470,18 +475,13 @@ export function HomeScreen(props: HomeScreenProps) { selectedThreadKey, }); const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; - useEffect(() => { - if (nextSnoozeWakeAt === null) return; - const wakeAtMs = Date.parse(nextSnoozeWakeAt); - if (Number.isNaN(wakeAtMs)) return; - const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); - const id = setTimeout(() => { - setNow(new Date().toISOString()); - bumpSnoozeWakeTick((tick) => tick + 1); - }, delayMs); - return () => clearTimeout(id); - // snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. - }, [nextSnoozeWakeAt, snoozeWakeTick]); + const nextSnoozeWakeMs = nextSnoozeWakeAt === null ? null : Date.parse(nextSnoozeWakeAt); + const snoozeWakePassed = useDeadlinePassed(nextSnoozeWakeMs); + // The layout above still used the clock from before the wake. The wake is a + // lower bound for the clock, and exactly the instant the thread unsnoozes at. + if (snoozeWakePassed && nextSnoozeWakeMs !== null && Date.parse(now) < nextSnoozeWakeMs) { + setWokenAt(new Date(nextSnoozeWakeMs).toISOString()); + } // Called as `props.onSelectThread(...)`, the compiler would cache this handler on the whole // props object, which is new on every render of Home's parent. const { onSelectThread } = props; @@ -498,9 +498,6 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread(thread); }; useThreadJumpShortcuts(threadListV2Items, selectThread); - useEffect(() => { - if (swipeEnabled) activateVisibleRows(threadListV2Items); - }, [activateVisibleRows, swipeEnabled, threadListV2Items]); const renderV2Item = ({ item }: { readonly item: ThreadListV2ListItem }) => { if (item.type === "v2-pending") { @@ -788,6 +785,11 @@ export function HomeScreen(props: HomeScreenProps) { itemLayoutAnimation={primaryColumn ? THREAD_SIDEBAR_ROW_LAYOUT : undefined} ref={listRef} onLoad={() => activateVisibleRows(threadListV2Items)} + // Data changes and programmatic scrolls move the visible range + // without a drag; a drag activates once the scroll gate settles. + onViewableItemsChanged={() => { + if (swipeEnabled) activateVisibleRows(listRef.current?.getState().data ?? []); + }} onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)} onTouchCancel={(event) => trackListTouches(event, false)} diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts index 98f53a6c3aec0..8a2224ec0ef05 100644 --- a/apps/mobile/src/features/home/home-route-visibility.ts +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -1,41 +1,55 @@ import { RegistryContext } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; -import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useContext, useRef, useSyncExternalStore } from "react"; import type { Atom } from "effect/reactivity"; -/** Fabric never freezes the route below focus; Home must stop its own work beneath Thread. */ +/** + * Home stays mounted under a pushed Thread, and native-stack never freezes the + * route directly below the focused one on Fabric, so Home has to stop its own + * work. The Home screen's static `listeners` (Stack.tsx) drive this store. + */ +let homeVisible: boolean | null = null; +const visibilityListeners = new Set<() => void>(); + +function setHomeVisible(next: boolean) { + if (next === homeVisible) return; + homeVisible = next; + for (const listener of visibilityListeners) listener(); +} + +function subscribeHomeVisibility(onChange: () => void) { + visibilityListeners.add(onChange); + return () => { + visibilityListeners.delete(onChange); + }; +} + +export function recordHomeTransitionStart(closing: boolean): void { + if (!closing) setHomeVisible(true); +} + +export function recordHomeTransitionEnd(closing: boolean): void { + if (closing) setHomeVisible(false); +} + +/** Popping a sheet or a deep-linked route above Home has no appear event. */ +export function recordHomeFocus(): void { + setHomeVisible(true); +} + export function useHomeRouteVisible(): boolean { - const navigation = - useNavigation>(); - // A cold deep link mounts Home under its Thread without ever showing it. - const [visible, setVisible] = useState(() => navigation.isFocused()); - useEffect(() => { - const show = () => setVisible(true); - const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { - if (!data.closing) show(); - }); - const removeTransitionEnd = navigation.addListener("transitionEnd", ({ data }) => { - if (data.closing) setVisible(false); - }); - // Popping a sheet or a deep-linked route above Home has no appear event. - const removeFocus = navigation.addListener("focus", show); - return () => { - removeTransitionStart(); - removeTransitionEnd(); - removeFocus(); - }; - }, [navigation]); - return visible; + const navigation = useNavigation(); + const visible = useSyncExternalStore(subscribeHomeVisibility, () => homeVisible); + // Before Home's first navigation event: a cold deep link mounts Home under + // its Thread without ever showing it. + return visible ?? navigation.isFocused(); } export function useAtomValueWhileVisible(atom: Atom.Atom, visible: boolean): A { const registry = useContext(RegistryContext); const held = useRef<{ readonly value: A } | null>(null); - const subscribe = useCallback( - (onChange: () => void) => (visible ? registry.subscribe(atom, onChange) : () => undefined), - [registry, atom, visible], - ); + const subscribe = (onChange: () => void) => + visible ? registry.subscribe(atom, onChange) : () => undefined; return useSyncExternalStore(subscribe, () => { if (visible || held.current === null) held.current = { value: registry.get(atom) }; return held.current.value; diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index 698df0bfce702..dfb847b4dc517 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -10,6 +10,7 @@ import { holdEditingQueuedMessage, releaseEditingQueuedMessage, } from "../../state/use-thread-outbox"; +import { refreshNewTaskDraftBranches } from "../threads/new-task-project-branches"; import { withThreadDismissal } from "./thread-dismissal"; export function usePendingTaskListActions( @@ -20,6 +21,7 @@ export function usePendingTaskListActions( } { const openPendingTask = useCallback( (pendingTask: PendingNewTask) => { + if (pendingTask.kind === "draft") refreshNewTaskDraftBranches(pendingTask.draftKey); selectThreadRoute( pendingTask.kind === "pending" ? { diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index eeb06c4e2e303..3bd8a53626a80 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -5,7 +5,6 @@ import { createContext, use, useCallback, - useEffect, useMemo, useRef, useState, @@ -23,6 +22,7 @@ import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useThreadShell } from "../../state/entities"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; +import { withOpenedNewTaskDraft } from "../threads/open-new-task-draft"; import { CommandPalette } from "./CommandPalette"; import { dispatchHardwareKeyboardCommand, @@ -89,14 +89,6 @@ export function HardwareKeyboardCommandProvider({ setCopyFeedback(EMPTY_COPY_FEEDBACK); }, COPY_FEEDBACK_DISMISS_MS); }, []); - useEffect( - () => () => { - if (copyFeedbackTimerRef.current !== null) { - clearTimeout(copyFeedbackTimerRef.current); - } - }, - [], - ); const registrationVersion = useSyncExternalStore( subscribeToHardwareKeyboardCommandRegistrations, getHardwareKeyboardCommandRegistrationVersion, @@ -154,7 +146,9 @@ export function HardwareKeyboardCommandProvider({ } if (command === "newTask") { - navigation.dispatch(selectWorkspaceDetail({ name: "Thread", params: { draft: {} } })); + navigation.dispatch( + selectWorkspaceDetail({ name: "Thread", params: withOpenedNewTaskDraft({ draft: {} }) }), + ); return; } if (command === "back") { diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 872f1f521f79a..efd7980ac8d13 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -1,6 +1,6 @@ import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { AdaptiveWorkspaceContext, useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; -import { createDeferredNavigation } from "./deferred-navigation"; +import { threadSelectionDeferral } from "./deferred-navigation"; import { uuidv4 } from "../../lib/uuid"; import type { EnvironmentProject, @@ -9,17 +9,13 @@ import type { import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; import { - use, - useDeferredValue, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; + NavigationContext, + NavigationRouteContext, + useNavigation, + useNavigationState, +} from "@react-navigation/native"; +import { use, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { @@ -70,6 +66,7 @@ import { startOpeningDetail } from "../workspace/useSelectWorkspaceDetail"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; +import { withOpenedNewTaskDraft } from "../threads/open-new-task-draft"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests"; @@ -124,38 +121,11 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi ); })(); - const focusedRef = useRef(false); - const deactivateRef = useRef<(() => void) | null>(null); - - const syncRegistration = useEffectEvent(() => { - if (!focusedRef.current || wrappedRender === undefined) { - deactivateRef.current?.(); - return; - } - deactivateRef.current = registerWorkspaceInspector(wrappedRender); - }); - - useFocusEffect(() => { - focusedRef.current = true; - syncRegistration(); - return () => { - focusedRef.current = false; - syncRegistration(); - }; - }); - - useEffect(() => { - if (focusedRef.current) { - syncRegistration(); - } - }, [wrappedRender]); - - useEffect( - () => () => { - deactivateRef.current?.(); - deactivateRef.current = null; - }, - [], + // useFocusEffect re-runs when the renderer changes while focused, so the + // pane always shows this render's content, and its cleanup deactivates on + // blur and unmount. + useFocusEffect(() => + wrappedRender === undefined ? undefined : registerWorkspaceInspector(wrappedRender), ); } @@ -351,6 +321,24 @@ function AdaptiveWorkspaceLayoutContent( return null; } })(); + const workspaceRouteName = useNavigationState( + (state) => state?.routes.find((route) => route.key === props.workspaceRouteKey)?.name, + ); + // Opening a review or the files route reveals its inspector once per route + // and thread. Coming back to that route from a screen pushed over it keeps + // the user's choice, so the last revealed key is not cleared on other routes. + const inspectorRevealKey = + (workspaceRouteName === "ThreadReview" || + (workspaceRouteName === "ThreadFiles" && fileInspector.supported)) && + selectedThreadKey !== null + ? `${props.workspaceRouteKey}:${selectedThreadKey}` + : null; + const [revealedInspectorKey, setRevealedInspectorKey] = useState(null); + if (inspectorRevealKey !== null && inspectorRevealKey !== revealedInspectorKey) { + setRevealedInspectorKey(inspectorRevealKey); + setFocusedAuxiliaryPaneRole("inspector"); + setFileInspectorPreferredVisible(true); + } const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; readonly active: boolean; @@ -488,10 +476,13 @@ function AdaptiveWorkspaceLayoutContent( }; const handleOpenSettings = () => openWorkspaceTab("settings"); - const deferredThreadSelection = useMemo(() => createDeferredNavigation(), []); /** The one way to show a page in the detail column; see `selectWorkspaceDetail`. */ - function handleSelectDetail(detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) { - deferredThreadSelection.run(() => { + function handleSelectDetail(selected: WorkspaceDetail, sheet?: WorkspaceDetailSheet) { + threadSelectionDeferral.run(() => { + const detail = + selected.name === "Thread" + ? { ...selected, params: withOpenedNewTaskDraft(selected.params) } + : selected; if (Platform.OS === "android") Keyboard.dismiss(); setFileInspectorPreferredVisible(false); if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false); @@ -749,7 +740,6 @@ function AdaptiveWorkspaceLayoutContent( fileInspector, primarySidebarSearchQuery, startNewTask: handleStartNewTask, - deferThreadSelectionUntil: deferredThreadSelection.waitFor, selectDetail: handleSelectDetail, activateAuxiliaryPaneRole, registerWorkspaceInspector, diff --git a/apps/mobile/src/features/layout/adaptive-workspace-context.ts b/apps/mobile/src/features/layout/adaptive-workspace-context.ts index 4f5f2033e029a..3e80c9d275e13 100644 --- a/apps/mobile/src/features/layout/adaptive-workspace-context.ts +++ b/apps/mobile/src/features/layout/adaptive-workspace-context.ts @@ -20,7 +20,6 @@ interface AdaptiveWorkspaceContextValue { readonly fileInspector: FileInspectorPaneLayout; readonly primarySidebarSearchQuery: string; readonly startNewTask: () => void; - readonly deferThreadSelectionUntil: (operation: Promise) => void; /** Use `useSelectWorkspaceDetail`, which also works outside the layout. */ readonly selectDetail?: (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => void; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; @@ -62,7 +61,6 @@ export const AdaptiveWorkspaceContext = createContext undefined, - deferThreadSelectionUntil: () => undefined, activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, diff --git a/apps/mobile/src/features/layout/deferred-navigation.ts b/apps/mobile/src/features/layout/deferred-navigation.ts index 8da68b10b394c..579e1fed0a1cd 100644 --- a/apps/mobile/src/features/layout/deferred-navigation.ts +++ b/apps/mobile/src/features/layout/deferred-navigation.ts @@ -20,3 +20,9 @@ export function createDeferredNavigation() { }, }; } + +/** + * The app's one thread-selection gate: a new-task branch checkout holds + * split-view thread selection until the draft route can be left. + */ +export const threadSelectionDeferral = createDeferredNavigation(); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index fe2cb546a3ca2..6958600841a89 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -49,6 +49,10 @@ vi.mock("../../components/EnvironmentMachineSymbol", () => ({ EnvironmentMachineSymbol: "EnvironmentMachineSymbol", })); vi.mock("../../components/ui/ErrorBanner", () => ({ ErrorBanner: "ErrorBanner" })); +// Opening a draft route stamps a persisted draft; these tests cover navigation only. +vi.mock("../threads/open-new-task-draft", () => ({ + withOpenedNewTaskDraft: (params: Params) => params, +})); vi.mock("./SourceControlIcon", () => ({ SourceControlIcon: "SourceControlIcon" })); vi.mock("../../lib/uuid", () => ({ uuidv4: () => "project" })); vi.mock("../../state/session", () => ({ diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 6de42ccd7a1bc..e73707ecf6cb6 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -93,6 +93,7 @@ import { useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; +import { withOpenedNewTaskDraft } from "../threads/open-new-task-draft"; import { createNewTaskDraft, setComposerDraftText, @@ -291,23 +292,29 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto const environmentId = environment?.environmentId ?? null; const environmentBaseDirectory = environment?.baseDirectory ?? null; const clonePathCaseSensitive = !isWindowsPlatform(environment?.platform ?? ""); - const [pathInput, commitPathInput] = useState(() => - getCloneDestinationPath( - getAddProjectInitialQuery(environmentBaseDirectory), - pinnedDirectoryName, - ), + const seedPath = getCloneDestinationPath( + getAddProjectInitialQuery(environmentBaseDirectory), + pinnedDirectoryName, ); - const previousEnvironmentIdRef = useRef(environmentId); + // Path edits belong to the environment they were made for; switching to another + // environment shows its default path, and a late browse commit for the old one is ignored. + const [pathState, setPathState] = useState({ environmentId, path: seedPath }); + const pathInput = + environmentId === null || pathState.environmentId === environmentId ? pathState.path : seedPath; + const commitPathInput = (path: string) => setPathState({ environmentId, path }); const environmentRuntime = useRemoteEnvironmentRuntime(environmentId); const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { reportFailure: false, reportDefect: false, }); const [browseNavigation] = useState(createBrowseNavigationCoordinator); - const [isBrowseNavigating, setIsBrowseNavigating] = useState(false); + const [navigatingEnvironmentId, setNavigatingEnvironmentId] = useState< + EnvironmentId | null | undefined + >(undefined); + const isBrowseNavigating = navigatingEnvironmentId === environmentId; const setPathInput = (path: string) => { browseNavigation.invalidate(); - setIsBrowseNavigating(false); + setNavigatingEnvironmentId(undefined); commitPathInput(path); }; const navigateToBrowsePath = async (input: { @@ -326,7 +333,7 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto caseSensitive: clonePathCaseSensitive, }) : getCloneDestinationPath(selectedDirectoryPath, pinnedDirectoryName); - setIsBrowseNavigating(true); + setNavigatingEnvironmentId(environmentId); const committed = await browseNavigation.run( async () => { if ( @@ -343,23 +350,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto () => commitPathInput(nextPathInput), ); if (committed) { - setIsBrowseNavigating(false); + setNavigatingEnvironmentId(undefined); } return committed; }; - useEffect(() => { - if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { - previousEnvironmentIdRef.current = environmentId; - setPathInput( - getCloneDestinationPath( - getAddProjectInitialQuery(environmentBaseDirectory), - pinnedDirectoryName, - ), - ); - } - }, [environmentBaseDirectory, environmentId, pinnedDirectoryName, setPathInput]); - useEffect( () => () => { browseNavigation.invalidate(); @@ -668,7 +663,7 @@ function openNewTaskDraft( const threadIndex = state.routes.findLastIndex((route) => route.name === "Thread"); const routes = [ ...state.routes.slice(0, threadIndex < 0 ? state.index : threadIndex), - { key: uuidv4(), name: "Thread", params: { draft: params } }, + { key: uuidv4(), name: "Thread", params: withOpenedNewTaskDraft({ draft: params }) }, ]; navigation.dispatch(CommonActions.reset({ ...state, index: routes.length - 1, routes })); } diff --git a/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts b/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts new file mode 100644 index 0000000000000..a1311a5601f9b --- /dev/null +++ b/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts @@ -0,0 +1,377 @@ +import * as Dialog from "../../components/ui/Dialog"; +import { + StackActions, + type NavigationState, + type PartialState, + type StackActionType, +} from "@react-navigation/native"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; +import { + findPromptLinkProject, + findPromptLinkPullRequestThread, + resolvePromptLinkModelSelection, +} from "@t3tools/client-runtime/prompt-links"; +import { + executeAtomQuery, + isAtomCommandInterrupted, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + parseNewThreadLink, + type LinkedPrompt, + type PromptLinkPullRequest, + type PromptLinkRepository, +} from "@t3tools/shared/promptLinks"; +import { Atom } from "effect/reactivity"; + +import { findLinkedRoute, updateLinkedRoute } from "../../lib/appLinking"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentProjects, projectEnvironment } from "../../state/projects"; +import { composerPullRequests } from "../../state/pull-requests"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { environmentThreadShells } from "../../state/threads"; +import { + createNewTaskDraft, + setComposerDraftText, + updateComposerDraftSettings, + waitForComposerDraftsLoaded, +} from "../../state/use-composer-drafts"; +import { workspaceConnections, workspaceStateAtom } from "../../state/workspace"; +import { + INACTIVE_FLOW_KEY, + newTaskFlowKey, + readNewTaskFlowState, +} from "../threads/new-task-flow-state"; +import { refreshNewTaskDraftBranches } from "../threads/new-task-project-branches"; +import { isDraftThreadRoute } from "../threads/thread-route-state"; +import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions"; +import { + promptLinkNotice, + promptLinkRepositoryName, + latestThreadEnvironmentIds, + promptLinkScratchEnvironmentId, + promptLinkSearchFromParams, + stepPendingPromptLink, + withoutPromptLinkParams, + type PendingPromptLink, +} from "./prompt-link-model"; + +const NEW_TASK_ROUTES = ["NewTaskProject"]; + +type LinkedDraftRequest = { + readonly prompt: LinkedPrompt | null; + readonly provider: string | null; + readonly model: string | null; + readonly pullRequest: PromptLinkPullRequest | null; +}; + +// A cold launch from a link has no projects or threads yet. Matching then would offer to clone a +// repository the user already has. +const shellsSettledAtom = Atom.make((get) => { + const workspace = get(workspaceStateAtom); + return !( + workspace.isLoadingConnections || + workspace.hasPendingShellSnapshot || + // A machine still connecting may hold the repository or thread. One that keeps failing to + // reconnect has settled offline, and must not hold every link. + workspace.connectingEnvironments.some( + (environment) => + environment.connectionState === "connecting" || environment.connectionError === null, + ) + ); +}).pipe(Atom.withLabel("mobile:prompt-link-shells-settled")); + +let pendingLink: PendingPromptLink | null = null; +let stopWaitingForShells: (() => void) | null = null; + +/** + * `new?prompt=…&repo=…` drops its params as it arrives, so the screen never sees them and each + * opening resolves once. It resolves when its screen is on the stack and the shells have + * settled; a newer link replaces one still waiting. + */ +export function takeNewTaskPromptLink( + state: PartialState | undefined, +): PartialState | undefined { + const route = findLinkedRoute(state, NEW_TASK_ROUTES); + const search = promptLinkSearchFromParams( + route?.params as Readonly> | undefined, + ); + if (search === null) return state; + pendingLink = { search, arrived: false }; + return updateLinkedRoute(state, NEW_TASK_ROUTES, (linked) => ({ + ...linked, + params: withoutPromptLinkParams(linked.params), + })); +} + +/** Wired to the navigation container's state changes. */ +export function resolvePendingNewTaskPromptLink(state: NavigationState | undefined): void { + if (pendingLink === null) return; + const step = stepPendingPromptLink(pendingLink, { + screenOnStack: state?.routes.some((route) => route.name === "NewTaskProject") === true, + shellsSettled: appAtomRegistry.get(shellsSettledAtom), + }); + if (step.pending === null) dropPendingLink(); + else pendingLink = step.pending; + if (step.resolve !== null) resolveLink(step.resolve); + else if (step.pending?.arrived) { + stopWaitingForShells ??= appAtomRegistry.subscribe(shellsSettledAtom, (settled) => { + if (settled) resolvePendingNewTaskPromptLink(rootNavigationRef.getRootState()); + }); + } +} + +function dropPendingLink(): void { + pendingLink = null; + stopWaitingForShells?.(); + stopWaitingForShells = null; +} + +/** Dispatches as the new-task screen would, so `replace` swaps that screen out. */ +function dispatchFromNewTask(action: StackActionType): void { + const root = rootNavigationRef.getRootState(); + const source = root?.routes.findLast((route) => route.name === "NewTaskProject")?.key; + rootNavigationRef.dispatch({ + ...action, + ...(root ? { target: root.key } : {}), + ...(source ? { source } : {}), + }); +} + +function selectedEnvironmentId(): EnvironmentId | null { + const root = rootNavigationRef.getRootState(); + const draftRoute = root?.routes.findLast(isDraftThreadRoute); + const override = readNewTaskFlowState( + (draftRoute && newTaskFlowKey(draftRoute)) ?? INACTIVE_FLOW_KEY, + ).environmentId; + const projects = appAtomRegistry.get(environmentProjects.projectsAtom); + return override !== null && projects.some((project) => project.environmentId === override) + ? override + : (projects[0]?.environmentId ?? null); +} + +function writeDraft( + draftKey: string, + environmentId: EnvironmentId, + request: LinkedDraftRequest, +): boolean { + if (request.prompt) setComposerDraftText(draftKey, request.prompt.text); + if (request.provider === null) return false; + const resolved = resolvePromptLinkModelSelection( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.providers ?? [], + { provider: request.provider, model: request.model }, + ); + if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection }); + return resolved?.exact !== true; +} + +async function openProjectDraft(project: EnvironmentProject, request: LinkedDraftRequest) { + try { + await waitForComposerDraftsLoaded(); + } catch { + Dialog.alert("Could not open link", "Drafts could not be loaded, so the prompt was not added."); + return; + } + const draftKey = createNewTaskDraft({ + environmentId: project.environmentId, + projectId: project.id, + }); + let pullRequestUnavailable: number | null = null; + if (request.pullRequest) { + const detailAtom = composerPullRequests.detail({ + environmentId: project.environmentId, + input: { + projectId: project.id, + repository: promptLinkRepositoryName(request.pullRequest), + number: request.pullRequest.number, + }, + }); + const detail = await executeAtomQuery(appAtomRegistry, detailAtom, { + label: detailAtom.label?.[0] ?? "atom query", + reportFailure: false, + }); + if (detail._tag === "Success") { + updateComposerDraftSettings(draftKey, { + workspaceSelection: { + mode: "worktree", + branch: detail.value.headBranch, + worktreePath: null, + startFromOrigin: false, + pullRequestReference: detail.value.url, + }, + }); + } else { + pullRequestUnavailable = request.pullRequest.number; + } + } + const modelUnavailable = writeDraft(draftKey, project.environmentId, request); + refreshNewTaskDraftBranches(draftKey); + dispatchFromNewTask( + StackActions.push("Thread", { + draft: { + draftId: draftKey, + title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title, + }, + }), + ); + if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) { + showPromptLinkNotice( + promptLinkNotice({ + hiddenCharacters: request.prompt?.hiddenCharacters ?? 0, + modelUnavailable, + pullRequestUnavailable, + }), + ); + } +} + +async function openScratchDraft(request: LinkedDraftRequest) { + const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); + const environmentId = promptLinkScratchEnvironmentId( + latestThreadEnvironmentIds(appAtomRegistry.get(environmentThreadShells.threadShellsAtom)), + appAtomRegistry + .get(workspaceConnections.environmentsAtom) + .filter( + (environment) => + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, + ) + .map((environment) => environment.environmentId), + ); + if (environmentId === null) { + Dialog.alert( + "Could not start without a project", + "No connected environment offers threads without a project.", + ); + return; + } + const result = await runAtomCommand( + appAtomRegistry, + projectEnvironment.openScratch, + { environmentId, input: {} }, + { reportFailure: false }, + ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Could not start without a project", + error instanceof Error + ? error.message + : "The folder for threads without a project could not be created.", + ); + } + return; + } + await openProjectDraft(result.value, { ...request, pullRequest: null }); +} + +function cloneRepository(repository: PromptLinkRepository, request: LinkedDraftRequest) { + const environmentId = + selectedEnvironmentId() ?? + appAtomRegistry.get(workspaceConnections.environmentsAtom)[0]?.environmentId ?? + null; + if (environmentId === null) { + Dialog.alert("Could not clone", "Connect an environment first."); + return; + } + const prompt = + request.pullRequest === null && + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .projectCloneTracking === true + ? request.prompt + : null; + const name = promptLinkRepositoryName(repository); + dispatchFromNewTask( + StackActions.push("AddProjectDestination", { + environmentId, + remoteUrl: `https://github.com/${name}.git`, + repositoryTitle: name, + repositoryName: repository.name, + ...(prompt ? { prompt: prompt.text } : {}), + }), + ); + if (!request.prompt) return; + showPromptLinkNotice( + [ + promptLinkNotice({ + hiddenCharacters: request.prompt.hiddenCharacters, + modelUnavailable: prompt !== null && request.provider !== null, + }), + prompt + ? "The prompt opens in a new task once the clone starts." + : "Open the link again after the clone to get the prompt.", + ].join("\n\n"), + ); +} + +function resolveLink(linkSearch: string) { + const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const parsed = parseNewThreadLink(linkSearch); + if (!parsed.ok) { + Dialog.alert("Could not open link", parsed.error); + return; + } + const link = parsed.value; + const request: LinkedDraftRequest = + link.kind === "new" + ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null } + : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest }; + + if (link.kind === "pull-request") { + const thread = findPromptLinkPullRequestThread(threads, link.pullRequest); + if (thread) { + dispatchFromNewTask( + StackActions.replace("Thread", { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }), + ); + if (link.prompt) { + void applyLinkedPromptToThreadDraft( + scopedThreadKey(thread.environmentId, thread.id), + link.prompt, + promptLinkNotice({ hiddenCharacters: link.prompt.hiddenCharacters }), + ); + } + return; + } + } + + const repository = link.kind === "new" ? link.repository : link.pullRequest; + if (repository === null) { + void openScratchDraft(request); + return; + } + const lastActivity = new Map(); + for (const thread of threads) { + const key = `${thread.environmentId}:${thread.projectId}`; + if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt); + } + const activityOf = (project: EnvironmentProject) => + lastActivity.get(`${project.environmentId}:${project.id}`) ?? ""; + const project = findPromptLinkProject( + [...appAtomRegistry.get(environmentProjects.projectsAtom)].sort((left, right) => + activityOf(right).localeCompare(activityOf(left)), + ), + repository, + ); + if (project) { + void openProjectDraft(project, request); + return; + } + Dialog.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [ + { text: "Cancel", style: "cancel" }, + ...(request.prompt + ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }] + : []), + { text: "Clone", onPress: () => cloneRepository(repository, request) }, + ]); +} diff --git a/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts b/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts index 9bf74ef042ddc..7cafb94a55026 100644 --- a/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts +++ b/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts @@ -1,7 +1,11 @@ import { parseNewThreadLink } from "@t3tools/shared/promptLinks"; import { describe, expect, it } from "vite-plus/test"; -import { promptLinkSearchFromParams } from "./prompt-link-model"; +import { + promptLinkSearchFromParams, + stepPendingPromptLink, + withoutPromptLinkParams, +} from "./prompt-link-model"; describe("promptLinkSearchFromParams", () => { it("round-trips decoded route params into the shared parser", () => { @@ -24,3 +28,39 @@ describe("promptLinkSearchFromParams", () => { }); }); }); + +describe("stepPendingPromptLink", () => { + const waiting = { search: "repo=t3tools%2Ft3code", arrived: false }; + + it("waits for the link's screen before it can be abandoned", () => { + expect(stepPendingPromptLink(waiting, { screenOnStack: false, shellsSettled: true })).toEqual({ + pending: waiting, + resolve: null, + }); + }); + + it("holds an arrived link until the shells settle, then resolves it once", () => { + const arrived = stepPendingPromptLink(waiting, { screenOnStack: true, shellsSettled: false }); + expect(arrived).toEqual({ pending: { ...waiting, arrived: true }, resolve: null }); + expect( + stepPendingPromptLink(arrived.pending!, { screenOnStack: true, shellsSettled: true }), + ).toEqual({ pending: null, resolve: waiting.search }); + }); + + it("drops a link whose screen was left while the shells were loading", () => { + expect( + stepPendingPromptLink( + { ...waiting, arrived: true }, + { screenOnStack: false, shellsSettled: false }, + ), + ).toEqual({ pending: null, resolve: null }); + }); +}); + +describe("withoutPromptLinkParams", () => { + it("keeps the route's other params", () => { + expect(withoutPromptLinkParams({ prompt: "hi", repo: "a/b", incomingShareId: "1" })).toEqual({ + incomingShareId: "1", + }); + }); +}); diff --git a/apps/mobile/src/features/prompt-links/prompt-link-model.ts b/apps/mobile/src/features/prompt-links/prompt-link-model.ts index 5872b8c9f7260..d1d88dba49aaf 100644 --- a/apps/mobile/src/features/prompt-links/prompt-link-model.ts +++ b/apps/mobile/src/features/prompt-links/prompt-link-model.ts @@ -17,8 +17,12 @@ export function promptLinkSearchFromParams( return search.size > 0 ? search.toString() : null; } -export function clearedPromptLinkParams(keys: ReadonlyArray = PROMPT_LINK_PARAM_KEYS) { - return Object.fromEntries(keys.map((key) => [key, undefined])); +export function withoutPromptLinkParams(params: object | undefined): Record { + return Object.fromEntries( + Object.entries(params ?? {}).filter( + ([key]) => !(PROMPT_LINK_PARAM_KEYS as ReadonlyArray).includes(key), + ), + ); } export function promptLinkNotice(input: { @@ -73,3 +77,21 @@ export function latestThreadEnvironmentIds( export const promptLinkRepositoryName = (repository: PromptLinkRepository) => `${repository.owner}/${repository.name}`; + +export interface PendingPromptLink { + readonly search: string; + /** Set once the link's screen is on the stack; leaving it after that drops the link. */ + readonly arrived: boolean; +} + +/** Advances a link waiting for its screen and settled shells; `resolve` is the search to open. */ +export function stepPendingPromptLink( + pending: PendingPromptLink, + input: { readonly screenOnStack: boolean; readonly shellsSettled: boolean }, +): { readonly pending: PendingPromptLink | null; readonly resolve: string | null } { + if (!input.screenOnStack) { + return { pending: pending.arrived ? null : pending, resolve: null }; + } + if (input.shellsSettled) return { pending: null, resolve: pending.search }; + return { pending: pending.arrived ? pending : { ...pending, arrived: true }, resolve: null }; +} diff --git a/apps/mobile/src/features/prompt-links/threadPromptLink.ts b/apps/mobile/src/features/prompt-links/threadPromptLink.ts new file mode 100644 index 0000000000000..99374fda3f436 --- /dev/null +++ b/apps/mobile/src/features/prompt-links/threadPromptLink.ts @@ -0,0 +1,52 @@ +import * as Dialog from "../../components/ui/Dialog"; +import type { NavigationState, PartialState } from "@react-navigation/native"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { parseThreadLinkPrompt } from "@t3tools/shared/promptLinks"; + +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { applyLinkedPromptToThreadDraft } from "./prompt-link-actions"; +import { promptLinkNotice, promptLinkSearchFromParams } from "./prompt-link-model"; + +const THREAD_LINK_KEYS = ["prompt"]; + +function firstString(value: unknown): string | null { + const first = Array.isArray(value) ? value[0] : value; + return typeof first === "string" && first.length > 0 ? first : null; +} + +/** + * `threads//?prompt=…` fills that thread's composer draft. + * The link applies the prompt as it arrives and drops the param, so the thread + * screen never sees it and a later visit cannot apply it again. + */ +export function takeThreadPromptLink( + state: PartialState | undefined, +): PartialState | undefined { + if (!state) return state; + const index = state.routes.findIndex((route) => route.name === "Thread"); + const route = state.routes[index]; + const params = route?.params as Readonly> | undefined; + const search = promptLinkSearchFromParams(params, THREAD_LINK_KEYS); + if (!route || !params || search === null) return state; + const { prompt: _prompt, ...rest } = params; + const routes = state.routes.slice(); + routes[index] = { ...route, params: rest }; + const nextState = { ...state, routes }; + + const environmentId = firstString(params.environmentId); + const threadId = firstString(params.threadId); + if (environmentId === null || threadId === null) return nextState; + const parsed = parseThreadLinkPrompt(search); + if (!parsed.ok) { + Dialog.alert("Could not open link", parsed.error); + return nextState; + } + if (parsed.value !== null) { + void applyLinkedPromptToThreadDraft( + scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)), + parsed.value, + promptLinkNotice({ hiddenCharacters: parsed.value.hiddenCharacters }), + ); + } + return nextState; +} diff --git a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts deleted file mode 100644 index 4a3557c19dd67..0000000000000 --- a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts +++ /dev/null @@ -1,296 +0,0 @@ -import * as Dialog from "../../components/ui/Dialog"; -import { StackActions, useNavigation } from "@react-navigation/native"; -import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; -import { - findPromptLinkProject, - findPromptLinkPullRequestThread, - resolvePromptLinkModelSelection, -} from "@t3tools/client-runtime/prompt-links"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { - parseNewThreadLink, - type LinkedPrompt, - type PromptLinkPullRequest, - type PromptLinkRepository, -} from "@t3tools/shared/promptLinks"; -import { useEffect, useEffectEvent, useRef } from "react"; - -import { scopedThreadKey } from "../../lib/scopedEntities"; -import { useServerConfigs, useThreadShells } from "../../state/entities"; -import { projectEnvironment } from "../../state/projects"; -import { composerPullRequests } from "../../state/pull-requests"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; -import { - createNewTaskDraft, - setComposerDraftText, - updateComposerDraftSettings, - waitForComposerDraftsLoaded, -} from "../../state/use-composer-drafts"; -import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; -import { useWorkspaceState } from "../../state/workspace"; -import { useNewTaskFlow } from "../threads/new-task-flow-provider"; -import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions"; -import { - clearedPromptLinkParams, - promptLinkNotice, - promptLinkRepositoryName, - latestThreadEnvironmentIds, - promptLinkScratchEnvironmentId, - promptLinkSearchFromParams, -} from "./prompt-link-model"; - -type LinkedDraftRequest = { - readonly prompt: LinkedPrompt | null; - readonly provider: string | null; - readonly model: string | null; - readonly pullRequest: PromptLinkPullRequest | null; -}; - -export function usePromptLinkResolver(params: Readonly> | undefined) { - const navigation = useNavigation(); - const flow = useNewTaskFlow(); - const { state: catalogState } = useWorkspaceState(); - const threads = useThreadShells(); - const serverConfigs = useServerConfigs(); - const { connectedEnvironments } = useRemoteConnectionStatus(); - const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); - const loadPullRequest = useAtomQueryRunner(composerPullRequests.detail, { - reportFailure: false, - }); - const search = promptLinkSearchFromParams(params); - // Wait for cold-launch shells or existing repositories may be offered for cloning. - const waitingForShells = - catalogState.isLoadingConnections || - catalogState.hasPendingShellSnapshot || - // Connecting machines may hold the target; persistently offline ones must not block every link. - catalogState.connectingEnvironments.some( - (environment) => - environment.connectionState === "connecting" || environment.connectionError === null, - ); - - const writeDraft = ( - draftKey: string, - environmentId: EnvironmentId, - request: LinkedDraftRequest, - ): boolean => { - if (request.prompt) setComposerDraftText(draftKey, request.prompt.text); - if (request.provider === null) return false; - const resolved = resolvePromptLinkModelSelection( - serverConfigs.get(environmentId)?.providers ?? [], - { provider: request.provider, model: request.model }, - ); - if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection }); - return resolved?.exact !== true; - }; - - const openProjectDraft = async (project: EnvironmentProject, request: LinkedDraftRequest) => { - try { - await waitForComposerDraftsLoaded(); - } catch { - Dialog.alert( - "Could not open link", - "Drafts could not be loaded, so the prompt was not added.", - ); - return; - } - const draftKey = createNewTaskDraft({ - environmentId: project.environmentId, - projectId: project.id, - }); - let pullRequestUnavailable: number | null = null; - if (request.pullRequest) { - const detail = await loadPullRequest({ - environmentId: project.environmentId, - input: { - projectId: project.id, - repository: promptLinkRepositoryName(request.pullRequest), - number: request.pullRequest.number, - }, - }); - if (detail._tag === "Success") { - updateComposerDraftSettings(draftKey, { - workspaceSelection: { - mode: "worktree", - branch: detail.value.headBranch, - worktreePath: null, - startFromOrigin: false, - pullRequestReference: detail.value.url, - }, - }); - } else { - pullRequestUnavailable = request.pullRequest.number; - } - } - const modelUnavailable = writeDraft(draftKey, project.environmentId, request); - navigation.dispatch( - StackActions.push("Thread", { - draft: { - draftId: draftKey, - title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title, - }, - }), - ); - if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) { - showPromptLinkNotice( - promptLinkNotice({ - hiddenCharacters: request.prompt?.hiddenCharacters ?? 0, - modelUnavailable, - pullRequestUnavailable, - }), - ); - } - }; - - const openScratchDraft = async (request: LinkedDraftRequest) => { - const environmentId = promptLinkScratchEnvironmentId( - latestThreadEnvironmentIds(threads), - connectedEnvironments - .filter( - (environment) => - availableScratchWorkspaceRoot( - environment.connectionState, - serverConfigs.get(environment.environmentId), - ) !== null, - ) - .map((environment) => environment.environmentId), - ); - if (environmentId === null) { - Dialog.alert( - "Could not start without a project", - "No connected environment offers threads without a project.", - ); - return; - } - const result = await openScratch({ environmentId, input: {} }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Dialog.alert( - "Could not start without a project", - error instanceof Error - ? error.message - : "The folder for threads without a project could not be created.", - ); - } - return; - } - await openProjectDraft(result.value, { ...request, pullRequest: null }); - }; - - const cloneRepository = (repository: PromptLinkRepository, request: LinkedDraftRequest) => { - const environmentId = - flow.selectedEnvironmentId ?? connectedEnvironments[0]?.environmentId ?? null; - if (environmentId === null) { - Dialog.alert("Could not clone", "Connect an environment first."); - return; - } - const prompt = - request.pullRequest === null && - serverConfigs.get(environmentId)?.environment.capabilities.projectCloneTracking === true - ? request.prompt - : null; - const name = promptLinkRepositoryName(repository); - navigation.dispatch( - StackActions.push("AddProjectDestination", { - environmentId, - remoteUrl: `https://github.com/${name}.git`, - repositoryTitle: name, - repositoryName: repository.name, - ...(prompt ? { prompt: prompt.text } : {}), - }), - ); - if (!request.prompt) return; - showPromptLinkNotice( - [ - promptLinkNotice({ - hiddenCharacters: request.prompt.hiddenCharacters, - modelUnavailable: prompt !== null && request.provider !== null, - }), - prompt - ? "The prompt opens in a new task once the clone starts." - : "Open the link again after the clone to get the prompt.", - ].join("\n\n"), - ); - }; - - const resolveLink = useEffectEvent((linkSearch: string) => { - const parsed = parseNewThreadLink(linkSearch); - if (!parsed.ok) { - Dialog.alert("Could not open link", parsed.error); - return; - } - const link = parsed.value; - const request: LinkedDraftRequest = - link.kind === "new" - ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null } - : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest }; - - if (link.kind === "pull-request") { - const thread = findPromptLinkPullRequestThread(threads, link.pullRequest); - if (thread) { - (navigation.getParent() ?? navigation).dispatch( - StackActions.replace("Thread", { - environmentId: String(thread.environmentId), - threadId: String(thread.id), - }), - ); - if (link.prompt) { - void applyLinkedPromptToThreadDraft( - scopedThreadKey(thread.environmentId, thread.id), - link.prompt, - promptLinkNotice({ hiddenCharacters: link.prompt.hiddenCharacters }), - ); - } - return; - } - } - - const repository = link.kind === "new" ? link.repository : link.pullRequest; - if (repository === null) { - void openScratchDraft(request); - return; - } - const lastActivity = new Map(); - for (const thread of threads) { - const key = `${thread.environmentId}:${thread.projectId}`; - if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt); - } - const activityOf = (project: EnvironmentProject) => - lastActivity.get(`${project.environmentId}:${project.id}`) ?? ""; - const project = findPromptLinkProject( - flow.projectScopes - .flatMap((scope) => scope.projects) - .sort((left, right) => activityOf(right).localeCompare(activityOf(left))), - repository, - ); - if (project) { - void openProjectDraft(project, request); - return; - } - Dialog.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [ - { text: "Cancel", style: "cancel" }, - ...(request.prompt - ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }] - : []), - { text: "Clone", onPress: () => cloneRepository(repository, request) }, - ]); - }); - - const handledSearchRef = useRef(null); - useEffect(() => { - if (search === null) { - handledSearchRef.current = null; - return; - } - if (waitingForShells || handledSearchRef.current === search) return; - handledSearchRef.current = search; - navigation.setParams(clearedPromptLinkParams()); - resolveLink(search); - }, [navigation, search, waitingForShells]); -} diff --git a/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts b/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts deleted file mode 100644 index f01f5c39643a3..0000000000000 --- a/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as Dialog from "../../components/ui/Dialog"; -import { useNavigation } from "@react-navigation/native"; -import { parseThreadLinkPrompt } from "@t3tools/shared/promptLinks"; -import { useEffect, useRef } from "react"; - -import { applyLinkedPromptToThreadDraft } from "./prompt-link-actions"; -import { - clearedPromptLinkParams, - promptLinkNotice, - promptLinkSearchFromParams, -} from "./prompt-link-model"; - -const THREAD_LINK_KEYS = ["prompt"]; - -export function useThreadPromptLink( - params: Readonly> | undefined, - threadDraftKey: string | null, -) { - const navigation = useNavigation(); - const search = promptLinkSearchFromParams(params, THREAD_LINK_KEYS); - const handledSearchRef = useRef(null); - useEffect(() => { - if (search === null) { - handledSearchRef.current = null; - return; - } - if (threadDraftKey === null || handledSearchRef.current === search) return; - handledSearchRef.current = search; - navigation.setParams(clearedPromptLinkParams(THREAD_LINK_KEYS)); - const parsed = parseThreadLinkPrompt(search); - if (!parsed.ok) { - Dialog.alert("Could not open link", parsed.error); - return; - } - if (parsed.value === null) return; - void applyLinkedPromptToThreadDraft( - threadDraftKey, - parsed.value, - promptLinkNotice({ hiddenCharacters: parsed.value.hiddenCharacters }), - ); - }, [navigation, search, threadDraftKey]); -} diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index e1d9d87151069..02349696d77b9 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -36,6 +36,7 @@ import { updateComposerDraftSettings, waitForComposerDraftsLoaded, } from "../../state/use-composer-drafts"; +import { refreshNewTaskDraftBranches } from "../threads/new-task-project-branches"; import { usePullRequestConnected } from "./usePullRequestConnected"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { @@ -160,7 +161,10 @@ export function usePullRequestThreads( ); previousPrompts.current.set(draftKey, pullRequestAgentPrompt(task, terminology)); if (thread) openThread(thread); - else openThreadRoute({ draft: { draftId: draftKey, title: `Ask about #${pr.number}` } }); + else { + refreshNewTaskDraftBranches(draftKey); + openThreadRoute({ draft: { draftId: draftKey, title: `Ask about #${pr.number}` } }); + } }); pending.current = false; setBusy(false); diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index ff806c53ec036..433a353e99c36 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -5,7 +5,7 @@ import { UserProfileView, } from "@clerk/expo/native-components"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useLayoutEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; @@ -20,15 +20,8 @@ const USER_PROFILE_CUSTOM_PAGES = [ }, ] satisfies UserProfileCustomPage[]; +// Without cloud config the app has no entry point here and appLinking drops the deep link. export function SettingsAuthRouteScreen() { - const navigation = useNavigation(); - - useLayoutEffect(() => { - if (!hasCloudPublicConfig()) { - navigation.goBack(); - } - }, [navigation]); - return hasCloudPublicConfig() ? : null; } @@ -41,6 +34,8 @@ function ConfiguredSettingsAuthRouteScreen() { const [hasBeenSignedIn, setHasBeenSignedIn] = useState(isSignedIn === true); if (isSignedIn && !hasBeenSignedIn) setHasBeenSignedIn(true); + // Clerk reports sign-out only through its auth store (the native profile view + // and the T3 Connect page can both sign out), so this follows that store. useEffect(() => { if (hasBeenSignedIn && isLoaded && isSignedIn === false) { navigation.goBack(); diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 99d3fa658cf94..2233e2d85ce07 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -32,7 +32,7 @@ import { WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; -import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { AppState, Platform, Pressable, TextInput as RNTextInput, View } from "react-native"; @@ -449,15 +449,9 @@ export function SettingsScheduledTasksRouteScreen() { ); } +// The "New task" action and takeScheduledTaskNewLink clear the editor before +// this screen opens, so it starts from the default target. export function SettingsScheduledTaskNewRouteScreen() { - const { resetEditor } = useScheduledTaskEditor(); - const { availableTargets } = useSettingsEnvironmentFilter(); - const initialized = useRef(false); - useLayoutEffect(() => { - if (initialized.current || availableTargets.length === 0) return; - initialized.current = true; - resetEditor(); - }, [availableTargets.length, resetEditor]); return ; } @@ -479,7 +473,8 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); + // Set once the save lands, so the removal guard lets the editor close. + const saved = useRef(false); const ownerKey = editor ? `${voiceOwnerKey}:${editor.environmentId}` : null; const prompt = editor?.draft.prompt ?? ""; const [selectionState, setSelectionState] = useState<{ @@ -518,8 +513,12 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } }, disabled: saving, }); - const preventRemove = !saved && (hasChanges || saving); + const preventRemove = hasChanges || saving; usePreventRemove(preventRemove, ({ data }) => { + if (saved.current) { + navigation.dispatch(data.action); + return; + } if (saving) { Dialog.alert("Saving task", "Wait for the task to finish saving before leaving."); return; @@ -537,13 +536,6 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } }, ]); }); - useEffect(() => { - if (!saved) return; - const frame = requestAnimationFrame(() => { - if (navigation.isFocused()) navigation.goBack(); - }); - return () => cancelAnimationFrame(frame); - }, [navigation, saved]); return ( {Platform.OS === "ios" ? ( @@ -599,7 +591,10 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } setDraft={(draft) => { if (!saving) setEditor({ ...editor, draft }); }} - onSaved={() => setSaved(true)} + onSaved={() => { + saved.current = true; + if (navigation.isFocused()) navigation.goBack(); + }} onChangeEnvironment={(target) => { if (target.environmentId === editor.environmentId) return; setEditor({ diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index a7b2807d78226..a44617488ae77 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,19 +1,9 @@ -import { - createContext, - startTransition, - use, - useCallback, - useEffect, - useState, - useLayoutEffect, - useMemo, - useRef, - type ReactNode, -} from "react"; -import { AppState, Appearance, Platform, useColorScheme } from "react-native"; +import * as SplashScreen from "expo-splash-screen"; +import { createContext, startTransition, use, useCallback, useMemo, type ReactNode } from "react"; +import { AppState, Appearance, Platform, View, useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/reactivity"; +import { Atom, AsyncResult } from "effect/reactivity"; import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind"; @@ -22,9 +12,14 @@ import { resolveAppearancePreferences, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; +import { appAtomRegistry } from "../../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; -import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette"; +import { + isSystemColorsAvailable, + readSystemColorPalettes, + type MaterialYouPalettes, +} from "../../../lib/materialYouPalette"; import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme"; import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; import type { MobileThemeVariables } from "../../../lib/mobileTheme"; @@ -74,6 +69,117 @@ interface AppearancePreferencesContextValue { const AppearancePreferencesContext = createContext(null); +function storedPreferencesOf(result: AsyncResult.AsyncResult) { + return AsyncResult.isSuccess(result) ? result.value : null; +} + +/** + * Material You palettes follow the system wallpaper, which can change while the + * app is in the background. + */ +export const systemColorPalettesAtom = Atom.make((get): MaterialYouPalettes | null => { + const initial = readSystemColorPalettes(); + if (!isSystemColorsAvailable) return initial; + let current = initial; + const refresh = () => { + const next = readSystemColorPalettes(); + if (JSON.stringify(current) === JSON.stringify(next)) return; + current = next; + get.setSelf(next); + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); + }); + const focusSubscription = AppState.addEventListener("focus", refresh); + get.addFinalizer(() => { + subscription.remove(); + focusSubscription.remove(); + }); + return initial; +}).pipe(Atom.keepAlive, Atom.withLabel("mobile:system-color-palettes")); + +export function resolveThemeVariablesByAppearance( + themeIds: MobileThemeIds, + palettes: MaterialYouPalettes | null, +): Readonly> { + const resolve = (appearance: MobileThemeAppearance) => { + const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); + return themeIds[appearance] === "material-you" && palettes + ? materialYouPaletteToMobileThemeVariables(palettes[appearance], appearance, base) + : base; + }; + return { light: resolve("light"), dark: resolve("dark") }; +} + +/** The appearance whose theme is on screen, read from the native scheme. */ +export function resolveThemeAppearance(stored: Preferences | null): MobileThemeAppearance { + const themeMode = normalizeMobileThemeMode(stored?.themeMode); + if (themeMode !== "system") return themeMode; + return Appearance.getColorScheme() === "dark" ? "dark" : "light"; +} + +function runtimeStateFor(stored: Preferences | null): MobileThemeRuntimeState { + return { + baseFontSize: resolveAppearancePreferences(stored).baseFontSize, + themeMode: normalizeMobileThemeMode(stored?.themeMode), + themeAppearance: resolveThemeAppearance(stored), + }; +} + +const themeRuntimeStateAtom = Atom.make((get) => + runtimeStateFor(storedPreferencesOf(get(mobilePreferencesAtom))), +).pipe( + Atom.withEquality( + (left: MobileThemeRuntimeState, right: MobileThemeRuntimeState) => + left.baseFontSize === right.baseFontSize && + left.themeMode === right.themeMode && + left.themeAppearance === right.themeAppearance, + ), +); + +// Native appearance and Uniwind text variables are process-global, so the +// last applied state is too. Setters apply ahead of the preference write so the +// first frame after a change already uses it; the atom subscription then sees +// no difference. +let appliedRuntimeState: MobileThemeRuntimeState | null = null; + +function applyThemeRuntime(next: MobileThemeRuntimeState) { + for (const operation of createMobileThemeRuntimeOperations(appliedRuntimeState, next)) { + if (operation.kind === "update-text-variables") { + Uniwind.updateCSSVariables(operation.themeName, operation.variables); + continue; + } + if (operation.kind === "set-appearance-mode") { + Appearance.setColorScheme( + operation.themeMode === "system" ? "unspecified" : operation.appearance, + ); + } + } + appliedRuntimeState = next; +} + +function currentRuntimeState() { + return appliedRuntimeState ?? appAtomRegistry.get(themeRuntimeStateAtom); +} + +/** App-lifetime: applies theme preferences to the native runtime. */ +export function installAppearanceRuntime(): () => void { + return appAtomRegistry.subscribe(themeRuntimeStateAtom, applyThemeRuntime, { immediate: true }); +} + +let splashHidden = false; + +/** + * Mounted only in the commit that has stored preferences, so its first layout + * means the themed tree is on screen and the splash can lift without a flash + * of default preferences. + */ +function hideSplashOnThemedLayout() { + if (splashHidden) return; + splashHidden = true; + void SplashScreen.hide(); +} + export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -94,37 +200,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN ); const themeId = themeIds[themeAppearance]; const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; - const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); - useEffect(() => { - if (!isSystemColorsAvailable) return; - const refresh = () => { - const next = readSystemColorPalettes(); - setSystemColorPalettes((previous) => - JSON.stringify(previous) === JSON.stringify(next) ? previous : next, - ); - }; - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") refresh(); - }); - const focusSubscription = AppState.addEventListener("focus", refresh); - return () => { - subscription.remove(); - focusSubscription.remove(); - }; - }, []); - const themeVariablesByAppearance = useMemo(() => { - const resolve = (appearance: MobileThemeAppearance) => { - const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); - return themeIds[appearance] === "material-you" && systemColorPalettes - ? materialYouPaletteToMobileThemeVariables( - systemColorPalettes[appearance], - appearance, - base, - ) - : base; - }; - return { light: resolve("light"), dark: resolve("dark") }; - }, [themeIds, systemColorPalettes]); + const systemColorPalettes = useAtomValue(systemColorPalettesAtom); + const themeVariablesByAppearance = resolveThemeVariablesByAppearance( + themeIds, + systemColorPalettes, + ); const themeVariables = themeVariablesByAppearance[themeAppearance]; const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; @@ -133,38 +213,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize], ); const isReady = AsyncResult.isSuccess(preferencesResult); - const runtimeState = useMemo( - () => ({ - baseFontSize, - themeAppearance, - themeMode, - }), - [baseFontSize, themeAppearance, themeMode], - ); - const appliedRuntimeStateRef = useRef(null); - const selectedThemeIdsRef = useRef(themeIds); - - const applyThemeRuntime = useCallback((next: MobileThemeRuntimeState) => { - const operations = createMobileThemeRuntimeOperations(appliedRuntimeStateRef.current, next); - for (const operation of operations) { - if (operation.kind === "update-text-variables") { - Uniwind.updateCSSVariables(operation.themeName, operation.variables); - continue; - } - if (operation.kind === "set-appearance-mode") { - Appearance.setColorScheme( - operation.themeMode === "system" ? "unspecified" : operation.appearance, - ); - } - } - appliedRuntimeStateRef.current = next; - }, []); - - const syncThemeRuntime = useCallback( - (next: MobileThemeRuntimeState) => applyThemeRuntime(next), - [applyThemeRuntime], - ); - const updatePreferences = useCallback( (patch: Partial) => { startTransition(() => savePreferences(patch)); @@ -179,37 +227,30 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [savePreferences], ); - useLayoutEffect(() => { - selectedThemeIdsRef.current = themeIds; - syncThemeRuntime(runtimeState); - }, [runtimeState, syncThemeRuntime, themeIds]); - const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { - const patch = createMobileThemeSelectionPatch( - selectedThemeIdsRef.current, - themeAppearance, - appearance, - value, + // Read at call time: a second pick before the next render must build on + // the first one's optimistic write. + const selected = resolveMobileThemeIds( + storedPreferencesOf(appAtomRegistry.get(mobilePreferencesAtom)) ?? {}, + ); + updateThemePreferences( + createMobileThemeSelectionPatch(selected, themeAppearance, appearance, value), ); - selectedThemeIdsRef.current = resolveMobileThemeIds(patch); - updateThemePreferences(patch); }, [themeAppearance, updateThemePreferences], ); const setThemeIdForBothAppearances = useCallback( (value: MobileThemeId) => { - const patch = createMobileThemePairPatch(value); - selectedThemeIdsRef.current = resolveMobileThemeIds(patch); - updateThemePreferences(patch); + updateThemePreferences(createMobileThemePairPatch(value)); }, [updateThemePreferences], ); const setThemeMode = useCallback( (value: MobileThemeMode) => { - const current = appliedRuntimeStateRef.current ?? runtimeState; + const current = currentRuntimeState(); // Clear forced appearance before publishing System so useColorScheme batches the system palette into the preference commit. if (value === "system") { @@ -223,23 +264,22 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeMode: value, }; - updateThemePreferences({ themeMode: value }); if (value === "system") { - appliedRuntimeStateRef.current = next; + appliedRuntimeState = next; } else { - syncThemeRuntime(next); + applyThemeRuntime(next); } + updateThemePreferences({ themeMode: value }); }, - [runtimeState, syncThemeRuntime, updateThemePreferences], + [updateThemePreferences], ); const setBaseFontSize = useCallback( (value: number) => { - const current = appliedRuntimeStateRef.current ?? runtimeState; - syncThemeRuntime({ ...current, baseFontSize: value }); + applyThemeRuntime({ ...currentRuntimeState(), baseFontSize: value }); updatePreferences({ baseFontSize: value }); }, - [runtimeState, syncThemeRuntime, updatePreferences], + [updatePreferences], ); const setTerminalFontSize = useCallback( @@ -318,7 +358,16 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN return ( - {props.children} + + {props.children} + {isReady ? ( + + ) : null} + ); 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 index 97a7d38a44bfc..bfc4fc63dfb83 100644 --- a/apps/mobile/src/features/settings/scheduled-task-editor-state.test.ts +++ b/apps/mobile/src/features/settings/scheduled-task-editor-state.test.ts @@ -7,6 +7,7 @@ import { readScheduledTaskEditor, scheduledTaskEditorSessionAtom, startScheduledTaskEditor, + takeScheduledTaskNewLink, updateScheduledTaskEditor, } from "./scheduled-task-editor-state"; @@ -55,3 +56,34 @@ describe("scheduled task voice draft", () => { expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBeNull(); }); }); + +describe("new scheduled task link", () => { + const editing = { + environmentId: EnvironmentId.make("environment"), + environmentLabel: "Environment", + draft: createDraft(null, null), + }; + const newTaskLink = { + routes: [ + { name: "Home" }, + { + name: "SettingsSheet", + state: { + routes: [{ name: "SettingsScheduledTasks" }, { name: "SettingsScheduledTaskNew" }], + }, + }, + ], + }; + + it("drops the last edited task so the new-task screen starts from the default", () => { + startScheduledTaskEditor(editing); + expect(takeScheduledTaskNewLink(newTaskLink)).toBe(newTaskLink); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)).toBeNull(); + }); + + it("keeps the editor for links to other screens", () => { + startScheduledTaskEditor(editing); + takeScheduledTaskNewLink({ routes: [{ name: "SettingsSheet" }] }); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBe(editing); + }); +}); diff --git a/apps/mobile/src/features/settings/scheduled-task-editor-state.ts b/apps/mobile/src/features/settings/scheduled-task-editor-state.ts index a0ab412a83441..96d0b124affbb 100644 --- a/apps/mobile/src/features/settings/scheduled-task-editor-state.ts +++ b/apps/mobile/src/features/settings/scheduled-task-editor-state.ts @@ -1,6 +1,9 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import type { NavigationState, PartialState } from "@react-navigation/native"; import { Atom } from "effect/reactivity"; +import { findLinkedRoute } from "../../lib/appLinking"; + import { appAtomRegistry } from "../../state/atom-registry"; import type { ScheduledTaskDraft } from "./scheduledTaskDraft"; @@ -53,3 +56,17 @@ export function readScheduledTaskEditor( if (`scheduled-task:${session?.id ?? 0}` !== voiceOwnerKey) return null; return session ? session.current : defaultEditor; } + +/** + * A link to the new-task screen starts a fresh editor, like the in-app "New + * task" action. Without this a warm app would reopen the last edited task's + * draft under the "New" title and save over that task. + */ +export function takeScheduledTaskNewLink( + state: PartialState | undefined, +): PartialState | undefined { + if (findLinkedRoute(state, ["SettingsScheduledTaskNew"])) { + appAtomRegistry.set(scheduledTaskEditorSessionAtom, null); + } + return state; +} diff --git a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx index 62425e49af6ef..3fc22a494b5e7 100644 --- a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx +++ b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx @@ -1,7 +1,7 @@ import * as Dialog from "../../components/ui/Dialog"; import Constants from "expo-constants"; import * as Crypto from "expo-crypto"; -import { PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts"; +import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts"; import { clearSharedPayloads, getResolvedSharedPayloadsAsync, @@ -9,40 +9,45 @@ import { type ResolvedSharePayload, type SharePayload, } from "expo-sharing"; -import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { AppState, Platform } from "react-native"; +import { useSyncExternalStore } from "react"; +import { Platform } from "react-native"; +import { CommonActions } from "@react-navigation/native"; import { buildIncomingShareDraft, isShareFileUriUnderOwnedRoots, + selectIncomingShareAttachmentsForServer, type IncomingShareDestination, type IncomingShareDraft, } from "./incoming-share-model"; import { createIncomingSharePayloadReader } from "./incoming-share-native"; import { IncomingShareInbox } from "./incoming-share-inbox"; import { persistComposerAttachmentFile } from "../../lib/composerImages"; +import { subscribeAppActive } from "../../lib/appState"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; +import { isDraftThreadRoute } from "../threads/thread-route-state"; +import { makeIncomingShareImports } from "../threads/incomingShareImport"; +import { liveNewTaskFlowKeys } from "../threads/new-task-flow-state"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { serverEnvironment } from "../../state/server"; +import { + getComposerDraftSnapshot, + mergeComposerDraftContent, + restoreComposerDraftSnapshot, + scheduleUnusedComposerAttachmentCleanup, +} from "../../state/use-composer-drafts"; +import { + EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + transitionIncomingSharePresentation, +} from "./incoming-share-presentation"; +import { withOpenedNewTaskDraft } from "../threads/open-new-task-draft"; import { loadIncomingShareDrafts, removeIncomingShareDraft, writeIncomingShareDraft, } from "./incoming-share-storage"; -type IncomingShareContextValue = { - readonly pendingShare: IncomingShareDraft | null; - readonly isLoading: boolean; - readonly error: Error | null; - readonly getShare: (shareId: string) => IncomingShareDraft | null; - readonly reserveShare: (shareId: string, destination: IncomingShareDestination) => Promise; - readonly releaseShareReservation: ( - shareId: string, - expectedDestination: IncomingShareDestination, - ) => Promise; - readonly consumeShare: (shareId: string) => Promise; - readonly refresh: () => Promise; -}; - -const IncomingShareContext = React.createContext(null); - function receiveSharingEnabled(): boolean { if (Platform.OS === "android") { return true; @@ -137,7 +142,8 @@ async function removeReplayedPayloadFiles(payloads: ReadonlyArray) await Promise.all([...uris].map(removeOwnedFile)); } -// Share the queue across remounts so lifecycle notifications cannot ingest one handoff twice. +// One operation queue for the app so two lifecycle notifications cannot +// ingest the same native handoff independently. const incomingShareInbox = new IncomingShareInbox({ loadDrafts: loadIncomingShareDrafts, writeDraft: writeIncomingShareDraft, @@ -197,153 +203,180 @@ const incomingShareInbox = new IncomingShareInbox({ }, }); -export function IncomingShareProvider(props: React.PropsWithChildren) { - const enabled = receiveSharingEnabled(); - const [drafts, setDrafts] = useState>([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const refreshPromiseRef = useRef | null>(null); - const mountedRef = useRef(false); +interface IncomingShareSnapshot { + readonly drafts: ReadonlyArray; + readonly isLoading: boolean; +} - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); +let snapshot: IncomingShareSnapshot = { drafts: [], isLoading: true }; +const snapshotListeners = new Set<() => void>(); +let refreshInFlight: Promise | null = null; +let presentation = EMPTY_INCOMING_SHARE_PRESENTATION_STATE; - const refresh = useCallback(async () => { - if (refreshPromiseRef.current) { - return refreshPromiseRef.current; - } +function publish(drafts: ReadonlyArray) { + snapshot = { drafts, isLoading: false }; + for (const listener of snapshotListeners) listener(); + presentPendingIncomingShare(); +} - const operation = (async () => { - try { - const snapshot = await incomingShareInbox.refresh({ ingestNative: enabled }); - if (mountedRef.current) { - setDrafts(snapshot); - setError(null); - } - } catch (cause) { - const persisted = await incomingShareInbox - .refresh({ ingestNative: false }) - .catch(() => null); - if (mountedRef.current) { - if (persisted) { - setDrafts(persisted); - } - setError(cause instanceof Error ? cause : new Error("Could not import shared content.")); - } - } - })().finally(() => { - refreshPromiseRef.current = null; - }); +function subscribeIncomingShares(onChange: () => void) { + snapshotListeners.add(onChange); + return () => { + snapshotListeners.delete(onChange); + }; +} - refreshPromiseRef.current = operation; - return operation; - }, [enabled]); +function reportRefreshFailure(cause: unknown) { + const error = cause instanceof Error ? cause : new Error("Could not import shared content."); + Dialog.alert("Could not import shared content", error.message, [ + { text: "Dismiss", style: "cancel" }, + { text: "Retry", onPress: () => void refreshIncomingShares() }, + ]); +} - useEffect(() => { - void refresh().finally(() => { - if (mountedRef.current) { - setIsLoading(false); - } - }); - }, [refresh]); +export function refreshIncomingShares(): Promise { + if (refreshInFlight) return refreshInFlight; + const enabled = receiveSharingEnabled(); + refreshInFlight = (async () => { + try { + publish(await incomingShareInbox.refresh({ ingestNative: enabled })); + } catch (cause) { + const persisted = await incomingShareInbox.refresh({ ingestNative: false }).catch(() => null); + publish(persisted ?? snapshot.drafts); + reportRefreshFailure(cause); + } + })().finally(() => { + refreshInFlight = null; + }); + return refreshInFlight; +} - const refreshOnAppActive = useEffectEvent(() => { - void refresh(); +/** + * Presents the newest pending share as a new-task draft unless the user + * already dismissed it. Runs whenever the inbox or the root route changes. + */ +export function presentPendingIncomingShare(): void { + if (!rootNavigationRef.isReady()) return; + const state = rootNavigationRef.getRootState(); + if (!state) return; + const transition = transitionIncomingSharePresentation(presentation, { + isShareSheetPresented: isDraftThreadRoute(state.routes[state.index]), + pendingShareId: snapshot.drafts[0]?.id ?? null, }); + presentation = transition.state; + if (transition.shareIdToPresent) { + // A reserved share resumes in its project: an interrupted transfer must + // not copy into a second project's draft. + const destination = snapshot.drafts[0]?.destination; + rootNavigationRef.dispatch( + selectWorkspaceDetail({ + name: "Thread", + params: withOpenedNewTaskDraft({ + draft: { + ...(destination + ? { environmentId: destination.environmentId, projectId: destination.projectId } + : {}), + incomingShareId: transition.shareIdToPresent, + }, + }), + }), + ); + } +} - useEffect(() => { - if (!enabled) { - return; - } - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") { - refreshOnAppActive(); - } - }); - return () => subscription.remove(); - }, [enabled]); +/** App-lifetime: loads the inbox and re-ingests native handoffs on every resume. */ +export function startIncomingShareInbox(): () => void { + void refreshIncomingShares(); + return receiveSharingEnabled() + ? subscribeAppActive(() => void refreshIncomingShares()) + : () => {}; +} - useEffect(() => { - if (!error) { - return; - } - Dialog.alert("Could not import shared content", error.message, [ - { text: "Dismiss", style: "cancel", onPress: () => setError(null) }, - { - text: "Retry", - onPress: () => { - setError(null); - void refresh(); - }, - }, - ]); - }, [error, refresh]); +async function consumeShare(shareId: string) { + publish(await incomingShareInbox.consume(shareId)); +} - const consumeShare = useCallback(async (shareId: string) => { - const snapshot = await incomingShareInbox.consume(shareId); - if (mountedRef.current) { - setDrafts(snapshot); - } - }, []); - const reserveShare = useCallback( - async (shareId: string, destination: IncomingShareDestination) => { - const snapshot = await incomingShareInbox.reserve(shareId, destination); - if (mountedRef.current) { - setDrafts(snapshot); - } - }, - [], - ); - const releaseShareReservation = useCallback( - async (shareId: string, expectedDestination: IncomingShareDestination) => { - const snapshot = await incomingShareInbox.releaseReservation(shareId, expectedDestination); - if (mountedRef.current) { - setDrafts(snapshot); - } - }, - [], - ); - const getShare = useCallback( - (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null, - [drafts], - ); +async function reserveShare(shareId: string, destination: IncomingShareDestination) { + publish(await incomingShareInbox.reserve(shareId, destination)); +} + +async function releaseShareReservation( + shareId: string, + expectedDestination: IncomingShareDestination, +) { + publish(await incomingShareInbox.releaseReservation(shareId, expectedDestination)); +} - const value = useMemo( - () => ({ - pendingShare: drafts[0] ?? null, - isLoading, - error, - getShare, - releaseShareReservation, - reserveShare, - consumeShare, - refresh, +/** Moves shares into new-task drafts; see makeIncomingShareImports. */ +export const incomingShareImports = makeIncomingShareImports({ + registry: appAtomRegistry, + getShare: (shareId) => snapshot.drafts.find((draft) => draft.id === shareId) ?? null, + reserve: reserveShare, + release: releaseShareReservation, + consume: consumeShare, + readDraft: getComposerDraftSnapshot, + restoreDraft: restoreComposerDraftSnapshot, + mergeIntoDraft: mergeComposerDraftContent, + selectAttachments: (share, environmentId) => + selectIncomingShareAttachmentsForServer({ + attachments: share.attachments, + serverConfig: appAtomRegistry.get( + serverEnvironment.configValueAtom(EnvironmentId.make(environmentId)), + ), }), - [ - consumeShare, - drafts, - error, - getShare, - isLoading, - refresh, - releaseShareReservation, - reserveShare, - ], - ); + releaseUnusedAttachments: scheduleUnusedComposerAttachmentCleanup, + onWarnings: (warnings) => { + Dialog.alert("Some shared content was skipped", warnings.join("\n")); + }, + onImportFailure: (target, error) => { + Dialog.alert( + "Could not import shared content", + error instanceof Error ? error.message : "The shared content could not be saved.", + [ + { + text: "Cancel import", + style: "cancel", + onPress: () => void incomingShareImports.cancel(target), + }, + { text: "Retry", onPress: () => incomingShareImports.retry(target) }, + ], + { cancelable: false }, + ); + }, + onCancelFailure: (target, error) => { + Dialog.alert( + "Could not cancel import", + error instanceof Error ? error.message : "The shared content could not be restored safely.", + [ + { text: "Retry import", onPress: () => incomingShareImports.retry(target) }, + { text: "Retry cancel", onPress: () => void incomingShareImports.cancel(target) }, + ], + { cancelable: false }, + ); + }, + leave: (target) => { + rootNavigationRef.dispatch({ + ...CommonActions.goBack(), + source: target.routeKey, + target: rootNavigationRef.getRootState()?.key, + }); + }, +}); - return ( - {props.children} - ); +/** Abandons the share imports of draft routes that left the stack. */ +export function pruneIncomingShareImports(state: Parameters[0]) { + void incomingShareImports.prune(liveNewTaskFlowKeys(state)); } -export function useIncomingShare(): IncomingShareContextValue { - const value = React.use(IncomingShareContext); - if (value === null) { - throw new Error("useIncomingShare must be used within IncomingShareProvider."); - } - return value; +export function useIncomingShare() { + const { drafts, isLoading } = useSyncExternalStore(subscribeIncomingShares, () => snapshot); + return { + pendingShare: drafts[0] ?? null, + isLoading, + getShare: (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null, + reserveShare, + releaseShareReservation, + consumeShare, + refresh: refreshIncomingShares, + }; } diff --git a/apps/mobile/src/features/shortcuts/useAppShortcuts.test.ts b/apps/mobile/src/features/shortcuts/useAppShortcuts.test.ts new file mode 100644 index 0000000000000..18c972428f792 --- /dev/null +++ b/apps/mobile/src/features/shortcuts/useAppShortcuts.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("expo-quick-actions", () => ({})); +vi.mock("react-native", () => ({ Platform: { OS: "android" } })); +vi.mock("../../persistence/imperative", () => ({})); +vi.mock("../../state/atom-registry", () => ({})); +vi.mock("../../state/threads", () => ({ environmentThreadShells: {} })); +vi.mock("./appShortcuts", async () => { + const { withRecentThreadShortcut } = + await vi.importActual("./appShortcuts"); + return { withRecentThreadShortcut }; +}); + +import type { RecentThreadShortcut } from "../../persistence/imperative"; +import { createRecentThreadShortcuts } from "./useAppShortcuts"; + +const thread = (threadId: string, title = threadId): RecentThreadShortcut => ({ + environmentId: "env", + threadId, + title, +}); + +function setup(load: () => Promise>) { + const save = vi.fn<(list: ReadonlyArray) => Promise>(() => + Promise.resolve(), + ); + const publish = vi.fn<(list: ReadonlyArray) => Promise>(() => + Promise.resolve(), + ); + return { save, publish, recents: createRecentThreadShortcuts({ load, save, publish }) }; +} + +describe("createRecentThreadShortcuts", () => { + it("waits for the persisted list before recording the open thread", async () => { + const { save, publish, recents } = setup(() => Promise.resolve([thread("old")])); + recents.recordActiveThread(thread("new")); + expect(publish).not.toHaveBeenCalled(); + await recents.loaded; + await recents.saved(); + expect(publish).toHaveBeenLastCalledWith([thread("new"), thread("old")]); + expect(save).toHaveBeenLastCalledWith([thread("new"), thread("old")]); + }); + + it("does not persist the empty fallback after a failed load", async () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + const { save, publish, recents } = setup(() => Promise.reject(new Error("read failed"))); + await recents.loaded; + await recents.saved(); + expect(publish).toHaveBeenLastCalledWith([]); + expect(save).not.toHaveBeenCalled(); + + recents.recordActiveThread(thread("a")); + await recents.saved(); + expect(save).toHaveBeenLastCalledWith([thread("a")]); + }); + + it("records title changes of the open thread and ignores repeats", async () => { + const { publish, recents } = setup(() => Promise.resolve([])); + await recents.loaded; + recents.recordActiveThread(thread("a", "")); + recents.recordActiveThread(thread("a", "Named")); + recents.recordActiveThread(thread("a", "Named")); + recents.recordActiveThread(null); + expect(publish.mock.calls.map(([list]) => list)).toEqual([ + [], + [thread("a", "")], + [thread("a", "Named")], + ]); + }); +}); diff --git a/apps/mobile/src/features/shortcuts/useAppShortcuts.ts b/apps/mobile/src/features/shortcuts/useAppShortcuts.ts index 677dd8110dee9..b182c33b07866 100644 --- a/apps/mobile/src/features/shortcuts/useAppShortcuts.ts +++ b/apps/mobile/src/features/shortcuts/useAppShortcuts.ts @@ -1,14 +1,16 @@ import * as QuickActions from "expo-quick-actions"; -import { useEffect, useMemo, useRef, useState } from "react"; import { Platform } from "react-native"; -import { useLinkTo, type NavigationState } from "@react-navigation/native"; +import type { NavigationState } from "@react-navigation/native"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; import { loadRecentThreadShortcuts, saveRecentThreadShortcuts, type RecentThreadShortcut, } from "../../persistence/imperative"; -import { useThreadShell } from "../../state/entities"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentThreadShells } from "../../state/threads"; import { activeThreadRef, buildShortcutActions, @@ -16,110 +18,132 @@ import { withRecentThreadShortcut, } from "./appShortcuts"; -export function useAppShortcuts(state: NavigationState): void { - useShortcutNavigation(); - useRecentThreadShortcutSync(state); +export interface RecentThreadShortcutsDependencies { + readonly load: () => Promise>; + readonly save: (recents: ReadonlyArray) => Promise; + readonly publish: (recents: ReadonlyArray) => Promise; } -function useShortcutNavigation(): void { - const linkTo = useLinkTo(); - const handledInitialAction = useRef(false); +/** + * Keeps the launcher's recent-thread list. Recording waits for the persisted + * list so the first thread opened after a cold start cannot clobber older + * entries, and a failed load never persists its empty fallback over valid + * history until a real thread open makes the in-memory list the new truth. + */ +export function createRecentThreadShortcuts(dependencies: RecentThreadShortcutsDependencies) { + let recents: ReadonlyArray | null = null; + let persistable = false; + let active: RecentThreadShortcut | null = null; + // Serialize saves so older lists cannot overwrite newer ones. + let saveQueue: Promise = Promise.resolve(); - useEffect(() => { - // Handle the launch action before listeners; push over Home so Back returns there. - if (!handledInitialAction.current) { - handledInitialAction.current = true; - const initialHref = QuickActions.initial ? shortcutHref(QuickActions.initial) : null; - if (initialHref !== null) { - linkTo(initialHref); - } + const commit = (next: ReadonlyArray) => { + recents = next; + if (persistable) { + saveQueue = saveQueue.then( + () => + dependencies.save(next).catch((error: unknown) => { + console.warn("[app-shortcuts] failed to persist recent threads", error); + }), + () => undefined, + ); } - - const subscription = QuickActions.addListener((action) => { - const href = shortcutHref(action); - if (href !== null) { - linkTo(href); - } + void dependencies.publish(next).catch((error: unknown) => { + console.warn("[app-shortcuts] failed to update launcher shortcuts", error); }); - return () => subscription.remove(); - }, [linkTo]); -} + }; + + const withActive = (list: ReadonlyArray) => { + if (active === null) return list; + const next = withRecentThreadShortcut(list, active); + if (next !== list) persistable = true; + return next; + }; -function useRecentThreadShortcutSync(state: NavigationState): void { - // iOS subscriptions for Android-only shortcuts would re-render every screen on active-thread updates. - const threadRef = useMemo( - () => (Platform.OS === "android" ? activeThreadRef(state) : null), - [state], + const loaded = dependencies.load().then( + (threads) => { + persistable = true; + commit(withActive(threads)); + }, + (error: unknown) => { + console.warn("[app-shortcuts] failed to load recent threads", error); + commit(withActive([])); + }, ); - const threadShell = useThreadShell(threadRef); - // Wait for persisted recents before recording visits, or cold starts overwrite older entries. - const [recents, setRecents] = useState | null>(null); - // Do not persist failed-load fallbacks; only a real visit makes the list authoritative. - const persistableRef = useRef(false); - // Serialize saves so older lists cannot overwrite newer ones. - const saveQueueRef = useRef>(Promise.resolve()); - useEffect(() => { - if (Platform.OS !== "android") { - return; - } + return { + loaded, + /** Resolves once every save issued so far has finished. */ + saved: () => saveQueue, + recordActiveThread(next: RecentThreadShortcut | null): void { + active = next; + if (recents === null || next === null) return; + const updated = withActive(recents); + if (updated !== recents) commit(updated); + }, + }; +} - let cancelled = false; - void loadRecentThreadShortcuts() - .then((threads) => { - if (!cancelled) { - persistableRef.current = true; - setRecents(threads); - } - }) - .catch((error) => { - console.warn("[app-shortcuts] failed to load recent threads", error); - if (!cancelled) { - setRecents([]); - } - }); - return () => { - cancelled = true; - }; - }, []); +const activeThreadRefAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:app-shortcuts-active-thread-ref"), +); - const loaded = recents !== null; - const environmentId = threadRef?.environmentId ?? null; - const threadId = threadRef?.threadId ?? null; - const title = threadShell?.title ?? ""; - useEffect(() => { - if (!loaded || environmentId === null || threadId === null) { - return; - } +const activeThreadShortcutAtom = Atom.make((get): RecentThreadShortcut | null => { + const ref = get(activeThreadRefAtom); + if (ref === null) return null; + const title = get(environmentThreadShells.threadShellAtom(ref))?.title ?? ""; + return { environmentId: ref.environmentId, threadId: ref.threadId, title }; +}).pipe( + Atom.withEquality( + (left: RecentThreadShortcut | null, right: RecentThreadShortcut | null) => + left === right || + (left !== null && + right !== null && + left.environmentId === right.environmentId && + left.threadId === right.threadId && + left.title === right.title), + ), +); - setRecents((current) => { - if (current === null) { - return current; - } - const next = withRecentThreadShortcut(current, { environmentId, threadId, title }); - if (next !== current) { - persistableRef.current = true; - } - return next; - }); - }, [loaded, environmentId, threadId, title]); +/** Wired to the root navigation container's state changes. */ +export function recordAppShortcutNavigationState(state: NavigationState): void { + // Launcher shortcuts are Android-only; iOS never subscribes to thread shells. + if (Platform.OS !== "android") return; + const next = activeThreadRef(state); + const current = appAtomRegistry.get(activeThreadRefAtom); + if (next?.environmentId === current?.environmentId && next?.threadId === current?.threadId) { + return; + } + appAtomRegistry.set(activeThreadRefAtom, next); +} - useEffect(() => { - if (recents === null) { - return; - } +/** + * App-lifetime quick actions. `navigate` must queue until navigation is ready: + * a cold-start shortcut arrives as the launch action, and pushing it over the + * initial Home route makes back return home instead of exiting the app. + */ +export function installAppShortcuts(navigate: (href: string) => void): () => void { + const initialHref = QuickActions.initial ? shortcutHref(QuickActions.initial) : null; + if (initialHref !== null) navigate(initialHref); + const subscription = QuickActions.addListener((action) => { + const href = shortcutHref(action); + if (href !== null) navigate(href); + }); + if (Platform.OS !== "android") return () => subscription.remove(); - if (persistableRef.current) { - saveQueueRef.current = saveQueueRef.current.then( - () => - saveRecentThreadShortcuts(recents).catch((error) => { - console.warn("[app-shortcuts] failed to persist recent threads", error); - }), - () => undefined, - ); - } - void QuickActions.setItems(buildShortcutActions(recents)).catch((error) => { - console.warn("[app-shortcuts] failed to update launcher shortcuts", error); - }); - }, [recents]); + const recents = createRecentThreadShortcuts({ + load: loadRecentThreadShortcuts, + save: saveRecentThreadShortcuts, + publish: (list) => QuickActions.setItems(buildShortcutActions(list)), + }); + const unsubscribe = appAtomRegistry.subscribe( + activeThreadShortcutAtom, + (active) => recents.recordActiveThread(active), + { immediate: true }, + ); + return () => { + subscription.remove(); + unsubscribe(); + }; } diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 10df7a298a4c6..51eb368027329 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -1,16 +1,30 @@ -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { Keyboard, View } from "react-native"; -import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { useSyncExternalStore } from "react"; +import { Appearance, Keyboard, View } from "react-native"; +import { + CommonActions, + StackActions, + type NavigationAction, + type NavigationState, +} from "@react-navigation/native"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { requireOptionalNativeModule } from "expo"; -import { useConnectionController } from "../connection/useConnectionController"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import type { MobileThemeId } from "../../lib/mobileTheme"; -import { useProjects, useThreadShells } from "../../state/entities"; +import { connectPairingUrl } from "../../connection/onboarding"; +import { + createMobileThemePairPatch, + resolveMobileThemeIds, + type MobileThemeId, +} from "../../lib/mobileTheme"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { environmentProjects } from "../../state/projects"; import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { environmentThreadShells } from "../../state/threads"; import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; -import { useWorkspaceState } from "../../state/workspace"; +import { workspaceConnections } from "../../state/workspace"; +import { resolveThemeAppearance } from "../settings/appearance/AppearancePreferencesProvider"; import { applyNativeShowcaseOrientation, getNativeShowcaseOrientation, @@ -20,319 +34,378 @@ import { markNativeShowcaseReady, type ShowcaseScene, } from "./nativeShowcaseScene"; +import { buildShowcaseAgentActivity } from "./showcaseAgentActivity"; +import { + isShowcaseThemeApplied, + SHOWCASE_THREAD_ID, + showcaseReadinessKey, + showcaseSceneOnScreen, +} from "./showcaseCaptureModel"; import { buildShowcasePendingTasks, SHOWCASE_PENDING_TASK_DEFINITIONS, } from "./showcasePendingTasks"; -import { buildShowcaseAgentActivity } from "./showcaseAgentActivity"; -import { retryShowcaseOperation } from "./showcaseRetry"; import { clearShowcaseRenderSignal, getShowcaseRenderSignal, - isShowcaseNativeContentReady, subscribeToShowcaseRenderSignal, } from "./showcaseRenderSignal"; +import { retryShowcaseOperation } from "./showcaseRetry"; import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity"; -import { openSettings } from "../settings/settings-navigation"; +import { settingsNavigationState } from "../settings/settings-navigation"; import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; -const SHOWCASE_THREAD_ID = "remote-command-center"; +// Native launch requests (pairing links, scene switches) have no change event. +const NATIVE_POLL_MS = 250; +const READY_SETTLE_MS = 500; -function sceneFromPathname(pathname: string): ShowcaseScene | null { - const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; - if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { - return "environments"; - } - if (routePath.endsWith("/terminal")) return "terminal"; - if (routePath.endsWith("/review")) return "review"; - if (routePath.startsWith("/threads/")) return "thread"; - if (routePath === "/") return "threads"; - return null; -} +const readySceneListeners = new Set<() => void>(); +let readyScene: ShowcaseScene | null = null; -export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { - if (!SHOWCASE_ENABLED) return null; - return ; +function setReadyScene(scene: ShowcaseScene | null): void { + if (readyScene === scene) return; + readyScene = scene; + for (const listener of readySceneListeners) listener(); } -function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string }) { - const navigation = useNavigation(); - useEffect(() => { - const menu = requireOptionalNativeModule<{ - closeMenu(): void; - setToolsButtonVisible(visible: boolean): void; - }>("ExpoDevMenu"); - // The dev menu can open after bundle loading and block orientation requests. - const timer = setInterval(() => { - menu?.closeMenu(); - menu?.setToolsButtonVisible(false); - }, 1_000); - return () => clearInterval(timer); - }, []); - const { connectPairingUrl } = useConnectionController(); - const { - isReady: appearancePreferencesReady, - themeId, - themeIds, - setThemeIdForBothAppearances, - } = useAppearancePreferences(); - const workspace = useWorkspaceState(); - const projects = useProjects(); - const threads = useThreadShells(); - const attemptedPairingRef = useRef(new Set()); - const seededPendingTaskIdsRef = useRef(new Set()); - const [pairingUrls, setPairingUrls] = useState>([]); - const [pendingTasksReady, setPendingTasksReady] = useState(false); - const [requestedScene, setRequestedScene] = useState(null); - const [requestedTheme, setRequestedTheme] = useState(null); - const [themeRequestSettled, setThemeRequestSettled] = useState(false); - const [readyScene, setReadyScene] = useState(null); - const [orientationSettled, setOrientationSettled] = useState(false); - const [agentActivityStaged, setAgentActivityStaged] = useState(false); - const requestedSceneRef = useRef(null); - // Read current entities without restarting on shell updates and re-entering open permission prompts. - const entitiesRef = useRef({ threads, projects }); - useEffect(() => { - entitiesRef.current = { threads, projects }; - }, [projects, threads]); - const renderSignal = useSyncExternalStore( - subscribeToShowcaseRenderSignal, - getShowcaseRenderSignal, - getShowcaseRenderSignal, - ); - - useEffect(() => { - if (pairingUrls.length > 0) return; - - const readLaunchRequest = () => { - const values = getNativeShowcasePairingUrls(); - if (values.length === 0) return; - setRequestedTheme(getNativeShowcaseTheme()); - setThemeRequestSettled(true); - setPairingUrls(values); - }; - readLaunchRequest(); - const interval = setInterval(readLaunchRequest, 250); - return () => clearInterval(interval); - }, [pairingUrls.length]); - - useEffect(() => { - if (orientationSettled) return; - const orientation = getNativeShowcaseOrientation(); - if (orientation === null) { - setOrientationSettled(true); - return; - } +function subscribeToReadyScene(listener: () => void): () => void { + readySceneListeners.add(listener); + return () => readySceneListeners.delete(listener); +} - let cancelled = false; - let lastOutcome: string | null = null; - void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { - isCancelled: () => cancelled, - }).then((applied) => { - if (!cancelled && applied) setOrientationSettled(true); - }); - return () => { - cancelled = true; - }; - }, [orientationSettled]); +function getReadyScene(): ShowcaseScene | null { + return readyScene; +} - useEffect(() => { - const readRequestedScene = () => { - const value = getNativeShowcaseScene(); - if (!value || requestedSceneRef.current === value) return; - requestedSceneRef.current = value; - // Draws belong to their scene request; revisiting review must wait for its new surface. - clearShowcaseRenderSignal(); - setAgentActivityStaged(false); - setRequestedScene(value); - }; - readRequestedScene(); - const interval = setInterval(readRequestedScene, 250); - return () => clearInterval(interval); - }, []); +interface Run { + cancelled: boolean; + done: boolean; +} - const themeApplied = - requestedTheme === null - ? themeRequestSettled - : themeIds.light === requestedTheme && themeIds.dark === requestedTheme; +function cancelRun(run: Run | null): null { + if (run) run.cancelled = true; + return null; +} - useEffect(() => { - if (requestedTheme === null || themeApplied || !appearancePreferencesReady) { - return; - } - setThemeIdForBothAppearances(requestedTheme); - }, [appearancePreferencesReady, requestedTheme, setThemeIdForBothAppearances, themeApplied]); +function showcaseNavigationAction( + scene: ShowcaseScene, + environmentId: string, + rootState: NavigationState, +): NavigationAction { + const params = { environmentId, threadId: SHOWCASE_THREAD_ID }; + switch (scene) { + case "threads": + return StackActions.popToTop(); + case "agent-activity": + return CommonActions.reset({ index: 0, routes: [{ name: "Home" }] }); + case "environments": + return CommonActions.reset(settingsNavigationState(rootState, "SettingsEnvironments")); + case "terminal": + return CommonActions.navigate("ThreadTerminal", { ...params, terminalId: "term-1" }); + case "review": + return CommonActions.navigate("ThreadReview", params); + case "thread": + return selectWorkspaceDetail({ name: "Thread", params }); + } +} - useEffect(() => { - if (pairingUrls.length === 0) return; - let cancelled = false; - void (async () => { - await Promise.all( - pairingUrls.map(async (pairingUrl) => { - if (cancelled || attemptedPairingRef.current.has(pairingUrl)) return; - const paired = await retryShowcaseOperation( - async () => AsyncResult.isSuccess(await connectPairingUrl(pairingUrl)), - { isCancelled: () => cancelled }, - ); - if (paired) attemptedPairingRef.current.add(pairingUrl); - }), - ); - })(); - return () => { - cancelled = true; - }; - }, [connectPairingUrl, pairingUrls]); +/** + * Drives a showcase capture: pairs servers, seeds the fixture, applies theme and + * orientation, and navigates to each scene the native harness requests. Inputs + * are read from their stores on each step, so nothing depends on render timing. + */ +export function installShowcaseCapture( + pathnameFromState: (state: NavigationState) => string, +): () => void { + if (!SHOWCASE_ENABLED) return () => {}; - const routeScene = sceneFromPathname(props.pathname); - const scene = - requestedScene === "agent-activity" && routeScene === "threads" ? "agent-activity" : routeScene; - const hasServerFixture = - workspace.state.hasReadyEnvironment && - workspace.environments.length >= 3 && - projects.length >= 3 && - threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID); - const hasFixture = hasServerFixture && pendingTasksReady; - const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + let disposed = false; + let launchRequestRead = false; + let requestedTheme: MobileThemeId | null = null; + let orientationSettled = false; + let requestedScene: ShowcaseScene | null = null; + let pendingTasks: Run | null = null; + let agentActivity: Run | null = null; + let lastNavigation: string | null = null; + let readiness: { key: string; cancel: () => void } | null = null; + let renderReports = 0; + const isCancelled = (run?: Run) => () => disposed || run?.cancelled === true; - useEffect(() => { - if (!hasServerFixture || pendingTasksReady) return; + const devMenu = requireOptionalNativeModule<{ + closeMenu(): void; + setToolsButtonVisible(visible: boolean): void; + }>("ExpoDevMenu"); + // The dev menu can open after bundle loading and block orientation requests. + const devMenuTimer = setInterval(() => { + devMenu?.closeMenu(); + devMenu?.setToolsButtonVisible(false); + }, 1_000); - const pendingTasks = buildShowcasePendingTasks(projects, Date.now()); - if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; + const orientation = getNativeShowcaseOrientation(); + if (orientation === null) { + orientationSettled = true; + } else { + void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { + isCancelled: isCancelled(), + }).then((applied) => { + if (!applied) return; + orientationSettled = true; + evaluate(); + }); + } - let cancelled = false; - for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId); - void (async () => { - const results = await Promise.all( - pendingTasks.map(async (task) => { - const messageId = String(task.messageId); - if (seededPendingTaskIdsRef.current.has(messageId)) return true; - const seeded = await retryShowcaseOperation( - async () => { - await enqueueThreadOutboxMessage(task); - return true; - }, - { isCancelled: () => cancelled }, - ); - if (seeded) seededPendingTaskIdsRef.current.add(messageId); - return seeded; - }), + const pair = (pairingUrls: ReadonlyArray) => { + for (const pairingUrl of pairingUrls) { + void retryShowcaseOperation( + async () => + AsyncResult.isSuccess( + await runAtomCommand( + appAtomRegistry, + connectPairingUrl, + { pairingUrl }, + { reportFailure: false }, + ), + ), + { isCancelled: isCancelled() }, ); - if (!cancelled && results.every(Boolean)) setPendingTasksReady(true); - })(); - return () => { - cancelled = true; - }; - }, [hasServerFixture, pendingTasksReady, projects]); - - useEffect(() => { - if (requestedScene === null || !hasFixture || !showcaseThread) return; - if (scene === requestedScene) return; - - const params = { - environmentId: String(showcaseThread.environmentId), - threadId: SHOWCASE_THREAD_ID, - }; - if (requestedScene === "threads") { - navigation.dispatch(StackActions.popToTop()); - return; } - if (requestedScene === "agent-activity") { - navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: "Home" }] })); - return; + }; + + const readNativeRequests = () => { + if (!launchRequestRead) { + const pairingUrls = getNativeShowcasePairingUrls(); + if (pairingUrls.length > 0) { + launchRequestRead = true; + requestedTheme = getNativeShowcaseTheme(); + pair(pairingUrls); + } } - if (requestedScene === "environments") { - openSettings(navigation, "SettingsEnvironments"); - } else if (requestedScene === "terminal") { - navigation.dispatch( - CommonActions.navigate("ThreadTerminal", { ...params, terminalId: "term-1" }), - ); - } else if (requestedScene === "review") { - navigation.dispatch(CommonActions.navigate("ThreadReview", params)); - } else { - navigation.dispatch(selectWorkspaceDetail({ name: "Thread", params })); + const scene = getNativeShowcaseScene(); + if (scene && scene !== requestedScene) { + requestedScene = scene; + // Draws belong to their scene request; revisiting review must wait for its new surface. + clearShowcaseRenderSignal(); + agentActivity = cancelRun(agentActivity); } - }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); + }; - useEffect(() => { - if (scene !== "agent-activity" || !hasFixture || agentActivityStaged) { - return; - } - let cancelled = false; + const seedPendingTasks = () => { + const pendingTaskList = buildShowcasePendingTasks( + appAtomRegistry.get(environmentProjects.projectsAtom), + Date.now(), + ); + if (pendingTaskList.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; + const run: Run = { cancelled: false, done: false }; + pendingTasks = run; + for (const task of pendingTaskList) holdEditingQueuedMessage(task.messageId); + void Promise.all( + pendingTaskList.map((task) => + retryShowcaseOperation( + async () => { + await enqueueThreadOutboxMessage(task); + return true; + }, + { isCancelled: isCancelled(run) }, + ), + ), + ).then((results) => { + if (run.cancelled || !results.every(Boolean)) return; + run.done = true; + evaluate(); + }); + }; + + const stageAgentActivity = () => { + const run: Run = { cancelled: false, done: false }; + agentActivity = run; let lastOutcome: string | null = null; void retryShowcaseOperation( async () => { const now = Date.now(); - const { threads: latestThreads, projects: latestProjects } = entitiesRef.current; - const activity = buildShowcaseAgentActivity(latestThreads, latestProjects, now); + const activity = buildShowcaseAgentActivity( + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + appAtomRegistry.get(environmentProjects.projectsAtom), + now, + ); const outcome = activity === null ? "fixture threads not loaded" : await stageShowcaseAgentActivity(activity, now); if (outcome === true) return true; - if (outcome !== lastOutcome) + if (outcome !== lastOutcome) { console.warn(`[showcase] agent activity not staged: ${outcome}`); + } lastOutcome = outcome; return false; }, - { isCancelled: () => cancelled, attemptTimeoutMs: 60_000 }, + { isCancelled: isCancelled(run), attemptTimeoutMs: 60_000 }, ).then((staged) => { - if (!cancelled && staged) setAgentActivityStaged(true); + if (run.cancelled || !staged) return; + run.done = true; + evaluate(); }); - return () => { - cancelled = true; - }; - }, [agentActivityStaged, hasFixture, scene]); + }; - useEffect(() => { - if ( - scene === null || - requestedScene === null || - scene !== requestedScene || - !hasFixture || - !orientationSettled || - !themeApplied || - (scene === "agent-activity" && !agentActivityStaged) || - !isShowcaseNativeContentReady({ scene, themeId, renderSignal }) - ) { + const updateReadiness = (key: string | null, scene: ShowcaseScene | null) => { + if (readiness?.key === key) return; + readiness?.cancel(); + readiness = null; + if (key === null || scene === null) { setReadyScene(null); return; } if (scene === "terminal") Keyboard.dismiss(); - - let renderFrame: number | null = null; - let readyFrame: number | null = null; + let frame: number | null = null; + // Two frames after the settle delay so the scene has been drawn, not just committed. const settleTimer = setTimeout(() => { - renderFrame = requestAnimationFrame(() => { - readyFrame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + frame = null; markNativeShowcaseReady(scene); setReadyScene(scene); }); }); - }, 500); - return () => { - clearTimeout(settleTimer); - if (renderFrame !== null) cancelAnimationFrame(renderFrame); - if (readyFrame !== null) cancelAnimationFrame(readyFrame); + }, READY_SETTLE_MS); + readiness = { + key, + cancel: () => { + clearTimeout(settleTimer); + if (frame !== null) cancelAnimationFrame(frame); + }, }; - }, [ - agentActivityStaged, - hasFixture, - orientationSettled, - renderSignal, - requestedScene, - scene, - themeApplied, - themeId, - ]); + }; - if (readyScene === null) return null; + const step = () => { + readNativeRequests(); + + const preferences = appAtomRegistry.get(mobilePreferencesAtom); + const storedPreferences = AsyncResult.isSuccess(preferences) ? preferences.value : null; + const themeIds = resolveMobileThemeIds(storedPreferences ?? {}); + const themeApplied = isShowcaseThemeApplied({ launchRequestRead, requestedTheme, themeIds }); + // The write patches preferences optimistically, so the step it triggers + // already sees the theme applied. A failed save rolls the patch back, and + // the next step writes again. + if (requestedTheme !== null && !themeApplied && storedPreferences) { + appAtomRegistry.set(updateMobilePreferencesAtom, createMobileThemePairPatch(requestedTheme)); + } + const themeId = themeIds[resolveThemeAppearance(storedPreferences)]; + + const projects = appAtomRegistry.get(environmentProjects.projectsAtom); + const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + const hasServerFixture = + appAtomRegistry.get(workspaceConnections.stateAtom).hasReadyEnvironment && + appAtomRegistry.get(workspaceConnections.environmentsAtom).length >= 3 && + projects.length >= 3 && + showcaseThread !== undefined; + if (hasServerFixture && pendingTasks === null) seedPendingTasks(); + const hasFixture = hasServerFixture && pendingTasks?.done === true; + + const navigationReady = rootNavigationRef.isReady(); + const rootState = navigationReady ? rootNavigationRef.getRootState() : undefined; + const scene = rootState + ? showcaseSceneOnScreen(requestedScene, pathnameFromState(rootState)) + : null; + + if (requestedScene !== null && hasFixture && showcaseThread && rootState) { + const navigation = `${requestedScene}|${scene}`; + if (scene === requestedScene) { + lastNavigation = null; + } else if (navigation !== lastNavigation) { + lastNavigation = navigation; + rootNavigationRef.dispatch({ + ...showcaseNavigationAction( + requestedScene, + String(showcaseThread.environmentId), + rootState, + ), + target: rootState.key, + }); + } + } + + if (scene === "agent-activity" && hasFixture) { + if (agentActivity === null) stageAgentActivity(); + } else if (agentActivity?.done === false) { + agentActivity = cancelRun(agentActivity); + } + + updateReadiness( + showcaseReadinessKey({ + scene, + requestedScene, + hasFixture, + orientationSettled, + themeApplied, + agentActivityStaged: agentActivity?.done === true, + themeId, + renderSignal: getShowcaseRenderSignal(), + renderReports, + }), + scene, + ); + }; + + // Steps write atoms they also observe; a nested change reruns the step once + // the current one finishes instead of interleaving with it. + let stepping = false; + let stepAgain = false; + function evaluate() { + if (disposed) return; + if (stepping) { + stepAgain = true; + return; + } + stepping = true; + try { + do { + stepAgain = false; + step(); + } while (stepAgain && !disposed); + } finally { + stepping = false; + } + } + + const unsubscribers = [ + appAtomRegistry.subscribe(mobilePreferencesAtom, evaluate), + appAtomRegistry.subscribe(environmentProjects.projectsAtom, evaluate), + appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, evaluate), + appAtomRegistry.subscribe(workspaceConnections.stateAtom, evaluate), + appAtomRegistry.subscribe(workspaceConnections.environmentsAtom, evaluate), + subscribeToShowcaseRenderSignal(() => { + renderReports += 1; + evaluate(); + }), + rootNavigationRef.addListener("state", evaluate), + ]; + const appearanceSubscription = Appearance.addChangeListener(evaluate); + const pollTimer = setInterval(evaluate, NATIVE_POLL_MS); + evaluate(); + + return () => { + disposed = true; + clearInterval(devMenuTimer); + clearInterval(pollTimer); + appearanceSubscription.remove(); + for (const unsubscribe of unsubscribers) unsubscribe(); + pendingTasks = cancelRun(pendingTasks); + agentActivity = cancelRun(agentActivity); + readiness?.cancel(); + readiness = null; + setReadyScene(null); + }; +} +/** The capture harness waits for this marker before taking a screenshot. */ +export function ShowcaseReadyMarker() { + const scene = useSyncExternalStore(subscribeToReadyScene, getReadyScene, getReadyScene); + if (scene === null) return null; return ( ); diff --git a/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx b/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx index d8d882d6f9163..75e007159818d 100644 --- a/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx +++ b/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx @@ -14,6 +14,8 @@ export function ComposerPopoverAnchor(props: { readonly children: ReactNode }) { const anchorRef = useRef(null); const [frame, setFrame] = useState(null); const popoverHeight = host?.popoverHeight; + // The host value changes on every host layout; the cleanup must only depend on stable parts. + const setHostContent = host?.setContent; const measure = () => { const hostView = host?.hostRef.current; @@ -49,22 +51,20 @@ export function ComposerPopoverAnchor(props: { readonly children: ReactNode }) { ); }); - const setHostContent = host?.setContent; - useEffect( - () => () => { - setHostContent?.(null); - popoverHeight?.set(0); - }, - [setHostContent, popoverHeight], - ); - if (host === null) { return {props.children}; } return ( { + anchorRef.current = node; + return () => { + anchorRef.current = null; + setHostContent?.(null); + popoverHeight?.set(0); + }; + }} collapsable={false} pointerEvents="none" className="absolute inset-0" diff --git a/apps/mobile/src/features/threads/CopyTextButton.tsx b/apps/mobile/src/features/threads/CopyTextButton.tsx index e99f60b30d3d2..ee3d1ef9d1841 100644 --- a/apps/mobile/src/features/threads/CopyTextButton.tsx +++ b/apps/mobile/src/features/threads/CopyTextButton.tsx @@ -1,8 +1,9 @@ import * as Dialog from "../../components/ui/Dialog"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useState } from "react"; import { Pressable, type ColorValue } from "react-native"; +import { useDeadlinePassed } from "../../lib/clock"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; const COPY_FEEDBACK_DURATION_MS = 1200; @@ -20,17 +21,11 @@ export const CopyTextButton = memo(function CopyTextButton(props: { readonly iconSize?: number; readonly buttonSize?: number; }) { - const [copied, setCopied] = useState(false); - const resetTimeoutRef = useRef | null>(null); - - useEffect( - () => () => { - if (resetTimeoutRef.current) { - clearTimeout(resetTimeoutRef.current); - } - }, - [], + const [copiedAtMs, setCopiedAtMs] = useState(null); + const feedbackEnded = useDeadlinePassed( + copiedAtMs === null ? null : copiedAtMs + COPY_FEEDBACK_DURATION_MS, ); + const copied = copiedAtMs !== null && !feedbackEnded; return ( { - setCopied(false); - resetTimeoutRef.current = null; - }, COPY_FEEDBACK_DURATION_MS); + setCopiedAtMs(Date.now()); }} style={({ pressed }) => ({ width: props.buttonSize ?? 30, diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 4672101899887..9d3a482994e6e 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -1,7 +1,5 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import * as Haptics from "expo-haptics"; -import { useEffect, useRef } from "react"; import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Toast } from "../../components/ui/Toast"; @@ -17,18 +15,6 @@ export function GitActionProgressOverlay(props: { const { progress, onDismiss } = props; const openPullRequest = props.onOpenPullRequest; const insets = useSafeAreaInsets(); - const prevPhaseRef = useRef(progress.phase); - - useEffect(() => { - const prev = prevPhaseRef.current; - prevPhaseRef.current = progress.phase; - - if (prev === "running" && progress.phase === "success") { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } else if (prev === "running" && progress.phase === "error") { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - } - }, [progress.phase]); const handlePress = () => { if (progress.prUrl) { diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx index ccfd2ffd15df8..aa6e4cce537d3 100644 --- a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx +++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx @@ -3,7 +3,7 @@ import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -64,11 +64,19 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { const insets = useSafeAreaInsets(); // Wait for focus before goBack; an exit under another screen must not pop that screen. const focused = useIsFocused(); - const [exitRequested, setExitRequested] = useState(false); - const onClose = useCallback(() => { - if (navigation.isFocused()) navigation.goBack(); - else setExitRequested(true); - }, [navigation]); + const exitPending = useRef(false); + const onClose = () => { + if (navigation.isFocused()) { + navigation.goBack(); + return; + } + if (exitPending.current) return; + exitPending.current = true; + const unsubscribe = navigation.addListener("focus", () => { + unsubscribe(); + navigation.goBack(); + }); + }; const [size, setSize] = useState({ width: 0, height: 0 }); const params = route.params; const environmentId = EnvironmentId.make(params.environmentId); @@ -97,9 +105,10 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId)); const awaitingUser = conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true; + // A request for the user is server state with no local handler to react in. useEffect(() => { - if ((awaitingUser || exitRequested) && focused) navigation.goBack(); - }, [awaitingUser, exitRequested, focused, navigation]); + if (awaitingUser && focused && !exitPending.current) navigation.goBack(); + }, [awaitingUser, focused, navigation]); return ( diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx index 4a64b5b4b1516..7e8dfcc34481a 100644 --- a/apps/mobile/src/features/threads/McpAppWebView.tsx +++ b/apps/mobile/src/features/threads/McpAppWebView.tsx @@ -21,6 +21,7 @@ import { type McpAppReference, } from "@t3tools/shared/mcpApp"; import * as Predicate from "effect/Predicate"; +import { AsyncResult } from "effect/reactivity"; import Constants from "expo-constants"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -34,6 +35,7 @@ import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { uuidv4 } from "../../lib/uuid"; import { useAssetUrlState } from "../../state/assets"; +import { appAtomRegistry } from "../../state/atom-registry"; import { useThreadShell } from "../../state/entities"; import { mcpAppEnvironment } from "../../state/mcpApps"; import { orchestrationEnvironment } from "../../state/orchestration"; @@ -88,6 +90,18 @@ const confirm = (title: string, message: string, action: string) => const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; +// The feed omits tool input and output; the app needs both. +function storedToolCall( + item: { readonly type: string; readonly input?: unknown; readonly output?: unknown } | null, +) { + if (item?.type !== "dynamic_tool") return undefined; + const output = item.output as { readonly result?: unknown } | undefined; + return { + arguments: item.input, + result: output?.result as McpAppCallToolResult | undefined, + }; +} + /** WebViews reload when moved; tear down inline before opening fullscreen and recreate it on return. */ export function ThreadMcpApp(props: { readonly environmentId: EnvironmentId; @@ -166,42 +180,25 @@ export function ThreadMcpApp(props: { if (asset._tag === "Success") setUri(asset.url); setDocumentKeyState(next); }; - const openNewDocument = useRef(setDocumentKey); - useEffect(() => { - openNewDocument.current = setDocumentKey; - }); - useEffect(() => { - if (!presentedFullscreen) return; - return navigation.addListener("focus", () => { - openNewDocument.current((value) => value + 1); - setPresentedFullscreen(false); - }); - }, [navigation, presentedFullscreen]); - // The feed omits tool input and output; the app needs both. - const detail = useEnvironmentQuery( - orchestrationEnvironment.turnItem({ + const detailAtom = orchestrationEnvironment.turnItem({ + environmentId: props.environmentId, + input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision }, + }); + const toolInfo = useEnvironmentQuery( + mcpAppEnvironment.toolInfoQuery({ environmentId: props.environmentId, - input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision }, + input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, }), ); - const storedItem = detail.data?.item; - const toolCall = useMemo(() => { - if (storedItem?.type !== "dynamic_tool") return undefined; - const output = storedItem.output as { readonly result?: unknown } | undefined; - return { - arguments: storedItem.input, - result: output?.result as McpAppCallToolResult | undefined, - }; - }, [storedItem]); + const toolDefinition = toolInfo.data?.tool; const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); - const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); + const toolInfoCommand = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false }); const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, { reportFailure: false, }); - const [toolDefinition, setToolDefinition] = useState(undefined); // Read current context per message without rebuilding the host and losing its session. const conversation = useThreadShell( scopeThreadRef(props.environmentId, props.conversationThreadId), @@ -212,8 +209,10 @@ export function ThreadMcpApp(props: { awaitingUser, theme, props, + asset, + detailAtom, callTool, - toolInfo, + toolInfo: toolInfoCommand, readResource, updateModelContext, insets, @@ -221,31 +220,26 @@ export function ThreadMcpApp(props: { navigation, }; const latest = useRef(live); + const hostRef = useRef(null); + // The host diffs the context it last sent, so this resends only what changed. useEffect(() => { latest.current = live; + hostRef.current?.updateHostContext(); }); - useEffect(() => { - let cancelled = false; - void latest.current - .toolInfo({ - environmentId: props.environmentId, - input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, - }) - .then((info) => { - if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) { - setToolDefinition(info.value.tool); - } - }); - return () => { - cancelled = true; - }; - }, [props.environmentId, props.threadId, props.itemId, app.tool]); - const webView = useRef>(null); - const hostRef = useRef(null); const userActive = useRef(false); - useEffect(() => { - if (uri === null) return; + // Each WebView document gets its own host: the view is keyed by document, + // restart generation and URL, and a changed app re-attaches this callback. + const attachWebView = (view: WebView | null) => { + if (view === null) return; + const reopenDocument = () => { + setLoaded(false); + setHeight(MCP_APP_INLINE_MAX_HEIGHT); + setNavigatedAway(false); + const current = latest.current.asset; + if (current._tag === "Success") setUri(current.url); + setDocumentKeyState((value) => value + 1); + }; const scope = () => { const { environmentId, threadId, itemId } = latest.current.props; return { environmentId, input: { threadId, itemId } }; @@ -277,11 +271,11 @@ export function ThreadMcpApp(props: { : { toolInfo: { tool: current.toolDefinition } }), }; }; - const next = makeMcpAppHost({ + const host: McpAppHost = makeMcpAppHost({ app, hostVersion: Constants.expoConfig?.version ?? "0.0.0", post: (message) => - webView.current?.injectJavaScript( + view.injectJavaScript( `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`, ), hostContext, @@ -364,14 +358,21 @@ export function ThreadMcpApp(props: { return current.displayMode ?? "inline"; } if (mode === "fullscreen" && current.displayMode !== "fullscreen") { - await hostRef.current?.teardown(); + await host.teardown(); // Approval arrival or navigation during the wait requires reloading inline. - if (latest.current.awaitingUser || !latest.current.navigation.isFocused()) { - openNewDocument.current((value) => value + 1); + const navigation = latest.current.navigation; + if (latest.current.awaitingUser || !navigation.isFocused()) { + reopenDocument(); return "inline"; } + // Coming back from full screen reloads the inline view. + const unsubscribe = navigation.addListener("focus", () => { + unsubscribe(); + reopenDocument(); + setPresentedFullscreen(false); + }); setPresentedFullscreen(true); - latest.current.navigation.navigate("ThreadMcpApp", { + navigation.navigate("ThreadMcpApp", { environmentId: String(current.environmentId), threadId: String(current.threadId), conversationThreadId: String(current.conversationThreadId), @@ -381,7 +382,7 @@ export function ThreadMcpApp(props: { return "fullscreen"; } if (mode === "inline" && current.displayMode === "fullscreen") { - await hostRef.current?.teardown(); + await host.teardown(); current.onExitFullscreen?.(); return "inline"; } @@ -424,9 +425,9 @@ export function ThreadMcpApp(props: { onRequestTeardown: () => { const current = latest.current.props; if (current.displayMode === "fullscreen") { - void hostRef.current?.teardown().then(() => current.onExitFullscreen?.()); + void host.teardown().then(() => current.onExitFullscreen?.()); } else { - void hostRef.current?.teardown().then(() => setClosed(true)); + void host.teardown().then(() => setClosed(true)); } }, onSizeChanged: (size) => { @@ -435,23 +436,24 @@ export function ThreadMcpApp(props: { if (nextHeight !== undefined) setHeight(nextHeight); }, }); - hostRef.current = next; + hostRef.current = host; + // The host keeps the first call it is given, and sends it once the app + // initializes, so a stored item that lands late still reaches it. + const unsubscribeToolCall = appAtomRegistry.subscribe( + latest.current.detailAtom, + (result) => { + const call = AsyncResult.isSuccess(result) ? storedToolCall(result.value.item) : undefined; + if (call !== undefined) host.setToolCall(call); + }, + { immediate: true }, + ); return () => { + unsubscribeToolCall(); // An unmount cannot wait: the request goes out before the view does. - void next.teardown(); - hostRef.current = null; + void host.teardown(); + if (hostRef.current === host) hostRef.current = null; }; - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A restarted view needs a new host. - }, [uri, app, generation, documentKey]); - - useEffect(() => { - hostRef.current?.updateHostContext(); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend. - }, [theme, props.width, props.height, insets, toolDefinition]); - useEffect(() => { - if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call. - }, [toolCall, uri, generation, documentKey]); + }; const source = useMemo( () => @@ -505,8 +507,8 @@ export function ThreadMcpApp(props: { ) : uri !== null && !crashed ? ( - key={`${documentKey}:${generation}`} - ref={webView} + key={`${documentKey}:${generation}:${uri}`} + ref={attachWebView} onContentProcessDidTerminate={restart} onRenderProcessGone={restart} source={source!} diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 8a56f3c16066b..04c9a4bf4c921 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -20,7 +20,8 @@ import { } from "@t3tools/client-runtime/state/runtime"; import * as Haptics from "expo-haptics"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; +import { sanitizeNewRefName } from "@t3tools/shared/git"; import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -42,6 +43,9 @@ import { import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; +import { filterNewTaskBranches } from "./new-task-context-presentation"; +import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; +import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -52,6 +56,8 @@ import { } from "../connection/ConnectionDiagnosisDetails"; import { environmentStatusText } from "../connection/environmentStatusText"; +const BRANCH_SEARCH_DEBOUNCE_MS = 150; + function EnvironmentPickerRow(props: { environmentId: EnvironmentId; title: string; @@ -240,26 +246,22 @@ export function NewTaskBranchPickerRouteScreen() { const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const [switchingBranchName, setSwitchingBranchName] = useState(null); const selectingBranchNameRef = useRef(null); - const allowSelectionNavigationRef = useRef(false); - const mountedRef = useRef(true); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - flow.setBranchQuery(""); - }; - }, [flow.setBranchQuery]); - - useEffect( - () => - navigation.addListener("beforeRemove", (event) => { - if (selectingBranchNameRef.current !== null && !allowSelectionNavigationRef.current) { - event.preventDefault(); - } - }), - [navigation], - ); + const [branchQuery, setBranchQuery] = useState(""); + const branchSearchQuery = sanitizeNewRefName(branchQuery); + const debouncedBranchQuery = useDebouncedValue(branchSearchQuery, BRANCH_SEARCH_DEBOUNCE_MS); + const branchState = usePaginatedBranches({ + environmentId: flow.selectedProject?.environmentId ?? null, + cwd: flow.selectedProject?.workspaceRoot || null, + query: debouncedBranchQuery, + }); + const branchesLoading = + branchSearchQuery !== debouncedBranchQuery || + (branchState.isPending && branchState.data === null); + const refreshBranches = () => { + if (!flow.selectedProject) return; + setPendingConnectionError(null); + branchState.refresh(); + }; const selectBranch = async (branch: VcsRef) => { const needsCheckout = shouldCheckoutNewTaskBranch({ @@ -279,17 +281,22 @@ export function NewTaskBranchPickerRouteScreen() { return; } setSwitchingBranchName(branch.name); + // The guard belongs to this checkout: back actions are refused while it is + // in flight, and our own goBack runs after it is lifted. + const releaseRemovalGuard = navigation.addListener("beforeRemove", (event) => { + event.preventDefault(); + }); const result = await checkoutNewTaskBranch({ branch, project, workspaceMode: flow.workspaceMode, readRefs, switchRef, - }); - if (mountedRef.current) setSwitchingBranchName(null); + }).finally(releaseRemovalGuard); + setSwitchingBranchName(null); if (result._tag === "Failure") { selectingBranchNameRef.current = null; - if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) { + if (navigation.isFocused() && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); Dialog.alert( "Could not switch branch", @@ -301,15 +308,11 @@ export function NewTaskBranchPickerRouteScreen() { // Checkout already changed Git; persist the draft selection even if the sheet dismissed mid-command. flow.selectBranch(result.value); - if (!mountedRef.current || !navigation.isFocused()) { + if (!navigation.isFocused()) { selectingBranchNameRef.current = null; return; } - flow.setBranchQuery(""); - // beforeRemove blocks dismissal while a switch is in flight; this one is ours. - allowSelectionNavigationRef.current = true; navigation.goBack(); - allowSelectionNavigationRef.current = false; selectingBranchNameRef.current = null; }; @@ -317,21 +320,21 @@ export function NewTaskBranchPickerRouteScreen() { branch.current)?.name ?? flow.availableBranches.find((branch) => branch.isDefault)?.name ?? null } - query={flow.branchQuery} - onQueryChange={flow.setBranchQuery} - loading={flow.branchesLoading} - error={flow.branchesError} - refreshing={flow.branchesFetchingNextPage} - hasMore={flow.hasMoreBranches} - onRefresh={flow.loadBranches} - onLoadMore={flow.loadMoreBranches} + query={branchQuery} + onQueryChange={setBranchQuery} + loading={branchesLoading} + error={branchState.error} + refreshing={branchState.isFetchingNextPage} + hasMore={branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined} + onRefresh={refreshBranches} + onLoadMore={branchState.loadNext} selectionDisabled={switchingBranchName !== null} onSelect={selectBranch} worktree={ diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 009d2b7d77eeb..f75cb972c21a2 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -1,26 +1,32 @@ -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { useNewTaskFlow } from "./new-task-flow-provider"; import * as Dialog from "../../components/ui/Dialog"; import { - useNavigation, + CommonActions, useRoute, usePreventRemove, usePreventRemoveContext, type StaticScreenProps, } from "@react-navigation/native"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect } from "react"; import { View } from "react-native"; +import { useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, + runAtomCommand, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult, Atom } from "effect/reactivity"; import { AppText as Text } from "../../components/ui/AppText"; import { useProjects } from "../../state/entities"; -import { useAtomCommand } from "../../state/use-atom-command"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { threadSelectionDeferral } from "../layout/deferred-navigation"; import { useWorkspaceState } from "../../state/workspace"; import { vcsEnvironment } from "../../state/vcs"; import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; import { resolveNewTaskBranchWorktreePath } from "./new-task-context-presentation"; +import { type BranchCheckoutResult, makeNewTaskBranchCheckouts } from "./new-task-branch-checkout"; +import { liveNewTaskFlowKeys, newTaskFlowKey, routeNewTaskDraftKey } from "./new-task-flow-state"; +import { updateComposerDraftSettings } from "../../state/use-composer-drafts"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { ThreadScreenFallback } from "./ThreadScreenHost"; @@ -29,56 +35,96 @@ import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; import type { NewTaskDraftRouteParams } from "./thread-route-state"; export type { NewTaskDraftRouteParams } from "./thread-route-state"; +const branchCheckouts = makeNewTaskBranchCheckouts({ + run: (request) => + checkoutNewTaskBranch({ + branch: { + name: request.branch, + current: false, + isDefault: false, + worktreePath: request.worktreePath, + }, + project: + request.environmentId !== null && request.workspaceRoot !== null + ? { environmentId: request.environmentId, workspaceRoot: request.workspaceRoot } + : null, + workspaceMode: "local", + readRefs: (input) => + runAtomCommand(appAtomRegistry, vcsEnvironment.readRefs, input, { + reportFailure: false, + }), + switchRef: (input) => + runAtomCommand(appAtomRegistry, vcsEnvironment.switchRef, input, { + reportFailure: false, + }), + }), + holdNavigation: threadSelectionDeferral.waitFor, + // The route's draft starts on the branch it checked out. Local mode reuses + // the existing worktree; worktree mode would create another. + onSuccess: (request, result) => { + if (request.workspaceRoot === null) return; + updateComposerDraftSettings(request.draftKey, { + workspaceSelection: { + mode: "local", + branch: result.value.name, + worktreePath: resolveNewTaskBranchWorktreePath({ + workspaceMode: "local", + projectCwd: request.workspaceRoot, + branchWorktreePath: result.value.worktreePath, + }), + startFromOrigin: false, + }, + }); + }, + onFailure: (request, result) => { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Could not switch branch", + error instanceof Error ? error.message : "The branch could not be checked out.", + ); + } + rootNavigationRef.dispatch({ + ...CommonActions.goBack(), + source: request.routeKey, + target: rootNavigationRef.getRootState()?.key, + }); + }, +}); + +/** Abandons the branch checkouts of draft routes that left the stack. */ +export function pruneNewTaskBranchCheckouts(state: Parameters[0]) { + branchCheckouts.prune(liveNewTaskFlowKeys(state)); +} + +const idleBranchCheckoutAtom = Atom.make(AsyncResult.initial()).pipe( + Atom.withLabel("mobile:new-task-branch-checkout:idle"), +); + export function NewTaskDraftRouteScreen({ route }: StaticScreenProps) { const currentRoute = useRoute(); - const params = useMemo(() => route.params ?? {}, [route.params]); - const { reset } = useNewTaskFlow(); - const { deferThreadSelectionUntil } = useAdaptiveWorkspaceLayout(); - const [initialized, setInitialized] = useState(false); - useLayoutEffect(() => { - reset(); - setInitialized(true); - }, [reset]); - const pendingTaskId = Array.isArray(params.pendingTaskId) - ? params.pendingTaskId[0] - : params.pendingTaskId; + const params = route.params ?? {}; const draftId = Array.isArray(params.draftId) ? params.draftId[0] : params.draftId; const projects = useProjects(); const { state: catalogState } = useWorkspaceState(); - const navigation = useNavigation(); - const readRefs = useAtomCommand(vcsEnvironment.readRefs, { reportFailure: false }); - const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); - const initialProjectRef = useMemo( - () => ({ - environmentId: Array.isArray(params.environmentId) - ? params.environmentId[0] - : params.environmentId, - projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, - branch: params.branch, - worktreePath: params.worktreePath, - cloning: (Array.isArray(params.cloning) ? params.cloning[0] : params.cloning) === "1", - }), - [params], - ); + const initialProjectRef = { + environmentId: Array.isArray(params.environmentId) + ? params.environmentId[0] + : params.environmentId, + projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, + branch: params.branch, + worktreePath: params.worktreePath, + cloning: (Array.isArray(params.cloning) ? params.cloning[0] : params.cloning) === "1", + }; - const [preparation, setPreparation] = useState<{ - request: typeof initialProjectRef; - result: Awaited>; - workspaceRoot: string | undefined; - } | null>(null); const project = projects.find( (candidate) => candidate.environmentId === initialProjectRef.environmentId && candidate.id === initialProjectRef.projectId, ); - const environmentId = project?.environmentId; const workspaceRoot = project?.workspaceRoot; - const needsPreparation = Boolean(initialProjectRef.branch && !pendingTaskId && !draftId); - - const [pendingCheckouts, setPendingCheckouts] = useState(0); - const checkoutTail = useRef(Promise.resolve()); - const committedCheckoutReleases = useRef(new Set<() => void>()); + const needsPreparation = Boolean(initialProjectRef.branch); const waitingForProject = !project && (catalogState.isLoadingConnections || @@ -86,101 +132,38 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps { - if (!needsPreparation || !initialProjectRef.branch || waitingForProject) return; - const branchName = initialProjectRef.branch; - let active = true; - setPendingCheckouts((count) => count + 1); - // Serialize replacements: ignoring a stale result cannot undo its Git mutation. - checkoutTail.current = checkoutTail.current.then(async () => { - if (!active) { - setPendingCheckouts((count) => count - 1); - return; - } - const result = await checkoutNewTaskBranch({ - branch: { - name: branchName, - current: false, - isDefault: false, + const flowKey = newTaskFlowKey(currentRoute); + const checkoutKey = + needsPreparation && initialProjectRef.branch && !waitingForProject && flowKey !== undefined + ? branchCheckouts.requestKey({ + flowKey, + draftKey: draftId ?? routeNewTaskDraftKey(flowKey), + routeKey: currentRoute.key, + environmentId: project?.environmentId ?? null, + workspaceRoot: workspaceRoot ?? null, + branch: initialProjectRef.branch, worktreePath: initialProjectRef.worktreePath ?? null, - }, - project: environmentId && workspaceRoot ? { environmentId, workspaceRoot } : null, - workspaceMode: "local", - readRefs, - switchRef, - }); - setPendingCheckouts((count) => count - 1); - if (active) setPreparation({ request: initialProjectRef, result, workspaceRoot }); - }); - const committed = Promise.withResolvers(); - committedCheckoutReleases.current.add(committed.resolve); - deferThreadSelectionUntil(Promise.allSettled([checkoutTail.current, committed.promise])); - return () => { - active = false; - }; - }, [ - deferThreadSelectionUntil, - environmentId, - workspaceRoot, - initialProjectRef, - needsPreparation, - readRefs, - switchRef, - waitingForProject, - ]); - - const result = - preparation?.request === initialProjectRef && preparation.workspaceRoot === workspaceRoot - ? preparation.result + }) : null; - // Guard native swipes too; replaced checkout requests must settle before leaving the shared checkout. - const checkoutPending = pendingCheckouts > 0 || (needsPreparation && result === null); + const checkout = useAtomValue( + checkoutKey === null ? idleBranchCheckoutAtom : branchCheckouts.atom(checkoutKey), + ); + const result = AsyncResult.isSuccess(checkout) ? checkout.value : null; + // The native-stack guard covers iOS swipe dismissal as well as back actions. + const checkoutPending = needsPreparation && result === null; usePreventRemove(checkoutPending, () => undefined); const { preventedRoutes } = usePreventRemoveContext(); const removalBlocked = preventedRoutes[currentRoute.key]?.preventRemove === true; + // Native-stack reads the provider's prevent state, which commits a + // render after usePreventRemove updates its registry; the deferred thread + // selection (and the failure goBack) must wait for that commit. useEffect(() => { - if (checkoutPending || removalBlocked) return; - // Native-stack reads the provider state, which commits after the hook updates its registry. - for (const release of committedCheckoutReleases.current) release(); - committedCheckoutReleases.current.clear(); - }, [checkoutPending, removalBlocked]); - useEffect(() => { - const releases = committedCheckoutReleases.current; - return () => { - for (const release of releases) release(); - releases.clear(); - }; - }, []); - useEffect(() => { - if (checkoutPending || result?._tag !== "Failure") return; - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Dialog.alert( - "Could not switch branch", - error instanceof Error ? error.message : "The branch could not be checked out.", - ); - } - navigation.goBack(); - }, [checkoutPending, result, navigation]); + if (checkoutKey === null || checkoutPending || removalBlocked) return; + branchCheckouts.release(checkoutKey); + }, [checkoutKey, checkoutPending, removalBlocked]); - const preparedProjectRef = useMemo( - () => - result?._tag === "Success" && workspaceRoot - ? { - ...initialProjectRef, - branch: result.value.name, - worktreePath: resolveNewTaskBranchWorktreePath({ - workspaceMode: "local", - projectCwd: workspaceRoot, - branchWorktreePath: result.value.worktreePath, - }), - } - : initialProjectRef, - [initialProjectRef, result, workspaceRoot], - ); const preparingBranch = checkoutPending || (needsPreparation && result?._tag !== "Success"); - if (!initialized) return null; return ( <> {preparingBranch ? ( @@ -192,13 +175,12 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps ) : ( )} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ccb2365eb62bb..1c4aef3b34c2e 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -22,8 +22,10 @@ import { StackActions, useNavigation, usePreventRemove, + usePreventRemoveContext, + useRoute, } from "@react-navigation/native"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { KeyboardController } from "react-native-keyboard-controller"; @@ -54,14 +56,9 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { clearComposerDraftContent, getComposerDraftSnapshot, - mergeComposerDraftContent, - restoreComposerDraftSnapshot, - updateComposerDraftSettings, scheduleUnusedComposerAttachmentCleanup, - type ComposerDraft, - waitForComposerDraftsLoaded, } from "../../state/use-composer-drafts"; -import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; +import { useEnvironmentServerConfig } from "../../state/entities"; import { useProjectClone } from "../../state/projectClones"; import { projectEnvironment } from "../../state/projects"; import { sourceControlEnvironment } from "../../state/sourceControl"; @@ -78,21 +75,25 @@ import { useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useNewTaskFlow } from "./new-task-flow-provider"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { nextEnvironmentId, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; -import { resolveDraftProjectSelection } from "./new-task-project-selection"; import { resolveNewTaskBranchLabel, resolveNewTaskWorkspaceLabel, - resolveNewTaskWorkspaceSelection, } from "./new-task-context-presentation"; -import { useIncomingShare } from "../sharing/IncomingShareProvider"; -import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-share-model"; +import { incomingShareImports, useIncomingShare } from "../sharing/IncomingShareProvider"; +import { + idleImportedShareAtom, + idleShareImportPhaseAtom, + isShareImportInFlight, +} from "./incomingShareImport"; import { appAtomRegistry } from "../../state/atom-registry"; -import { serverEnvironment } from "../../state/server"; +import { newTaskFlowKey } from "./new-task-flow-state"; +import { abortComposerDraftOperations } from "../../state/composerDraftOperations"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; @@ -145,27 +146,19 @@ export function NewTaskDraftScreen(props: { readonly initialProjectRef?: { readonly environmentId?: string; readonly projectId?: string; - readonly branch?: string | null; - readonly worktreePath?: string | null; readonly cloning?: boolean; }; - readonly pendingTaskId?: string; readonly draftId?: string; readonly incomingShareId?: string; }) { const [creationMetadata] = useState(makeTurnCommandMetadata); - const projects = useProjects(); const flow = useNewTaskFlow(); const navigation = useNavigation(); const goBack = useWorkspaceBackNavigation("threads"); - const { - consumeShare, - getShare, - isLoading: isIncomingShareInboxLoading, - releaseShareReservation, - reserveShare, - } = useIncomingShare(); - const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; + const currentRoute = useRoute(); + const flowKey = newTaskFlowKey(currentRoute); + const { getShare, isLoading: isIncomingShareInboxLoading } = useIncomingShare(); + const { projectScopes, selectedProject } = flow; const selectedProjectLabel = (() => { if (!selectedProject) return null; const scope = projectScopes.find((candidate) => @@ -219,10 +212,6 @@ export function NewTaskDraftScreen(props: { } }; const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); - const selectedProjectRef = useRef(selectedProject); - useEffect(() => { - selectedProjectRef.current = selectedProject; - }, [selectedProject]); const removeClonedProject = async () => { if (!selectedProject) return; const removed = selectedProject; @@ -238,8 +227,7 @@ export function NewTaskDraftScreen(props: { ); return; } - const current = selectedProjectRef.current; - if (current?.id === removed.id && current.environmentId === removed.environmentId) { + if (flow.getSelectedProjectKey() === scopedProjectKey(removed.environmentId, removed.id)) { navigation.dispatch(StackActions.replace("Home")); } }; @@ -264,48 +252,74 @@ export function NewTaskDraftScreen(props: { }); const queuesInsteadOfStarting = !environmentConnected || attachmentsUploading; const promptInputRef = useRef(null); - const loadedBranchesProjectKeyRef = useRef(null); - const [importingShareKey, setImportingShareKey] = useState(null); - const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); - const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); - const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false); - const [shareImportAttempt, setShareImportAttempt] = useState(0); - const startedShareImportKeyRef = useRef(null); - const cancellingShareImportKeyRef = useRef(null); - const shareImportDraftBackupRef = useRef(new Map()); - const activeShareImportTokenRef = useRef(null); - const shareImportMountedRef = useRef(true); - // The share import resumes after awaits; this reads the screen's current - // draft and share at that point without re-running the import effect. - const isCurrentShareImport = useEffectEvent( - (draftKey: string, shareId: string) => - flow.draftKey === draftKey && props.incomingShareId === shareId, - ); - const isImportingShare = importingShareKey !== null; - const alertedUnavailableIncomingShareIdRef = useRef(null); - // Remember consumed shares locally: send clears the receipt before navigation and can trigger a missing-content render. - const [consumedIncomingShareId, setConsumedIncomingShareId] = useState(null); const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null; - const requestedInitialProjectAvailable = Boolean( - props.initialProjectRef?.environmentId && - props.initialProjectRef.projectId && - projects.some( - (project) => - project.environmentId === props.initialProjectRef?.environmentId && - project.id === props.initialProjectRef?.projectId, - ), - ); - const isProjectPickerReturnActive = - isReturningToProjectPicker && !requestedInitialProjectAvailable; + // A route naming a draft or project that is gone, or no project when there + // is a choice to make, falls back to the project picker. + const isRouteTargetMissing = flow.routeTargetMissing; const isIncomingShareAwaitingServerConfig = Boolean( incomingShare?.attachments.some((attachment) => attachment.type === "file") && selectedEnvironmentServerConfig === null, ); + const routeImportedShare = useAtomValue( + flowKey !== undefined && props.incomingShareId + ? incomingShareImports.importedAtom(flowKey, props.incomingShareId) + : idleImportedShareAtom, + ); + const hasImportedIncomingShare = Boolean( + props.incomingShareId && + (routeImportedShare || + (flow.draftKey && + getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId))), + ); + const isIncomingShareUnavailable = Boolean( + props.incomingShareId && + !isIncomingShareInboxLoading && + !incomingShare && + !hasImportedIncomingShare, + ); + const shareDestinationProject = (() => { + const initialEnvironmentId = props.initialProjectRef?.environmentId; + const initialProjectId = props.initialProjectRef?.projectId; + const selectedProjectMatchesRoute = + !initialEnvironmentId || + !initialProjectId || + (selectedProject?.environmentId === initialEnvironmentId && + selectedProject.id === initialProjectId); + return selectedProjectMatchesRoute ? selectedProject : null; + })(); + const shareImportTarget = (() => { + if (!props.incomingShareId || flowKey === undefined || !flow.draftKey) return null; + if (!shareDestinationProject || !incomingShare || isIncomingShareAwaitingServerConfig) { + return null; + } + return { + flowKey, + routeKey: currentRoute.key, + draftKey: flow.draftKey, + destination: { + environmentId: String(shareDestinationProject.environmentId), + projectId: String(shareDestinationProject.id), + }, + shareId: incomingShare.id, + }; + })(); + const shareImportPhase = useAtomValue( + shareImportTarget === null + ? idleShareImportPhaseAtom + : incomingShareImports.phaseAtom(shareImportTarget), + ); + const isImportingShare = isShareImportInFlight(shareImportPhase); const isIncomingShareTransferPending = Boolean( - incomingShare && - cancelledIncomingShareId !== props.incomingShareId && - !isIncomingShareAwaitingServerConfig, + incomingShare && shareImportPhase !== "cancelled" && !isIncomingShareAwaitingServerConfig, ); + // A failed import waits in a modal alert, and cancelling finishes its undo + // even after the route leaves (the import machine keeps both past the + // route), so neither holds the route: the cancel handler can leave as soon + // as it is done. + const isIncomingShareHoldingRoute = + isIncomingShareTransferPending && + shareImportPhase !== "failed" && + shareImportPhase !== "cancelling"; const contextImports = useAtomValue(composerContextImportsAtom); const isImportingContext = flow.draftKey ? contextImports[flow.draftKey] === true : false; const isComposerInteractionLocked = @@ -323,441 +337,23 @@ export function NewTaskDraftScreen(props: { (flow.workspaceMode === "worktree" ? selectedProject?.workspaceRoot : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null; - const preventRemove = - (isIncomingShareTransferPending && !isProjectPickerReturnActive) || - isCancellingShareImport || - flow.submitting; + const preventRemove = (isIncomingShareHoldingRoute && !isRouteTargetMissing) || flow.submitting; usePreventRemove(preventRemove, () => undefined); - const hasImportedIncomingShare = Boolean( - props.incomingShareId && - (consumedIncomingShareId === props.incomingShareId || - (flow.draftKey && - getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId))), - ); - const isIncomingShareUnavailable = Boolean( - props.incomingShareId && - !isIncomingShareInboxLoading && - !incomingShare && - !hasImportedIncomingShare, - ); + const { preventedRoutes } = usePreventRemoveContext(); + const removalBlocked = preventedRoutes[currentRoute.key]?.preventRemove === true; const isIncomingShareReady = !props.incomingShareId || (hasImportedIncomingShare && !incomingShare) || isIncomingShareUnavailable; - const appliedInitialProjectKeyRef = useRef(null); - useEffect(() => { - if (cancelledIncomingShareId === props.incomingShareId) { - navigation.goBack(); - } - }, [cancelledIncomingShareId, navigation, props.incomingShareId]); - useEffect(() => { - if (!isReturningToProjectPicker) { - return; - } - if (requestedInitialProjectAvailable) { - setIsReturningToProjectPicker(false); - return; - } - // Wait for usePreventRemove to disable before replacing, or the transfer guard swallows the fallback. - const frame = requestAnimationFrame(() => { - navigation.dispatch( - StackActions.replace("NewTaskProject", { incomingShareId: props.incomingShareId }), - ); - }); - return () => cancelAnimationFrame(frame); - }, [ - isReturningToProjectPicker, - navigation, - props.incomingShareId, - requestedInitialProjectAvailable, - ]); - useEffect(() => { - if (!shareImportMountedRef.current) { - startedShareImportKeyRef.current = null; - } - shareImportMountedRef.current = true; - return () => { - appliedInitialProjectKeyRef.current = null; - shareImportMountedRef.current = false; - activeShareImportTokenRef.current = null; - cancellingShareImportKeyRef.current = null; - }; - }, []); - - const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow; - // Cold launches load drafts and projects separately; wait for hydration and retry while shells load. - const attemptedDraftIdRef = useRef(null); - useEffect(() => { - if (!props.draftId || props.pendingTaskId) { - return; - } - const draftId = props.draftId; - if (attemptedDraftIdRef.current === draftId) { - return; - } - let cancelled = false; - void waitForComposerDraftsLoaded().then(() => { - if (cancelled || attemptedDraftIdRef.current === draftId) { - return; - } - if (openDraft(draftId)) { - attemptedDraftIdRef.current = draftId; - return; - } - if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) { - return; - } - attemptedDraftIdRef.current = draftId; - navigation.dispatch(CommonActions.navigate("NewTaskProject")); - }); - return () => { - cancelled = true; - }; - }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]); - - const attemptedPendingTaskIdRef = useRef(null); - useEffect(() => { - if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) { - return; - } - if (attemptedPendingTaskIdRef.current === props.pendingTaskId) { - return; - } - attemptedPendingTaskIdRef.current = props.pendingTaskId; - if (!beginEditingPendingTask(props.pendingTaskId)) { - navigation.dispatch(CommonActions.navigate("NewTaskProject")); - } - }, [beginEditingPendingTask, editingPendingTask?.messageId, navigation, props.pendingTaskId]); - - useEffect(() => { - if (!props.pendingTaskId) return; - return () => { - attemptedPendingTaskIdRef.current = null; - cancelEditingPendingTask(); - }; - }, [props.pendingTaskId, cancelEditingPendingTask]); - - const lastInitialProjectRefRef = useRef(props.initialProjectRef); - - useEffect(() => { - if (props.pendingTaskId || props.draftId) { - return; - } - if (lastInitialProjectRefRef.current !== props.initialProjectRef) { - lastInitialProjectRefRef.current = props.initialProjectRef; - appliedInitialProjectKeyRef.current = null; - } - const initialEnvironmentId = props.initialProjectRef?.environmentId; - const initialProjectId = props.initialProjectRef?.projectId; - if (initialEnvironmentId && initialProjectId) { - const directProject = - projects.find( - (project) => - project.environmentId === initialEnvironmentId && project.id === initialProjectId, - ) ?? null; - - if (directProject) { - // Apply the route project once or picker changes would immediately revert. - const directProjectKey = `${directProject.environmentId}:${directProject.id}`; - if (appliedInitialProjectKeyRef.current === directProjectKey) { - return; - } - if (props.initialProjectRef?.branch) { - if ( - selectedProject?.environmentId !== directProject.environmentId || - selectedProject.id !== directProject.id - ) { - setProject(directProject); - return; - } - if (!flow.draftKey) return; - // Checkout completed before mount; local mode reuses it while worktree mode would create another. - updateComposerDraftSettings(flow.draftKey, { - workspaceSelection: { - mode: "local", - branch: props.initialProjectRef.branch, - worktreePath: props.initialProjectRef.worktreePath ?? null, - startFromOrigin: false, - }, - }); - } - appliedInitialProjectKeyRef.current = directProjectKey; - if ( - selectedProject?.environmentId === directProject.environmentId && - selectedProject.id === directProject.id - ) { - return; - } - setProject(directProject); - return; - } - - if (projects.length > 0) { - setIsReturningToProjectPicker(true); - } - return; - } - - const selection = resolveDraftProjectSelection(selectedProjectKey, projects, projectScopes); - if (selection.kind === "preserve") { - return; - } - if (selection.kind === "select") { - setProject(selection.project); - return; - } - - navigation.dispatch(CommonActions.navigate("NewTaskProject")); - }, [ - projectScopes, - projects, - flow.draftKey, - props.initialProjectRef, - props.incomingShareId, - props.pendingTaskId, - props.draftId, - navigation, - selectedProject, - selectedProjectKey, - setProject, - ]); - - useEffect(() => { - if (!selectedProject) { - loadedBranchesProjectKeyRef.current = null; - return; - } - const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`; - if (loadedBranchesProjectKeyRef.current === projectKey) { - return; - } - loadedBranchesProjectKeyRef.current = projectKey; - flow.loadBranches(); - }, [flow.loadBranches, selectedProject]); - + // Navigation is an external system: the replace runs after the commit, and + // only once this route no longer blocks removal for any reason (share + // transfer, submit), as the navigator's guard would drop it. useEffect(() => { - const shareId = props.incomingShareId; - const draftKey = flow.draftKey; - const destinationProject = selectedProject; - const initialEnvironmentId = props.initialProjectRef?.environmentId; - const initialProjectId = props.initialProjectRef?.projectId; - const selectedProjectMatchesRoute = - !initialEnvironmentId || - !initialProjectId || - (destinationProject?.environmentId === initialEnvironmentId && - destinationProject.id === initialProjectId); - if ( - !shareId || - !draftKey || - !destinationProject || - !selectedProjectMatchesRoute || - cancelledIncomingShareId === shareId - ) { - return; - } - const importKey = `${shareId}:${draftKey}`; - if ( - startedShareImportKeyRef.current === importKey || - cancellingShareImportKeyRef.current === importKey - ) { - return; - } - - if (!incomingShare) { - if (isIncomingShareUnavailable && alertedUnavailableIncomingShareIdRef.current !== shareId) { - alertedUnavailableIncomingShareIdRef.current = shareId; - Dialog.alert( - "Shared content unavailable", - "The shared content is no longer in the inbox. You can continue editing this task draft.", - ); - } - return; - } - - if ( - incomingShare.attachments.some((attachment) => attachment.type === "file") && - selectedEnvironmentServerConfig === null - ) { - return; - } - - if (alertedUnavailableIncomingShareIdRef.current === shareId) { - alertedUnavailableIncomingShareIdRef.current = null; - } - startedShareImportKeyRef.current = importKey; - const draftBackup = - shareImportDraftBackupRef.current.get(importKey) ?? getComposerDraftSnapshot(draftKey); - shareImportDraftBackupRef.current.set(importKey, draftBackup); - const importToken = Symbol(importKey); - let didReserveShare = false; - let didConsumeShare = false; - let needsDraftRestore = false; - activeShareImportTokenRef.current = importToken; - setImportingShareKey(importKey); - void (async () => { - await reserveShare(shareId, { - environmentId: String(destinationProject.environmentId), - projectId: String(destinationProject.id), - }); - didReserveShare = true; - if ( - !shareImportMountedRef.current || - activeShareImportTokenRef.current !== importToken || - !isCurrentShareImport(draftKey, shareId) - ) { - return; - } - const selectedAttachments = selectIncomingShareAttachmentsForServer({ - attachments: incomingShare.attachments, - serverConfig: appAtomRegistry.get( - serverEnvironment.configValueAtom(destinationProject.environmentId), - ), - }); - if (selectedAttachments.status === "pending") { - throw new Error("Server attachment support is still loading."); - } - needsDraftRestore = true; - const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, { - text: incomingShare.text, - attachments: selectedAttachments.attachments, - sourceShareId: shareId, - }); - if ( - !shareImportMountedRef.current || - activeShareImportTokenRef.current !== importToken || - !isCurrentShareImport(draftKey, shareId) - ) { - // Durable reservations resume interrupted transfers only in this project, avoiding duplicate draft copies. - return; - } - await consumeShare(shareId); - didConsumeShare = true; - setConsumedIncomingShareId(shareId); - // Release skipped files after consumption even on unmount; sweeping re-checks new owners. - const retainedAttachmentIds = new Set( - getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), - ); - scheduleUnusedComposerAttachmentCleanup( - incomingShare.attachments.filter((attachment) => !retainedAttachmentIds.has(attachment.id)), - ); - if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { - return; - } - const warnings = [...incomingShare.warnings, ...selectedAttachments.warnings]; - if (skippedAttachmentCount > 0) { - warnings.push( - `${skippedAttachmentCount} shared file${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, - ); - } - if (warnings.length > 0) { - Dialog.alert("Some shared content was skipped", warnings.join("\n")); - } - shareImportDraftBackupRef.current.delete(importKey); - })() - .catch((error) => { - if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { - return; - } - Dialog.alert( - "Could not import shared content", - error instanceof Error ? error.message : "The shared content could not be saved.", - [ - { - text: "Cancel import", - style: "cancel", - onPress: () => { - const cancelImport = async (): Promise => { - if (!shareImportMountedRef.current) { - return; - } - // Latch before restoring: atom publication can re-run import before React commits cancellation. - cancellingShareImportKeyRef.current = importKey; - setIsCancellingShareImport(true); - try { - if (needsDraftRestore) { - // Sweep restored attachments only after inbox consumption; unconsumed inbox entries still own them. - const mergedAttachments = getComposerDraftSnapshot(draftKey).attachments; - await restoreComposerDraftSnapshot(draftKey, draftBackup); - needsDraftRestore = false; - if (didConsumeShare) { - scheduleUnusedComposerAttachmentCleanup(mergedAttachments); - } - } - if (didReserveShare) { - await releaseShareReservation(shareId, { - environmentId: String(destinationProject.environmentId), - projectId: String(destinationProject.id), - }); - } - shareImportDraftBackupRef.current.delete(importKey); - if (shareImportMountedRef.current) { - setIsCancellingShareImport(false); - setCancelledIncomingShareId(shareId); - } - } catch (cancelError) { - if (!shareImportMountedRef.current) { - return; - } - Dialog.alert( - "Could not cancel import", - cancelError instanceof Error - ? cancelError.message - : "The shared content could not be restored safely.", - [ - { - text: "Retry import", - onPress: () => { - cancellingShareImportKeyRef.current = null; - setIsCancellingShareImport(false); - setShareImportAttempt((attempt) => attempt + 1); - }, - }, - { - text: "Retry cancel", - onPress: () => void cancelImport(), - }, - ], - { cancelable: false }, - ); - } - }; - void cancelImport(); - }, - }, - { - text: "Retry", - onPress: () => setShareImportAttempt((attempt) => attempt + 1), - }, - ], - { cancelable: false }, - ); - }) - .finally(() => { - if (startedShareImportKeyRef.current === importKey) { - startedShareImportKeyRef.current = null; - } - if (shareImportMountedRef.current && activeShareImportTokenRef.current === importToken) { - activeShareImportTokenRef.current = null; - setImportingShareKey(null); - } - }); - }, [ - consumeShare, - cancelledIncomingShareId, - flow.draftKey, - hasImportedIncomingShare, - incomingShare, - isIncomingShareInboxLoading, - isIncomingShareUnavailable, - props.incomingShareId, - props.initialProjectRef?.environmentId, - props.initialProjectRef?.projectId, - releaseShareReservation, - reserveShare, - selectedEnvironmentServerConfig, - selectedProject, - shareImportAttempt, - ]); + if (!isRouteTargetMissing || removalBlocked) return; + navigation.dispatch( + StackActions.replace("NewTaskProject", { incomingShareId: props.incomingShareId }), + ); + }, [isRouteTargetMissing, navigation, props.incomingShareId, removalBlocked]); const selectedEnvironmentLabel = flow.environments.find( @@ -825,15 +421,7 @@ export function NewTaskDraftScreen(props: { draft.modelSelection ?? null, selectedProject.id, ) ?? flow.selectedModel; - const workspaceSelection = resolveNewTaskWorkspaceSelection({ - selection: draft.workspaceSelection, - fallback: { - mode: flow.workspaceMode, - branch: flow.selectedBranchName, - worktreePath: flow.selectedWorktreePath, - }, - isGitRepo: flow.isGitRepo, - }); + const workspaceSelection = flow.resolveWorkspaceSelection(draft.workspaceSelection); const workspaceMode = workspaceSelection.mode; const selectedBranchName = workspaceSelection.branch; const initialMessageText = draft.text.trim(); @@ -894,17 +482,7 @@ export function NewTaskDraftScreen(props: { return null; } - const editingPendingTask = flow.editingPendingTask; - - const metadata = editingPendingTask - ? { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - } - : creationMetadata; - const message = flow.buildPendingTaskMessage(metadata, { + const message = flow.buildPendingTaskMessage(creationMetadata, { currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName, }); if (!message) { @@ -928,18 +506,17 @@ export function NewTaskDraftScreen(props: { submittedThreadParams(String(message.environmentId), String(message.threadId)), ), ); + // The composer has moved to the submitted thread; a pick still converting + // would otherwise resurrect this draft. + abortComposerDraftOperations(draftKey); const draftSnapshot = getComposerDraftSnapshot(draftKey); - if (editingPendingTask) { - flow.finishEditingPendingTask(); - } else { - // The queued message owns the attachments now, so the sweep is deferred - // until the write confirms it. - clearComposerDraftContent(draftKey, { - clearModelSelection: true, - clearWorkspaceSelection: true, - deferAttachmentCleanup: true, - }); - } + // The queued message owns the attachments now, so the sweep is deferred + // until the write confirms it. + clearComposerDraftContent(draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + deferAttachmentCleanup: true, + }); scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments); return message.messageId; } @@ -984,6 +561,11 @@ export function NewTaskDraftScreen(props: { onChooseEnvironment={() => openContextPicker("NewTaskEnvironment")} /> ); + const shareUnavailableNotice = isIncomingShareUnavailable ? ( + + The shared content is no longer in the inbox. You can continue editing this task draft. + + ) : null; const hero = flow.isScratchDraft ? ( @@ -1001,6 +583,7 @@ export function NewTaskDraftScreen(props: { /> {environmentControl} + {shareUnavailableNotice} ) : ( @@ -1030,6 +613,7 @@ export function NewTaskDraftScreen(props: { {environmentControl} + {shareUnavailableNotice} ); // The nested hero scroll view does not receive UIKit's automatic navigation inset in split columns. @@ -1136,7 +720,7 @@ export function NewTaskDraftScreen(props: { const thread = draftThreadShell({ environmentId: selectedProject.environmentId, projectId: selectedProject.id, - threadId: ThreadId.make(flow.editingPendingTask?.threadId ?? creationMetadata.threadId), + threadId: ThreadId.make(creationMetadata.threadId), modelSelection: flow.selectedModel, runtimeMode: flow.runtimeMode, interactionMode: flow.interactionMode, @@ -1236,10 +820,6 @@ export function NewTaskDraftScreen(props: { onSendMessage={handleStart} onReconnectEnvironment={() => onReconnectEnvironment(selectedProject.environmentId)} canSwitchThreadProvider - onUpdateThreadModelSelection={(selection) => - flow.setSelectedModelKey(`${selection.instanceId}:${selection.model}`, selection.options) - } - onUpdateThreadRuntimeMode={flow.setRuntimeMode} onUpdateThreadInteractionMode={flow.planModeEnabled ? flow.setInteractionMode : undefined} onRespondToApproval={async () => {}} onSelectUserInputOption={() => {}} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 01b3ed0c0f49f..c53561cc37da3 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -3,12 +3,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { NavigationRow } from "../../components/ui/NavigationRow"; import { AppBar } from "../../components/ui/AppBar"; -import { - StackActions, - useIsFocused, - useNavigation, - type StaticScreenProps, -} from "@react-navigation/native"; +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { SymbolView } from "../../components/ui/AppSymbol"; import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; @@ -17,7 +12,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; @@ -33,7 +28,6 @@ import { useRemoteConnectionStatus } from "../../state/use-remote-environment-re import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; -import { usePromptLinkResolver } from "../prompt-links/usePromptLinkResolver"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection"; @@ -156,10 +150,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps(null); const reservedDestinationProject = incomingShare?.destination ? (projects.find( (project) => @@ -207,9 +198,19 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps + reservedDestinationProject !== null && + !scope.projects.some( + (project) => + project.environmentId === reservedDestinationProject.environmentId && + project.id === reservedDestinationProject.id, + ); const scratchStartInFlightRef = useRef(false); - async function selectProject(project: EnvironmentProject): Promise { + async function selectProject(selected: EnvironmentProject): Promise { + // A reserved share resumes in its project: an interrupted transfer must + // not copy into a second project's draft. + const project = reservedDestinationProject ?? selected; if (incomingShare?.destination && !reservedDestinationProject) { try { await releaseShareReservation(incomingShare.id, incomingShare.destination); @@ -268,38 +269,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { - const destination = incomingShare?.destination; - if (!destination) { - resumedDestinationKeyRef.current = null; - return; - } - if (!isFocused) { - // Reset on return so reserved-draft resume can retry without leaving project rows disabled. - resumedDestinationKeyRef.current = null; - return; - } - const destinationKey = `${incomingShare.id}:${destination.environmentId}:${destination.projectId}`; - if (resumedDestinationKeyRef.current === destinationKey) { - return; - } - if (!reservedDestinationProject) { - return; - } - resumedDestinationKeyRef.current = destinationKey; - selectDetail({ - name: "Thread", - params: { - draft: { - environmentId: reservedDestinationProject.environmentId, - projectId: reservedDestinationProject.id, - title: reservedDestinationProject.title, - incomingShareId: incomingShare.id, - }, - }, - }); - }, [incomingShare, isFocused, reservedDestinationProject, selectDetail]); - return ( void selectProject(selectionTarget)} leading={ void selectProject(selectionTarget)} className="flex-row items-center gap-3 bg-grouped-card px-4 py-3.5" > diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index af6663538f864..d035806f6469c 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -269,6 +269,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.allowCustomAnswer !== false ? ( (null); const configs = useServerConfigs(); const drafts = useAtomValue(composerDraftsAtom); - const scopeKey = JSON.stringify([ - selectedThread?.environmentId, - selectedThread?.id, - props.requestId, - props.question.id, - ]); - // Unmounted inputs never emit onBlur; explicitly release keyboard ownership. const focusedRef = useRef(false); const onInputFocusChange = props.onInputFocusChange; - const releaseFocus = useEffectEvent(() => { - if (focusedRef.current) onInputFocusChange?.(false); - }); - useEffect(() => () => releaseFocus(), []); - const pickerScope = useRef<{ key: string; active: boolean } | null>(null); + // The caller keys this component by request and question, so unmounting ends any open + // picker's scope. + const pickerScope = useRef<{ active: boolean }>({ active: false }); useEffect(() => { - const scope = { key: scopeKey, active: true }; + const scope = { active: true }; pickerScope.current = scope; return () => { scope.active = false; }; - }, [scopeKey]); + }, []); + // Unmounted inputs never emit onBlur; explicitly release keyboard ownership. + const releaseFocus = useEffectEvent(() => { + if (focusedRef.current) onInputFocusChange?.(false); + }); + useEffect(() => () => releaseFocus(), []); const append = ( key: string, attachments: Parameters[1], @@ -106,7 +102,7 @@ export function QuestionAttachments(props: { }) .then(async (images) => { if ( - scope?.active && + scope.active && (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0 ) { if (append(key, images) > 0) @@ -150,7 +146,7 @@ export function QuestionAttachments(props: { const picked = "files" in result ? result.files : result.attachments; // Resolution on another client clears the reservation while the picker is open. if ( - !scope?.active || + !scope.active || (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) === 0 ) { await releaseUnusedComposerAttachmentFiles(picked); diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index 70179a06de82c..0ee5dd9618d44 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -11,7 +11,6 @@ import { StackActions, useNavigation, type StaticScreenProps } from "@react-navi import * as DateTime from "effect/DateTime"; import { Atom } from "effect/reactivity"; import * as Haptics from "expo-haptics"; -import { useEffect, useState } from "react"; import { Platform, Pressable, ScrollView, View } from "react-native"; import { ScreenStack } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -22,6 +21,7 @@ import { environmentThreadDetails } from "../../state/threads"; import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack"; import { SubagentRow } from "./SubagentRow"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; @@ -184,13 +184,8 @@ function useSubagentElapsed( subagent: Pick, tickSeconds: boolean, ): string | null { - const [nowMs, setNowMs] = useState(() => Date.now()); const running = isOrchestrationV2WorkActive(subagent.status); - useEffect(() => { - if (!tickSeconds || !running) return; - const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(intervalId); - }, [running, tickSeconds]); + const nowMs = useVisibleSecondClock(tickSeconds && running); const elapsedMs = deriveSubagentElapsedMs( { status: subagent.status, diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 447254e2bbf28..d6bcb54167c67 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox"; -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { Animated, FlatList, Modal, Pressable, View } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; @@ -29,6 +29,7 @@ import { type ThreadMoveDestination, } from "./threadOrder"; import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2"; +import { useThreadListNow } from "./use-thread-list-now"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; const ROW_HEIGHT = 56; @@ -43,8 +44,13 @@ type Row = { offset: number; height: number; }; +type Planners = Record<"pinned" | "active", ReturnType>; type Drag = { orderVersion: string; + // The layout the drag started on. Any change to it changes orderVersion, + // which ends the drag, so the snapshot cannot go stale while it is in use. + rows: readonly Row[]; + planners: Planners; sourceSection: Section; thread: EnvironmentThreadShell; startY: number; @@ -52,6 +58,10 @@ type Drag = { destination: Destination | null; }; +function sameDestination(a: Destination | null, b: Destination | null) { + return a?.targetId === b?.targetId && a?.section === b?.section && a?.placement === b?.placement; +} + function ArrangementRow(props: { height: number; offset: number; @@ -92,21 +102,17 @@ function DragHandle(props: { canMoveUp: boolean; canMoveDown: boolean; }) { - const latest = useRef(props); - latest.current = props; - const gesture = useMemo( - () => - Gesture.Pan() - .enabled(!props.disabled) - .minDistance(0) - .shouldCancelWhenOutside(false) - .runOnJS(true) - .onStart(() => latest.current.onStart()) - .onUpdate((event) => latest.current.onMove(event.translationY)) - .onEnd((event) => latest.current.onMove(event.translationY)) - .onFinalize((_, success) => latest.current.onEnd(!success)), - [props.disabled], - ); + // A new config each render keeps its handler tag (same gesture type), so + // GestureDetector swaps in the current callbacks without dropping a live pan. + const gesture = Gesture.Pan() + .enabled(!props.disabled) + .minDistance(0) + .shouldCancelWhenOutside(false) + .runOnJS(true) + .onStart(() => props.onStart()) + .onUpdate((event) => props.onMove(event.translationY)) + .onEnd((event) => props.onMove(event.translationY)) + .onFinalize((_, success) => props.onEnd(!success)); return ( void }) { const dropBusy = useAtomValue(threadDropBusyAtom); const { moveThread } = useThreadListActions(); const { workingShelfEnabled } = useThreadListV2ShelfPreferences(); - const [now, setNow] = useState(() => new Date().toISOString()); + const now = useThreadListNow(threads); const [expanded, setExpanded] = useState({ snoozed: false, settled: false }); - useEffect(() => { - const wakeAt = Math.min( - ...threads.flatMap((thread) => { - const at = Date.parse(thread.snoozedUntil ?? ""); - return at > Date.parse(now) ? [at] : []; - }), - ); - if (!Number.isFinite(wakeAt)) return; - const timer = setTimeout( - () => setNow(new Date().toISOString()), - Math.min(Math.max(0, wakeAt - Date.now()) + 1, 2_147_483_647), - ); - return () => clearTimeout(timer); - }, [threads, now]); - const sections = useMemo(() => { + const sections = (() => { const shared = { threads, now, @@ -203,8 +195,8 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), }; - }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]); - const planners = useMemo(() => { + })(); + const planners = (() => { const planner = (section: "pinned" | "active") => createThreadMovePlanner({ ordered: sections[section], @@ -223,8 +215,8 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { ), }); return { pinned: planner("pinned"), active: planner("active") }; - }, [sections, threads, configs, workingShelfEnabled]); - const rows = useMemo(() => { + })(); + const rows = (() => { const result: Row[] = []; let offset = 0; for (const section of ["pinned", "active", "snoozed", "settled"] as const) { @@ -238,15 +230,13 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { } } return result; - }, [sections, expanded]); + })(); const list = useRef>(null); const geometry = useRef({ height: 0, offset: 0 }); const drag = useRef(null); const frame = useRef(null); const [preview, setPreview] = useState(null); - const translateY = useRef(new Animated.Value(0)).current; - const latest = useRef({ rows, planners, moveThread }); - latest.current = { rows, planners, moveThread }; + const [translateY] = useState(() => new Animated.Value(0)); function stop() { if (frame.current !== null) cancelAnimationFrame(frame.current); @@ -257,9 +247,6 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { const orderVersion = rows .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`) .join("|"); - useEffect(() => { - stop(); - }, [orderVersion]); useEffect( () => () => { if (frame.current !== null) cancelAnimationFrame(frame.current); @@ -270,14 +257,19 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { function update(translation: number) { const current = drag.current; if (current === null) return; + // The order changed under the drag (a drop landed, or another client moved + // a thread), so its rows and planners no longer describe the list. + if (current.orderVersion !== orderVersion) { + stop(); + return; + } current.translation = translation; const { height, offset } = geometry.current; const y = current.startY + translation; translateY.setValue(Math.max(0, Math.min(height - ROW_HEIGHT, y - ROW_HEIGHT / 2))); const contentY = Math.max(0, y + offset); const target = - latest.current.rows.find((row) => contentY < row.offset + row.height) ?? - latest.current.rows.at(-1); + current.rows.find((row) => contentY < row.offset + row.height) ?? current.rows.at(-1); let destination: Destination | null = null; if ( target && @@ -297,14 +289,10 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { configs.get(current.thread.environmentId)?.environment.capabilities.threadSettlement ) destination = { section: "settled", targetId: null, placement: "before" }; - } else if (latest.current.planners[target.section](keyOf(current.thread), candidate) !== null) + } else if (current.planners[target.section](keyOf(current.thread), candidate) !== null) destination = candidate; } - if ( - current.destination?.targetId !== destination?.targetId || - current.destination?.section !== destination?.section || - current.destination?.placement !== destination?.placement - ) { + if (!sameDestination(current.destination, destination)) { current.destination = destination; setPreview({ ...current }); } @@ -313,6 +301,8 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { if (!row.thread || preview !== null) return; drag.current = { orderVersion, + rows, + planners, sourceSection: row.section, thread: row.thread, startY: row.offset + ROW_HEIGHT / 2 - geometry.current.offset, @@ -336,7 +326,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { : y > bounds.height - 48 ? Math.min(1, (y - bounds.height + 48) / 48) : 0; - const tail = latest.current.rows.at(-1); + const tail = current.rows.at(-1); const maximum = Math.max(0, (tail ? tail.offset + tail.height : 0) - bounds.height); const offset = Math.max(0, Math.min(maximum, bounds.offset + speed * dt * 0.5)); if (offset !== bounds.offset) { @@ -401,6 +391,10 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { removeClippedSubviews={false} onScroll={(event) => { geometry.current.offset = event.nativeEvent.contentOffset.y; + // The auto-scroll tick holds the start render's update; this + // handler is the current render's, so it ends a drag whose + // order changed while the list scrolls under a still finger. + if (drag.current) update(drag.current.translation); }} scrollEventThrottle={16} getItemLayout={(_, index) => ({ @@ -496,7 +490,11 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { onMove={update} onEnd={(cancelled) => { const current = drag.current; - if (cancelled || !current?.destination) { + if ( + cancelled || + !current?.destination || + current.orderVersion !== orderVersion + ) { stop(); return; } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 2f913144332d6..e3aba8544fe51 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -45,6 +45,7 @@ import Animated, { type LayoutAnimationFunction, ReduceMotion, useAnimatedStyle, + useDerivedValue, useSharedValue, withTiming, } from "react-native-reanimated"; @@ -81,15 +82,10 @@ import { composerStripAttachments, type DraftComposerFileAttachment, } from "../../lib/composerImages"; -import { - buildModelOptions, - groupByProvider, - isModelSelectionUnavailable, -} from "../../lib/modelOptions"; +import { buildModelOptions, isModelSelectionUnavailable } from "../../lib/modelOptions"; import { useScaledTextRole, type ScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; -import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { Menu } from "../../components/ui/Menu"; import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; import type { FollowUpBehavior } from "../../lib/followUpBehavior"; @@ -111,14 +107,7 @@ import { } from "../voice-input/ComposerDictationControl"; import { useVoiceInputController } from "../voice-input/useVoiceInputController"; import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; -import { - rememberModelOptions, - withRememberedModelOptions, -} from "../../state/use-model-option-memory"; -import { - type ExistingThreadSettingsRouteSession, - useExistingThreadSettingsRoutePresentation, -} from "./ThreadSettingsSheet"; +import type { ExistingThreadSettingsRouteParams } from "./ThreadSettingsSheet"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, @@ -275,8 +264,6 @@ export interface ThreadComposerProps { ) => Promise; readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly canSwitchProvider: boolean; - readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; - readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode?: (interactionMode: ProviderInteractionMode) => void; readonly onHeightChange?: (height: number) => void; readonly onEditorFocusChange?: (focused: boolean) => void; @@ -382,16 +369,15 @@ export function ComposerSurface(props: { const colors = useUniwindTheme(); const targetBorderRadius = typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; - const animatedBorderRadius = useSharedValue(targetBorderRadius); const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android"; - useLayoutEffect(() => { - animatedBorderRadius.value = shouldAnimate + const animatedBorderRadius = useDerivedValue(() => + shouldAnimate ? withTiming(targetBorderRadius, { duration: COMPOSER_TRANSITION_DURATION_MS, reduceMotion: ReduceMotion.System, }) - : targetBorderRadius; - }, [animatedBorderRadius, shouldAnimate, targetBorderRadius]); + : targetBorderRadius, + ); const borderCurve = props.style.borderCurve; const animatedShapeStyle = useAnimatedStyle(() => ({ borderRadius: animatedBorderRadius.value, @@ -452,12 +438,25 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const pendingPastedTextAttachmentCountRef = useRef(0); const pendingNativeImagePasteCountRef = useRef(0); const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); + const settingsRoutePresentedRef = useRef(false); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, - isEditorFocused: isFocused, + onPresent: () => { + if (settingsRoutePresentedRef.current) return; + settingsRoutePresentedRef.current = true; + // The draft composer edits the new-task flow, which owns its own settings route. + navigation.dispatch( + props.isDraft + ? StackActions.push("NewTaskThreadSettings") + : StackActions.push("ThreadSettingsSheet", { + environmentId: props.environmentId, + threadId: props.selectedThread.id, + draftKey: composerDraftKey, + } satisfies ExistingThreadSettingsRouteParams), + ); + }, }); - const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation(); - const settingsRoutePresentedRef = useRef(false); + const disposeSettingsSheetPresentation = settingsSheetPresentation.dispose; const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); @@ -503,7 +502,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps }); const sendLabel = sendPresentation.label; const currentModelSelection = props.selectedThread.modelSelection; - const currentRuntimeMode = props.selectedThread.runtimeMode; const modelUnavailable = props.connectionState === "connected" && isModelSelectionUnavailable( @@ -625,7 +623,15 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps sendBlockedReason === null && !modelUnavailable; - useEffect(() => () => setComposerEditing(false), [setComposerEditing]); + // An editor that unmounts while focused never reports its blur, and a pending + // sheet presentation or focus restore has no composer left to act on. + useEffect( + () => () => { + setComposerEditing(false); + disposeSettingsSheetPresentation(); + }, + [setComposerEditing, disposeSettingsSheetPresentation], + ); const onPressPreview = (source: FilePreviewSource) => { wasExpandedBeforePreviewRef.current = isFocused; @@ -658,12 +664,14 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = () => { setIsFocused(true); + settingsSheetPresentation.onEditorFocusChange(true); onEditorFocusChange?.(true); setComposerEditing(true); }; const handleBlur = () => { setIsFocused(false); + settingsSheetPresentation.onEditorFocusChange(false); onEditorFocusChange?.(false); setComposerEditing(false); }; @@ -719,64 +727,16 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps lockedProviderInstanceId, props.selectedThread.projectId, ); - const threadProviderGroups = groupByProvider(modelOptions); const currentModelOption = modelOptions.find( (option) => option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model, ) ?? null; - const providerOptionDescriptors = resolveProviderOptionDescriptors({ - capabilities: currentModelOption?.capabilities, - selections: currentModelSelection.options, - }); - const settingsOwnerId = composerOwnerKey; - const settingsRouteSession = { - ownerId: settingsOwnerId, - environmentId: props.environmentId, - projectId: props.selectedThread.projectId, - providerInstanceId: currentModelSelection.instanceId, - ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}), - providerGroups: threadProviderGroups, - selectedModel: currentModelSelection, - prompt: draftMessage, - onPromptChange: (text) => setComposerDraftText(composerDraftKey, text), - reportedModelSelection: props.reportedModelSelection, - onSelectModel: (option) => - props.onUpdateModelSelection(withRememberedModelOptions(option.selection)), - restoreSelection: withRememberedModelOptions, - optionDescriptors: providerOptionDescriptors, - onUpdateOptionSelections: (options) => { - rememberModelOptions( - currentModelSelection.instanceId, - currentModelSelection.model, - options ?? [], - ); - props.onUpdateModelSelection({ ...currentModelSelection, options }); - }, - runtimeMode: currentRuntimeMode, - onUpdateRuntimeMode: props.onUpdateRuntimeMode, - } satisfies ExistingThreadSettingsRouteSession; const openSettings = () => { - settingsRoutePresentation.present(settingsRouteSession); settingsSheetPresentation.open(); }; - useEffect(() => { - if (settingsSheetPresentation.isActive) { - settingsRoutePresentation.present(settingsRouteSession); - } - }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]); - - useEffect(() => { - if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { - return; - } - - settingsRoutePresentedRef.current = true; - navigation.dispatch(StackActions.push("ThreadSettingsSheet")); - }, [navigation, settingsSheetPresentation.isVisible]); - useFocusEffect(() => { if (!settingsRoutePresentedRef.current) { return; @@ -784,7 +744,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps settingsRoutePresentedRef.current = false; settingsSheetPresentation.onDismissed(); - settingsRoutePresentation.clear(settingsOwnerId); }); useEffect( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 40cdfc1e3f83d..5c079529f9fbb 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -7,15 +7,14 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import * as Dialog from "../../components/ui/Dialog"; -import { useAtomValue } from "@effect/atom-react"; -import { - useChildThreadInputs, - useThreadReportedModelSelection, - useThreadShell, -} from "../../state/entities"; +import { useAtomMount, useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; +import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; +import { createThreadVisitTracker } from "@t3tools/client-runtime/state/thread-visits"; +import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { ChatGptUsageLimitNotice } from "./ChatGptUsageLimitNotice"; -import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; +import { StackActions, useNavigation } from "@react-navigation/native"; import { effectiveSnoozed, threadWokeAt, @@ -47,7 +46,6 @@ import type { RuntimeRequestId, ScopedThreadRef, ServerConfig as T3ServerConfig, - ThreadId, UsageLimitsReport, } from "@t3tools/contracts"; import { @@ -80,13 +78,11 @@ import { useContext, useEffect, useEffectEvent, - useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react"; import { - AppState, Keyboard, Platform, useWindowDimensions, @@ -106,6 +102,7 @@ import Animated, { FadeInDown, FadeOut, ReduceMotion, + runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, @@ -141,7 +138,6 @@ import { ThreadBrowserFloat } from "../browser/ThreadBrowserFloat"; import { useThreadServerBrowserTabs } from "../../state/preview"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { claimThreadOpen } from "@t3tools/client-runtime/state/thread-search"; import { finishThreadMessageJump, pendingThreadMessageJump, @@ -151,7 +147,15 @@ import { clearThreadComposerError, threadComposerErrorsAtom, } from "../../state/thread-composer-error"; -import { threadEnvironment } from "../../state/threads"; +import { + environmentThreadDetails, + environmentThreadShells, + threadEnvironment, +} from "../../state/threads"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { subscribeAppActive } from "../../lib/appState"; +import { useNowAfter, useNowMinute } from "../../lib/clock"; +import { threadStreamHaptics } from "./threadStreamHaptics"; import { useAtomCommand } from "../../state/use-atom-command"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; import type { @@ -184,7 +188,7 @@ import { ThreadComposer, } from "./ThreadComposer"; import { ComposerPopoverHost } from "./ComposerPopoverHost"; -import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed"; +import { ThreadFeed, type ThreadFeedHandle, type ThreadFeedHistoryControls } from "./ThreadFeed"; import { useThreadTurnSubagents } from "./ThreadAgentsSheet"; import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; import { useThreadQueuedCount } from "./ThreadQueueControl"; @@ -282,8 +286,6 @@ export interface ThreadDetailScreenProps { ) => Promise; readonly onReconnectEnvironment: () => void; readonly canSwitchThreadProvider: boolean; - readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; - readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateThreadInteractionMode?: (interactionMode: ProviderInteractionMode) => void; readonly onRespondToApproval: ApprovalResponder; readonly onSelectUserInputOption: ( @@ -301,76 +303,6 @@ export interface ThreadDetailScreenProps { readonly showContent?: boolean; } -function latestStreamingAssistantMessage( - feed: ReadonlyArray, -): { readonly id: string; readonly textLength: number } | null { - for (let index = feed.length - 1; index >= 0; index -= 1) { - const entry = feed[index]; - if (entry?.type !== "message") { - continue; - } - if (entry.message.role !== "assistant" || !entry.message.streaming) { - continue; - } - return { - id: entry.message.id, - textLength: entry.message.text.length, - }; - } - - return null; -} - -function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray) { - const lastStreamingAssistantRef = useRef<{ - readonly id: string; - readonly textLength: number; - } | null>(null); - const lastStreamHapticAtRef = useRef(0); - const hydratedRef = useRef(false); - const previousThreadIdRef = useRef(threadId); - - useEffect(() => { - if (previousThreadIdRef.current !== threadId) { - previousThreadIdRef.current = threadId; - hydratedRef.current = false; - } - - const latestStreamingMessage = latestStreamingAssistantMessage(feed); - - if (!hydratedRef.current) { - hydratedRef.current = true; - lastStreamingAssistantRef.current = latestStreamingMessage; - return; - } - - if (!latestStreamingMessage) { - lastStreamingAssistantRef.current = null; - return; - } - - const previousStreamingMessage = lastStreamingAssistantRef.current; - lastStreamingAssistantRef.current = latestStreamingMessage; - - const isNewStream = previousStreamingMessage?.id !== latestStreamingMessage.id; - const textGrew = - previousStreamingMessage?.id === latestStreamingMessage.id && - latestStreamingMessage.textLength > previousStreamingMessage.textLength; - - if (!isNewStream && !textGrew) { - return; - } - - const now = Date.now(); - if (!isNewStream && now - lastStreamHapticAtRef.current < 320) { - return; - } - - lastStreamHapticAtRef.current = now; - void Haptics.selectionAsync(); - }, [threadId, feed]); -} - const USAGE_LIMITS_PANEL_GAP = 12; const USAGE_LIMITS_PANEL_MIN_HEIGHT = 44; @@ -379,130 +311,93 @@ const USER_INPUT_TOGGLE_TIMING = { easing: Easing.out(Easing.cubic), }; -// The visit watermark follows the shell's updatedAt, which ticks while a run -// streams. Keeping it in a leaf lets the screen read the quiet shell. -function ThreadVisitWatermark(props: { - readonly environmentId: EnvironmentId; - readonly selectedThread: EnvironmentThreadShell; - readonly showContent: boolean; -}) { - const liveThread = useThreadShell({ - environmentId: props.environmentId, - threadId: props.selectedThread.id, - }); - const thread = liveThread ?? props.selectedThread; - const showContent = props.showContent; - const selectedThreadKey = scopedThreadKey(props.environmentId, thread.id); - const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); - const lastDispatchedVisitRef = useRef(null); - const lastVisitDispatchRef = useRef({ threadKey: selectedThreadKey, at: 0 }); - const selectedThreadId = thread.id; - const snoozedUntil = thread.snoozedUntil; - // Keep the clock in state so React Compiler invalidates the wake calculation. - const [wakeNow, setWakeNow] = useState(() => new Date().toISOString()); - useEffect(() => { - if (!showContent || snoozedUntil == null) return; - const delay = Date.parse(snoozedUntil) - Date.now(); - if (!Number.isFinite(delay)) return; - const timer = setTimeout(() => setWakeNow(new Date().toISOString()), Math.max(0, delay) + 50); - return () => clearTimeout(timer); - }, [showContent, snoozedUntil]); - const selectedThreadWokeAt = threadWokeAt(thread, { - now: wakeNow, - }); - // A scheduled wake can happen without a newer server update. - const selectedThreadUpdatedAt = - selectedThreadWokeAt !== null && Date.parse(selectedThreadWokeAt) > Date.parse(thread.updatedAt) - ? selectedThreadWokeAt - : thread.updatedAt; - const wakeIsLatestUpdate = selectedThreadUpdatedAt === selectedThreadWokeAt; - const selectedThreadLastVisitedAt = thread.lastVisitedAt; - const selectedThreadCompletedAt = thread.latestRun?.completedAt; - // Opening a thread is one visit that also counts toward search ranking; a - // palette pick already sent it with the query. The watermark effect below - // skips it. - const recordedOpenRef = useRef(null); - useEffect(() => { - if (!showContent) return; - if (recordedOpenRef.current === selectedThreadKey) return; - recordedOpenRef.current = selectedThreadKey; - // Recorded here even when the server cannot take opens, so recents follow it. - const claimed = claimThreadOpen(props.environmentId, selectedThreadId, Date.now()); - if (!claimed || selectedThreadLastVisitedAt === undefined) return; - lastDispatchedVisitRef.current = `${selectedThreadKey}:${selectedThreadUpdatedAt}`; - lastVisitDispatchRef.current = { threadKey: selectedThreadKey, at: Date.now() }; - void visitThread({ - environmentId: props.environmentId, - input: { - threadId: selectedThreadId, - visitedAt: selectedThreadUpdatedAt, - opened: true, - }, - }); - }, [ - props.environmentId, - selectedThreadId, - selectedThreadKey, - selectedThreadLastVisitedAt, - selectedThreadUpdatedAt, - showContent, - visitThread, - ]); - useEffect(() => { - if (!showContent || selectedThreadLastVisitedAt === undefined) return; - const threadUpdatedAtMs = Date.parse(selectedThreadUpdatedAt); - if (Number.isNaN(threadUpdatedAtMs)) return; - const lastVisitedAtMs = selectedThreadLastVisitedAt - ? Date.parse(selectedThreadLastVisitedAt) - : NaN; - if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= threadUpdatedAtMs) return; - const dispatchKey = `${selectedThreadKey}:${selectedThreadUpdatedAt}`; - if (lastDispatchedVisitRef.current === dispatchKey) return; - const dispatch = () => { - lastDispatchedVisitRef.current = dispatchKey; - lastVisitDispatchRef.current = { threadKey: selectedThreadKey, at: Date.now() }; - void visitThread({ - environmentId: props.environmentId, - input: { threadId: selectedThreadId, visitedAt: selectedThreadUpdatedAt }, - }); - }; - const completedAtMs = selectedThreadCompletedAt ? Date.parse(selectedThreadCompletedAt) : NaN; - const hasUnseenCompletion = - !Number.isNaN(completedAtMs) && - (Number.isNaN(lastVisitedAtMs) || completedAtMs > lastVisitedAtMs); - const previous = lastVisitDispatchRef.current; - const elapsed = Date.now() - previous.at; - // Like a completion, a wake the user is looking at must not wait out the throttle: - // leaving within it would cancel the dispatch and keep Woke in the list. - if ( - previous.threadKey !== selectedThreadKey || - hasUnseenCompletion || - wakeIsLatestUpdate || - elapsed >= 10_000 - ) { - dispatch(); - return; +// A scheduled wake is an update the user sees without a newer server +// updatedAt, so the watermark advances to it. The shell's updatedAt ticks +// while a run streams; the tracker follows it outside React. +const wakeAwareVisitShell = Atom.family((key: string) => { + const ref = parseThreadKey(key); + return Atom.make((get) => { + const shell = get(environmentThreadShells.threadShellAtom(ref)); + if (shell === null) return null; + const nowMs = Date.now(); + const wokeAt = threadWokeAt(shell, { now: new Date(nowMs).toISOString() }); + const wakeAtMs = shell.snoozedUntil == null ? NaN : Date.parse(shell.snoozedUntil); + if (wokeAt === null && wakeAtMs > nowMs) { + const timer = setTimeout( + () => get.refreshSelf(), + Math.min(wakeAtMs - nowMs + 50, 2_147_483_647), + ); + get.addFinalizer(() => clearTimeout(timer)); } - const timer = setTimeout(dispatch, 10_000 - elapsed); - return () => clearTimeout(timer); - }, [ - props.environmentId, - selectedThreadId, - selectedThreadKey, - selectedThreadLastVisitedAt, - selectedThreadCompletedAt, - selectedThreadUpdatedAt, - showContent, - visitThread, - wakeIsLatestUpdate, - ]); + if (wokeAt === null || Date.parse(wokeAt) <= Date.parse(shell.updatedAt)) return shell; + // Like a completion, a wake the user is looking at must not wait out the + // throttle: leaving within it would cancel the dispatch and keep Woke in + // the list. The tracker sends unseen completions at once. + return { + environmentId: shell.environmentId, + id: shell.id, + updatedAt: wokeAt, + lastVisitedAt: shell.lastVisitedAt, + latestRun: { completedAt: wokeAt }, + }; + }).pipe(Atom.withLabel(`mobile-thread-visit-shell:${key}`)); +}); + +const threadVisitTracker = createThreadVisitTracker({ + threadShellAtom: (ref) => wakeAwareVisitShell(threadKey(ref)), + visit: threadEnvironment.visit, +}); + +function ThreadVisitWatermark(props: { readonly threadRef: ScopedThreadRef }) { + useAtomMount(threadVisitTracker(props.threadRef)); + return null; +} + +function ThreadStreamHaptics(props: { readonly threadRef: ScopedThreadRef }) { + useAtomMount(threadStreamHaptics(props.threadRef)); return null; } +// Android can lose IME hide callbacks across a background switch, so each +// return to the foreground is an input to the keyboard quarantine below. +const appResumes = (() => { + let count = 0; + const listeners = new Set<() => void>(); + let unsubscribe: (() => void) | null = null; + return { + getSnapshot: () => count, + subscribe: (listener: () => void) => { + listeners.add(listener); + unsubscribe ??= subscribeAppActive(() => { + count += 1; + for (const notify of listeners) notify(); + }); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + unsubscribe?.(); + unsubscribe = null; + } + }; + }, + }; +})(); + // Past the composer's 180 ms height animation. const COMPOSER_HEIGHT_SETTLE_MS = 220; -export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetailScreenProps) { +// Anchors, the submitted message, follow state and pending scrolls belong to +// one thread, so the body remounts when the thread changes. +export function ThreadDetailScreen(props: ThreadDetailScreenProps) { + return ( + + ); +} + +function ThreadDetailBody(props: ThreadDetailScreenProps) { const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const deviceType = useDeviceType(); const workspace = useWorkspaceTabs(); @@ -546,30 +441,38 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const liveKeyboardHeight = useKeyboardState((state) => state.height); // Android can lose IME hide callbacks on backgrounding. Quarantine translation on resume unless focused input retained its keyboard. - const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); - const ownedInputFocusedRef = useRef(false); - useEffect(() => { - if (Platform.OS !== "android") { - return; - } - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") { - setKeyboardStateSuspect(!ownedInputFocusedRef.current); - } - }); - return () => { - subscription.remove(); - }; - }, []); - useEffect(() => { - setKeyboardStateSuspect(false); - }, [isKeyboardVisible, liveKeyboardHeight]); + // The quarantine holds the keyboard state seen on resume and lifts on the + // first keyboard change after it, or when an owned input takes focus. + const [ownedInputFocused, setOwnedInputFocused] = useState(false); + const resumeCount = useSyncExternalStore(appResumes.subscribe, appResumes.getSnapshot); + const [seenResumeCount, setSeenResumeCount] = useState(resumeCount); + const [keyboardQuarantine, setKeyboardQuarantine] = useState<{ + readonly visible: boolean; + readonly height: number; + } | null>(null); + if (seenResumeCount !== resumeCount) { + setSeenResumeCount(resumeCount); + setKeyboardQuarantine( + Platform.OS === "android" && !ownedInputFocused + ? { visible: isKeyboardVisible, height: liveKeyboardHeight } + : null, + ); + } else if ( + keyboardQuarantine !== null && + (keyboardQuarantine.visible !== isKeyboardVisible || + keyboardQuarantine.height !== liveKeyboardHeight) + ) { + setKeyboardQuarantine(null); + } + const keyboardStateSuspect = keyboardQuarantine !== null; + // Must keep a stable identity: QuestionAttachments releases focus from a ref cleanup that + // re-runs whenever this changes, so it captures no render values. composerOwnsKeyboard is + // only read on the Duo, so recording ownership on other devices is harmless. const handleOwnedInputFocusChange = (focused: boolean) => { - ownedInputFocusedRef.current = focused; + setOwnedInputFocused(focused); if (focused) { - if (deviceType === "duo-folded" || deviceType === "duo-unfolded") - setComposerOwnsKeyboard(true); - setKeyboardStateSuspect(false); + setComposerOwnsKeyboard(true); + setKeyboardQuarantine(null); } }; const windowHeight = useWindowDimensions().height; @@ -601,8 +504,9 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const composerOverlayRef = useRef(null); const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); - const selectedThreadKeyRef = useRef(selectedThreadKey); - const lastScrolledSubmittedMessageIdRef = useRef(null); + // Aborted when this thread's body unmounts, so async work started for it + // stops touching a list or composer that now shows another thread. + const [threadScope] = useState(() => new AbortController()); const [composerHeight, setComposerHeight] = useState(null); // The composer animates its height and reports a layout every frame. The feed follows those // frames on the UI thread through onComposerLayout; this is only an estimate, so committing @@ -616,14 +520,8 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai setComposerHeight(height); }, COMPOSER_HEIGHT_SETTLE_MS); }, []); - useEffect( - () => () => { - if (composerHeightSettle.current !== null) clearTimeout(composerHeightSettle.current); - }, - [], - ); const [anchorMessageId, setAnchorMessageId] = useState(null); - const [submittedMessageId, setSubmittedMessageId] = useState(null); + const feedRef = useRef(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Keep the bottom padding fixed while the keyboard animates. Changing it at // keyboardDidHide makes the composer land low, then jump back above the safe area. @@ -671,27 +569,21 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null); const statusPending = statusRequest?.threadKey === selectedThreadKey; - const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString()); - useFocusEffect( - useCallback(() => { - setLifecycleNow(new Date().toISOString()); - const timer = setInterval(() => setLifecycleNow(new Date().toISOString()), 60_000); - return () => clearInterval(timer); - }, []), - ); + const snoozedUntil = props.selectedThread.snoozedUntil; + const snoozeWakeMs = + props.serverConfig?.environment.capabilities.threadSnooze === true && snoozedUntil !== null + ? Date.parse(snoozedUntil) + : NaN; + // The minute tick keeps the wake countdown fresh; the deadline flips the + // snooze exactly at its wake time. + const nowMinute = useNowMinute(); + const nowAtWake = useNowAfter(Number.isFinite(snoozeWakeMs) ? snoozeWakeMs : null); + const lifecycleNow = new Date( + Math.max(Date.parse(`${nowMinute}:00.000Z`), nowAtWake), + ).toISOString(); const snoozed = props.serverConfig?.environment.capabilities.threadSnooze === true && effectiveSnoozed(props.selectedThread, { now: lifecycleNow }); - const snoozedUntil = props.selectedThread.snoozedUntil; - useEffect(() => { - if (!snoozed || snoozedUntil === null) return; - const timer = setTimeout( - () => setLifecycleNow(new Date().toISOString()), - Math.min(Math.max(0, Date.parse(snoozedUntil) - Date.now()) + 50, 2_147_483_647), - ); - return () => clearTimeout(timer); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Re-arm if timer clamping fires before the wake time. - }, [snoozed, snoozedUntil, lifecycleNow]); const settled = props.serverConfig?.environment.capabilities.threadSettlement === true && props.selectedThread.settledOverride === "settled"; @@ -885,8 +777,6 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const jumpMessageLoaded = jumpMessageId !== null && selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === jumpMessageId); - // A jump this screen never reached lets go when the thread closes. - useEffect(() => () => finishThreadMessageJump(selectedThreadKey), [selectedThreadKey]); const historyForJump = props.historyControls; // History down to the message in one request; another page if it is not // there yet. @@ -922,8 +812,11 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai props.selectedThread.latestUserMessageAt !== null); const composerOverlapHeight = composerHeight ?? COMPOSER_COLLAPSED_CHROME + (hasSidebarTabBar ? 14 : 0) + composerBottomInset; - const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = - useState(null); + // The last collapse toggle; it only applies to the request it was made on. + const [userInputToggle, setUserInputToggle] = useState<{ + readonly requestId: RuntimeRequestId; + readonly collapsed: boolean; + } | null>(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; const composerSlotHidden = activeUserInputRequestId !== null || props.creationState?.kind === "failed"; @@ -967,16 +860,15 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai setUsageLimitsPanel((current) => current !== null && current.threadKey === threadKey ? null : current, ); - const userInputCollapsed = - activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; + const userInputToggled = + userInputToggle !== null && userInputToggle.requestId === activeUserInputRequestId; + const userInputCollapsed = userInputToggled && userInputToggle.collapsed; // A height React sets from keyboard state lags the sticky translation by a Yoga pass and // flashes over the nav header on the way up, so the usage panel reserves the keyboard instead. const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); - useEffect(() => { - if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { - setLastKnownKeyboardHeight(liveKeyboardHeight); - } - }, [lastKnownKeyboardHeight, liveKeyboardHeight]); + if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { + setLastKnownKeyboardHeight(liveKeyboardHeight); + } const reservedKeyboardHeight = lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT; // Waiting for a visibility render misses the first keyboard frames. @@ -1052,19 +944,69 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai USAGE_LIMITS_PANEL_GAP * 2, ), ); - const userInputCardProgress = useSharedValue(1); - const userInputInsetProgress = useSharedValue(1); + // A toggle animates the card; a new request starts expanded at once. + const userInputMotion = !userInputToggled + ? "fresh" + : userInputCollapsed + ? "collapsed" + : "expanded"; + const userInputCardProgress = useDerivedValue( + () => + userInputMotion === "fresh" + ? 1 + : withTiming(userInputMotion === "collapsed" ? 0 : 1, USER_INPUT_TOGGLE_TIMING), + [userInputMotion], + ); + // Collapsing the inset is instant: the sinking card still covers the strip + // being revealed, and animating the inset downward drifted the short-content + // end anchor. + const userInputInsetProgress = useDerivedValue(() => { + if (userInputMotion === "collapsed") return 0; + return userInputMotion === "fresh" ? 1 : withTiming(1, USER_INPUT_TOGGLE_TIMING); + }, [userInputMotion]); const userInputCardCoverage = useSharedValue(0); const composerPopoverHeight = useSharedValue(0); - const floatingControlCoverage = useSharedValue( - showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0, + const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); + const reduceMotion = useReducedMotion(); + // Repin timers fire after the render that scheduled them, so they read follow + // state from the ref the writers below keep in step with it. + const endFollowEnabledRef = useRef(true); + const overlayRepinTimerRef = useRef | null>(null); + const scheduleOverlayRepin = (delayMs: number) => { + if (overlayRepinTimerRef.current !== null) { + clearTimeout(overlayRepinTimerRef.current); + } + overlayRepinTimerRef.current = setTimeout(() => { + overlayRepinTimerRef.current = null; + if (!endFollowEnabledRef.current) { + return; + } + void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { + freeze.set(false); + }); + }, delayMs); + }; + // LegendList does not re-anchor short followed conversations when floating + // coverage changes after load. Re-pin each time it settles at a new value; + // the repin checks follow state so a user who scrolled up stays put. + const settledFloatingCoverageRef = useRef(0); + const repinAfterCoverageSettles = (coverage: number) => { + if (coverage === settledFloatingCoverageRef.current) return; + settledFloatingCoverageRef.current = coverage; + scheduleOverlayRepin(50); + }; + const floatingCoverageTarget = showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0; + const floatingControlCoverage = useDerivedValue( + () => + withTiming( + floatingCoverageTarget, + { duration: 180, reduceMotion: ReduceMotion.System }, + (finished) => { + if (finished) runOnJS(repinAfterCoverageSettles)(floatingCoverageTarget); + }, + ), + [floatingCoverageTarget], ); - useEffect(() => { - floatingControlCoverage.value = withTiming( - showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0, - { duration: 180, reduceMotion: ReduceMotion.System }, - ); - }, [floatingControlCoverage, showFloatingStatus]); // Android measures its expanded card in-flow; only iOS needs extra overlay coverage. const userInputCoverageApplies = Platform.OS === "ios" && activeUserInputRequestId !== null; const combinedContentInsetEndAdjustment = useSharedValue( @@ -1094,78 +1036,33 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai ), [userInputCoverageApplies], ); - const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); - const reduceMotion = useReducedMotion(); - // Repin timers fire after the render that scheduled them, so they read follow - // state from the ref the writers below keep in step with it. - const endFollowEnabledRef = useRef(true); - const overlayRepinTimerRef = useRef | null>(null); - const previousWorkingControlStateRef = useRef({ - threadKey: selectedThreadKey, - visible: false, - }); const handleEndFollowEnabledChange = (enabled: boolean) => { endFollowEnabledRef.current = enabled; setEndFollowEnabled(enabled); }; - const scheduleOverlayRepin = (delayMs: number) => { - if (overlayRepinTimerRef.current !== null) { - clearTimeout(overlayRepinTimerRef.current); - } - overlayRepinTimerRef.current = setTimeout(() => { - overlayRepinTimerRef.current = null; - if (!endFollowEnabledRef.current) { - return; - } - void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { - freeze.set(false); - }); - }, delayMs); - }; + // A jump this thread never reached lets go when it closes. useEffect( () => () => { - if (overlayRepinTimerRef.current !== null) { - clearTimeout(overlayRepinTimerRef.current); - } + threadScope.abort(); + if (overlayRepinTimerRef.current !== null) clearTimeout(overlayRepinTimerRef.current); + if (composerHeightSettle.current !== null) clearTimeout(composerHeightSettle.current); + finishThreadMessageJump(selectedThreadKey); }, - [], + [threadScope, selectedThreadKey], ); - useEffect(() => { - const previous = previousWorkingControlStateRef.current; - const threadChanged = previous.threadKey !== selectedThreadKey; - const visibilityChanged = previous.visible !== showFloatingStatus; - previousWorkingControlStateRef.current = { - threadKey: selectedThreadKey, - visible: showFloatingStatus, - }; - if ((!threadChanged && !visibilityChanged) || (threadChanged && !showFloatingStatus)) { - return; - } - // LegendList misses short-conversation re-anchoring after coverage changes; re-pin after transition only if still following. - scheduleOverlayRepin(230); - }, [scheduleOverlayRepin, selectedThreadKey, showFloatingStatus]); const handleToggleUserInputCollapsed = () => { if (activeUserInputRequestId === null) { return; } if (userInputCollapsed) { - userInputCardProgress.set(withTiming(1, USER_INPUT_TOGGLE_TIMING)); - userInputInsetProgress.set(withTiming(1, USER_INPUT_TOGGLE_TIMING)); - setCollapsedUserInputRequestId(null); + setUserInputToggle({ requestId: activeUserInputRequestId, collapsed: false }); scheduleOverlayRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); } else { Keyboard.dismiss(); - userInputCardProgress.set(withTiming(0, USER_INPUT_TOGGLE_TIMING)); - // The sinking card already covers this strip. Animating the inset downward would drift the end anchor. - userInputInsetProgress.set(0); - setCollapsedUserInputRequestId(activeUserInputRequestId); + setUserInputToggle({ requestId: activeUserInputRequestId, collapsed: true }); scheduleOverlayRepin(60); } }; - useEffect(() => { - userInputCardProgress.set(1); - userInputInsetProgress.set(1); - }, [activeUserInputRequestId, userInputCardProgress, userInputInsetProgress]); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; @@ -1202,7 +1099,6 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai : { width: "auto", right: controlInsets.right }, ); const selectedInstanceId = props.selectedThread.modelSelection.instanceId; - useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); const selectedProviderSkills = (() => { const provider = props.serverConfig?.providers.find( (candidate) => candidate.instanceId === selectedInstanceId, @@ -1212,81 +1108,12 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai : []; })(); - useLayoutEffect(() => { - selectedThreadKeyRef.current = selectedThreadKey; - }, [selectedThreadKey]); - - useEffect(() => { - setAnchorMessageId(null); - setSubmittedMessageId(null); - lastScrolledSubmittedMessageIdRef.current = null; - endFollowEnabledRef.current = true; - setEndFollowEnabled(true); - freeze.set(false); - }, [freeze, selectedThreadKey]); - - useEffect(() => { - if ( - submittedMessageId === null || - anchorMessageId !== submittedMessageId || - lastScrolledSubmittedMessageIdRef.current === submittedMessageId || - contentPresentationKind !== "ready" || - (!selectedThreadFeed.some( - (entry) => entry.type === "message" && entry.id === submittedMessageId, - ) && - !props.queuedMessages.some((message) => message.messageId === submittedMessageId)) - ) { - return; - } - - const targetThreadKey = selectedThreadKey; - const frame = requestAnimationFrame(() => { - if (selectedThreadKeyRef.current !== targetThreadKey) { - return; - } - lastScrolledSubmittedMessageIdRef.current = submittedMessageId; - // Dismiss before scrollMessageToEnd freezes insets, or the missed hide leaves permanent padding. - void KeyboardController.dismiss() - .then(() => { - if ( - selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledSubmittedMessageIdRef.current !== submittedMessageId - ) { - return; - } - return scrollMessageToEnd({ - animated: !reduceMotion, - closeKeyboard: false, - }); - }) - .catch(() => { - if ( - selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledSubmittedMessageIdRef.current !== submittedMessageId - ) { - return; - } - lastScrolledSubmittedMessageIdRef.current = null; - freeze.set(false); - }); - }); - return () => cancelAnimationFrame(frame); - }, [ - anchorMessageId, - submittedMessageId, - freeze, - contentPresentationKind, - props.queuedMessages, - selectedThreadFeed, - reduceMotion, - scrollMessageToEnd, - selectedThreadKey, - ]); - - const activePendingApprovalsRef = useRef(props.activePendingApprovals); - useLayoutEffect(() => { - activePendingApprovalsRef.current = props.activePendingApprovals; - }, [props.activePendingApprovals]); + const currentPendingApprovals = () => + props.selectedThreadDetailRef === null + ? [] + : (appAtomRegistry.get( + environmentThreadDetails.pendingRequestsAtom(props.selectedThreadDetailRef), + )?.approvals ?? []); const handleSendMessage = async ( followUp?: ActiveTurnComposerAction, stillSendable?: () => boolean, @@ -1302,7 +1129,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai approvalsToDecline.length > 0 && props.activePendingUserInput === null ? async (text: string, hasAttachmentsOrContext: boolean) => { if ( - selectedThreadKeyRef.current !== targetThreadKey || + threadScope.signal.aborted || stillSendable?.() === false || props.respondingApprovalId !== null ) @@ -1323,12 +1150,12 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai return ( props.respondingApprovalId === null && (await declinePendingApprovals(approvalsToDecline, props.onRespondToApproval)) && - selectedThreadKeyRef.current === targetThreadKey && + !threadScope.signal.aborted && // A draft edited or pasted into during the decline isn't what the user sent. stillSendable?.() !== false && // An approval that arrived during the decline would block the redirect; the draft // stays so the next send declines it too. - !activePendingApprovalsRef.current.some( + !currentPendingApprovals().some( (approval) => !approvalsToDecline.some((declined) => declined.requestId === approval.requestId), ) @@ -1336,24 +1163,41 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai } : undefined; const messageId = await props.onSendMessage(followUp, declineBeforeDispatch); - if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { + if (messageId === null || threadScope.signal.aborted) { return messageId; } clearUsageLimitsFor(targetThreadKey); - setSubmittedMessageId(messageId); - setAnchorMessageId( - resolveThreadFeedSubmissionAnchor({ - currentAnchorMessageId: anchorMessageId, - submittedMessageId: messageId, - hasStartedTurn: props.selectedThread.latestRun !== null, - hasUserMessage, - queuedMessageCount: props.selectedThreadQueueCount, - deliveryDeferred: props.connectionStateLabel !== "connected", - }), - ); + const nextAnchorMessageId = resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: anchorMessageId, + submittedMessageId: messageId, + hasStartedTurn: props.selectedThread.latestRun !== null, + hasUserMessage, + queuedMessageCount: props.selectedThreadQueueCount, + deliveryDeferred: props.connectionStateLabel !== "connected", + }); + feedRef.current?.resetEndFollow(); + setAnchorMessageId(nextAnchorMessageId); composerEditorRef.current?.blur(); + // The send enqueued the message optimistically, so it is in the feed or + // the queue by the next frame, once the new anchor has rendered. + if (nextAnchorMessageId === messageId && contentPresentationKind === "ready") { + requestAnimationFrame(() => { + if (threadScope.signal.aborted) return; + // Wait for the keyboard to finish dismissing: scrollMessageToEnd freezes + // keyboard-driven inset updates, and a close event swallowed by that + // freeze leaves a phantom bottom inset. + void KeyboardController.dismiss() + .then(() => { + if (threadScope.signal.aborted) return; + return scrollMessageToEnd({ animated: !reduceMotion, closeKeyboard: false }); + }) + .catch(() => { + if (!threadScope.signal.aborted) freeze.set(false); + }); + }); + } return messageId; }; @@ -1369,7 +1213,8 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai } if ( edited.value && - selectedThreadKeyRef.current === scopedThreadKey(message.environmentId, message.threadId) + !threadScope.signal.aborted && + selectedThreadKey === scopedThreadKey(message.environmentId, message.threadId) ) { composerEditorRef.current?.focus(); } @@ -1435,11 +1280,14 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai return ( {!usesNativeWorkspaceColumns ? : null} - + {showContent && !props.draftMode ? ( + + ) : null} + {props.selectedThreadDetailRef !== null ? ( + + ) : null} {props.draftMode ? ( props.draftMode.content( navigationHeaderHeight, @@ -1501,7 +1349,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai anchorMessageId={anchorMessageId} jumpToMessageId={jumpMessageLoaded ? jumpMessageId : null} onJumpedToMessage={() => finishThreadMessageJump(selectedThreadKey)} - submittedMessageId={submittedMessageId} + ref={feedRef} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} keyboardOffset={composerKeyboardOffset} contentTopInset={0} @@ -1822,8 +1670,6 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai onSendMessage={handleSendMessage} onShowUsageLimits={showUsageLimits} canSwitchProvider={props.canSwitchThreadProvider} - onUpdateModelSelection={props.onUpdateThreadModelSelection} - onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} onHeightChange={handleComposerHeightChange} onEditorFocusChange={handleOwnedInputFocusChange} @@ -1838,4 +1684,4 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai ) : null} ); -}; +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a292965a97512..1a4cfb7aef965 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -101,6 +101,7 @@ import { useContext, useEffect, useEffectEvent, + useImperativeHandle, useLayoutEffect, useMemo, useRef, @@ -108,6 +109,7 @@ import { useId, type ComponentPropsWithRef, type ReactNode, + type Ref, type RefObject, } from "react"; import { @@ -340,6 +342,11 @@ export interface ThreadFeedHistoryControls { readonly onLoadThrough: (entryId: string) => void; } +export interface ThreadFeedHandle { + /** A send re-arms following the end, whatever the reader had scrolled to. */ + readonly resetEndFollow: () => void; +} + export interface ThreadFeedProps { readonly worktreeSetup?: WorktreeSetupCardProps | null; readonly setupWorkingStartedAt?: string | null; @@ -363,7 +370,6 @@ export interface ThreadFeedProps { /** A message to scroll to once it is in the feed; see `onJumpedToMessage`. */ readonly jumpToMessageId?: MessageId | null; readonly onJumpedToMessage?: () => void; - readonly submittedMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; readonly keyboardOffset: number; readonly contentTopInset?: number; @@ -2100,7 +2106,10 @@ function useThreadFeedPresentation( ); } -export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { +export const ThreadFeed = memo(function ThreadFeed({ + ref, + ...props +}: ThreadFeedProps & { readonly ref?: Ref }) { const { listRef, onEndFollowEnabledChange, onHeaderMaterialVisibilityChange } = props; const workflowOpening = useWorkflowOpening(props.environmentId, props.threadId); const guestThreadRef = useMemo( @@ -2131,7 +2140,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); - const previousLatestTurnRef = useRef(props.latestRun); const userScrollSettleTimerRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const { width: windowWidth, fontScale } = useWindowDimensions(); @@ -2140,14 +2148,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { baseFontSize: appearance.baseFontSize, fontScale, }); - const previousTextSize = useRef(workRowSizing.textSizeKey); - useLayoutEffect(() => { - if (previousTextSize.current === workRowSizing.textSizeKey) { - return; - } - previousTextSize.current = workRowSizing.textSizeKey; - listRef.current?.clearCaches({ mode: "sizes" }); - }, [workRowSizing.textSizeKey, listRef]); const settledPaneWidth = useWorkspaceSettledContentWidth(); const [measuredViewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, @@ -2178,7 +2178,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const reflowAnchorRef = useRef(undefined); const reflowAnchorsSizeChanges = Platform.OS === "ios"; const [reflowFollowsEnd, setReflowFollowsEnd] = useState(false); - const [reflowSettleRevision, setReflowSettleRevision] = useState(0); + const reflowSettleTimerRef = useRef | null>(null); const listHeaderSizeRef = useRef(0); const loadedListKeyRef = useRef(null); const cancelReflow = () => { @@ -2198,31 +2198,45 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { expandedTurnIds: new Set(), }); const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; - const [expandedFile, setExpandedFile] = useState(null); - const [expandedVideo, setExpandedVideo] = useState(null); const markdownLinkRequest = useRef(null); useFocusEffect(useCallback(() => () => markdownLinkRequest.current?.abort(), [])); + // A preview belongs to the content it was opened from. One that resolves + // after the thread or its presentation changed is dropped by the scope check. const markdownLinkScope = JSON.stringify([ props.environmentId, props.threadId, props.contentPresentation.kind, ]); - const previousMarkdownLinkScope = useRef(markdownLinkScope); - useLayoutEffect(() => { - if (previousMarkdownLinkScope.current === markdownLinkScope) return; - previousMarkdownLinkScope.current = markdownLinkScope; - markdownLinkRequest.current?.abort(); - }, [markdownLinkScope]); + const [preview, setPreview] = useState<{ + readonly scope: string; + readonly file: FilePreviewSource | null; + readonly video: VideoPreviewSource | null; + } | null>(null); + const scopedPreview = preview?.scope === markdownLinkScope ? preview : null; + const expandedFile = scopedPreview?.file ?? null; + const expandedVideo = scopedPreview?.video ?? null; + const openPreview = ( + scope: string, + media: { file: FilePreviewSource } | { video: VideoPreviewSource }, + ) => + setPreview((current) => { + const live = current?.scope === scope ? current : null; + return { + scope, + file: live?.file ?? ("file" in media ? media.file : null), + video: live?.video ?? ("video" in media ? media.video : null), + }; + }); + const closeFilePreview = () => + setPreview((current) => (current === null ? null : { ...current, file: null })); + const closeVideoPreview = () => + setPreview((current) => (current === null ? null : { ...current, video: null })); const fileShareSourceIdentifier = useId(); const shareFileChip = useFileChipShare( props.environmentId, props.threadId, fileShareSourceIdentifier, ); - useEffect(() => { - setExpandedVideo(null); - setExpandedFile(null); - }, [props.environmentId, props.threadId, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentLeftInset = props.contentSideInsets?.left ?? 0; const contentRightInset = props.contentSideInsets?.right ?? 0; @@ -2299,13 +2313,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); if (media) { void Haptics.selectionAsync(); - const openPreview = () => { + const scope = markdownLinkScope; + const openMediaPreview = () => { if (request.signal.aborted) return; - if (media.kind === "video") { - setExpandedVideo((current) => current ?? media.source); - } else { - setExpandedFile((current) => current ?? media.source); - } + openPreview( + scope, + media.kind === "video" ? { video: media.source } : { file: media.source }, + ); }; const resource = "resource" in media.source ? media.source.resource : null; const mediaPath = resource && "path" in resource ? resource.path : null; @@ -2328,9 +2342,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { threadId: String(props.threadId), path: fileRoutePathSegments(mediaPath), }); - } else openPreview(); - }, openPreview); - } else openPreview(); + } else openMediaPreview(); + }, openMediaPreview); + } else openMediaPreview(); return; } @@ -2405,9 +2419,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { - setExpandedFile( - (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, - ); + openPreview(markdownLinkScope, { + file: { kind: "pdf", uri: presentation.href, name: "Document.pdf" }, + }); return; } void openPullRequest(presentation.href); @@ -2662,87 +2676,86 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { finishUserScroll(); }; - useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]); - const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); const workGroupScrollPositions = new Map(); - // Only a new thread or a send resets following. Listed as dependencies, these handlers also ran - // whenever a parent passed a new follow callback, as a fold does: following re-armed under a - // reader and a width reflow lost its place mid-fold. - const resetEndFollow = useEffectEvent((_trigger: unknown) => { - clearUserScrollSettle(); - userScrollSessionRef.current = false; - cancelReflow(); - transitionEndFollow({ type: "reset" }); - }); - useEffect(() => resetEndFollow(feedThreadKey), [feedThreadKey]); - useEffect(() => { - if (props.submittedMessageId !== null) resetEndFollow(props.submittedMessageId); - }, [props.submittedMessageId]); - - useEffect(() => { - reportHeaderMaterialVisibility(false); - }, [feedThreadKey, reportHeaderMaterialVisibility]); + // ThreadDetailScreen remounts the feed per thread, so only a send resets following. + useImperativeHandle(ref, () => ({ + resetEndFollow: () => { + clearUserScrollSettle(); + userScrollSessionRef.current = false; + cancelReflow(); + transitionEndFollow({ type: "reset" }); + }, + })); + // Opening at a message: once the filled list has loaded, the render that sees + // the jump unfolds a run hiding the message and stops following the end, so + // the committed list holds the target row with end maintenance off. + const [loadedListKey, setLoadedListKey] = useState(null); + const { jumpToMessageId } = props; + const jumpReady = !!jumpToMessageId && loadedListKey === `${feedThreadKey}:filled`; const presentedFeed = appendGuestPendingMessages( useThreadFeedPresentation(props, expandedTurnIds, expandedWorkGroups), guestShare, ); + const jumpRunId = + jumpReady && + !presentedFeed.some((entry) => entry.type === "message" && entry.id === jumpToMessageId) + ? messageFoldRunId(props.feed, jumpToMessageId) + : null; + if (jumpRunId && !expandedTurnIds.has(jumpRunId)) { + setInteractionState((current) => ({ + ...current, + expandedTurnIds: new Set(current.expandedTurnIds).add(jumpRunId), + })); + } const setupAnchorIndex = presentedFeed.findIndex( (entry) => entry.type === "message" && entry.message.role === "user", ); - // Empty/filled remounts reset imperative insets; seed the overlay first. Android uses a declarative inset floor. + // The empty↔filled key below remounts the list and resets its imperative + // content-inset override. Seed each fresh instance as it attaches with the + // current overlay height before the scroll integration's next reaction; + // on Android the declarative contentInset floor covers this same window. const listMountKey = `${feedThreadKey}:${presentedFeed.some((entry) => entry.type !== "thinking") ? "filled" : "empty"}`; - useLayoutEffect(() => { - const bottom = props.contentInsetEndAdjustment.value; - if (bottom > 0) { - listRef.current?.reportContentInset({ bottom }); + const { contentInsetEndAdjustment } = props; + const attachList = (list: LegendListRef | null) => { + listRef.current = list; + const bottom = contentInsetEndAdjustment.get(); + if (list && bottom > 0) list.reportContentInset({ bottom }); + }; + const jumpIndex = jumpReady + ? presentedFeed.findIndex((entry) => entry.type === "message" && entry.id === jumpToMessageId) + : null; + const followsEnd = endFollowEnabled && !jumpReady; + const landJump = useEffectEvent((index: number) => { + if (index !== -1) { + setEndFollow(false); + void listRef.current?.scrollToIndex({ index, viewPosition: 0.2, animated: false }); } - }, [listMountKey, props.contentInsetEndAdjustment, listRef]); - // Opening at a message: its run unfolds, the list stops following the end - // (a render later, so the end does not pull it back), then it scrolls there. - const [loadedListKey, setLoadedListKey] = useState(null); - const { jumpToMessageId } = props; - const finishJump = useEffectEvent(() => props.onJumpedToMessage?.()); - const stopFollowingEnd = useEffectEvent(() => setEndFollow(false)); + props.onJumpedToMessage?.(); + }); + // Z: a native list scroll after the commit that rendered its target row. The + // row is often already measured, so no LegendList callback fires for that + // commit, and setEndFollow notifies the parent, so it can't run during render. useEffect(() => { - if (!jumpToMessageId || loadedListKey !== listMountKey) return; - const index = presentedFeed.findIndex( - (entry) => entry.type === "message" && entry.id === jumpToMessageId, - ); - if (index === -1) { - const runId = messageFoldRunId(props.feed, jumpToMessageId); - if (runId && !expandedTurnIds.has(runId)) { - setInteractionState((current) => ({ - ...current, - expandedTurnIds: new Set(current.expandedTurnIds).add(runId), - })); - } else { - finishJump(); - } - return; - } - if (endFollowEnabled) { - stopFollowingEnd(); - return; - } - void listRef.current?.scrollToIndex({ index, viewPosition: 0.2, animated: false }); - finishJump(); - }, [ - endFollowEnabled, - expandedTurnIds, - jumpToMessageId, - listMountKey, - listRef, - loadedListKey, - presentedFeed, - props.feed, - ]); + if (jumpIndex !== null) landJump(jumpIndex); + }, [jumpToMessageId, jumpIndex]); const reflowLayout = () => ({ rowsTop: FEED_PADDING_TOP + listHeaderSizeRef.current, headerInset: anchorTopInset, }); + const settleReflow = () => { + reflowSettleTimerRef.current = null; + const anchor = reflowAnchorRef.current; + cancelReflow(); + const list = listRef.current; + if (reflowAnchorsSizeChanges || !anchor || anchor === "end" || !list) return; + if (userScrollSessionRef.current) return; + if (loadedListKeyRef.current !== listMountKey) return; + const target = resolveThreadFeedReflowScroll(anchor, list.getState(), reflowLayout()); + if (target) void list.scrollToIndex({ ...target, viewPosition: 0, animated: false }); + }; const beginReflow = () => { // Until the list loads, its own initial end positioning owns the offset. if (loadedListKeyRef.current !== listMountKey) return; @@ -2760,26 +2773,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { void listRef.current?.scrollToEnd({ animated: false }); } } - setReflowSettleRevision((revision) => revision + 1); - }; - const settleReflow = useEffectEvent(() => { - const anchor = reflowAnchorRef.current; - cancelReflow(); - const list = listRef.current; - if (reflowAnchorsSizeChanges || !anchor || anchor === "end" || !list) return; - if (userScrollSessionRef.current) return; - if (loadedListKeyRef.current !== listMountKey) return; - const target = resolveThreadFeedReflowScroll(anchor, list.getState(), reflowLayout()); - if (target) void list.scrollToIndex({ ...target, viewPosition: 0, animated: false }); - }); - useEffect(() => { - if (reflowSettleRevision === 0) return; - const timeout = setTimeout( - () => settleReflow(), + // Every re-measure restarts the countdown. + if (reflowSettleTimerRef.current !== null) clearTimeout(reflowSettleTimerRef.current); + reflowSettleTimerRef.current = setTimeout( + settleReflow, reflowAnchorsSizeChanges ? FEED_REFLOW_HOLD_MS : FEED_REFLOW_SETTLE_MS, ); - return () => clearTimeout(timeout); - }, [reflowSettleRevision]); + }; const holdReflowAnchor = () => { const anchor = reflowAnchorRef.current; @@ -2816,45 +2816,45 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } return new Set(terminalIdsByTurn.values()); })(); - useEffect(() => { - const previous = previousLatestTurnRef.current; - previousLatestTurnRef.current = props.latestRun; - if (!props.latestRun || !previous) { - return; - } - if (props.latestRun.runId === previous.runId) { - if (previous.status === "running" && props.latestRun.status === "interrupted") { - const interruptedTurnId = props.latestRun.runId; + // An interrupted run unfolds; a run that is no longer latest folds back. + const [previousLatestRun, setPreviousLatestRun] = useState(props.latestRun); + if (previousLatestRun !== props.latestRun) { + setPreviousLatestRun(props.latestRun); + const latest = props.latestRun; + const previous = previousLatestRun; + if (latest && previous) { + if (latest.runId !== previous.runId) { + if (expandedTurnIds.has(previous.runId)) { + setInteractionState((current) => { + const next = new Set(current.expandedTurnIds); + next.delete(previous.runId); + return { ...current, expandedTurnIds: next }; + }); + } + } else if (previous.status === "running" && latest.status === "interrupted") { setInteractionState((current) => ({ ...current, - expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), + expandedTurnIds: new Set(current.expandedTurnIds).add(latest.runId), })); } - return; } - setInteractionState((current) => { - if (!current.expandedTurnIds.has(previous.runId)) { - return current; - } - const next = new Set(current.expandedTurnIds); - next.delete(previous.runId); - return { ...current, expandedTurnIds: next }; - }); - }, [props.latestRun]); + } - useEffect(() => { - return () => { - if (copyFeedbackTimeoutRef.current) { - clearTimeout(copyFeedbackTimeoutRef.current); - } + // Timers and frames the feed schedules die with it. + useEffect( + () => () => { + if (userScrollSettleTimerRef.current !== null) clearTimeout(userScrollSettleTimerRef.current); + if (reflowSettleTimerRef.current !== null) clearTimeout(reflowSettleTimerRef.current); + if (copyFeedbackTimeoutRef.current) clearTimeout(copyFeedbackTimeoutRef.current); if (disclosureSettleFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleFrameRef.current); } if (disclosureSettleSecondFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleSecondFrameRef.current); } - }; - }, []); + }, + [], + ); const settleDisclosureAfterLayout = () => { if (disclosureSettleFrameRef.current !== null) { @@ -2881,19 +2881,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); }; + // The quiet-frame countdown's first frame lands after the disclosure's commit. + // Every measured item-size change restarts it, so end maintenance cannot + // wake between the data mutation and LegendList's final layout correction. const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string | null) => { cancelReflow(); disclosureAnchorKeyRef.current = anchorKey; setDisclosureToggleSettling(true); + settleDisclosureAfterLayout(); }; - // Start settling after React commits and restart on each size change, so end maintenance waits for LegendList correction. - useLayoutEffect(() => { - if (disclosureAnchorKeyRef.current !== null) { - settleDisclosureAfterLayout(); - } - }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]); - const handleItemSizeChanged = () => { if (disclosureAnchorKeyRef.current !== null) { settleDisclosureAfterLayout(); @@ -2969,14 +2966,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const onPressPreview = (source: FilePreviewSource) => { markdownLinkRequest.current?.abort(); - setExpandedFile((current) => current ?? source); + openPreview(markdownLinkScope, { file: source }); }; const onPressVideo = (attachment: ChatFileAttachment, sourceIdentifier: string) => { markdownLinkRequest.current?.abort(); - setExpandedVideo( - (current) => - current ?? attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), - ); + openPreview(markdownLinkScope, { + video: attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), + }); }; // Without this, every row above the viewport is assumed to be @@ -3019,15 +3015,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return undefined; } }; + // Row geometry inputs mostly arrive as props with no handler here. LegendList + // reads getFixedItemSize only when it renders, so its size cache is cleared + // after the commit that hands it rows at the new geometry; clearing earlier + // re-caches old sizes. + const { textSizeKey } = workRowSizing; const hasHtmlRenders = presentedFeed.some((entry) => entry.type === "html-render"); - const previousContentWidth = useRef(contentWidth); + const previousRowGeometry = useRef({ textSizeKey, contentWidth }); useLayoutEffect(() => { - if (previousContentWidth.current === contentWidth) return; - previousContentWidth.current = contentWidth; + const previous = previousRowGeometry.current; + if (previous.textSizeKey === textSizeKey && previous.contentWidth === contentWidth) return; + previousRowGeometry.current = { textSizeKey, contentWidth }; // Rows re-rendered at this width are measured in the next commit, after this effect. - beginReflow(); - if (hasHtmlRenders) listRef.current?.clearCaches({ mode: "sizes" }); - }, [beginReflow, contentWidth, hasHtmlRenders, listRef]); + if (previous.contentWidth !== contentWidth) beginReflow(); + if (previous.textSizeKey !== textSizeKey || hasHtmlRenders) { + listRef.current?.clearCaches({ mode: "sizes" }); + } + }, [textSizeKey, contentWidth, hasHtmlRenders, beginReflow, listRef]); const renderItem = (info: { item: ThreadFeedRow; index: number }) => ( {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- ThreadFeedScrollView is passed through the iOS-only spread below; the prop rejects undefined. */} ) : null} - setExpandedVideo(null)} /> - setExpandedFile(null)} /> + + ); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index 41e51180fa603..25074fdd29591 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -1,10 +1,14 @@ +import { useAtomValue } from "@effect/atom-react"; import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/reactivity"; import { Image } from "expo-image"; -import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react"; +import { createContext, useContext, useId, useState } from "react"; import { fetch } from "expo/fetch"; import { parse, SvgAst, type JsxAST } from "react-native-svg"; -import { ActivityIndicator, Pressable, StyleSheet, View, type ViewStyle } from "react-native"; +import { Pressable, StyleSheet, View, type ViewStyle } from "react-native"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; @@ -25,6 +29,47 @@ import { /** Use known feed width immediately; later image measurement would move already-positioned rows. */ export const MarkdownImageAvailableWidthContext = createContext(0); +type ImageSize = { readonly width: number; readonly height: number }; + +class SvgLoadError extends Data.TaggedError("SvgLoadError")<{ readonly cause: unknown }> {} + +async function readSvgXml(uri: string, signal: AbortSignal): Promise { + if (uri.startsWith("data:image/svg+xml")) { + const comma = uri.indexOf(","); + if (comma < 0) throw new Error("SVG data URI has no content"); + const content = decodeURIComponent(uri.slice(comma + 1)); + return uri.slice(0, comma).includes(";base64") + ? new TextDecoder().decode( + Uint8Array.from(atob(content), (character) => character.charCodeAt(0)), + ) + : content; + } + // expo/fetch, not the runtime HttpClient: it is the one that reads file:// URIs. + const response = await fetch(uri, { signal }); + if (!response.ok && !(response.status === 0 && uri.startsWith("file://"))) + throw new Error(`SVG request failed: ${response.status}`); + return response.text(); +} + +// Feed rows unmount as they scroll away; the idle TTL keeps a parsed document +// for the row that scrolls back instead of fetching it again. +const svgDocumentAtom = Atom.family((uri: string) => + Atom.make( + Effect.tryPromise({ + try: async (signal) => { + const ast = parse(await readSvgXml(uri, signal)); + if (!ast) throw new Error("SVG has no root element"); + return { ast, size: resolveSvgImageSize(ast.props) }; + }, + catch: (cause) => new SvgLoadError({ cause }), + }), + ).pipe(Atom.setIdleTTL(60_000), Atom.withLabel("mobile:thread-markdown-svg")), +); + +const noSvgDocumentAtom = Atom.make( + AsyncResult.initial<{ readonly ast: JsxAST; readonly size: ImageSize | null }, SvgLoadError>(), +); + export function ThreadMarkdownImageView(props: { readonly uri: string | null; readonly sourceKey: string; @@ -45,17 +90,15 @@ export function ThreadMarkdownImageView(props: { measuredWidth > 0 && contextWidth > 0 ? Math.min(contextWidth, measuredWidth) : contextWidth || measuredWidth; - const [decodedSize, setDecodedSize] = useState<{ width: number; height: number } | null>(null); + const svg = useAtomValue( + props.format === "svg" && props.uri !== null ? svgDocumentAtom(props.uri) : noSvgDocumentAtom, + ); + const svgDocument = AsyncResult.isSuccess(svg) ? svg.value : null; + const [decoded, setDecoded] = useState<{ sourceKey: string; size: ImageSize } | null>(null); + const decodedSize = + svgDocument?.size ?? (decoded?.sourceKey === props.sourceKey ? decoded.size : null); const [failedUri, setFailedUri] = useState(null); - useEffect(() => { - setDecodedSize(null); - }, [props.sourceKey]); - - useEffect(() => { - setFailedUri(null); - }, [props.uri]); - const sourceSize = decodedSize ?? props.knownSize ?? null; const displaySize: MarkdownImageDisplaySize | null = sourceSize === null || availableWidth <= 0 @@ -65,7 +108,10 @@ export function ThreadMarkdownImageView(props: { sourceHeight: sourceSize.height, availableWidth, }); - const failed = props.unavailable || (props.uri !== null && failedUri === props.uri); + const failed = + props.unavailable || + (props.uri !== null && failedUri === props.uri) || + AsyncResult.isFailure(svg); const placeholderWidth: ViewStyle["width"] = availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; @@ -123,13 +169,16 @@ export function ThreadMarkdownImageView(props: { className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" style={frameStyle} > - setFailedUri(props.uri)} - /> + {props.format === "svg" ? ( + + ) : ( + setDecoded({ sourceKey: props.sourceKey, size })} + onError={() => setFailedUri(props.uri)} + /> + )} @@ -146,89 +195,47 @@ export function ThreadMarkdownImageView(props: { function ThreadMarkdownImageRequest(props: { readonly uri: string; - readonly onLoad: (sourceSize: { width: number; height: number }) => void; + readonly onLoad: (sourceSize: ImageSize) => void; readonly onError: () => void; - readonly format?: "svg"; }) { const [loaded, setLoaded] = useState(false); return ( <> - {props.format === "svg" ? ( - { - setLoaded(true); - if (size) props.onLoad(size); - }} - onError={props.onError} - /> - ) : ( - { - setLoaded(true); - props.onLoad(event.source); - }} - onError={props.onError} - style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} - /> - )} - {loaded ? null : ( - - Loading image… - - )} + { + setLoaded(true); + props.onLoad(event.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + {loaded ? null : } ); } -function ThreadMarkdownSvg(props: { - readonly uri: string; - readonly onLoad: (size: { width: number; height: number } | null) => void; - readonly onError: () => void; -}) { - const [ast, setAst] = useState(null); - const onLoad = useEffectEvent(props.onLoad); - const onError = useEffectEvent(props.onError); - useEffect(() => { - const controller = new AbortController(); - const load = async () => { - if (props.uri.startsWith("data:image/svg+xml")) { - const comma = props.uri.indexOf(","); - if (comma < 0) throw new Error("SVG data URI has no content"); - const content = decodeURIComponent(props.uri.slice(comma + 1)); - return props.uri.slice(0, comma).includes(";base64") - ? new TextDecoder().decode( - Uint8Array.from(atob(content), (character) => character.charCodeAt(0)), - ) - : content; - } - const response = await fetch(props.uri, { signal: controller.signal }); - if (!response.ok && !(response.status === 0 && props.uri.startsWith("file://"))) - throw new Error(`SVG request failed: ${response.status}`); - return response.text(); - }; - void load() - .then((xml) => { - const parsed = parse(xml); - if (!parsed) throw new Error("SVG has no root element"); - if (controller.signal.aborted) return; - setAst(parsed); - onLoad(resolveSvgImageSize(parsed.props)); - }) - .catch(() => { - if (!controller.signal.aborted) onError(); - }); - return () => controller.abort(); - }, [props.uri]); - return ; +function ThreadMarkdownImageLoading() { + return ( + + Loading image… + + ); +} + +function ThreadMarkdownSvg(props: { readonly ast: JsxAST | null }) { + return props.ast === null ? ( + + ) : ( + + ); } export function ThreadMarkdownImage(props: { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 29ba2f4fe2452..1459c3db332ba 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -15,7 +15,7 @@ import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { THREAD_SIDEBAR_ROW_LAYOUT } from "./threadListAnimations"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent, TextInputInstance } from "react-native"; import { Platform, StyleSheet, TextInput, View } from "react-native"; import { GestureDetector, useNativeGesture } from "react-native-gesture-handler"; @@ -33,6 +33,7 @@ import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useNavigationThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; +import { useThreadListNow } from "./use-thread-list-now"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -196,11 +197,6 @@ function ThreadNavigationSidebarPane( sidebarProjectGroupingMode: options.projectGroupingMode, sidebarProjectGroupingOverrides: {}, }); - useEffect(() => { - if (resolvedProjectKey !== projectScopeKey) { - setSelectedProjectKey(resolvedProjectKey); - } - }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); const projectScopes = useMemo( () => buildHomeProjectScopes({ @@ -261,15 +257,8 @@ function ThreadNavigationSidebarPane( toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); - // Keep time in state; the compiler would cache a render-time new Date(). - const [now, setNow] = useState(() => new Date().toISOString()); + const now = useThreadListNow(threads); const nowMinute = now.slice(0, 16); - const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - useEffect(() => { - setNow(new Date().toISOString()); - const id = setInterval(() => setNow(new Date().toISOString()), 60_000); - return () => clearInterval(id); - }, []); const listEnvironments = useAtomValue(threadListEnvironmentsAtom); const { providersByEnvironmentId, @@ -283,7 +272,7 @@ function ThreadNavigationSidebarPane( titleRegenerationEnvironmentIds, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); - const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const pendingOrder = usePendingThreadOrder(); const { layout: threadListV2Layout, items: threadListV2Items, @@ -310,19 +299,6 @@ function ThreadNavigationSidebarPane( settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); - const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; - useEffect(() => { - if (nextSnoozeWakeAt === null) return; - const wakeAtMs = Date.parse(nextSnoozeWakeAt); - if (Number.isNaN(wakeAtMs)) return; - const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); - const id = setTimeout(() => { - setNow(new Date().toISOString()); - bumpSnoozeWakeTick((tick) => tick + 1); - }, delayMs); - return () => clearTimeout(id); - // snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. - }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems: readonly SidebarListItem[] = settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? [ diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 39c7ff8797352..f90bd5c7e7e20 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -1,5 +1,6 @@ import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; import { useAtomValue } from "@effect/atom-react"; +import { makeQueuedRunsAtom } from "@t3tools/client-runtime/queued-run-edit"; import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { Image } from "expo-image"; @@ -48,6 +49,12 @@ const EMPTY_QUEUED_COUNT_ATOM = Atom.make(0).pipe( type QueueTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; type QueueAction = "steer" | "edit" | "up" | "down" | "remove"; type QueueRowLayout = { readonly id: RunId; readonly y?: number; readonly height?: number }; +type QueueDrag = { + readonly runId: RunId; + readonly order: string; + beforeRunId: RunId | null | undefined; + readonly rows: ReadonlyArray; +}; export function useThreadQueueWorkflow(target: QueueTarget) { return useAtomValue(environmentThreadDetails.queueWorkflowAtom(target)); @@ -75,29 +82,18 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const [resuming, setResuming] = useState(false); const [busyRunId, setBusyRunId] = useState(null); const busyRef = useRef(false); - const [draggedRunId, setDraggedRunId] = useState(null); - const [previewBeforeRunId, setPreviewBeforeRunId] = useState(); - const [dragRows, setDragRows] = useState | null>(null); + const [dragState, setDragState] = useState(null); const rowLayouts = useRef(new Map()); - const drag = useRef<{ - runId: RunId; - order: string; - beforeRunId: RunId | null | undefined; - rows: ReadonlyArray; - } | null>(null); + const drag = useRef(null); const [translation] = useState(() => new Animated.Value(0)); const queuedRuns = workflow?.queuedRuns ?? []; const order = queuedRuns.map(({ run }) => run.id).join(","); - - useEffect(() => { - if (drag.current && drag.current.order !== order) { - drag.current = null; - setDraggedRunId(null); - setPreviewBeforeRunId(undefined); - setDragRows(null); - translation.setValue(0); - } - }, [order, translation]); + // A queue that changed under the drag (delivery, another client) ends it: + // the gesture handlers ignore it from then on and its preview disappears. + const visibleDrag = dragState?.order === order ? dragState : null; + const draggedRunId = visibleDrag?.runId ?? null; + const previewBeforeRunId = visibleDrag?.beforeRunId; + const dragRows = visibleDrag?.rows ?? null; const hadQueuedRuns = useRef(queuedRuns.length > 0); useEffect(() => { @@ -133,13 +129,24 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { if (action === "edit") { const entry = queuedRuns[index]!; void Haptics.selectionAsync(); - beginQueuedRunEdit(threadKey, { - runId, - messageId: entry.messageId, - originalText: entry.text, - existingAttachments: entry.attachments, - ...(entry.context ? { context: entry.context } : {}), - }); + beginQueuedRunEdit( + threadKey, + { + runId, + messageId: entry.messageId, + originalText: entry.text, + existingAttachments: entry.attachments, + ...(entry.context ? { context: entry.context } : {}), + }, + makeQueuedRunsAtom({ + threadId: target.threadId, + snapshot: threadEnvironment.snapshotAtom(target.environmentId), + detailStatus: environmentThreadDetails.statusAtom(target), + runs: Atom.make( + (get) => get(environmentThreadDetails.quietThreadAtom(target))?.projection.runs ?? null, + ), + }), + ); navigation.goBack(); return; } @@ -261,9 +268,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const beforeRunId = resolveQueueDragBeforeRunId(rows, run.id, 0); drag.current = { runId: run.id, order, beforeRunId, rows }; translation.setValue(0); - setDragRows(rows); - setPreviewBeforeRunId(beforeRunId); - setDraggedRunId(run.id); + setDragState({ ...drag.current }); void Haptics.selectionAsync(); }} onMove={(y) => { @@ -273,7 +278,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const before = resolveQueueDragBeforeRunId(current.rows, run.id, y); if (current.beforeRunId !== before) { current.beforeRunId = before; - setPreviewBeforeRunId(before); + setDragState({ ...current }); } }} onEnd={(y, success) => { @@ -281,9 +286,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const stop = () => { if (drag.current !== started) return; drag.current = null; - setDraggedRunId(null); - setPreviewBeforeRunId(undefined); - setDragRows(null); + setDragState(null); translation.setValue(0); }; if (!success || started?.order !== order || started.runId !== run.id) { @@ -314,7 +317,8 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { useNativeDriver: true, }).start(); } - setPreviewBeforeRunId(before); + started.beforeRunId = before; + setDragState({ ...started }); void move(run.id, before).finally(stop); }} /> diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 45b11324c1394..065eefe77b071 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -11,7 +11,7 @@ import { useNavigation, type StaticScreenProps, } from "@react-navigation/native"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { AuthOrchestrationOperateScope, @@ -34,6 +34,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useConnectionsReady } from "../../state/workspace"; import { useEnvironmentShellReadiness } from "../../state/shell"; import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; +import { refreshNewTaskDraftBranches } from "./new-task-project-branches"; import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; import { useEnvironmentQuery } from "../../state/query"; @@ -92,7 +93,6 @@ import { type ThreadInspectorMode, } from "./thread-inspector-content-stack"; import { threadRouteIsHydrating } from "./thread-route-hydration"; -import { useThreadPromptLink } from "../prompt-links/useThreadPromptLink"; import { ThreadHeader } from "./ThreadHeader"; import { useAndroidWorkspaceBack, @@ -101,11 +101,6 @@ import { import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; -interface ThreadInspectorSelection { - readonly routeThreadIdentity: string | null; - readonly mode: ThreadInspectorMode; -} - function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; @@ -232,7 +227,6 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); const selectedThreadDetailState = useSelectedThreadDetailState(); - useThreadPromptLink(params, routeThreadKey); if (environmentId === null || threadIdRaw === null) { return ; @@ -278,7 +272,7 @@ function ThreadRouteContent( ) { const { themeVariables } = useAppearancePreferences(); const headerColor = themeVariables["--color-header"]; - const { fileInspector, layout, panes, showAuxiliaryPane, toggleAuxiliaryPane } = + const { fileInspector, layout, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const { connectionState } = useRemoteConnectionStatus(); const { onReconnectEnvironment } = useRemoteConnections(); @@ -380,56 +374,25 @@ function ThreadRouteContent( const threadId = firstRouteParam(params.threadId); const routeThreadIdentity = environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; - const [inspectorSelection, setInspectorSelection] = useState( - () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), - ); + // The chosen mode survives thread switches; "route" only applies while the route + // supplies an inspector, which is also the default whenever it does. + const [chosenInspectorMode, setChosenInspectorMode] = useState(null); const inspectorMode = (() => { - if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) { - if (inspectorSelection.mode === "files" && selectedThreadCwd === null) { - return null; - } - return inspectorSelection.mode; - } - return null; + const mode = + props.renderInspector === undefined + ? chosenInspectorMode === "route" + ? null + : chosenInspectorMode + : chosenInspectorMode === null || chosenInspectorMode === "route" + ? "route" + : chosenInspectorMode; + return mode === "files" && selectedThreadCwd === null ? null : mode; })(); - useEffect(() => { - if ( - fileInspector.supported && - selectedThreadCwd === null && - inspectorMode === null && - panes.auxiliaryPaneVisible - ) { - toggleAuxiliaryPane(); - } - }, [ - fileInspector.supported, - inspectorMode, - panes.auxiliaryPaneVisible, - selectedThreadCwd, - toggleAuxiliaryPane, - ]); - - useEffect(() => { - setInspectorSelection((current) => { - if (props.renderInspector === undefined) { - if (current === null || current.mode === "route") { - return null; - } - return { ...current, routeThreadIdentity }; - } - - if (current === null || current.mode === "route") { - return { routeThreadIdentity, mode: "route" }; - } - - return { ...current, routeThreadIdentity }; - }); - }, [props.renderInspector, routeThreadIdentity]); useFocusEffect(() => { return () => { if (props.renderInspector === undefined) { - setInspectorSelection(null); + setChosenInspectorMode(null); } }; }); @@ -494,7 +457,7 @@ function ThreadRouteContent( }); return; } - setInspectorSelection({ routeThreadIdentity, mode: "git" }); + setChosenInspectorMode("git"); showAuxiliaryPane("inspector"); }; const handleOpenFilesInspector = () => { @@ -508,10 +471,7 @@ function ThreadRouteContent( }); return; } - setInspectorSelection({ - routeThreadIdentity, - mode: props.renderInspector === undefined ? "files" : "route", - }); + setChosenInspectorMode(props.renderInspector === undefined ? "files" : "route"); showAuxiliaryPane("inspector"); }; const handleToggleInspector = () => { @@ -567,6 +527,8 @@ function ThreadRouteContent( renderRoute={props.renderInspector ? RouteInspector : undefined} /> ); + // With no inspector registered the workspace hides the pane; the open + // preference survives for the next thread that has one. const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; useRegisterWorkspaceInspector(activeInspectorRenderer); @@ -772,6 +734,7 @@ function ThreadRouteContent( return; } clearPendingThreadCreationOutcome(routeThreadIdentity); + refreshNewTaskDraftBranches(restoredNewTaskDraftKey(creation.messageId)); navigation.dispatch( StackActions.replace("Thread", { draft: { @@ -1011,8 +974,6 @@ function ThreadRouteContent( onSendMessage={composer.onSendMessage} onReconnectEnvironment={handleReconnectEnvironment} canSwitchThreadProvider={composer.canSwitchThreadProvider} - onUpdateThreadModelSelection={composer.onUpdateModelSelection} - onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} onUpdateThreadInteractionMode={composer.onUpdateInteractionMode} onRespondToApproval={requests.onRespondToApproval} onSelectUserInputOption={requests.onSelectUserInputOption} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 030b317f41f92..15cce2e03f506 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -7,10 +7,7 @@ import { Toolbar } from "../../components/ui/Toolbar"; import * as Dialog from "../../components/ui/Dialog"; import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; -import { - modelPreferencesMigrationPatch, - resolveModelPreferences, -} from "@t3tools/shared/serverSettings"; +import { resolveModelPreferences } from "@t3tools/shared/serverSettings"; import type { EnvironmentId, ModelSelection, @@ -20,6 +17,7 @@ import type { ProviderOptionSelection, RuntimeMode, ServerSettings, + ThreadId, } from "@t3tools/contracts"; import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; import { RegistryContext, useAtomValue } from "@effect/atom-react"; @@ -29,19 +27,16 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; +import { + useNavigation, + useRoute, + type RouteProp, + type StaticScreenProps, +} from "@react-navigation/native"; import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; import { AsyncResult } from "effect/reactivity"; -import { - createContext, - use, - useEffect, - useEffectEvent, - useRef, - useState, - type ReactNode, -} from "react"; +import { createContext, use, useEffectEvent, useRef, useState, type ReactNode } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, TextInput, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -70,8 +65,18 @@ import { import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { ChatGptSharingStatus } from "./ChatGptSharingStatus"; -import { environmentCatalog } from "../../connection/catalog"; -import { useEnvironmentQuery } from "../../state/query"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { useThreadReportedModelSelection } from "../../state/entities"; +import { + setComposerDraftText, + updateComposerDraftSettings, + useComposerDraft, + useComposerDraftSettings, +} from "../../state/use-composer-drafts"; +import { useSelectedThreadProjection } from "../../state/use-thread-detail"; +import { updateThreadDraftModelSelection } from "../../state/use-thread-composer-state"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { threadAllowsProviderSwitch } from "../../state/thread-provider-switching"; import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; import { mobilePreferencesAtom } from "../../state/preferences"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -128,8 +133,6 @@ const EMPTY_MODEL_FAVORITES: ReadonlyArray<{ readonly model: string; }> = []; const FAVORITES_PROVIDER_FILTER = "@favorites"; -// Servers that predate `migrateModelPreferences` drop it and keep reporting null favorites. -const attemptedMigrationGenerations = new Map(); function ProviderHeader(props: { readonly driver: string | undefined; readonly iconUrl: string | undefined; @@ -260,46 +263,13 @@ type ThreadSettingsSessionProps = { readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; }; -export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & { - readonly ownerId: string; -}; - -type ExistingThreadSettingsRouteContextValue = { - readonly session: ExistingThreadSettingsRouteSession | null; - readonly present: (session: ExistingThreadSettingsRouteSession) => void; - readonly clear: (ownerId: string) => void; +/** The composer draft the sheet edits: the thread's own, or a queued message being edited. */ +export type ExistingThreadSettingsRouteParams = { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly draftKey: string; }; -const ExistingThreadSettingsRouteContext = - createContext(null); - -export function ExistingThreadSettingsRouteProvider(props: { readonly children: ReactNode }) { - const [session, setSession] = useState(null); - const present = (nextSession: ExistingThreadSettingsRouteSession) => { - setSession(nextSession); - }; - const clear = (ownerId: string) => { - setSession((current) => (current?.ownerId === ownerId ? null : current)); - }; - const value = { session, present, clear }; - - return ( - - {props.children} - - ); -} - -export function useExistingThreadSettingsRoutePresentation() { - const value = use(ExistingThreadSettingsRouteContext); - if (!value) { - throw new Error( - "useExistingThreadSettingsRoutePresentation must be used inside ExistingThreadSettingsRouteProvider.", - ); - } - return value; -} - type ThreadSettingsSessionValue = { readonly environmentId: EnvironmentId | null; readonly providerInstanceId?: ProviderInstanceId; @@ -345,10 +315,6 @@ function ThreadSettingsSessionProvider( const configs = useAtomValue(environmentServerConfigsAtom); const config = props.environmentId ? configs.get(props.environmentId) : undefined; const settings = config?.settings; - const connection = useEnvironmentQuery( - props.environmentId ? environmentCatalog.stateAtom(props.environmentId) : null, - ).data; - const connectionGeneration = connection?.phase === "connected" ? connection.generation : null; const [manageHidden, setManageHidden] = useState(false); const pendingHidden = useRef(new Set()); const pendingFavorites = useRef( @@ -376,27 +342,6 @@ function ThreadSettingsSessionProvider( favorites: legacyFavorites, }).favorites; const saveSettings = useAtomCommand(serverEnvironment.updateSettings, "model favorites update"); - useEffect(() => { - if ( - !props.environmentId || - !settings || - !AsyncResult.isSuccess(preferences) || - connectionGeneration === null || - attemptedMigrationGenerations.get(props.environmentId) === connectionGeneration - ) - return; - const patch = modelPreferencesMigrationPatch(settings, { favorites: legacyFavorites }); - if (!patch) return; - attemptedMigrationGenerations.set(props.environmentId, connectionGeneration); - void saveSettings({ environmentId: props.environmentId, input: { patch } }); - }, [ - connectionGeneration, - legacyFavorites, - preferences, - props.environmentId, - saveSettings, - settings, - ]); const favoriteKeys = new Set( modelFavorites.map((favorite) => modelFavoriteKey(favorite.provider, favorite.model)), ); @@ -1504,26 +1449,72 @@ export function ThreadSettingsPickerScreen( ); } -export function ExistingThreadSettingsRouteScreen() { +export function ExistingThreadSettingsRouteScreen({ + route, +}: StaticScreenProps) { const navigation = useNavigation>>(); - const presentation = useExistingThreadSettingsRoutePresentation(); - const session = presentation.session; - - useEffect(() => { - if (session) { - return; - } - - navigation.goBack(); - }, [navigation, session]); - - if (!session) { + // Reads the same environmentId/threadId params as the Thread route beneath it. + const { selectedThread, selectedThreadDetailRef, selectedEnvironmentRuntime } = + useThreadSelection(); + const projection = useSelectedThreadProjection(); + const reportedModelSelection = useThreadReportedModelSelection(selectedThreadDetailRef); + const threadKey = scopedThreadKey(route.params.environmentId, route.params.threadId); + const draftSettings = useComposerDraftSettings(threadKey); + const prompt = useComposerDraft(route.params.draftKey).text; + + if (!selectedThread) { return ; } - const { ownerId: _ownerId, ...settings } = session; + const serverConfig = selectedEnvironmentRuntime?.serverConfig ?? null; + const modelSelection = draftSettings.modelSelection ?? selectedThread.modelSelection; + const lockedProviderInstanceId = threadAllowsProviderSwitch({ + thread: selectedThread, + projection: projection?.projection, + }) + ? undefined + : modelSelection.instanceId; + const modelOptions = buildModelOptions( + serverConfig, + modelSelection, + lockedProviderInstanceId, + selectedThread.projectId, + ); + const currentModelOption = + modelOptions.find( + (option) => + option.selection.instanceId === modelSelection.instanceId && + option.selection.model === modelSelection.model, + ) ?? null; + const updateModelSelection = (selection: ModelSelection) => + updateThreadDraftModelSelection(threadKey, selection, serverConfig?.providers ?? []); - return navigation.goBack()} />; + return ( + setComposerDraftText(route.params.draftKey, text)} + reportedModelSelection={reportedModelSelection} + onSelectModel={(option) => updateModelSelection(withRememberedModelOptions(option.selection))} + restoreSelection={withRememberedModelOptions} + optionDescriptors={resolveProviderOptionDescriptors({ + capabilities: currentModelOption?.capabilities, + selections: modelSelection.options, + })} + onUpdateOptionSelections={(options) => { + rememberModelOptions(modelSelection.instanceId, modelSelection.model, options ?? []); + updateModelSelection({ ...modelSelection, options }); + }} + runtimeMode={draftSettings.runtimeMode ?? selectedThread.runtimeMode} + onUpdateRuntimeMode={(runtimeMode) => updateComposerDraftSettings(threadKey, { runtimeMode })} + onClose={() => navigation.goBack()} + /> + ); } /** RNS owns nested sheet dismissal; Reasoning and Runtime remain ordinary pushes. */ diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 22428209c8336..422f343e2f58e 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,7 +1,7 @@ import type { SubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { GlassContainer, GlassView } from "expo-glass-effect"; -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { type ReactNode, useRef, useState } from "react"; import { type LayoutChangeEvent, Pressable, @@ -16,6 +16,7 @@ import Animated, { ReduceMotion, type SharedValue, useAnimatedStyle, + useDerivedValue, useSharedValue, withTiming, } from "react-native-reanimated"; @@ -102,11 +103,10 @@ export function FloatingWorkingControl(props: { (hasAgents ? agentsWidth : 0) - (hasPreview ? previewWidth : 0), ); - const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); - - useEffect(() => { - separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING); - }, [props.showScrollToEnd, separationProgress]); + const showScrollToEnd = props.showScrollToEnd; + const separationProgress = useDerivedValue(() => + withTiming(showScrollToEnd ? 1 : 0, CONTROL_TIMING), + ); const lift = props.lift; const liftStyle = useAnimatedStyle(() => ({ @@ -133,12 +133,12 @@ export function FloatingWorkingControl(props: { }; const hasStatus = props.status !== null; const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview; - useEffect(() => { - if (!hasStatus) { - measuredWidthRef.current = null; - capsuleWidth.value = null; - } - }, [capsuleWidth, hasStatus]); + // The measuring host unmounts with the status, so the next status starts + // from its own width instead of animating from the last one. + const statusHostRef = () => () => { + measuredWidthRef.current = null; + capsuleWidth.value = null; + }; const capsuleStyle = useAnimatedStyle(() => ({ transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], })); @@ -164,6 +164,7 @@ export function FloatingWorkingControl(props: { const statusContent = props.status !== null ? ( diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index a91443401a236..b2f005c645607 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -1,4 +1,5 @@ import * as Dialog from "../../../components/ui/Dialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; import { SheetHeader } from "../../../components/ui/SheetHeader"; import { useOpenPullRequest } from "../../../native/useOpenPullRequest"; import { @@ -16,7 +17,7 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import { SymbolView } from "../../../components/ui/AppSymbol"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { ScreenStack } from "react-native-screens"; @@ -66,10 +67,21 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const navigation = useNavigation(); const { environmentId, threadId } = props.route.params; const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0; - useEffect(() => { - if (isBlankLink) navigation.goBack(); - }, [isBlankLink, navigation]); - if (isBlankLink) return null; + // A malformed deep link has nothing to show. Navigating away from render is + // not allowed, so offer the way out instead. + if (isBlankLink) { + return ( + + navigation.goBack()} + /> + + ); + } return ; } diff --git a/apps/mobile/src/features/threads/incomingShareImport.test.ts b/apps/mobile/src/features/threads/incomingShareImport.test.ts new file mode 100644 index 0000000000000..c53165b7745e4 --- /dev/null +++ b/apps/mobile/src/features/threads/incomingShareImport.test.ts @@ -0,0 +1,344 @@ +import { AtomRegistry } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { DraftComposerAttachment } from "../../lib/composerImages"; +import type { IncomingShareDraft } from "../sharing/incoming-share-model"; +import type { ComposerDraft } from "../../state/use-composer-drafts"; +import { + makeIncomingShareImports, + type ShareImportDeps, + type ShareImportPhase, + type ShareImportTarget, + transitionShareImport, +} from "./incomingShareImport"; + +const destination = { environmentId: "env-1", projectId: "project-1" }; + +function fileAttachment(id: string): DraftComposerAttachment { + return { + id, + type: "file", + name: `${id}.txt`, + mimeType: "text/plain", + sizeBytes: 4, + fileUri: `file:///${id}.txt`, + }; +} + +function share(attachments: ReadonlyArray = []): IncomingShareDraft { + return { + schemaVersion: 1, + id: "share-1", + createdAt: "2026-01-01T00:00:00.000Z", + text: "shared text", + attachments, + warnings: [], + }; +} + +function importTarget(overrides: Partial = {}): ShareImportTarget { + return { + flowKey: "flow-a", + routeKey: "route-a", + draftKey: "draft-a", + destination, + shareId: "share-1", + ...overrides, + }; +} + +function setup(overrides: Partial = {}) { + const drafts = new Map([["draft-a", { text: "mine", attachments: [] }]]); + const inbox = new Map([["share-1", share()]]); + const calls: Array = []; + const registry = overrides.registry ?? AtomRegistry.make(); + const deps: ShareImportDeps = { + registry, + getShare: (shareId) => inbox.get(shareId) ?? null, + reserve: vi.fn(async () => { + calls.push("reserve"); + }), + release: vi.fn(async () => { + calls.push("release"); + }), + consume: vi.fn(async () => { + calls.push("consume"); + }), + readDraft: (draftKey) => drafts.get(draftKey) ?? { text: "", attachments: [] }, + restoreDraft: vi.fn(async (draftKey, snapshot) => { + calls.push("restore"); + drafts.set(draftKey, snapshot); + }), + mergeIntoDraft: vi.fn(async (draftKey, content) => { + calls.push("merge"); + const existing = drafts.get(draftKey) ?? { text: "", attachments: [] }; + drafts.set(draftKey, { + text: `${existing.text}\n${content.text}`, + attachments: [...existing.attachments, ...content.attachments], + }); + return { skippedAttachmentCount: 0 }; + }), + selectAttachments: (incoming) => ({ + status: "ready", + attachments: incoming.attachments, + warnings: [], + }), + releaseUnusedAttachments: vi.fn(), + onWarnings: vi.fn(), + onImportFailure: vi.fn(), + onCancelFailure: vi.fn(), + leave: vi.fn(), + ...overrides, + }; + const imports = makeIncomingShareImports(deps); + const watch = (target: ShareImportTarget) => { + const atom = imports.phaseAtom(target); + const unmount = registry.mount(atom); + registry.get(atom); + return { phase: (): ShareImportPhase => registry.get(atom), unmount }; + }; + return { imports, deps, drafts, inbox, calls, watch, registry }; +} + +describe("transitionShareImport", () => { + it("only cancels or retries from a failure, and keeps terminal phases terminal", () => { + expect(transitionShareImport("attaching", "cancel")).toBe("attaching"); + expect(transitionShareImport("failed", "start")).toBe("reserving"); + expect(transitionShareImport("cancelling", "cancelFailed")).toBe("failed"); + expect(transitionShareImport("done", "start")).toBe("done"); + expect(transitionShareImport("cancelled", "start")).toBe("cancelled"); + }); +}); + +describe("incoming share imports", () => { + it("reserves, attaches and consumes once, releasing files the draft did not keep", async () => { + const kept = fileAttachment("kept"); + const skipped = fileAttachment("skipped"); + const { deps, calls, drafts, inbox, watch } = setup({ + selectAttachments: () => ({ status: "ready", attachments: [kept], warnings: ["too big"] }), + }); + inbox.set("share-1", share([kept, skipped])); + const watched = watch(importTarget()); + + expect(watched.phase()).toBe("reserving"); + await vi.waitFor(() => expect(watched.phase()).toBe("done")); + + expect(calls).toEqual(["reserve", "merge", "consume"]); + expect(drafts.get("draft-a")?.attachments).toEqual([kept]); + expect(deps.releaseUnusedAttachments).toHaveBeenCalledWith([skipped]); + expect(deps.onWarnings).toHaveBeenCalledWith(["too big"]); + }); + + it("runs once when its atom node is removed and rebuilt", async () => { + const { imports, deps } = setup(); + const tasks: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + tasks.push(task); + return () => undefined; + }, + }); + const atom = imports.phaseAtom(importTarget()); + + registry.get(atom); + tasks.splice(0).forEach((task) => task()); + expect(registry.getNodes().size).toBe(0); + const unmount = registry.mount(atom); + await vi.waitFor(() => expect(registry.get(atom)).toBe("done")); + unmount(); + + expect(deps.reserve).toHaveBeenCalledTimes(1); + expect(deps.consume).toHaveBeenCalledTimes(1); + }); + + it("marks the share imported for its route until the route leaves", async () => { + const { imports, watch, registry } = setup(); + const watched = watch(importTarget()); + const imported = imports.importedAtom("flow-a", "share-1"); + expect(registry.get(imported)).toBe(false); + await vi.waitFor(() => expect(watched.phase()).toBe("done")); + expect(registry.get(imported)).toBe(true); + expect(registry.get(imports.importedAtom("flow-b", "share-1"))).toBe(false); + + imports.prune(new Set()); + expect(registry.get(imported)).toBe(false); + }); + + it("cancel after a failed consume restores the draft, releases the reservation and leaves", async () => { + const { deps, calls, drafts, watch, imports } = setup({ + consume: vi.fn(async () => { + throw new Error("disk full"); + }), + }); + const target = importTarget(); + const watched = watch(target); + await vi.waitFor(() => expect(watched.phase()).toBe("failed")); + expect(deps.onImportFailure).toHaveBeenCalledWith(target, new Error("disk full")); + + await imports.cancel(target); + + expect(watched.phase()).toBe("cancelled"); + expect(calls).toEqual(["reserve", "merge", "restore", "release"]); + expect(drafts.get("draft-a")).toEqual({ text: "mine", attachments: [] }); + // Unconsumed: the inbox still owns the files. + expect(deps.releaseUnusedAttachments).not.toHaveBeenCalled(); + expect(deps.leave).toHaveBeenCalledWith(target); + }); + + it("cancel before anything was reserved only leaves", async () => { + const { calls, deps, watch, imports } = setup({ + reserve: vi.fn(async () => { + throw new Error("reserved elsewhere"); + }), + }); + const target = importTarget(); + const watched = watch(target); + await vi.waitFor(() => expect(watched.phase()).toBe("failed")); + + await imports.cancel(target); + + expect(calls).toEqual([]); + expect(deps.leave).toHaveBeenCalledTimes(1); + }); + + it("a failed cancel can be retried and keeps the original backup", async () => { + let releaseAttempts = 0; + const { deps, drafts, watch, imports } = setup({ + consume: vi.fn(async () => { + throw new Error("disk full"); + }), + release: vi.fn(async () => { + releaseAttempts += 1; + if (releaseAttempts === 1) throw new Error("locked"); + }), + }); + const target = importTarget(); + const watched = watch(target); + await vi.waitFor(() => expect(watched.phase()).toBe("failed")); + + await imports.cancel(target); + expect(watched.phase()).toBe("failed"); + expect(deps.onCancelFailure).toHaveBeenCalledWith(target, new Error("locked")); + expect(deps.leave).not.toHaveBeenCalled(); + + await imports.cancel(target); + expect(watched.phase()).toBe("cancelled"); + expect(deps.restoreDraft).toHaveBeenCalledTimes(1); + expect(drafts.get("draft-a")).toEqual({ text: "mine", attachments: [] }); + expect(deps.leave).toHaveBeenCalledTimes(1); + }); + + it("retry imports the share as the inbox holds it then", async () => { + let fail = true; + const { deps, imports, inbox, watch } = setup({ + mergeIntoDraft: vi.fn(async () => { + if (fail) throw new Error("busy"); + return { skippedAttachmentCount: 0 }; + }), + }); + const target = importTarget(); + const watched = watch(target); + await vi.waitFor(() => expect(watched.phase()).toBe("failed")); + + fail = false; + inbox.set("share-1", { ...share(), text: "edited" }); + imports.retry(target); + + await vi.waitFor(() => expect(watched.phase()).toBe("done")); + expect(deps.reserve).toHaveBeenCalledTimes(2); + expect(deps.mergeIntoDraft).toHaveBeenLastCalledWith("draft-a", { + text: "edited", + attachments: [], + sourceShareId: "share-1", + }); + }); + + it("stops before consuming once its route leaves the stack", async () => { + const merged = Promise.withResolvers<{ readonly skippedAttachmentCount: number }>(); + const { deps, imports, watch } = setup({ mergeIntoDraft: () => merged.promise }); + const watched = watch(importTarget()); + await vi.waitFor(() => expect(watched.phase()).toBe("attaching")); + + const pruned = imports.prune(new Set(["flow-b"])); + merged.resolve({ skippedAttachmentCount: 0 }); + await pruned; + + expect(deps.consume).not.toHaveBeenCalled(); + expect(deps.onImportFailure).not.toHaveBeenCalled(); + watched.unmount(); + }); + + it("a failed import keeps its cancel after its route left, and reports a failed undo", async () => { + const { calls, deps, imports, watch } = setup({ + consume: vi.fn(async () => { + throw new Error("disk full"); + }), + release: vi.fn(async () => { + throw new Error("locked"); + }), + }); + const target = importTarget(); + const watched = watch(target); + await vi.waitFor(() => expect(watched.phase()).toBe("failed")); + watched.unmount(); + await imports.prune(new Set()); + + await imports.cancel(target); + + expect(calls).toEqual(["reserve", "merge", "restore"]); + expect(deps.onCancelFailure).toHaveBeenCalledWith(target, new Error("locked")); + expect(deps.leave).not.toHaveBeenCalled(); + }); + + it("a cancel finishes its undo after the route left, without navigating", async () => { + const restored = Promise.withResolvers(); + const { deps, imports, watch } = setup({ + consume: vi.fn(async () => { + throw new Error("disk full"); + }), + restoreDraft: vi.fn(() => restored.promise), + }); + const target = importTarget(); + const watched = watch(target); + await vi.waitFor(() => expect(watched.phase()).toBe("failed")); + + const cancelling = imports.cancel(target); + await imports.prune(new Set()); + restored.resolve(); + await cancelling; + + expect(deps.release).toHaveBeenCalledTimes(1); + expect(deps.leave).not.toHaveBeenCalled(); + }); + + it("a route switching drafts abandons the in-flight import", async () => { + const reserved = Promise.withResolvers(); + const { deps, watch } = setup({ + reserve: vi.fn().mockReturnValueOnce(reserved.promise).mockResolvedValue(undefined), + }); + watch(importTarget()).unmount(); + const second = watch(importTarget({ draftKey: "draft-b" })); + reserved.resolve(); + + await vi.waitFor(() => expect(second.phase()).toBe("done")); + expect(deps.mergeIntoDraft).toHaveBeenCalledTimes(1); + expect(deps.mergeIntoDraft).toHaveBeenCalledWith("draft-b", expect.anything()); + }); + + it("a route switching back to a superseded draft starts it over", async () => { + const reserved = Promise.withResolvers(); + // Keep unsubscribed nodes cached, as they are between a screen's renders. + const registry = AtomRegistry.make({ scheduleTask: () => () => undefined }); + const { deps, watch } = setup({ + registry, + reserve: vi.fn().mockReturnValueOnce(reserved.promise).mockResolvedValue(undefined), + }); + watch(importTarget()).unmount(); + watch(importTarget({ draftKey: "draft-b" })).unmount(); + const back = watch(importTarget()); + reserved.resolve(); + + await vi.waitFor(() => expect(back.phase()).toBe("done")); + expect(deps.mergeIntoDraft).toHaveBeenLastCalledWith("draft-a", expect.anything()); + }); +}); diff --git a/apps/mobile/src/features/threads/incomingShareImport.ts b/apps/mobile/src/features/threads/incomingShareImport.ts new file mode 100644 index 0000000000000..07eca39d5eacc --- /dev/null +++ b/apps/mobile/src/features/threads/incomingShareImport.ts @@ -0,0 +1,361 @@ +import { Atom, type AtomRegistry } from "effect/reactivity"; + +import type { DraftComposerAttachment } from "../../lib/composerImages"; +import type { ComposerDraft } from "../../state/use-composer-drafts"; +import type { + IncomingShareDestination, + IncomingShareDraft, + selectIncomingShareAttachmentsForServer, +} from "../sharing/incoming-share-model"; + +export type ShareImportPhase = + | "idle" + | "reserving" + | "attaching" + | "consuming" + | "done" + | "failed" + | "cancelling" + | "cancelled"; + +export type ShareImportEvent = + | "start" + | "reserved" + | "attached" + | "consumed" + | "failed" + | "cancel" + | "cancelFailed" + | "cancelled"; + +const TRANSITIONS: Readonly< + Record>> +> = { + idle: { start: "reserving" }, + reserving: { reserved: "attaching", failed: "failed" }, + attaching: { attached: "consuming", failed: "failed" }, + consuming: { consumed: "done", failed: "failed" }, + done: {}, + // "start" from failed is a retry. + failed: { start: "reserving", cancel: "cancelling" }, + cancelling: { cancelled: "cancelled", cancelFailed: "failed" }, + cancelled: {}, +}; + +/** Events that do not apply to the current phase leave it unchanged. */ +export function transitionShareImport( + phase: ShareImportPhase, + event: ShareImportEvent, +): ShareImportPhase { + return TRANSITIONS[phase][event] ?? phase; +} + +export function isShareImportInFlight(phase: ShareImportPhase): boolean { + return phase === "reserving" || phase === "attaching" || phase === "consuming"; +} + +/** + * One draft route importing one share into one draft. Plain data, compared + * structurally, so the screen can build it on every render. + */ +export interface ShareImportTarget { + /** The draft route's flow key; the import is abandoned once it leaves the stack. */ + readonly flowKey: string; + readonly routeKey: string; + readonly draftKey: string; + readonly destination: IncomingShareDestination; + readonly shareId: string; +} + +export interface ShareImportDeps { + readonly registry: AtomRegistry.AtomRegistry; + /** The share as the inbox holds it now. */ + readonly getShare: (shareId: string) => IncomingShareDraft | null; + readonly reserve: (shareId: string, destination: IncomingShareDestination) => Promise; + readonly release: (shareId: string, destination: IncomingShareDestination) => Promise; + readonly consume: (shareId: string) => Promise; + readonly readDraft: (draftKey: string) => ComposerDraft; + readonly restoreDraft: (draftKey: string, snapshot: ComposerDraft) => Promise; + readonly mergeIntoDraft: ( + draftKey: string, + content: { + readonly text: string; + readonly attachments: ReadonlyArray; + readonly sourceShareId: string; + }, + ) => Promise<{ readonly skippedAttachmentCount: number }>; + readonly selectAttachments: ( + share: IncomingShareDraft, + environmentId: string, + ) => ReturnType; + /** Sweeps files no draft retained; the sweep re-checks ownership. */ + readonly releaseUnusedAttachments: (attachments: ReadonlyArray) => void; + readonly onWarnings: (warnings: ReadonlyArray) => void; + readonly onImportFailure: (target: ShareImportTarget, error: unknown) => void; + /** Reported even after the route left: the undo is still incomplete. */ + readonly onCancelFailure: (target: ShareImportTarget, error: unknown) => void; + /** Leaves the draft route once its import is cancelled. */ + readonly leave: (target: ShareImportTarget) => void; +} + +interface Entry { + readonly target: ShareImportTarget; + phase: ShareImportPhase; + readonly controller: AbortController; + /** Set when another draft of the same route took over before this import finished. */ + superseded: boolean; + /** Set when the route left while a failed import still waits for retry or cancel. */ + detached: boolean; + run: Promise; + /** The draft before the first attempt merged into it; cancel restores it. */ + backup: ComposerDraft | null; + reservedFor: IncomingShareDestination | null; + consumed: boolean; + needsRestore: boolean; + readonly listeners: Set<() => void>; +} + +function keyOf(target: ShareImportTarget): string { + return JSON.stringify([ + target.flowKey, + target.routeKey, + target.draftKey, + target.destination.environmentId, + target.destination.projectId, + target.shareId, + ]); +} + +interface ImportedShare { + readonly flowKey: string; + readonly shareId: string; +} + +/** + * Share imports for draft routes. Imports do durable writes, so each is tied to + * its target, not an atom node that can be rebuilt. Reading a target's phase atom + * starts its import at most once; alerts, navigation and notifications all + * follow an await, never run inside the read. + */ +export function makeIncomingShareImports(deps: ShareImportDeps) { + const entries = new Map(); + // Shares each route already moved into a draft. Sending clears the draft's + // import receipt a frame before the route leaves, so the route needs its + // own record to tell "imported" from "vanished". + const importedAtom = Atom.make>([]).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:incoming-share-imported"), + ); + const isImportedAtom = Atom.family((key: string) => + Atom.make((get) => + get(importedAtom).some( + (imported) => JSON.stringify([imported.flowKey, imported.shareId]) === key, + ), + ).pipe(Atom.withLabel("mobile:incoming-share-imported:route")), + ); + + function notify(entry: Entry): void { + // Listeners rebuild their node, which re-registers them; iterate a copy. + for (const listener of [...entry.listeners]) listener(); + } + + function advance(entry: Entry, event: ShareImportEvent): void { + const next = transitionShareImport(entry.phase, event); + if (next === entry.phase) return; + entry.phase = next; + notify(entry); + } + + function settle(key: string, entry: Entry): void { + if (entry.detached && entries.get(key) === entry) entries.delete(key); + } + + async function runImport(key: string, entry: Entry): Promise { + const { signal } = entry.controller; + const { shareId, destination, draftKey } = entry.target; + // A phase atom node of a superseded import may still be cached; rebuilding + // it lets a route that switches back start over instead of showing the + // abandoned import's last phase. + const stopped = () => { + if (!signal.aborted) return false; + if (entry.superseded) notify(entry); + return true; + }; + try { + await deps.reserve(shareId, destination); + entry.reservedFor = destination; + // The durable reservation makes an interrupted transfer resume only in + // this project instead of copying into a second project draft. + if (stopped()) return; + advance(entry, "reserved"); + const share = deps.getShare(shareId); + if (!share) throw new Error("The shared content is no longer in the inbox."); + const selected = deps.selectAttachments(share, destination.environmentId); + if (selected.status === "pending") { + throw new Error("Server attachment support is still loading."); + } + entry.needsRestore = true; + const { skippedAttachmentCount } = await deps.mergeIntoDraft(draftKey, { + text: share.text, + attachments: selected.attachments, + sourceShareId: shareId, + }); + if (stopped()) return; + advance(entry, "attached"); + await deps.consume(shareId); + entry.consumed = true; + // The consumed inbox draft was the last owner of files that never made + // it into the composer draft (unsupported server, oversize, limit + // skips). Release them even when the route left meanwhile. + const retainedIds = new Set( + deps.readDraft(draftKey).attachments.map((attachment) => attachment.id), + ); + deps.releaseUnusedAttachments( + share.attachments.filter((attachment) => !retainedIds.has(attachment.id)), + ); + deps.registry.set(importedAtom, [ + ...deps.registry.get(importedAtom), + { flowKey: entry.target.flowKey, shareId }, + ]); + advance(entry, "consumed"); + entry.backup = null; + settle(key, entry); + if (stopped()) return; + const warnings = [...share.warnings, ...selected.warnings]; + if (skippedAttachmentCount > 0) { + warnings.push( + `${skippedAttachmentCount} shared file${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, + ); + } + if (warnings.length > 0) deps.onWarnings(warnings); + } catch (error) { + if (stopped()) return; + advance(entry, "failed"); + deps.onImportFailure(entry.target, error); + } + } + + function begin(key: string, entry: Entry): void { + entry.backup ??= deps.readDraft(entry.target.draftKey); + advance(entry, "start"); + entry.run = runImport(key, entry); + } + + function abandon(key: string, entry: Entry): void { + entry.controller.abort(); + entries.delete(key); + } + + function start(target: ShareImportTarget): Entry { + const key = keyOf(target); + const existing = entries.get(key); + if (existing) return existing; + // A route imports into one draft at a time; a superseded import stops at + // its next step and no longer reports failures. + for (const [otherKey, other] of entries) { + if (other.target.flowKey === target.flowKey && isShareImportInFlight(other.phase)) { + other.superseded = true; + abandon(otherKey, other); + } + } + const entry: Entry = { + target, + phase: "idle", + controller: new AbortController(), + superseded: false, + detached: false, + run: Promise.resolve(), + backup: null, + reservedFor: null, + consumed: false, + needsRestore: false, + listeners: new Set(), + }; + entries.set(key, entry); + begin(key, entry); + return entry; + } + + async function cancel(target: ShareImportTarget): Promise { + const key = keyOf(target); + const entry = entries.get(key); + if (!entry || entry.phase !== "failed") return; + const { draftKey, shareId } = entry.target; + advance(entry, "cancel"); + // Cancelling finishes even if the route leaves meanwhile: it only undoes + // durable writes this import made. + try { + if (entry.needsRestore && entry.backup) { + // The restore drops the share's merged-in attachments from the draft. + // Sweep them only when the inbox entry was consumed: before that, the + // inbox still references these files and keeps them for a later import. + const mergedAttachments = deps.readDraft(draftKey).attachments; + await deps.restoreDraft(draftKey, entry.backup); + entry.needsRestore = false; + if (entry.consumed) deps.releaseUnusedAttachments(mergedAttachments); + } + if (entry.reservedFor) { + await deps.release(shareId, entry.reservedFor); + entry.reservedFor = null; + } + advance(entry, "cancelled"); + if (entry.detached) settle(key, entry); + else deps.leave(entry.target); + } catch (error) { + advance(entry, "cancelFailed"); + deps.onCancelFailure(entry.target, error); + } + } + + return { + phaseAtom: Atom.family((target: ShareImportTarget) => + Atom.make((get): ShareImportPhase => { + const entry = start(target); + const rebuild = () => get.refreshSelf(); + entry.listeners.add(rebuild); + get.addFinalizer(() => entry.listeners.delete(rebuild)); + return entry.phase; + }).pipe(Atom.withLabel("mobile:incoming-share-import")), + ), + /** Whether this route already moved the share into one of its drafts. */ + importedAtom(flowKey: string, shareId: string) { + return isImportedAtom(JSON.stringify([flowKey, shareId])); + }, + retry(target: ShareImportTarget): void { + const key = keyOf(target); + const entry = entries.get(key); + if (!entry || entry.phase !== "failed") return; + begin(key, entry); + }, + cancel, + /** + * Abandons the imports of draft routes that are no longer on the stack. + * A failed import keeps its retry and cancel until the user answers. + * Resolves once the abandoned imports have stopped. + */ + prune(liveFlowKeys: ReadonlySet): Promise { + const stopping: Array> = []; + for (const [key, entry] of entries) { + if (liveFlowKeys.has(entry.target.flowKey)) continue; + if (entry.phase === "failed" || entry.phase === "cancelling") { + entry.detached = true; + } else { + abandon(key, entry); + stopping.push(entry.run); + } + } + const imported = deps.registry.get(importedAtom); + const kept = imported.filter((share) => liveFlowKeys.has(share.flowKey)); + if (kept.length !== imported.length) deps.registry.set(importedAtom, kept); + return Promise.all(stopping).then(() => undefined); + }, + }; +} + +export const idleShareImportPhaseAtom = Atom.make("idle").pipe( + Atom.withLabel("mobile:incoming-share-import:idle"), +); + +export const idleImportedShareAtom = Atom.make(false).pipe( + Atom.withLabel("mobile:incoming-share-imported:idle"), +); diff --git a/apps/mobile/src/features/threads/new-task-branch-checkout.test.ts b/apps/mobile/src/features/threads/new-task-branch-checkout.test.ts new file mode 100644 index 0000000000000..f8b6560b4ac60 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-branch-checkout.test.ts @@ -0,0 +1,156 @@ +import { AsyncResult, AtomRegistry } from "effect/reactivity"; +import * as Cause from "effect/Cause"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { + type BranchCheckoutRequest, + type BranchCheckoutResult, + makeNewTaskBranchCheckouts, +} from "./new-task-branch-checkout"; + +function request(flowKey: string, branch: string): BranchCheckoutRequest { + return { + flowKey, + draftKey: `new-task:${flowKey}`, + routeKey: flowKey, + environmentId: null, + workspaceRoot: "/repo", + branch, + worktreePath: null, + }; +} + +function setup() { + const runs: Array<{ + readonly request: BranchCheckoutRequest; + readonly settle: (result: BranchCheckoutResult) => void; + }> = []; + const holds: Array> = []; + const onFailure = vi.fn(); + const onSuccess = vi.fn(); + const checkouts = makeNewTaskBranchCheckouts({ + run: (input) => + new Promise((resolve) => { + runs.push({ request: input, settle: resolve }); + }), + holdNavigation: (operation) => holds.push(operation), + onFailure, + onSuccess, + }); + return { checkouts, runs, holds, onFailure, onSuccess }; +} + +const success = (name: string): BranchCheckoutResult => + AsyncResult.success({ name, current: true, isDefault: false, worktreePath: null }); +const failure: BranchCheckoutResult = AsyncResult.failure(Cause.fail(new Error("dirty tree"))); + +describe("new task branch checkouts", () => { + it("runs once per request even when its atom node is removed and rebuilt", async () => { + const { checkouts, runs } = setup(); + const key = checkouts.requestKey(request("flow-a", "feature")); + + // A render read with no subscriber, then the node is dropped before the + // subscription commits, then the subscription builds a fresh node. + const tasks: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + tasks.push(task); + return () => undefined; + }, + }); + const flushRemovals = () => tasks.splice(0).forEach((task) => task()); + + registry.get(checkouts.atom(key)); + await vi.waitFor(() => expect(runs).toHaveLength(1)); + flushRemovals(); + expect(registry.getNodes().size).toBe(0); + const unmount = registry.mount(checkouts.atom(key)); + runs[0]!.settle(success("feature")); + await vi.waitFor(() => + expect(AsyncResult.isSuccess(registry.get(checkouts.atom(key)))).toBe(true), + ); + unmount(); + + expect(runs).toHaveLength(1); + }); + + it("serializes checkouts across routes", async () => { + const { checkouts, runs } = setup(); + const unmountA = appAtomRegistry.mount( + checkouts.atom(checkouts.requestKey(request("flow-a", "a"))), + ); + const unmountB = appAtomRegistry.mount( + checkouts.atom(checkouts.requestKey(request("flow-b", "b"))), + ); + await vi.waitFor(() => expect(runs).toHaveLength(1)); + expect(runs[0]!.request.branch).toBe("a"); + + runs[0]!.settle(success("a")); + await vi.waitFor(() => expect(runs).toHaveLength(2)); + expect(runs[1]!.request.branch).toBe("b"); + runs[1]!.settle(success("b")); + unmountA(); + unmountB(); + }); + + it("handles a failure only after the route is released, and never once it left", async () => { + const { checkouts, runs, holds, onFailure } = setup(); + const keyA = checkouts.requestKey(request("flow-a", "a")); + const keyB = checkouts.requestKey(request("flow-b", "b")); + const unmountA = appAtomRegistry.mount(checkouts.atom(keyA)); + const unmountB = appAtomRegistry.mount(checkouts.atom(keyB)); + + await vi.waitFor(() => expect(runs).toHaveLength(1)); + runs[0]!.settle(failure); + await vi.waitFor(() => expect(runs).toHaveLength(2)); + expect(onFailure).not.toHaveBeenCalled(); + checkouts.release(keyA); + await vi.waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1)); + expect(onFailure.mock.calls[0]![0]).toMatchObject({ flowKey: "flow-a" }); + + checkouts.prune(new Set(["flow-a"])); + runs[1]!.settle(failure); + checkouts.release(keyB); + // The hold settles only after the failure continuation has run. + await holds[1]; + expect(onFailure).toHaveBeenCalledTimes(1); + unmountA(); + unmountB(); + }); + + it("stops holding navigation when its route leaves the stack", async () => { + const { checkouts, runs, holds } = setup(); + const unmount = appAtomRegistry.mount( + checkouts.atom(checkouts.requestKey(request("flow-a", "a"))), + ); + await vi.waitFor(() => expect(runs).toHaveLength(1)); + runs[0]!.settle(success("a")); + + checkouts.prune(new Set()); + await expect(holds[0]).resolves.toBeDefined(); + unmount(); + }); + + it("applies a success only while its route is on the stack", async () => { + const { checkouts, runs, holds, onSuccess } = setup(); + const keyA = checkouts.requestKey(request("flow-a", "a")); + const keyB = checkouts.requestKey(request("flow-b", "b")); + const unmountA = appAtomRegistry.mount(checkouts.atom(keyA)); + const unmountB = appAtomRegistry.mount(checkouts.atom(keyB)); + + await vi.waitFor(() => expect(runs).toHaveLength(1)); + runs[0]!.settle(success("a")); + await vi.waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(onSuccess.mock.calls[0]![0]).toMatchObject({ flowKey: "flow-a" }); + + // B's route leaves while its checkout is in flight. + await vi.waitFor(() => expect(runs).toHaveLength(2)); + checkouts.prune(new Set(["flow-a"])); + runs[1]!.settle(success("b")); + await holds[1]; + expect(onSuccess).toHaveBeenCalledTimes(1); + unmountA(); + unmountB(); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-branch-checkout.ts b/apps/mobile/src/features/threads/new-task-branch-checkout.ts new file mode 100644 index 0000000000000..a6455365c73eb --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-branch-checkout.ts @@ -0,0 +1,109 @@ +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/reactivity"; + +export type BranchCheckoutResult = AtomCommandResult; +type BranchCheckoutFailure = Extract; +type BranchCheckoutSuccess = Extract; + +export interface BranchCheckoutRequest { + /** The draft route's flow key; the checkout is abandoned once it leaves the stack. */ + readonly flowKey: string; + /** The draft the checked-out branch is written into. */ + readonly draftKey: string; + readonly routeKey: string; + readonly environmentId: EnvironmentId | null; + readonly workspaceRoot: string | null; + readonly branch: string; + readonly worktreePath: string | null; +} + +interface CheckoutEntry { + readonly request: BranchCheckoutRequest; + readonly checkout: Promise; + readonly release: () => void; + abandoned: boolean; +} + +/** + * Branch checkouts for draft routes. A checkout is a Git mutation, so it is + * tied to the request, not to an atom node: the node can be removed and rebuilt + * (a render read before the subscription commits) without running it again. + */ +export function makeNewTaskBranchCheckouts(deps: { + readonly run: (request: BranchCheckoutRequest) => Promise; + /** Holds navigation until the checkout settles and its route is released. */ + readonly holdNavigation: (operation: Promise) => void; + /** Runs when the checkout succeeds while its route is still on the stack. */ + readonly onSuccess: (request: BranchCheckoutRequest, result: BranchCheckoutSuccess) => void; + /** Runs once the route is released, unless the route was abandoned first. */ + readonly onFailure: (request: BranchCheckoutRequest, result: BranchCheckoutFailure) => void; +}) { + // Ignoring a stale result cannot undo its Git mutation, so checkouts run one + // at a time across routes. + let tail: Promise = Promise.resolve(); + const entries = new Map(); + // The family key is the request's serialized identity; the typed request is + // registered alongside it so start never has to decode the key. + const requests = new Map(); + + function abandon(key: string, entry: CheckoutEntry): void { + entry.abandoned = true; + entry.release(); + entries.delete(key); + requests.delete(key); + } + + function start(key: string): Promise { + const existing = entries.get(key); + if (existing) return existing.checkout; + const request = requests.get(key); + if (!request) return Promise.reject(new Error(`Unknown branch checkout request: ${key}`)); + // A route asks for one checkout at a time; a superseded request no longer + // owns the route's failure handling or holds navigation. + for (const [otherKey, other] of entries) { + if (other.request.flowKey === request.flowKey) abandon(otherKey, other); + } + const released = Promise.withResolvers(); + const checkout = tail.then(() => deps.run(request)); + tail = checkout.catch(() => undefined); + const entry: CheckoutEntry = { request, checkout, release: released.resolve, abandoned: false }; + entries.set(key, entry); + const handled = checkout.then(async (result) => { + if (result._tag === "Success") { + if (!entry.abandoned) deps.onSuccess(request, result); + return; + } + await released.promise; + if (!entry.abandoned) deps.onFailure(request, result); + }); + // Navigation waits for the failure handling too, so its goBack lands first. + deps.holdNavigation(Promise.allSettled([handled, released.promise])); + return checkout; + } + + return { + requestKey(request: BranchCheckoutRequest): string { + const key = JSON.stringify(request); + requests.set(key, request); + return key; + }, + atom: Atom.family((key: string) => + Atom.make(Effect.promise(() => start(key))).pipe( + Atom.withLabel("mobile:new-task-branch-checkout"), + ), + ), + /** The route rendered with removal unblocked, so navigation may proceed. */ + release(key: string): void { + entries.get(key)?.release(); + }, + /** Abandons the checkouts of draft routes that are no longer on the stack. */ + prune(liveFlowKeys: ReadonlySet): void { + for (const [key, entry] of entries) { + if (!liveFlowKeys.has(entry.request.flowKey)) abandon(key, entry); + } + }, + }; +} diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts index 682b83c74a348..022dd5a23bd9e 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + fillNewTaskWorkspaceBranch, resolveNewTaskBranchLabel, resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, @@ -105,3 +106,52 @@ describe("resolveNewTaskBranchWorktreePath", () => { ).toBeNull(); }); }); + +describe("fillNewTaskWorkspaceBranch", () => { + const branches = [ + { name: "origin/main", current: false, isDefault: true, isRemote: true }, + { name: "feature", current: true, isDefault: false, worktreePath: "/repo-feature" }, + ]; + const base = { + branches, + projectCwd: "/repo", + worktreeDefaultSettled: true, + }; + + it("bases a new worktree on the default branch, even when only the remote has it", () => { + expect( + fillNewTaskWorkspaceBranch({ + ...base, + selection: { mode: "worktree", branch: null, worktreePath: null }, + }), + ).toEqual({ mode: "worktree", branch: "origin/main", worktreePath: null }); + }); + + it("waits for the workspace default to settle before picking a base", () => { + const selection = { mode: "worktree", branch: null, worktreePath: null } as const; + expect(fillNewTaskWorkspaceBranch({ ...base, selection, worktreeDefaultSettled: false })).toBe( + selection, + ); + }); + + it("follows the current checkout only for a local selection the user just chose", () => { + const selection = { mode: "local", branch: null, worktreePath: null } as const; + expect(fillNewTaskWorkspaceBranch({ ...base, selection })).toBe(selection); + expect( + fillNewTaskWorkspaceBranch({ + ...base, + selection: { ...selection, followsCurrentCheckout: true }, + }), + ).toEqual({ + mode: "local", + branch: "feature", + worktreePath: "/repo-feature", + followsCurrentCheckout: true, + }); + }); + + it("keeps an explicit branch", () => { + const selection = { mode: "worktree", branch: "release", worktreePath: null } as const; + expect(fillNewTaskWorkspaceBranch({ ...base, selection })).toBe(selection); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index 479ecfff4ea15..6251006a298c5 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -111,3 +111,43 @@ export function filterNewTaskBranches( ? branches : branches.filter((branch) => branch.name.toLowerCase().includes(query)); } + +/** + * A worktree with no chosen base starts from the default branch, and a local + * selection the user just switched to follows the current checkout until refs + * load. Both are derived, never written back, so a late ref list or t3.json + * default still wins. + */ +export function fillNewTaskWorkspaceBranch(input: { + readonly selection: ComposerDraftWorkspaceSelection; + readonly branches: ReadonlyArray<{ + readonly name: string; + readonly current: boolean; + readonly isDefault: boolean; + readonly isRemote?: boolean; + readonly worktreePath?: string | null; + }>; + readonly projectCwd: string | null; + readonly worktreeDefaultSettled: boolean; +}): ComposerDraftWorkspaceSelection { + const { selection } = input; + if (selection.branch !== null) return selection; + if (selection.mode === "worktree") { + // The default may only exist as origin/, so search remote refs too. + const base = + input.branches.find((branch) => branch.isDefault) ?? + input.branches.find((branch) => !branch.isRemote && branch.current) ?? + null; + return input.worktreeDefaultSettled && base + ? { ...selection, branch: base.name, worktreePath: null } + : selection; + } + if (!selection.followsCurrentCheckout || input.projectCwd === null) return selection; + const local = resolveNewTaskLocalWorkspaceSelection({ + branches: input.branches.filter((branch) => !branch.isRemote), + projectCwd: input.projectCwd, + }); + return local.awaitsCurrentBranch + ? selection + : { ...selection, branch: local.branch, worktreePath: local.worktreePath }; +} 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 54d78769c4484..5224b0b7bf774 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -3,7 +3,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { useEnvironmentPresentation } from "../../state/presentation"; import { environmentSession } from "../../state/session"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import type { EnvironmentId, @@ -12,22 +12,22 @@ import type { ProviderInteractionMode, ProviderOptionSelection, RuntimeMode, + ServerConfig, ServerProvider, } from "@t3tools/contracts"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, DEFAULT_SERVER_SETTINGS, MessageId, repositoryGroupingKeyOf, T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; -import { sanitizeNewRefName } from "@t3tools/shared/git"; import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { useAtomValue } from "@effect/atom-react"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -55,43 +55,28 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { appendComposerDraftAttachments, type ComposerDraftInsertion, - clearComposerDraft, - composerDraftsAtom, + type ComposerDraftWorkspaceSelection, + composerDraftsLoadedAtom, createNewTaskDraft, + ensureNewTaskDraft, getComposerDraftSnapshot, - isComposerDraftEmpty, isNewTaskDraftKey, removeComposerDraftAttachment, replaceComposerDraftAttachments, retargetNewTaskDraft, - scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, - setComposerDraftContext, setStickyComposerModelSelection, updateComposerDraftSettings, useComposerDraft, useStickyComposerModelSelection, } from "../../state/use-composer-drafts"; -import { - capturePendingTaskEditorWriteBaseline, - flushPendingTaskEditorWrite, -} from "../../state/pending-task-editor-writes"; import { rememberModelOptions, withRememberedModelOptions, } from "../../state/use-model-option-memory"; -import { useBranches, useDebouncedValue, usePaginatedBranches } from "../../state/queries"; +import { useBranches, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; -import { - flattenQueuedThreadMessages, - threadOutboxManager, - type QueuedThreadMessage, -} from "../../state/thread-outbox"; -import { - holdEditingQueuedMessage, - releaseEditingQueuedMessage, - useThreadOutboxMessages, -} from "../../state/use-thread-outbox"; +import { type QueuedThreadMessage } from "../../state/thread-outbox"; import { setPendingConnectionError, useRemoteConnectionStatus, @@ -116,8 +101,20 @@ import { resolveProviderInteractionMode, } from "../../state/legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; +import { refreshChosenProjectBranches } from "./new-task-project-branches"; import { - filterNewTaskBranches, + INACTIVE_FLOW_KEY, + newTaskFlowStateAtom, + type NewTaskFlowState, + readNewTaskFlowState, + type RouteDraftResolution, + resolveRouteDraft, + resolveRouteProject, + routeNewTaskDraftKey, + updateNewTaskFlowState, +} from "./new-task-flow-state"; +import { + fillNewTaskWorkspaceBranch, resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, resolveNewTaskWorkspaceSelection, @@ -127,20 +124,76 @@ import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValid type WorkspaceMode = "local" | "worktree"; -const BRANCH_SEARCH_DEBOUNCE_MS = 150; - -function pendingTaskDraftKey(messageId: string): string { - return `pending-task:${messageId}`; +interface PendingTaskMessageInput { + readonly project: EnvironmentProject; + readonly draft: ReturnType; + readonly serverConfig: ServerConfig | null; + readonly fallbackModelSelection: ModelSelection | null; + readonly workspaceSelection: ComposerDraftWorkspaceSelection; + readonly startFromOriginDefault: boolean; + readonly defaultRuntimeMode: RuntimeMode; + readonly planModeEnabled: boolean; + readonly metadata: TurnCommandMetadata; + readonly currentCheckoutBranch: string | null; } -// Track ownership across providers so old unmount flushes cannot drop newer drafts or drain locks. -let activeEditingMessageId: string | null = null; - -function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { - const message = flattenQueuedThreadMessages( - appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), - ).find((candidate) => candidate.messageId === messageId); - return message?.creation !== undefined ? message : null; +function assemblePendingTaskMessage(input: PendingTaskMessageInput): QueuedThreadMessage | null { + const { project, draft, workspaceSelection, metadata } = input; + const text = draft.text.trim(); + const modelSelection = + resolveSelectableModelSelection(input.serverConfig, draft.modelSelection ?? null, project.id) ?? + input.fallbackModelSelection; + if ((text.length === 0 && draft.attachments.length === 0) || !modelSelection) { + return null; + } + if ( + threadContextsOutsideEnvironment({ + text: draft.text, + records: draft.context?.records ?? [], + environmentId: project.environmentId, + }).length > 0 + ) { + return null; + } + const mode = workspaceSelection.mode; + return { + environmentId: project.environmentId, + threadId: ThreadId.make(metadata.threadId), + messageId: MessageId.make(metadata.messageId), + commandId: CommandId.make(metadata.commandId), + text, + attachments: draft.attachments, + context: draft.context, + modelSelection, + runtimeMode: draft.runtimeMode ?? input.defaultRuntimeMode, + interactionMode: resolvePendingTaskInteractionMode({ + planModeEnabled: input.planModeEnabled, + draftInteractionMode: draft.interactionMode, + provider: input.serverConfig?.providers.find( + (candidate) => candidate.instanceId === modelSelection.instanceId, + ), + }), + creation: { + projectId: project.id, + projectTitle: project.title, + projectCwd: project.workspaceRoot, + workspaceMode: mode, + ...(mode === "worktree" && workspaceSelection.pullRequestReference + ? { pullRequestReference: workspaceSelection.pullRequestReference } + : {}), + // Queued local tasks use drain-time checkout; snapshot branches only for immediate sends or explicit choices. + branch: resolveProjectThreadCreationBranch({ + workspaceMode: mode, + selectedBranch: workspaceSelection.branch, + currentCheckoutBranch: input.currentCheckoutBranch, + }), + worktreePath: mode === "worktree" ? null : workspaceSelection.worktreePath, + ...((workspaceSelection.startFromOrigin ?? input.startFromOriginDefault) + ? { startFromOrigin: true } + : {}), + }, + createdAt: metadata.createdAt, + }; } export function branchBadgeLabel(input: { @@ -177,15 +230,10 @@ type NewTaskFlowContextValue = { readonly startFromOrigin: boolean; readonly pullRequestReference: string | undefined; readonly draftKey: string | null; - readonly editingPendingTask: QueuedThreadMessage | null; readonly prompt: string; readonly attachments: ReadonlyArray; readonly submitting: boolean; - readonly branchQuery: string; readonly branchesLoading: boolean; - readonly branchesError: string | null; - readonly branchesFetchingNextPage: boolean; - readonly hasMoreBranches: boolean; readonly availableBranches: ReadonlyArray; readonly currentCheckoutBranchName: string | null; readonly runtimeMode: RuntimeMode; @@ -203,11 +251,14 @@ type NewTaskFlowContextValue = { readonly selectedModelOption: ModelOption | null; readonly selectedProviderStatus: ServerProvider | null; readonly providerGroups: ReadonlyArray; - readonly filteredBranches: ReadonlyArray; - readonly reset: () => void; + /** The current selection, for continuations that resume after an await. */ + readonly getSelectedProjectKey: () => string | null; readonly setProject: (project: EnvironmentProject) => void; - /** Returns false if the existing draft is gone, allowing fallback to a fresh draft. */ - readonly openDraft: (draftKey: string) => boolean; + /** + * The route names a draft or project that is gone, or no project when there + * is a choice to make; the screen hands the route to the project picker. + */ + readonly routeTargetMissing: boolean; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly switchEnvironment: (environmentId: EnvironmentId) => Promise; readonly switchingToEnvironmentId: EnvironmentId | null; @@ -218,9 +269,10 @@ type NewTaskFlowContextValue = { readonly setWorkspaceMode: (mode: WorkspaceMode) => void; readonly selectBranch: (branch: VcsRef) => void; readonly setStartFromOrigin: (value: boolean) => void; - readonly beginEditingPendingTask: (messageId: string) => boolean; - readonly finishEditingPendingTask: () => void; - readonly cancelEditingPendingTask: () => void; + /** Resolves a draft's workspace selection the way the composer shows it. */ + readonly resolveWorkspaceSelection: ( + selection: ComposerDraftWorkspaceSelection | undefined, + ) => ComposerDraftWorkspaceSelection; readonly buildPendingTaskMessage: ( metadata: TurnCommandMetadata, options?: { @@ -237,9 +289,7 @@ type NewTaskFlowContextValue = { readonly removeAttachment: (imageId: string) => void; readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; - readonly setBranchQuery: (value: string) => void; readonly loadBranches: () => void; - readonly loadMoreBranches: () => void; readonly setRuntimeMode: (value: RuntimeMode) => void; readonly setInteractionMode: (value: ProviderInteractionMode) => void; readonly setSelectedModelOptions: ( @@ -253,16 +303,17 @@ const NewTaskFlowContext = React.createContext(n export function NewTaskFlowProvider( props: React.PropsWithChildren<{ active?: boolean; + /** Identifies the draft route this flow serves; a new key starts a fresh flow. */ + flowKey?: string; routeDraftId?: string; - routePendingTaskId?: string; + routeProject?: { readonly environmentId: string; readonly projectId: string }; }>, ) { const projects = useProjects(); const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); - const { enabled: legacyPlanModeEnabled, loaded: planModePreferenceLoaded } = - useLegacyPlanModeState(); + const { enabled: legacyPlanModeEnabled } = useLegacyPlanModeState(); const projectScopes = sortHomeProjectScopes({ scopes: buildHomeProjectScopes({ @@ -275,77 +326,94 @@ export function NewTaskFlowProvider( projectSortOrder: "updated_at", }); - const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState( - null, + const flowKey = props.flowKey ?? INACTIVE_FLOW_KEY; + const storedFlowState = useAtomValue(newTaskFlowStateAtom(flowKey)); + const draftsLoaded = useAtomValue(composerDraftsLoadedAtom); + // A draft route opened from a list row, a deep link, or restored state binds + // its draft here until the user's own choices write the flow. + const routeDraft = useComposerDraft( + storedFlowState.draftKey === null ? (props.routeDraftId ?? null) : null, ); + // A route naming a project without a draft (a deep link) binds the route's + // own draft, stamped on the flow's first write. + const resolveRoute = ( + flow: NewTaskFlowState, + loaded: boolean, + stamp: Parameters[0]["stamp"], + ): RouteDraftResolution => { + if (props.routeDraftId !== undefined) { + return resolveRouteDraft({ + flow, + routeDraftId: props.routeDraftId, + draftsLoaded: loaded, + stamp, + projects, + }); + } + if (props.flowKey === undefined) return { kind: "none" }; + return resolveRouteProject({ + flow, + routeProject: props.routeProject, + draftKey: routeNewTaskDraftKey(props.flowKey), + projects, + }); + }; + const routeDraftResolution = resolveRoute(storedFlowState, draftsLoaded, routeDraft.project); + const flowState = + routeDraftResolution.kind === "open" ? routeDraftResolution.flow : storedFlowState; + const { + environmentId: selectedEnvironmentIdOverride, + projectKey: selectedProjectKey, + draftKey: activeDraftKey, + submitting, + expandedProvider, + } = flowState; + // Every flow write stores the route's draft binding along with it, so the + // binding outlives the draft's project stamp (submit clears it) and an + // external delete once the user has acted. + const updateFlow = (patch: Partial) => + updateNewTaskFlowState(flowKey, { ...readFlow(), ...patch }); + // Continuations read the flow at use time, with the route's draft applied. + const readFlow = (): NewTaskFlowState => { + const stored = readNewTaskFlowState(flowKey); + const resolution = resolveRoute( + stored, + appAtomRegistry.get(composerDraftsLoadedAtom), + props.routeDraftId === undefined + ? undefined + : getComposerDraftSnapshot(props.routeDraftId).project, + ); + return resolution.kind === "open" ? resolution.flow : stored; + }; + // Draft writers bind the route's draft on their first write. + const writableDraftKey = (): string | null => { + const stored = readNewTaskFlowState(flowKey); + if (readFlow() === stored) return selectedProjectDraftKey; + updateFlow({}); + if (props.routeDraftId === undefined && selectedProjectDraftKey !== null && selectedProject) { + ensureNewTaskDraft(selectedProjectDraftKey, { + environmentId: selectedProject.environmentId, + projectId: selectedProject.id, + }); + } + return selectedProjectDraftKey; + }; const selectedEnvironmentId = selectedEnvironmentIdOverride !== null && projects.some((project) => project.environmentId === selectedEnvironmentIdOverride) ? selectedEnvironmentIdOverride : (projects[0]?.environmentId ?? null); - const [selectedProjectKey, setSelectedProjectKey] = useState(null); - const [activeDraftKey, setActiveDraftKey] = useState(null); - const [submitting, setSubmitting] = useState(false); - const [branchQuery, setBranchQuery] = useState(""); - const [expandedProvider, setExpandedProvider] = useState(null); - const [editingPendingTask, setEditingPendingTask] = useState(null); - const pendingLocalBranchSyncDraftKeysRef = useRef(new Set()); - // Mirror edit state synchronously so unmount flush cannot save a session ended this render. - const editingPendingTaskRef = useRef(null); - const editingRevisionRef = useRef(Promise.resolve(0)); - - const reset = useCallback(() => { - setSelectedEnvironmentId(null); - setSelectedProjectKey(null); - setActiveDraftKey(null); - setSubmitting(false); - setBranchQuery(""); - setExpandedProvider(null); - pendingLocalBranchSyncDraftKeysRef.current.clear(); - const editing = editingPendingTaskRef.current; - editingPendingTaskRef.current = null; - setEditingPendingTask(null); - if (editing) { - if (activeEditingMessageId === editing.messageId) { - activeEditingMessageId = null; - } - releaseEditingQueuedMessage(editing.messageId); - } - }, []); - const projectsForEnvironment = pipe( projects, Arr.filter((project) => project.environmentId === selectedEnvironmentId), ); - const editingPendingProject = ((): EnvironmentProject | null => { - const creation = editingPendingTask?.creation; - if (!editingPendingTask || !creation) { - return null; - } - return { - environmentId: editingPendingTask.environmentId, - id: creation.projectId, - title: creation.projectTitle ?? "Unknown project", - workspaceRoot: creation.projectCwd ?? "", - repositoryIdentity: null, - defaultModelSelection: editingPendingTask.modelSelection ?? null, - scripts: [], - createdAt: editingPendingTask.createdAt, - updatedAt: editingPendingTask.createdAt, - }; - })(); - const selectedProject = projectsForEnvironment.find( (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, ) ?? - // Pin queued edits to their original project when its shell is missing; fallback would silently retarget them. - (editingPendingProject !== null && - selectedProjectKey === - scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) - ? editingPendingProject - : (projectsForEnvironment[0] ?? null)); + projectsForEnvironment[0] ?? + null; const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, @@ -416,39 +484,15 @@ export function NewTaskFlowProvider( return result; })(); - const selectedProjectDraftKey = editingPendingTask - ? pendingTaskDraftKey(editingPendingTask.messageId) - : selectedProject - ? activeDraftKey - : null; - useEffect(() => { - if ( - props.active === false || - activeDraftKey !== null || - editingPendingTask !== null || - selectedProject === null - ) { - return; - } - setActiveDraftKey( - createNewTaskDraft({ - environmentId: selectedProject.environmentId, - projectId: selectedProject.id, - }), - ); - }, [activeDraftKey, editingPendingTask, selectedProject, props.active]); + const selectedProjectDraftKey = selectedProject ? activeDraftKey : null; const activePendingKey = props.active === false ? null - : props.routePendingTaskId - ? `pending-task:${props.routePendingTaskId}` - : props.routeDraftId - ? `draft-task:${props.routeDraftId}` - : editingPendingTask - ? `pending-task:${editingPendingTask.messageId}` - : selectedProjectDraftKey - ? `draft-task:${selectedProjectDraftKey}` - : null; + : props.routeDraftId + ? `draft-task:${props.routeDraftId}` + : selectedProjectDraftKey + ? `draft-task:${selectedProjectDraftKey}` + : null; const activeNewTask = React.useMemo( () => (props.active === false ? null : { pendingKey: activePendingKey }), [props.active, activePendingKey], @@ -532,11 +576,30 @@ export function NewTaskFlowProvider( selectedProjectDraft.workspaceSelection?.mode !== undefined || projectSettings.sources.defaultThreadEnvMode !== "environment" || (!t3ProjectFileQuery.isPending && !fileAccessPending); - const workspaceSelection = resolveNewTaskWorkspaceSelection({ - selection: canChooseWorkspace ? selectedProjectDraft.workspaceSelection : undefined, - fallback: { mode: defaultWorkspaceMode, branch: null, worktreePath: null }, - isGitRepo, + // The picker runs its own filtered query; this unfiltered list feeds defaults. + const branchState = usePaginatedBranches({ + environmentId: selectedProject?.environmentId ?? null, + cwd: selectedProject?.workspaceRoot || null, }); + const branchesLoading = branchState.isPending && branchState.data === null; + const allBranchRefs = branchState.refs; + const availableBranches = pipe( + allBranchRefs, + Arr.filter((branch) => !branch.isRemote), + ); + + const resolveWorkspaceSelection = (selection: ComposerDraftWorkspaceSelection | undefined) => + fillNewTaskWorkspaceBranch({ + selection: resolveNewTaskWorkspaceSelection({ + selection: canChooseWorkspace ? selection : undefined, + fallback: { mode: defaultWorkspaceMode, branch: null, worktreePath: null }, + isGitRepo, + }), + branches: allBranchRefs, + projectCwd: selectedProject?.workspaceRoot ?? null, + worktreeDefaultSettled: defaultWorkspaceModeSettled, + }); + const workspaceSelection = resolveWorkspaceSelection(selectedProjectDraft.workspaceSelection); const workspaceMode = workspaceSelection.mode; const selectedBranchName = workspaceSelection.branch; const selectedWorktreePath = workspaceSelection.worktreePath; @@ -546,9 +609,7 @@ export function NewTaskFlowProvider( const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = isGitRepo && (draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin); - const defaultRuntimeMode = editingPendingTask - ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE) - : projectSettings.settings.defaultRuntimeMode; + const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const runtimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode; // Antigravity keeps unavailable selections so sign-out or a catalog change @@ -606,7 +667,8 @@ export function NewTaskFlowProvider( key: string | null, options?: ReadonlyArray, ) => { - if (!key || !selectedProjectDraftKey) { + const draftKey = key ? writableDraftKey() : null; + if (!key || !draftKey) { return; } const option = modelOptions.find((candidate) => candidate.key === key); @@ -619,7 +681,7 @@ export function NewTaskFlowProvider( const provider = selectedEnvironmentServerConfig?.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); - updateComposerDraftSettings(selectedProjectDraftKey, { + updateComposerDraftSettings(draftKey, { modelSelection: selection, ...(provider?.showInteractionModeToggle === false ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE } @@ -628,7 +690,8 @@ export function NewTaskFlowProvider( setStickyComposerModelSelection(selection); }; const setSelectedModelOptions = (options: ReadonlyArray | undefined) => { - if (!selectedModel || !selectedProjectDraftKey) { + const draftKey = selectedModel ? writableDraftKey() : null; + if (!selectedModel || !draftKey) { return; } rememberModelOptions(selectedModel.instanceId, selectedModel.model, options ?? []); @@ -638,7 +701,7 @@ export function NewTaskFlowProvider( instanceId: selectedModel.instanceId, model: selectedModel.model, }; - updateComposerDraftSettings(selectedProjectDraftKey, { + updateComposerDraftSettings(draftKey, { modelSelection: nextSelection, }); setStickyComposerModelSelection(nextSelection); @@ -646,95 +709,73 @@ export function NewTaskFlowProvider( const providerGroups = groupByProvider(modelOptions); const setPrompt = (value: string) => { - if (!selectedProjectDraftKey) { + const draftKey = writableDraftKey(); + if (!draftKey) { return; } - setComposerDraftText(selectedProjectDraftKey, value); + setComposerDraftText(draftKey, value); }; const replaceAttachments = (nextAttachments: ReadonlyArray) => { - if (!selectedProjectDraftKey) { + const draftKey = writableDraftKey(); + if (!draftKey) { return; } - replaceComposerDraftAttachments(selectedProjectDraftKey, nextAttachments); + replaceComposerDraftAttachments(draftKey, nextAttachments); }; const appendAttachments = ( nextAttachments: ReadonlyArray, insertion?: ComposerDraftInsertion, ): number => { - if (!selectedProjectDraftKey) { + const draftKey = writableDraftKey(); + if (!draftKey) { return 0; } - return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments, { + return appendComposerDraftAttachments(draftKey, nextAttachments, { appendReference: true, insertion, }); }; const removeAttachment = (imageId: string) => { - if (!selectedProjectDraftKey) { + const draftKey = writableDraftKey(); + if (!draftKey) { return; } - removeComposerDraftAttachment(selectedProjectDraftKey, imageId); + removeComposerDraftAttachment(draftKey, imageId); }; const clearAttachments = () => { - if (!selectedProjectDraftKey) { + const draftKey = writableDraftKey(); + if (!draftKey) { return; } - replaceComposerDraftAttachments(selectedProjectDraftKey, []); + replaceComposerDraftAttachments(draftKey, []); }; - const branchSearchQuery = sanitizeNewRefName(branchQuery); - const debouncedBranchQuery = useDebouncedValue(branchSearchQuery, BRANCH_SEARCH_DEBOUNCE_MS); - const branchTarget = { - environmentId: selectedProject?.environmentId ?? null, - cwd: selectedProject?.workspaceRoot || null, - query: debouncedBranchQuery, - }; - const branchState = usePaginatedBranches(branchTarget); - const branchSearchIsDebouncing = branchSearchQuery !== debouncedBranchQuery; - const branchesLoading = - branchSearchIsDebouncing || (branchState.isPending && branchState.data === null); - const branchesFetchingNextPage = branchState.isFetchingNextPage; - const hasMoreBranches = - branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined; - const allBranchRefs = branchState.refs; - const availableBranches = pipe( - allBranchRefs, - Arr.filter((branch) => !branch.isRemote), - ); - - const filteredBranches = filterNewTaskBranches(allBranchRefs, branchQuery); - - const carryDraftContentTo = (project: EnvironmentProject) => { + // Moving to another project carries the draft along, or starts one when the + // flow has none yet. + const draftKeyFor = (project: EnvironmentProject): string | null => { const target = { environmentId: project.environmentId, projectId: project.id }; - if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) { - retargetNewTaskDraft(activeDraftKey, target); - } else if (!editingPendingTaskRef.current) { - setActiveDraftKey(createNewTaskDraft(target)); + const current = readFlow().draftKey; + if (current !== null && isNewTaskDraftKey(current)) { + retargetNewTaskDraft(current, target); + return current; } + return createNewTaskDraft(target); }; - const setProject = (project: EnvironmentProject) => { - carryDraftContentTo(project); - setSelectedEnvironmentId(project.environmentId); - setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id)); + const chooseProject = ( + environmentId: EnvironmentId, + project: EnvironmentProject | null, + draftKey: string | null, + ) => { + const projectKey = + project === null ? null : scopedProjectKey(project.environmentId, project.id); + if (project !== null && projectKey !== readFlow().projectKey) { + refreshChosenProjectBranches(project); + } + updateFlow({ environmentId, projectKey, draftKey }); }; - const openDraft = (draftKey: string): boolean => { - const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; - const stamp = draft?.project; - if (!isNewTaskDraftKey(draftKey) || !stamp) { - return false; - } - // Require the stamped project to load; selectedProject otherwise falls back and retargets the draft. - const projectLoaded = projects.some( - (project) => project.environmentId === stamp.environmentId && project.id === stamp.projectId, - ); - if (!projectLoaded) { - return false; - } - setActiveDraftKey(draftKey); - setSelectedEnvironmentId(stamp.environmentId); - setSelectedProjectKey(scopedProjectKey(stamp.environmentId, stamp.projectId)); - return true; + const setProject = (project: EnvironmentProject) => { + chooseProject(project.environmentId, project, draftKeyFor(project)); }; const selectEnvironment = (environmentId: EnvironmentId) => { @@ -742,11 +783,7 @@ export function NewTaskFlowProvider( projects.filter((project) => project.environmentId === environmentId), selectedProject, ); - if (match) { - carryDraftContentTo(match); - } - setSelectedEnvironmentId(environmentId); - setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); + chooseProject(environmentId, match ?? null, match ? draftKeyFor(match) : readFlow().draftKey); }; const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); @@ -788,7 +825,8 @@ export function NewTaskFlowProvider( }; const setWorkspaceMode = (mode: WorkspaceMode) => { - if (!selectedProjectDraftKey) { + const draftKey = writableDraftKey(); + if (!draftKey) { return; } if (!selectedProject) { @@ -798,61 +836,26 @@ export function NewTaskFlowProvider( branches: availableBranches, projectCwd: selectedProject.workspaceRoot, }); - if (mode === "local" && localSelection.awaitsCurrentBranch) { - pendingLocalBranchSyncDraftKeysRef.current.add(selectedProjectDraftKey); - } else { - pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); - } - updateComposerDraftSettings(selectedProjectDraftKey, { + updateComposerDraftSettings(draftKey, { workspaceSelection: { mode, branch: mode === "local" ? localSelection.branch : selectedBranchName, worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), + // Refs have not named the current branch yet; the selection follows it. + ...(mode === "local" && localSelection.awaitsCurrentBranch + ? { followsCurrentCheckout: true } + : {}), }, }); }; - useEffect(() => { - if ( - workspaceMode !== "local" || - !selectedProject || - !selectedProjectDraftKey || - !pendingLocalBranchSyncDraftKeysRef.current.has(selectedProjectDraftKey) - ) { - return; - } - const localSelection = resolveNewTaskLocalWorkspaceSelection({ - branches: availableBranches, - projectCwd: selectedProject.workspaceRoot, - }); - if (localSelection.awaitsCurrentBranch) { - return; - } - - pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); - updateComposerDraftSettings(selectedProjectDraftKey, { - workspaceSelection: { - mode: "local", - branch: localSelection.branch, - worktreePath: localSelection.worktreePath, - ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), - }, - }); - }, [ - availableBranches, - draftStartFromOrigin, - selectedProject, - selectedProjectDraftKey, - workspaceMode, - ]); - const selectBranch = (branch: VcsRef) => { - if (!selectedProject || !selectedProjectDraftKey) { + const draftKey = selectedProject ? writableDraftKey() : null; + if (!selectedProject || !draftKey) { return; } - pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); - updateComposerDraftSettings(selectedProjectDraftKey, { + updateComposerDraftSettings(draftKey, { workspaceSelection: { mode: workspaceMode, branch: branch.name, @@ -867,21 +870,24 @@ export function NewTaskFlowProvider( }; const setStartFromOrigin = (value: boolean) => { - if (!selectedProjectDraftKey) { + const draftKey = writableDraftKey(); + if (!draftKey) { return; } - updateComposerDraftSettings(selectedProjectDraftKey, { + updateComposerDraftSettings(draftKey, { workspaceSelection: { mode: workspaceMode, branch: selectedBranchName, worktreePath: selectedWorktreePath, startFromOrigin: value, + ...(selectedBranchName === null && workspaceSelection.followsCurrentCheckout + ? { followsCurrentCheckout: true } + : {}), }, }); }; const refreshBranches = branchState.refresh; - const loadMoreBranches = branchState.loadNext; const loadBranches = () => { if (!selectedProject) { return; @@ -890,82 +896,19 @@ export function NewTaskFlowProvider( refreshBranches(); }; - useEffect(() => { - if ( - !selectedProjectDraftKey || - !defaultWorkspaceModeSettled || - workspaceMode !== "worktree" || - selectedBranchName !== null - ) { - return; - } - // Re-read before replacing: the draft screen can update workspace selection in this same commit. - const live = getComposerDraftSnapshot(selectedProjectDraftKey).workspaceSelection; - if (live && (live.mode !== "worktree" || live.branch !== null)) { - return; - } - // Search unfiltered refs: the default may exist only as origin/, excluded by availableBranches. - const preferredBranch = - allBranchRefs.find((branch) => branch.isDefault) ?? - availableBranches.find((branch) => branch.current) ?? - null; - if (preferredBranch) { - selectBranch(preferredBranch); - } - }, [ - allBranchRefs, - availableBranches, - defaultWorkspaceModeSettled, - selectBranch, - selectedBranchName, - selectedProjectDraftKey, - workspaceMode, - ]); - const setRuntimeMode = (value: RuntimeMode) => { - if (selectedProjectDraftKey) { - updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); + const draftKey = writableDraftKey(); + if (draftKey) { + updateComposerDraftSettings(draftKey, { runtimeMode: value }); } }; const setInteractionMode = (value: ProviderInteractionMode) => { - if (selectedProjectDraftKey) { - updateComposerDraftSettings(selectedProjectDraftKey, { - interactionMode: resolveProviderInteractionMode(selectedProviderStatus, value), - }); - } - }; - - const beginEditingPendingTask = (messageId: string): boolean => { - const message = findQueuedPendingTask(messageId); - if (!message?.creation) { - return false; - } - const draftKey = pendingTaskDraftKey(message.messageId); - if (isComposerDraftEmpty(getComposerDraftSnapshot(draftKey))) { - setComposerDraftText(draftKey, message.text); - setComposerDraftContext(draftKey, message.context); - replaceComposerDraftAttachments(draftKey, message.attachments); + const draftKey = writableDraftKey(); + if (draftKey) { updateComposerDraftSettings(draftKey, { - modelSelection: message.modelSelection, - runtimeMode: message.runtimeMode, - interactionMode: message.interactionMode, - workspaceSelection: { - mode: message.creation.workspaceMode, - branch: message.creation.branch, - worktreePath: message.creation.worktreePath, - startFromOrigin: message.creation.startFromOrigin ?? false, - pullRequestReference: message.creation.pullRequestReference, - }, + interactionMode: resolveProviderInteractionMode(selectedProviderStatus, value), }); } - setSelectedEnvironmentId(message.environmentId); - setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId)); - activeEditingMessageId = message.messageId; - editingPendingTaskRef.current = message; - editingRevisionRef.current = capturePendingTaskEditorWriteBaseline(message.messageId); - setEditingPendingTask(message); - holdEditingQueuedMessage(message.messageId); - return true; }; const buildPendingTaskMessage = ( @@ -976,171 +919,20 @@ export function NewTaskFlowProvider( return null; } const draft = getComposerDraftSnapshot(selectedProjectDraftKey); - const text = draft.text.trim(); - const draftModelSelection = - resolveSelectableModelSelection( - selectedEnvironmentServerConfig, - draft.modelSelection ?? null, - selectedProject.id, - ) ?? selectedModel; - if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) { - return null; - } - if ( - threadContextsOutsideEnvironment({ - text: draft.text, - records: draft.context?.records ?? [], - environmentId: selectedProject.environmentId, - }).length > 0 - ) { - return null; - } - const workspaceSelection = resolveNewTaskWorkspaceSelection({ - selection: canChooseWorkspace ? draft.workspaceSelection : undefined, - fallback: { mode: workspaceMode, branch: null, worktreePath: null }, - isGitRepo, + return assemblePendingTaskMessage({ + project: selectedProject, + draft, + serverConfig: selectedEnvironmentServerConfig, + fallbackModelSelection: selectedModel, + workspaceSelection: resolveWorkspaceSelection(draft.workspaceSelection), + startFromOriginDefault: startFromOrigin, + defaultRuntimeMode, + planModeEnabled: legacyPlanModeEnabled, + metadata, + currentCheckoutBranch: options?.currentCheckoutBranch ?? null, }); - const mode = workspaceSelection.mode; - // When the selection is the stand-in built from the queued snapshot, - // persist the original (possibly absent) snapshot values — the - // stand-in's placeholder title/workspaceRoot must never be written back - // as if they were real project metadata. - const usingPendingSnapshot = selectedProject === editingPendingProject; - const projectTitle = usingPendingSnapshot - ? editingPendingTask?.creation?.projectTitle - : selectedProject.title; - const projectCwd = usingPendingSnapshot - ? editingPendingTask?.creation?.projectCwd - : selectedProject.workspaceRoot; - return { - environmentId: selectedProject.environmentId, - threadId: ThreadId.make(metadata.threadId), - messageId: MessageId.make(metadata.messageId), - commandId: CommandId.make(metadata.commandId), - text, - attachments: draft.attachments, - context: draft.context, - modelSelection: draftModelSelection, - runtimeMode: draft.runtimeMode ?? defaultRuntimeMode, - interactionMode: resolvePendingTaskInteractionMode({ - preferenceLoaded: planModePreferenceLoaded, - planModeEnabled: legacyPlanModeEnabled, - draftInteractionMode: draft.interactionMode, - queuedInteractionMode: editingPendingTask?.interactionMode, - provider: selectedEnvironmentServerConfig?.providers.find( - (candidate) => candidate.instanceId === draftModelSelection.instanceId, - ), - }), - creation: { - projectId: selectedProject.id, - ...(projectTitle !== undefined ? { projectTitle } : {}), - ...(projectCwd !== undefined ? { projectCwd } : {}), - workspaceMode: mode, - ...(mode === "worktree" && workspaceSelection?.pullRequestReference - ? { pullRequestReference: workspaceSelection.pullRequestReference } - : {}), - // Queued local tasks use drain-time checkout; snapshot branches only for immediate sends or explicit choices. - branch: resolveProjectThreadCreationBranch({ - workspaceMode: mode, - selectedBranch: workspaceSelection?.branch ?? null, - currentCheckoutBranch: options?.currentCheckoutBranch ?? null, - }), - worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), - ...((workspaceSelection?.startFromOrigin ?? startFromOrigin) - ? { startFromOrigin: true } - : {}), - }, - createdAt: metadata.createdAt, - }; }; - const finishEditingPendingTask = () => { - const editing = editingPendingTaskRef.current; - editingPendingTaskRef.current = null; - if (editing) { - if (activeEditingMessageId === editing.messageId) { - activeEditingMessageId = null; - } - clearComposerDraft(pendingTaskDraftKey(editing.messageId)); - releaseEditingQueuedMessage(editing.messageId); - scheduleUnusedComposerAttachmentCleanup(editing.attachments); - } - setEditingPendingTask(null); - }; - - // End editing without saving if the queued task disappears; a later flush must not resurrect it. - const queuedMessagesByThreadKey = useThreadOutboxMessages(); - useEffect(() => { - const editing = editingPendingTaskRef.current; - if (!editing) { - return; - } - const stillQueued = flattenQueuedThreadMessages(queuedMessagesByThreadKey).some( - (candidate) => candidate.messageId === editing.messageId, - ); - if (!stillQueued) { - finishEditingPendingTask(); - } - }, [finishEditingPendingTask, queuedMessagesByThreadKey]); - - const editingFlushRef = useRef<(() => void) | null>(null); - useEffect(() => { - editingFlushRef.current = () => { - const editing = editingPendingTaskRef.current; - if (!editing) { - return; - } - editingPendingTaskRef.current = null; - setEditingPendingTask(null); - if (activeEditingMessageId === editing.messageId) { - activeEditingMessageId = null; - } - - const message = buildPendingTaskMessage({ - threadId: editing.threadId, - commandId: editing.commandId, - messageId: editing.messageId, - createdAt: editing.createdAt, - }); - - if (!message) { - // Keep unsendable edits and the drain lock so removed content cannot auto-send from a stale payload. - return; - } - - // Hand off pending saves to reopened editors; CAS still rejects unrelated edits, deletes and deliveries. - void flushPendingTaskEditorWrite({ - message, - baseline: editingRevisionRef.current, - draftKey: pendingTaskDraftKey(editing.messageId), - }) - .then((savedDraftStillCurrent) => { - if (activeEditingMessageId === editing.messageId) { - return; - } - if (!savedDraftStillCurrent) { - return; - } - clearComposerDraft(pendingTaskDraftKey(editing.messageId)); - releaseEditingQueuedMessage(editing.messageId); - scheduleUnusedComposerAttachmentCleanup(editing.attachments); - }) - .catch((error) => { - // Keep the draft and drain lock on failure so stale payloads cannot discard edits; reopening retries. - console.warn("[new-task] failed to save edited pending task", error); - }); - }; - }, [buildPendingTaskMessage]); - const cancelEditingPendingTask = () => { - editingFlushRef.current?.(); - }; - useEffect( - () => () => { - editingFlushRef.current?.(); - }, - [], - ); - const value = { activeNewTask, projectScopes, @@ -1156,15 +948,10 @@ export function NewTaskFlowProvider( startFromOrigin, draftKey: selectedProjectDraftKey, pullRequestReference: selectedProjectDraft.workspaceSelection?.pullRequestReference, - editingPendingTask, prompt, attachments, submitting, - branchQuery, branchesLoading, - branchesError: branchState.error, - branchesFetchingNextPage, - hasMoreBranches, availableBranches, currentCheckoutBranchName, runtimeMode, @@ -1179,10 +966,9 @@ export function NewTaskFlowProvider( selectedModelOption, selectedProviderStatus, providerGroups, - filteredBranches, - reset, + getSelectedProjectKey: () => readFlow().projectKey, setProject, - openDraft, + routeTargetMissing: routeDraftResolution.kind === "missing", selectEnvironment, switchEnvironment, switchingToEnvironmentId, @@ -1190,23 +976,19 @@ export function NewTaskFlowProvider( setWorkspaceMode, selectBranch, setStartFromOrigin, - beginEditingPendingTask, - finishEditingPendingTask, - cancelEditingPendingTask, + resolveWorkspaceSelection, buildPendingTaskMessage, setPrompt, replaceAttachments, appendAttachments, removeAttachment, clearAttachments, - setSubmitting, - setBranchQuery, + setSubmitting: (value: boolean) => updateFlow({ submitting: value }), loadBranches, - loadMoreBranches, setRuntimeMode, setInteractionMode, setSelectedModelOptions, - setExpandedProvider, + setExpandedProvider: (value: string | null) => updateFlow({ expandedProvider: value }), } satisfies NewTaskFlowContextValue; return ( diff --git a/apps/mobile/src/features/threads/new-task-flow-state.test.ts b/apps/mobile/src/features/threads/new-task-flow-state.test.ts new file mode 100644 index 0000000000000..66d8a7c066e45 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-flow-state.test.ts @@ -0,0 +1,156 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { + INITIAL_NEW_TASK_FLOW_STATE, + newTaskFlowKey, + newTaskFlowStateAtom, + pruneNewTaskFlowStates, + readNewTaskFlowState, + resolveRouteDraft, + resolveRouteProject, + routeNewTaskDraftKey, + updateNewTaskFlowState, +} from "./new-task-flow-state"; +import { parseLegacyNewTaskDraftKey } from "../../state/new-task-draft-key"; + +function draftRoute(key: string, draftId: string) { + return { key, name: "Thread", params: { draft: { draftId } } }; +} + +describe("new task flow state", () => { + it("keeps a covered draft route's flow while another draft route is pushed above it", () => { + const routeA = draftRoute("route-a", "draft-a"); + const routeB = draftRoute("route-b", "draft-b"); + const flowA = newTaskFlowKey(routeA)!; + const flowB = newTaskFlowKey(routeB)!; + + // The provider subscribes to A, writes its selection, then moves to B. + const unmountA = appAtomRegistry.mount(newTaskFlowStateAtom(flowA)); + updateNewTaskFlowState(flowA, { projectKey: "env:project-a", draftKey: "draft-a" }); + unmountA(); + const unmountB = appAtomRegistry.mount(newTaskFlowStateAtom(flowB)); + pruneNewTaskFlowStates({ routes: [{ key: "home", name: "Home" }, routeA, routeB] }); + + expect(readNewTaskFlowState(flowB)).toBe(INITIAL_NEW_TASK_FLOW_STATE); + expect(appAtomRegistry.get(newTaskFlowStateAtom(flowA))).toMatchObject({ + projectKey: "env:project-a", + draftKey: "draft-a", + }); + + // Popping B returns to A with its state intact. + unmountB(); + pruneNewTaskFlowStates({ routes: [{ key: "home", name: "Home" }, routeA] }); + expect(readNewTaskFlowState(flowA).projectKey).toBe("env:project-a"); + }); + + it("drops a flow when its route is popped or promoted to a live thread", () => { + const routeA = draftRoute("route-a", "draft-a"); + const routeB = draftRoute("route-b", "draft-b"); + updateNewTaskFlowState(newTaskFlowKey(routeA)!, { projectKey: "env:project-a" }); + updateNewTaskFlowState(newTaskFlowKey(routeB)!, { projectKey: "env:project-b" }); + + pruneNewTaskFlowStates({ + routes: [{ key: "route-a", name: "Thread", params: { environmentId: "env", threadId: "t" } }], + }); + + expect(readNewTaskFlowState(newTaskFlowKey(routeA)!)).toBe(INITIAL_NEW_TASK_FLOW_STATE); + expect(readNewTaskFlowState(newTaskFlowKey(routeB)!)).toBe(INITIAL_NEW_TASK_FLOW_STATE); + }); +}); + +describe("resolveRouteDraft", () => { + const environmentId = EnvironmentId.make("env"); + const projectId = ProjectId.make("project"); + const stamp = { environmentId, projectId }; + const project = { environmentId, id: projectId }; + const base = { + flow: INITIAL_NEW_TASK_FLOW_STATE, + routeDraftId: "new-task:draft-1", + draftsLoaded: true, + stamp, + projects: [project], + }; + + it("binds the route's draft and its stamped project", () => { + expect(resolveRouteDraft(base)).toEqual({ + kind: "open", + flow: { + ...INITIAL_NEW_TASK_FLOW_STATE, + environmentId, + projectKey: "env:project", + draftKey: "new-task:draft-1", + }, + }); + }); + + it("leaves a flow that already bound a draft alone", () => { + const flow = { ...INITIAL_NEW_TASK_FLOW_STATE, draftKey: "new-task:other" }; + expect(resolveRouteDraft({ ...base, flow })).toEqual({ kind: "none" }); + }); + + it("waits for hydration, and for projects while none have loaded", () => { + expect(resolveRouteDraft({ ...base, draftsLoaded: false, stamp: undefined })).toEqual({ + kind: "pending", + }); + expect(resolveRouteDraft({ ...base, projects: [] })).toEqual({ kind: "pending" }); + }); + + it("reports a draft that is gone or whose project is gone", () => { + expect(resolveRouteDraft({ ...base, stamp: undefined })).toEqual({ kind: "missing" }); + expect( + resolveRouteDraft({ ...base, projects: [{ environmentId, id: ProjectId.make("other") }] }), + ).toEqual({ kind: "missing" }); + }); +}); + +describe("resolveRouteProject", () => { + const environmentId = EnvironmentId.make("env"); + const project = { environmentId, id: ProjectId.make("project") }; + const other = { environmentId, id: ProjectId.make("other") }; + const base = { + flow: INITIAL_NEW_TASK_FLOW_STATE, + routeProject: { environmentId: "env", projectId: "project" }, + draftKey: "new-task:route-1", + projects: [project, other], + }; + const opened = (projectKey: string) => ({ + kind: "open", + flow: { + ...INITIAL_NEW_TASK_FLOW_STATE, + environmentId, + projectKey, + draftKey: "new-task:route-1", + }, + }); + + it("binds the route's project to the route's draft", () => { + expect(resolveRouteProject(base)).toEqual(opened("env:project")); + }); + + it("sends a route with no project to choose, or a gone project, to the picker", () => { + expect(resolveRouteProject({ ...base, routeProject: undefined })).toEqual({ + kind: "missing", + }); + expect( + resolveRouteProject({ ...base, routeProject: { environmentId: "env", projectId: "gone" } }), + ).toEqual({ kind: "missing" }); + }); + + it("waits for projects to load before calling the route's project gone", () => { + expect(resolveRouteProject({ ...base, projects: [] })).toEqual({ kind: "pending" }); + }); + + it("leaves a flow the user already chose alone", () => { + const flow = { ...INITIAL_NEW_TASK_FLOW_STATE, environmentId, projectKey: "env:other" }; + expect(resolveRouteProject({ ...base, flow })).toEqual({ kind: "none" }); + }); +}); + +describe("routeNewTaskDraftKey", () => { + it("is not mistaken for a legacy new-task:: key on hydration", () => { + const route = { key: "route-link", name: "Thread", params: { draft: { requestId: "link" } } }; + expect(parseLegacyNewTaskDraftKey(routeNewTaskDraftKey(newTaskFlowKey(route)!))).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-flow-state.ts b/apps/mobile/src/features/threads/new-task-flow-state.ts new file mode 100644 index 0000000000000..4df6b6faae0ee --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-flow-state.ts @@ -0,0 +1,203 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { isNewTaskDraftKey, newTaskDraftKey } from "../../state/new-task-draft-key"; +import { + draftRouteIdentity, + isDraftThreadRoute, + type NewTaskDraftRouteParams, +} from "./thread-route-state"; + +/** The flow a provider serves while no draft route is on the stack. */ +export const INACTIVE_FLOW_KEY = "inactive"; + +/** The flow key for a draft Thread route: its key plus the draft it opened. */ +export function newTaskFlowKey(route: { + readonly key: string; + readonly params?: object; +}): string | undefined { + const draft = (route.params as { draft?: NewTaskDraftRouteParams } | undefined)?.draft; + return draft ? `${route.key}:${draftRouteIdentity(draft)}` : undefined; +} + +export interface NewTaskFlowState { + readonly environmentId: EnvironmentId | null; + readonly projectKey: string | null; + readonly draftKey: string | null; + readonly submitting: boolean; + readonly expandedProvider: string | null; +} + +export const INITIAL_NEW_TASK_FLOW_STATE: NewTaskFlowState = { + environmentId: null, + projectKey: null, + draftKey: null, + submitting: false, + expandedProvider: null, +}; + +// Every draft route on the stack keeps its flow, not just the one the provider +// currently serves: a draft Thread pushed above draft A must not drop A's +// selection. Entries live exactly as long as their route (see prune below). +const flowStatesAtom = Atom.make>>({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:new-task-flows"), +); + +export const newTaskFlowStateAtom = Atom.family((flowKey: string) => + Atom.make((get) => get(flowStatesAtom)[flowKey] ?? INITIAL_NEW_TASK_FLOW_STATE).pipe( + Atom.withLabel("mobile:new-task-flow"), + ), +); + +export function readNewTaskFlowState(flowKey: string): NewTaskFlowState { + return appAtomRegistry.get(flowStatesAtom)[flowKey] ?? INITIAL_NEW_TASK_FLOW_STATE; +} + +export function updateNewTaskFlowState(flowKey: string, patch: Partial): void { + const flows = appAtomRegistry.get(flowStatesAtom); + appAtomRegistry.set(flowStatesAtom, { + ...flows, + [flowKey]: { ...(flows[flowKey] ?? INITIAL_NEW_TASK_FLOW_STATE), ...patch }, + }); +} + +/** + * What a draft route's `draftId` means for a flow that has not bound a draft + * yet. Drafts hydrate from disk and projects arrive with the shell snapshot, + * so on a cold launch or deep link the answer can be "pending" for a while. + */ +export type RouteDraftResolution = + | { readonly kind: "none" } + | { readonly kind: "pending" } + | { readonly kind: "missing" } + | { readonly kind: "open"; readonly flow: NewTaskFlowState }; + +export function resolveRouteDraft(input: { + readonly flow: NewTaskFlowState; + readonly routeDraftId: string | undefined; + readonly draftsLoaded: boolean; + readonly stamp: + | { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } + | undefined; + readonly projects: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly id: ProjectId; + }>; +}): RouteDraftResolution { + const { routeDraftId, stamp } = input; + if (routeDraftId === undefined || input.flow.draftKey !== null) return { kind: "none" }; + if (!input.draftsLoaded) return { kind: "pending" }; + if (!isNewTaskDraftKey(routeDraftId) || stamp === undefined) return { kind: "missing" }; + // The stamped project must be loaded: the composer falls back to the + // environment's first project otherwise, and the draft would be sent + // somewhere the user never chose. + const loaded = input.projects.some( + (project) => project.environmentId === stamp.environmentId && project.id === stamp.projectId, + ); + if (!loaded) return input.projects.length === 0 ? { kind: "pending" } : { kind: "missing" }; + return { + kind: "open", + flow: { + ...input.flow, + environmentId: stamp.environmentId, + projectKey: scopedProjectKey(stamp.environmentId, stamp.projectId), + draftKey: routeDraftId, + }, + }; +} + +/** + * The draft a route that names a project without a `draftId` (a deep link) + * writes into. It is derived from the flow key, so the binding needs no write + * until the user acts. The key avoids `:`, which legacy + * `new-task::` keys use. + */ +export function routeNewTaskDraftKey(flowKey: string): string { + // cyrb53: 53 bits keeps collisions between persisted drafts out of reach. + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let index = 0; index < flowKey.length; index++) { + const code = flowKey.charCodeAt(index); + h1 = Math.imul(h1 ^ code, 2654435761); + h2 = Math.imul(h2 ^ code, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0); + return newTaskDraftKey(`route-${hash.toString(36)}`); +} + +/** + * What a draft route without a `draftId` means for a flow the user has not + * written yet: the route's project. Opening handlers give a route the only + * project there is, so one naming none has a choice to make and goes to the + * picker, as does one naming a project that is gone. + */ +export function resolveRouteProject(input: { + readonly flow: NewTaskFlowState; + readonly routeProject: { readonly environmentId: string; readonly projectId: string } | undefined; + readonly draftKey: string; + readonly projects: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly id: ProjectId; + }>; +}): RouteDraftResolution { + const { routeProject } = input; + if (input.flow.draftKey !== null || input.flow.environmentId !== null) return { kind: "none" }; + const project = + routeProject === undefined + ? null + : (input.projects.find( + (candidate) => + candidate.environmentId === routeProject.environmentId && + candidate.id === routeProject.projectId, + ) ?? null); + if (project === null) { + // An empty project list means environments are still loading. + return routeProject !== undefined && input.projects.length === 0 + ? { kind: "pending" } + : { kind: "missing" }; + } + return { + kind: "open", + flow: { + ...input.flow, + environmentId: project.environmentId, + projectKey: scopedProjectKey(project.environmentId, project.id), + draftKey: input.draftKey, + }, + }; +} + +interface NavigationRoutes { + readonly routes: ReadonlyArray<{ + readonly key: string; + readonly name: string; + readonly params?: object; + }>; +} + +/** Flow keys of the draft routes on the stack. */ +export function liveNewTaskFlowKeys(state: NavigationRoutes): ReadonlySet { + return new Set( + state.routes.flatMap((route) => { + const flowKey = isDraftThreadRoute(route) ? newTaskFlowKey(route) : undefined; + return flowKey === undefined ? [] : [flowKey]; + }), + ); +} + +/** + * Drops the flow of every draft route that left the stack: popped (discarded) + * or promoted to a live thread, whose params no longer carry the draft. + */ +export function pruneNewTaskFlowStates(state: NavigationRoutes): void { + const flows = appAtomRegistry.get(flowStatesAtom); + const live = liveNewTaskFlowKeys(state); + const kept = Object.entries(flows).filter(([flowKey]) => live.has(flowKey)); + if (kept.length === Object.keys(flows).length) return; + appAtomRegistry.set(flowStatesAtom, Object.fromEntries(kept)); +} diff --git a/apps/mobile/src/features/threads/new-task-project-branches.ts b/apps/mobile/src/features/threads/new-task-project-branches.ts new file mode 100644 index 0000000000000..81338ed987155 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-project-branches.ts @@ -0,0 +1,33 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentProjects } from "../../state/projects"; +import { refreshBranchList } from "../../state/queries"; +import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; +import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; + +// Choosing a project re-reads its refs so the picker and defaults never start +// from a list cached before the user's last checkout. +export function refreshChosenProjectBranches( + project: Pick, +): void { + if (project.workspaceRoot === "") return; + setPendingConnectionError(null); + refreshBranchList({ environmentId: project.environmentId, cwd: project.workspaceRoot }); +} + +/** + * Opening an existing new-task draft chooses its stamped project, so handlers + * that navigate to a draft route call this alongside the navigation. + */ +export function refreshNewTaskDraftBranches(draftKey: string): void { + const stamp = getComposerDraftSnapshot(draftKey).project; + if (stamp === undefined) return; + const project = appAtomRegistry + .get(environmentProjects.projectsAtom) + .find( + (candidate) => + candidate.environmentId === stamp.environmentId && candidate.id === stamp.projectId, + ); + if (project !== undefined) refreshChosenProjectBranches(project); +} diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 3d0c2658fe3e8..49834514d84d6 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -2,12 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { HomeProjectScope } from "../home/homeThreadList"; -import { - newTaskDestination, - resolveDraftProjectSelection, - resolveEnvironmentProjectMatch, -} from "./new-task-project-selection"; +import { newTaskDestination, resolveEnvironmentProjectMatch } from "./new-task-project-selection"; function makeProject( id: string, @@ -40,19 +35,6 @@ function makeProject( }; } -function makeScope(projects: ReadonlyArray): HomeProjectScope { - return { - key: "github.com/t3tools/t3code", - title: "T3 Code", - representative: projects[0]!, - projects, - projectRefs: projects.map((project) => ({ - environmentId: project.environmentId, - projectId: project.id, - })), - }; -} - describe("resolveEnvironmentProjectMatch", () => { it("follows the same repository onto the target machine", () => { const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); @@ -94,26 +76,6 @@ describe("resolveEnvironmentProjectMatch", () => { }); }); -describe("resolveDraftProjectSelection", () => { - it("selects one logical project even when it has multiple physical workspaces", () => { - const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; - expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({ - kind: "select", - project: projects[0], - }); - }); - - it("does not preserve a project key that is missing from the catalog", () => { - const project = makeProject("t3code"); - expect( - resolveDraftProjectSelection("environment:removed", [project], [makeScope([project])]), - ).toEqual({ - kind: "select", - project, - }); - }); -}); - describe("newTaskDestination", () => { it("opens the current thread's project directly in split view", () => { const projects = [makeProject("first"), makeProject("current", "other-environment")]; diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 1d61b3a6346c7..11b4ce7d71ea8 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -4,11 +4,6 @@ import { repositoryGroupingKeyOf, type EnvironmentId } from "@t3tools/contracts" import { scopedProjectKey } from "../../lib/scopedEntities"; import type { HomeProjectScope } from "../home/homeThreadList"; -type DraftProjectSelectionResolution = - | { readonly kind: "preserve" } - | { readonly kind: "select"; readonly project: EnvironmentProject } - | { readonly kind: "pick" }; - export function getProjectScopeSelectionTarget( scope: HomeProjectScope, preferredEnvironmentId: EnvironmentId | null, @@ -36,13 +31,6 @@ export function filterProjectScopes( ); } -function getOnlySelectableProject( - projectScopes: ReadonlyArray, -): EnvironmentProject | null { - const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; - return onlyScope?.representative ?? null; -} - export function resolveEnvironmentProjectMatch( projectsOnTarget: ReadonlyArray, selectedProject: EnvironmentProject | null, @@ -85,24 +73,6 @@ export function resolveEnvironmentProjectMatch( ); } -export function resolveDraftProjectSelection( - selectedProjectKey: string | null, - projects: ReadonlyArray, - projectScopes: ReadonlyArray, -): DraftProjectSelectionResolution { - const hasExplicitProjectSelection = - selectedProjectKey !== null && - projects.some( - (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, - ); - if (hasExplicitProjectSelection) { - return { kind: "preserve" }; - } - - const onlyProject = getOnlySelectableProject(projectScopes); - return onlyProject ? { kind: "select", project: onlyProject } : { kind: "pick" }; -} - export function newTaskDestination( _usesSplitView: boolean, projects: ReadonlyArray, @@ -125,3 +95,10 @@ export function newTaskDestination( } : { draft: { requestId } }; } + +/** The project a draft route naming none opens with: the only logical one there is. */ +export function onlyNewTaskProject( + scopes: ReadonlyArray, +): EnvironmentProject | null { + return scopes.length === 1 ? (scopes[0]?.representative ?? null) : null; +} diff --git a/apps/mobile/src/features/threads/open-new-task-draft.test.ts b/apps/mobile/src/features/threads/open-new-task-draft.test.ts new file mode 100644 index 0000000000000..76ab0ccee855f --- /dev/null +++ b/apps/mobile/src/features/threads/open-new-task-draft.test.ts @@ -0,0 +1,73 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; +import { INITIAL_NEW_TASK_FLOW_STATE, resolveRouteDraft } from "./new-task-flow-state"; +import { withOpenedNewTaskDraft } from "./open-new-task-draft"; +import type { NewTaskDraftRouteParams } from "./thread-route-state"; + +const onlyProject = vi.hoisted(() => ({ + current: null as { environmentId: string; id: string } | null, +})); + +vi.mock("./read-only-new-task-project", () => ({ + readOnlyNewTaskProject: () => onlyProject.current, +})); +vi.mock("./new-task-project-branches", () => ({ refreshNewTaskDraftBranches: () => {} })); + +const environmentId = EnvironmentId.make("env"); +const p1 = { environmentId, id: ProjectId.make("p1") }; +const p2 = { environmentId, id: ProjectId.make("p2") }; + +function setup(only: typeof p1 | null) { + onlyProject.current = only; +} + +function openedProject(draftId: string, projects: ReadonlyArray) { + return resolveRouteDraft({ + flow: INITIAL_NEW_TASK_FLOW_STATE, + routeDraftId: draftId, + draftsLoaded: true, + stamp: getComposerDraftSnapshot(draftId).project, + projects, + }); +} + +describe("withOpenedNewTaskDraft", () => { + it("gives a route naming a project a draft stamped with it", () => { + setup(null); + const { draft } = withOpenedNewTaskDraft<{ draft?: NewTaskDraftRouteParams }>({ + draft: { requestId: "r1", environmentId: "env", projectId: "p2" }, + }); + expect(getComposerDraftSnapshot(draft!.draftId as string).project).toMatchObject({ + environmentId: "env", + projectId: "p2", + }); + }); + + it("keeps the only project once the route opened, whatever projects arrive later", () => { + setup(p1); + const { draft } = withOpenedNewTaskDraft<{ draft?: NewTaskDraftRouteParams }>({ + draft: { requestId: "r2" }, + }); + const draftId = draft!.draftId as string; + expect(draft).toMatchObject({ environmentId: "env", projectId: "p1" }); + + // A second project connects: the route still opens on the first. + expect(openedProject(draftId, [p1, p2])).toMatchObject({ + kind: "open", + flow: { projectKey: "env:p1", draftKey: draftId }, + }); + // The project is swapped: the route goes to the picker instead of + // rebinding to whichever project is alone now. + expect(openedProject(draftId, [p2])).toEqual({ kind: "missing" }); + }); + + it("leaves a route with a choice to make, or an existing draft, alone", () => { + setup(null); + const choice = { draft: { requestId: "r3" } }; + expect(withOpenedNewTaskDraft(choice)).toBe(choice); + const existing = { draft: { draftId: "new-task:existing", environmentId: "env" } }; + expect(withOpenedNewTaskDraft(existing)).toBe(existing); + }); +}); diff --git a/apps/mobile/src/features/threads/open-new-task-draft.ts b/apps/mobile/src/features/threads/open-new-task-draft.ts new file mode 100644 index 0000000000000..fa9d5c54af5bd --- /dev/null +++ b/apps/mobile/src/features/threads/open-new-task-draft.ts @@ -0,0 +1,41 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +import { createNewTaskDraft } from "../../state/use-composer-drafts"; +import { refreshNewTaskDraftBranches } from "./new-task-project-branches"; +import { readOnlyNewTaskProject } from "./read-only-new-task-project"; +import type { NewTaskDraftRouteParams } from "./thread-route-state"; + +const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value); + +/** + * A new-task route that names a project, or opens while there is only one, gets + * its own stamped draft before it is shown, so composer writes land in a draft + * the home list can reopen. With no project and a choice, the picker decides. + */ +export function withOpenedNewTaskDraft( + params: Params, +): Params { + const draft = params.draft; + if (draft === undefined || first(draft.draftId)) return params; + const environmentId = first(draft.environmentId); + const projectId = first(draft.projectId); + const onlyProject = environmentId && projectId ? null : readOnlyNewTaskProject(); + const project = + environmentId && projectId + ? { environmentId: EnvironmentId.make(environmentId), projectId: ProjectId.make(projectId) } + : onlyProject && { environmentId: onlyProject.environmentId, projectId: onlyProject.id }; + if (!project) return params; + const draftId = createNewTaskDraft(project); + // A project that has not loaded yet has no cached refs to go stale; its + // first branch query reads them from the server. + refreshNewTaskDraftBranches(draftId); + return { + ...params, + draft: { + ...draft, + draftId, + environmentId: String(project.environmentId), + projectId: String(project.projectId), + }, + }; +} diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts index be82e4b6510ad..314dc1aa477b5 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.test.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts @@ -8,7 +8,11 @@ import { ThreadId, } from "@t3tools/contracts"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; -import { appendPendingThreadMessages, isCompactionCommandFeedEntry } from "./pending-thread-feed"; +import { + appendPendingThreadMessages, + isCompactionCommandFeedEntry, + selectPendingAcknowledgedMessages, +} from "./pending-thread-feed"; const pending = (id: string): QueuedThreadMessage => ({ environmentId: EnvironmentId.make("env"), @@ -109,4 +113,20 @@ describe("pending timeline messages", () => { expect(appendPendingThreadMessages([delivered], [delivered], [queued])).toEqual([delivered]); expect(appendPendingThreadMessages([], [delivered], [queued])).toEqual([]); }); + + it("stops showing an acknowledged message once the projection holds it, even when the feed hides it", () => { + // A rolled-back or cancelled queued run keeps its message in projection.messages + // while its user_message item leaves the visible feed. + const echoed = pending("rolled-back"); + const waiting = pending("waiting"); + const shown = selectPendingAcknowledgedMessages([echoed, waiting], { + threadKey: "env:thread", + queuedMessages: [], + projectedMessages: [{ id: echoed.messageId }], + }); + expect(shown).toEqual([waiting]); + expect(appendPendingThreadMessages([], [], shown).map((entry) => entry.id)).toEqual([ + "waiting", + ]); + }); }); diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts index 820afb66a75b1..9eaf42dcee81b 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.ts @@ -4,6 +4,7 @@ import { type ThreadGuestShare, } from "@t3tools/contracts"; import type { ThreadFeedEntry } from "../../lib/threadActivity"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; /** @@ -23,6 +24,24 @@ export type PendingThreadFeedEntry = ThreadFeedEntry & { readonly acknowledged?: boolean; }; +// An acknowledged row stops being pending once the projection holds its message, +// even when the feed hides that message (a rolled-back or cancelled run). +export function selectPendingAcknowledgedMessages( + acknowledged: ReadonlyArray, + thread: { + readonly threadKey: string | null; + readonly queuedMessages: ReadonlyArray; + readonly projectedMessages: ReadonlyArray<{ readonly id: string }> | undefined; + }, +): ReadonlyArray { + return acknowledged.filter( + (message) => + scopedThreadKey(message.environmentId, message.threadId) === thread.threadKey && + !thread.queuedMessages.some((queued) => queued.messageId === message.messageId) && + !thread.projectedMessages?.some((projected) => projected.id === message.messageId), + ); +} + /** A row of the thread feed's list. */ export type ThreadFeedRow = PendingThreadFeedEntry | GuestPendingFeedEntry; diff --git a/apps/mobile/src/features/threads/read-only-new-task-project.ts b/apps/mobile/src/features/threads/read-only-new-task-project.ts new file mode 100644 index 0000000000000..45bf0679f9a0a --- /dev/null +++ b/apps/mobile/src/features/threads/read-only-new-task-project.ts @@ -0,0 +1,27 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { AsyncResult } from "effect/reactivity"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { mobilePreferencesAtom } from "../../state/preferences"; +import { + DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, + resolveMobileProjectGroupingSettings, +} from "../../state/project-grouping.logic"; +import { environmentProjects } from "../../state/projects"; +import { buildHomeProjectScopes } from "../home/homeThreadList"; +import { onlyNewTaskProject } from "./new-task-project-selection"; + +/** The project a draft route naming none opens with, read at use time. */ +export function readOnlyNewTaskProject(): EnvironmentProject | null { + const preferences = appAtomRegistry.get(mobilePreferencesAtom); + const { sidebarProjectGroupingMode } = AsyncResult.isSuccess(preferences) + ? resolveMobileProjectGroupingSettings(preferences.value) + : DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS; + return onlyNewTaskProject( + buildHomeProjectScopes({ + projects: appAtomRegistry.get(environmentProjects.projectsAtom), + environmentId: null, + projectGroupingMode: sidebarProjectGroupingMode, + }), + ); +} diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index ae87fa9777e85..8bbbe0e1ddb68 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { Activity, useState, type ReactNode } from "react"; import { View } from "react-native"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; @@ -6,42 +6,46 @@ import { NativeInspectorContent } from "../../native/NativeInspectorContent"; export type ThreadInspectorMode = "route" | "git" | "files"; -const INSPECTOR_PREWARM_DELAY_MS = 350; +type PaneState = "unmounted" | "prewarm" | "mounted"; function InspectorContentPane(props: { readonly children: ReactNode; - readonly mounted: boolean; + readonly state: PaneState; readonly resetKeys: readonly [string | null, string | null]; readonly visible: boolean; }) { - if (!props.mounted) { + if (props.state === "unmounted") { return null; } + // A prewarmed pane renders at idle priority with its effects held back; once + // visited it stays mounted and live, hidden only by opacity. return ( - - ( - - - - )} + + - {props.children} - - + ( + + + + )} + > + {props.children} + + + ); } @@ -52,40 +56,27 @@ export function ThreadInspectorContentStack(props: { readonly resetKeys: readonly [string | null, string | null]; readonly renderRoute?: () => ReactNode; }) { - const [mountedModes, setMountedModes] = useState>( + const [visitedModes, setVisitedModes] = useState>( () => new Set([props.mode]), ); - - useEffect(() => { - setMountedModes((current) => { - if (current.has(props.mode)) { - return current; - } - return new Set([...current, props.mode]); - }); - - if (props.mode === "route") { - return; - } - - // Keep both inspectors alive after opening; detaching the file tree makes UIKit rebuild its expensive focus graph. - const alternateMode = props.mode === "files" ? "git" : "files"; - const timeout = setTimeout(() => { - setMountedModes((current) => { - if (current.has(alternateMode)) { - return current; - } - return new Set([...current, alternateMode]); - }); - }, INSPECTOR_PREWARM_DELAY_MS); - - return () => clearTimeout(timeout); - }, [props.mode]); + if (!visitedModes.has(props.mode)) { + setVisitedModes(new Set([...visitedModes, props.mode])); + } + // The file tree is expensive to detach because UIKit rebuilds its focus + // graph. Keep both chat inspectors alive so a Files/Git switch only changes + // visibility; the one not yet opened is prewarmed. + const prewarmMode = props.mode === "files" ? "git" : props.mode === "git" ? "files" : null; + const paneState = (mode: ThreadInspectorMode): PaneState => + visitedModes.has(mode) || props.mode === mode + ? "mounted" + : prewarmMode === mode + ? "prewarm" + : "unmounted"; return ( @@ -93,7 +84,7 @@ export function ThreadInspectorContentStack(props: { {props.renderGit ? ( @@ -102,7 +93,7 @@ export function ThreadInspectorContentStack(props: { ) : null} {props.renderRoute ? ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 8dcdbecd600b6..eca9b55eaf7b7 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -28,7 +28,7 @@ import { import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; -import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; +import { memo, useCallback, useMemo, useState, type ComponentProps } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -39,6 +39,7 @@ import { Menu } from "../../components/ui/Menu"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; +import { useNowAfter } from "../../lib/clock"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; @@ -694,21 +695,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); const canUnsettle = variant === "slim"; - // Keep time in state; cached render-time reads freeze the gate and trigger 50ms expiry loops. - const [snoozeGateClock, setSnoozeGateClock] = useState(() => ({ thread, now: Date.now() })); - if (snoozeGateClock.thread !== thread) setSnoozeGateClock({ thread, now: Date.now() }); - const snoozeGateNow = new Date(snoozeGateClock.now).toISOString(); + // The gate's only time-dependent edge is the queued-turn grace window, so its + // expiry is known without a clock: evaluate at the message's own timestamp. + // A gate held by runtime status has no expiry, and no clock tick opens it. const snoozeGateExpiryMs = props.snoozeSupported - ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: snoozeGateNow }) + ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: thread.latestUserMessageAt ?? "" }) : null; - useEffect(() => { - // A gate still closed past its expiry is held by runtime status, not time, - // so no clock tick can open it. - if (snoozeGateExpiryMs === null || snoozeGateExpiryMs <= snoozeGateClock.now) return; - const delayMs = Math.min(snoozeGateExpiryMs - Date.now() + 50, 2_147_483_647); - const id = setTimeout(() => setSnoozeGateClock({ thread, now: Date.now() }), delayMs); - return () => clearTimeout(id); - }, [snoozeGateExpiryMs, snoozeGateClock.now, thread]); + // The window is inclusive, so wake just past it. + const snoozeGateNowMs = useNowAfter(snoozeGateExpiryMs === null ? null : snoozeGateExpiryMs + 1); + const snoozeGateNow = new Date(snoozeGateNowMs).toISOString(); const swipeActions = resolveThreadListV2SwipeActions({ variant, settlementSupported: props.settlementSupported, diff --git a/apps/mobile/src/features/threads/thread-route-state.ts b/apps/mobile/src/features/threads/thread-route-state.ts index c9a6ab6a3ddba..e237d9bf69937 100644 --- a/apps/mobile/src/features/threads/thread-route-state.ts +++ b/apps/mobile/src/features/threads/thread-route-state.ts @@ -6,7 +6,6 @@ export type NewTaskDraftRouteParams = { readonly worktreePath?: string | null; readonly title?: string | string[]; readonly cloning?: string | string[]; - readonly pendingTaskId?: string | string[]; readonly draftId?: string | string[]; readonly incomingShareId?: string | string[]; }; @@ -32,7 +31,6 @@ export function draftRouteIdentity(draft: NewTaskDraftRouteParams): string { const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value); return JSON.stringify([ draft.requestId, - first(draft.pendingTaskId), first(draft.draftId), first(draft.environmentId), first(draft.projectId), diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index 5fa19568b06d4..d475b13ba0e46 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { StackActions, useIsFocused, useNavigation } from "@react-navigation/native"; +import { StackActions, useNavigation } from "@react-navigation/native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; @@ -8,8 +8,7 @@ import type { OrchestrationV2Subagent, OrchestrationV2TurnItem, } from "@t3tools/contracts"; -import { useEffect, useState } from "react"; -import { AppState, Pressable, View, type ColorValue } from "react-native"; +import { Pressable, View, type ColorValue } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -20,27 +19,15 @@ import { serverEnvironment } from "../../state/server"; import { environmentThreadDetails } from "../../state/threads"; import { subagentCardElapsed } from "./subagent-card-presentation"; import { SubagentRow } from "./SubagentRow"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; import { WorkLogBlock } from "./work-log-layout"; type SubagentItem = Extract; type AgentTiming = Pick; function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray }) { - const focused = useIsFocused(); const live = agents.some((agent) => isActiveSubagentStatus(agent.status)); - const [nowMs, setNowMs] = useState(() => Date.now()); - const [appActive, setAppActive] = useState(() => AppState.currentState === "active"); - useEffect(() => { - const subscription = AppState.addEventListener("change", (state) => - setAppActive(state === "active"), - ); - return () => subscription.remove(); - }, []); - useEffect(() => { - if (!live || !focused || !appActive) return; - const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(intervalId); - }, [appActive, focused, live]); + const nowMs = useVisibleSecondClock(live); const elapsed = subagentCardElapsed(agents, nowMs); return elapsed ? ( {elapsed} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 80e515aeec404..179d2b6a176b1 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -33,9 +33,7 @@ import { type ReactNode, } from "react"; import { - AccessibilityInfo, Animated as NativeAnimated, - AppState, type ColorValue, Easing, Pressable, @@ -63,6 +61,7 @@ import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { AppText as Text } from "../../components/ui/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; import { BrowserAppIcon } from "./BrowserAppIcon"; +import { useAppActive, useReduceMotionEnabled } from "../../lib/appState"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; import { @@ -100,6 +99,7 @@ import Animated, { LinearTransition, ReduceMotion, useAnimatedStyle, + useDerivedValue, useSharedValue, withTiming, } from "react-native-reanimated"; @@ -168,14 +168,13 @@ export function ThreadDisclosureChevron(props: { readonly tintColor: ColorValue; }) { const expandedAngle = props.collapsedDirection === "right" ? 90 : 180; - const rotation = useSharedValue(props.expanded ? expandedAngle : 0); - - useLayoutEffect(() => { - rotation.value = withTiming(props.expanded ? expandedAngle : 0, { + const targetAngle = props.expanded ? expandedAngle : 0; + const rotation = useDerivedValue(() => + withTiming(targetAngle, { duration: THREAD_DISCLOSURE_TRANSITION_MS, reduceMotion: ReduceMotion.System, - }); - }, [expandedAngle, props.expanded, rotation]); + }), + ); const rotationStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }], @@ -263,8 +262,8 @@ export function ShimmeringWorkContent(props: { }) { const [availableWidth, setAvailableWidth] = useState(0); const [textWidth, setTextWidth] = useState(0); - const [appIsActive, setAppIsActive] = useState(AppState.currentState === "active"); - const [reducedMotion, setReducedMotion] = useState(true); + const appIsActive = useAppActive(); + const reducedMotion = useReduceMotionEnabled(); const screenIsFocused = useIsFocused(); const progress = useAnimatedValue(0); const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`; @@ -273,22 +272,6 @@ export function ShimmeringWorkContent(props: { (props.showIcon ? SHIMMER_ICON_AND_GAP_WIDTH : 0) + Math.ceil(textWidth), ); - useEffect(() => { - const subscription = AppState.addEventListener("change", (state) => { - setAppIsActive(state === "active"); - }); - return () => subscription.remove(); - }, []); - - useEffect(() => { - void AccessibilityInfo.isReduceMotionEnabled().then(setReducedMotion); - const subscription = AccessibilityInfo.addEventListener( - "reduceMotionChanged", - setReducedMotion, - ); - return () => subscription.remove(); - }, []); - // Runs on React Native's native driver instead of Reanimated, whose // per-frame commits starve React's while commit pausing is disabled // (package.json). See docs/internals/performance-regressions.md. diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 424bd792c40d2..bc95c1bfabef7 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -140,6 +140,20 @@ export function resolveThreadListV2SnoozeGateExpiryMs( return messageAtMs + QUEUED_TURN_START_GRACE_MS; } +/** The earliest `snoozedUntil` strictly after `nowMs`, in epoch ms. */ +export function nextSnoozeWakeAfter( + threads: ReadonlyArray<{ readonly snoozedUntil?: string | null }>, + nowMs: number, +): number | null { + let next: number | null = null; + for (const thread of threads) { + if (thread.snoozedUntil == null) continue; + const wakeAtMs = Date.parse(thread.snoozedUntil); + if (wakeAtMs > nowMs && (next === null || wakeAtMs < next)) next = wakeAtMs; + } + return next; +} + export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; diff --git a/apps/mobile/src/features/threads/threadStreamHaptics.test.ts b/apps/mobile/src/features/threads/threadStreamHaptics.test.ts new file mode 100644 index 0000000000000..6598aa071f62a --- /dev/null +++ b/apps/mobile/src/features/threads/threadStreamHaptics.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("expo-haptics", () => ({ selectionAsync: vi.fn() })); +vi.mock("../../state/threads", () => ({ environmentThreadDetails: {} })); + +import { shouldTickStreamHaptic } from "./threadStreamHaptics"; + +const mark = (id: string, textLength: number) => ({ id, textLength }); + +describe("shouldTickStreamHaptic", () => { + it("ticks for a new stream even inside the throttle window", () => { + expect( + shouldTickStreamHaptic({ + previous: mark("a", 40), + latest: mark("b", 1), + lastTickAt: 0, + now: 10, + }), + ).toBe(true); + expect( + shouldTickStreamHaptic({ previous: null, latest: mark("a", 1), lastTickAt: 0, now: 10 }), + ).toBe(true); + }); + + it("throttles growth of the same stream", () => { + const growth = { previous: mark("a", 10), latest: mark("a", 20), lastTickAt: 1_000 }; + expect(shouldTickStreamHaptic({ ...growth, now: 1_200 })).toBe(false); + expect(shouldTickStreamHaptic({ ...growth, now: 1_320 })).toBe(true); + }); + + it("stays quiet when nothing streams or the text did not grow", () => { + expect( + shouldTickStreamHaptic({ previous: mark("a", 10), latest: null, lastTickAt: 0, now: 5_000 }), + ).toBe(false); + expect( + shouldTickStreamHaptic({ + previous: mark("a", 10), + latest: mark("a", 10), + lastTickAt: 0, + now: 5_000, + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/threadStreamHaptics.ts b/apps/mobile/src/features/threads/threadStreamHaptics.ts new file mode 100644 index 0000000000000..2b910d8b599dd --- /dev/null +++ b/apps/mobile/src/features/threads/threadStreamHaptics.ts @@ -0,0 +1,75 @@ +// @effect-diagnostics globalDate:off - The tracker runs in an atom subscription, outside an Effect runtime. +import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; +import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import * as Haptics from "expo-haptics"; + +import { environmentThreadDetails } from "../../state/threads"; + +export interface StreamingMessageMark { + readonly id: string; + readonly textLength: number; +} + +const STREAM_HAPTIC_THROTTLE_MS = 320; + +export function latestStreamingAssistantMessage( + thread: EnvironmentThread | null, +): StreamingMessageMark | null { + const messages = thread?.projection.messages ?? []; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role === "assistant" && message.streaming) { + return { id: message.id, textLength: message.text.length }; + } + } + return null; +} + +/** + * A new stream always ticks; growth of the same stream ticks at most once per + * throttle window, so a fast stream reads as texture rather than a buzz. + */ +export function shouldTickStreamHaptic(input: { + readonly previous: StreamingMessageMark | null; + readonly latest: StreamingMessageMark | null; + readonly lastTickAt: number; + readonly now: number; +}): boolean { + const { previous, latest } = input; + if (latest === null) return false; + if (previous?.id !== latest.id) return true; + if (latest.textLength <= previous.textLength) return false; + return input.now - input.lastTickAt >= STREAM_HAPTIC_THROTTLE_MS; +} + +const streamHapticsFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + return Atom.make((get) => { + // The first snapshot only seeds the mark: opening a thread mid-stream is silent. + let seeded = false; + let previous: StreamingMessageMark | null = null; + let lastTickAt = 0; + // `subscribe`, not `get`: a rebuild per streaming tick would reset the mark. + get.subscribe( + environmentThreadDetails.threadAtom(ref), + (thread) => { + const latest = latestStreamingAssistantMessage(thread); + const now = Date.now(); + const tick = seeded && shouldTickStreamHaptic({ previous, latest, lastTickAt, now }); + seeded = true; + previous = latest; + if (!tick) return; + lastTickAt = now; + void Haptics.selectionAsync(); + }, + { immediate: true }, + ); + }).pipe(Atom.withLabel(`thread-stream-haptics:${key}`)); +}); + +/** Mount while the thread is on screen to tick haptics as its reply streams. */ +export function threadStreamHaptics(ref: ScopedThreadRef): Atom.Atom { + return streamHapticsFamily(threadKey(ref)); +} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index f038fe8f9c25f..c0044a74f7944 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; -const refreshProviders = vi.hoisted(() => vi.fn()); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ @@ -10,22 +9,58 @@ vi.mock("../../state/queries", () => ({ })); vi.mock("../../state/use-composer-drafts", () => ({ getComposerDraftSnapshot: vi.fn(), - readComposerDraftSelection: vi.fn(), setComposerDraftContext: vi.fn(), + useComposerDraftSelection: vi.fn(), })); vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" })); vi.mock("../../state/server", () => ({ - serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, -})); -vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: () => refreshProviders, + serverEnvironment: { + providersValueAtom: vi.fn(), + refreshProviders: Symbol("refreshProviders"), + }, })); import { buildComposerSlashCommandItems, resolveComposerCommandSelection, + resolveComposerMenuSelection, } from "./use-composer-command-menu"; +describe("resolveComposerMenuSelection", () => { + it("prefers the caret an insertion placed in the current draft text", () => { + expect( + resolveComposerMenuSelection({ + draftMessage: "hello @chip ", + ownerKey: "draft-a", + inserted: { start: 12, end: 12 }, + local: { ownerKey: "draft-a", start: 2, end: 2 }, + }), + ).toEqual({ start: 12, end: 12 }); + }); + + it("clamps the composer's own caret when the draft shrinks", () => { + expect( + resolveComposerMenuSelection({ + draftMessage: "hi", + ownerKey: "draft-a", + inserted: null, + local: { ownerKey: "draft-a", start: 5, end: 9 }, + }), + ).toEqual({ start: 2, end: 2 }); + }); + + it("starts at the end of the draft after switching owners", () => { + expect( + resolveComposerMenuSelection({ + draftMessage: "other draft", + ownerKey: "draft-b", + inserted: null, + local: { ownerKey: "draft-a", start: 1, end: 1 }, + }), + ).toEqual({ start: 11, end: 11 }); + }); +}); + describe("mobile slash commands", () => { const antigravity = { driver: ProviderDriverKind.make("antigravity"), diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 7d3e795bf853b..43d32aa76f515 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -11,17 +11,14 @@ import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThread import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; const EMPTY_THREAD_SHELLS: ReadonlyArray = []; -import { - COMPOSER_CONTEXT_MAX_RECORDS, - PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, -} from "@t3tools/contracts"; +import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; import { uuidv4 } from "../../lib/uuid"; import { getComposerDraftSnapshot, - readComposerDraftSelection, setComposerDraftContext, + useComposerDraftSelection, } from "../../state/use-composer-drafts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { @@ -36,30 +33,58 @@ import { scoreQueryMatch, } from "@t3tools/shared/searchRanking"; import { + createProviderWorkspaceSnapshotRefreshAtomFamily, dedupeProviderSkillsByName, getProviderSkillsForSlashMenu, getProviderSlashCommandsForSlashMenu, isProviderSkillUserInvocable, - hasCompleteProviderWorkspaceSnapshot, - hasCurrentProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; -import { useEffect, useRef, useState } from "react"; +import { useAtomRefresh } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; +import { useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; import { useComposerPathSearch, useComposerPullRequestSearch } from "../../state/queries"; import type { ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; -const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; +const providerWorkspaceSnapshotRefresh = createProviderWorkspaceSnapshotRefreshAtomFamily({ + providers: serverEnvironment.providersValueAtom, + refreshProviders: serverEnvironment.refreshProviders, +}); + +const idleWorkspaceSnapshotRefreshAtom: Atom.Atom = Atom.make(undefined).pipe( + Atom.withLabel("mobile:provider-workspace-snapshot-refresh:idle"), +); function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; } +/** + * A caret placed by an insertion (chips, pasted context) wins while the draft + * still has the text it was placed in. Otherwise the composer's own caret is + * clamped to the draft, and a caret from another owner starts at the end. + */ +export function resolveComposerMenuSelection(input: { + readonly draftMessage: string; + readonly ownerKey: string | null; + readonly inserted: ComposerEditorSelection | null; + readonly local: { + readonly ownerKey: string | null; + readonly start: number; + readonly end: number; + }; +}): ComposerEditorSelection { + if (input.inserted) return input.inserted; + if (input.local.ownerKey !== input.ownerKey) return composerSelectionAtEnd(input.draftMessage); + const length = input.draftMessage.length; + return { start: Math.min(input.local.start, length), end: Math.min(input.local.end, length) }; +} + export function buildComposerSlashCommandItems(input: { readonly query: string; readonly atMessageStart: boolean; @@ -204,134 +229,40 @@ export function useComposerCommandMenu({ readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; readonly onUsageLimits?: () => void; }) { - const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); + const [localSelection, setLocalSelection] = useState(() => ({ + ownerKey, + ...composerSelectionAtEnd(draftMessage), + })); // Thread mentions rank by recency as of when the composer mounted. const [now] = useState(() => Date.now()); - const previousOwnerKeyRef = useRef(ownerKey); - const onSelectionChange = (nextSelection: ComposerEditorSelection) => { - setSelection(nextSelection); - }; - useEffect(() => { - const inserted = ownerKey ? readComposerDraftSelection(ownerKey, draftMessage) : null; - if (inserted) { - setSelection((current) => - current.start === inserted.start && current.end === inserted.end ? current : inserted, - ); - return; - } - const end = draftMessage.length; - setSelection((current) => { - const start = Math.min(current.start, end); - const selectionEnd = Math.min(current.end, end); - if (start === current.start && selectionEnd === current.end) { - return current; - } - return { start, end: selectionEnd }; - }); - }, [draftMessage, ownerKey]); - useEffect(() => { - if (previousOwnerKeyRef.current === ownerKey) return; - previousOwnerKeyRef.current = ownerKey; - setSelection(composerSelectionAtEnd(draftMessage)); - }, [draftMessage, ownerKey]); + const insertedSelection = useComposerDraftSelection(ownerKey, draftMessage); + const selection = resolveComposerMenuSelection({ + draftMessage, + ownerKey, + inserted: insertedSelection, + local: localSelection, + }); + const setSelection = (next: ComposerEditorSelection) => + setLocalSelection({ ownerKey, start: next.start, end: next.end }); const skills = selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : []; - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); const selectedProviderInstanceId = selectedProviderStatus?.instanceId; - const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( - selectedProviderStatus, - projectCwd, - ); - // Record client request time to enforce TTL even when the client clock runs ahead of the server. - const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); - const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ - key: string; - notBefore: number; - } | null>(null); - const workspaceRefreshScopeKey = + const refreshWorkspaceSnapshot = useAtomRefresh( environmentId && projectCwd && selectedProviderInstanceId - ? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}` - : null; - const workspaceSlashCommandsPending = - selectedProviderStatus?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === projectCwd && snapshot.slashCommandsPending === true, - ) ?? false; - useEffect(() => { - if ( - !workspaceSlashCommandsPending || - !workspaceRefreshRetry || - workspaceRefreshRetry.key !== workspaceRefreshScopeKey - ) - return; - const timeout = setTimeout( - () => { - setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); - }, - Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), - ); - return () => clearTimeout(timeout); - }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); - const hadWorkspaceSnapshotRef = useRef(false); - useEffect(() => { - if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { - workspaceRefreshKeyRef.current = null; - setWorkspaceRefreshRetry(null); - } - hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; - }, [hasWorkspaceSnapshot]); - useEffect(() => { - if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; - const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; - const now = Date.now(); - const lastRequest = workspaceRefreshKeyRef.current; - if ( - lastRequest?.key === key && - now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - ) - return; - if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, projectCwd, now)) { - setWorkspaceRefreshRetry(null); - return; - } - const retry = workspaceRefreshRetry; - if (retry?.key === key && now < retry.notBefore) return; - const request = { key, requestedAt: now }; - workspaceRefreshKeyRef.current = request; - const retryLater = () => { - if (workspaceRefreshKeyRef.current !== request) return; - workspaceRefreshKeyRef.current = null; - setWorkspaceRefreshRetry({ - key, - notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }); - }; - void refreshProviders({ - environmentId, - input: { instanceId: selectedProviderInstanceId, cwd: projectCwd }, - }).then((result) => { - const refreshed = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot( - result.value.providers.find( - (provider) => provider.instanceId === selectedProviderInstanceId, - ), - projectCwd, - ); - if (!refreshed) retryLater(); - }, retryLater); - }, [ - draftMessage, - environmentId, - projectCwd, - refreshProviders, - selectedProviderInstanceId, - selectedProviderStatus, - workspaceRefreshRetry, - ]); + ? providerWorkspaceSnapshotRefresh({ + environmentId, + instanceId: selectedProviderInstanceId, + cwd: projectCwd, + }) + : idleWorkspaceSnapshotRefreshAtom, + ); + // Selection changes on every keystroke, which is when a stale snapshot is rechecked. + const onSelectionChange = (nextSelection: ComposerEditorSelection) => { + setSelection(nextSelection); + refreshWorkspaceSnapshot(); + }; const trigger = (() => { if (!enabled || selection.start !== selection.end) { diff --git a/apps/mobile/src/features/threads/use-thread-list-now.ts b/apps/mobile/src/features/threads/use-thread-list-now.ts new file mode 100644 index 0000000000000..9bc5021751d12 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-now.ts @@ -0,0 +1,29 @@ +import { useState } from "react"; + +import { useDeadlinePassed, useNowMinute } from "../../lib/clock"; +import { nextSnoozeWakeAfter } from "./threadListV2"; + +/** + * The thread list's clock as an ISO timestamp. It advances on each wall-clock + * minute and the moment any snooze expires, so the list re-partitions on time + * instead of on the next minute tick. Waking on every snoozed thread, not just + * the ones the list shows as snoozed, only costs an extra render. + */ +export function useThreadListNow( + threads: ReadonlyArray<{ readonly snoozedUntil?: string | null }>, +): string { + const minute = useNowMinute(); + const [clock, setClock] = useState(() => ({ + minute, + wakeMs: null as number | null, + nowMs: Date.now(), + })); + const wakeMs = nextSnoozeWakeAfter(threads, clock.nowMs); + const wakePassed = useDeadlinePassed(wakeMs); + if (clock.minute !== minute || (wakePassed && clock.wakeMs !== wakeMs)) { + const next = { minute, wakeMs, nowMs: Date.now() }; + setClock(next); + return new Date(next.nowMs).toISOString(); + } + return new Date(clock.nowMs).toISOString(); +} diff --git a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts index 57f728613877c..2ea4d6c375583 100644 --- a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts +++ b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { useRef, useState, type RefObject } from "react"; import { KeyboardController } from "react-native-keyboard-controller"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; @@ -18,41 +18,30 @@ const NATIVE_DISMISSAL_ECHO_WINDOW_MS = 150; /** Blur/focus costs two keyboard animations; inputView overrides also replay show when removed. */ export function useThreadSettingsSheetPresentation(input: { readonly editorRef: RefObject; - readonly isEditorFocused: boolean; + /** Runs on the frame the sheet becomes visible, after the keyboard started leaving. */ + readonly onPresent: () => void; }) { + // Destructured so the compiler keys memoized handlers on the stable editor ref, not on + // the fresh input object: the finishTransitioning listener re-subscribes on every change. + const { editorRef, onPresent } = input; const [phase, setPhase] = useState("closed"); const isActiveRef = useRef(false); - const isMountedRef = useRef(true); - const isEditorFocusedRef = useRef(input.isEditorFocused); + // Written by the editor's focus handlers; read when restoring focus after dismissal. + const isEditorFocusedRef = useRef(false); const openingIdRef = useRef(0); const focusRestoreIdRef = useRef(0); const restoreFocusAfterDismissRef = useRef(false); const restorePendingRef = useRef(false); const lastStackTransitionFinishedAtRef = useRef(0); const dismissRestoreTimerRef = useRef | null>(null); - const clearDismissRestoreTimer = useCallback(() => { + const clearDismissRestoreTimer = () => { if (dismissRestoreTimerRef.current !== null) { clearTimeout(dismissRestoreTimerRef.current); dismissRestoreTimerRef.current = null; } - }, []); - - useEffect(() => { - isEditorFocusedRef.current = input.isEditorFocused; - }, [input.isEditorFocused]); - - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - isActiveRef.current = false; - openingIdRef.current += 1; - focusRestoreIdRef.current += 1; - clearDismissRestoreTimer(); - }; - }, [clearDismissRestoreTimer]); + }; - const open = useCallback(() => { + const open = () => { if (isActiveRef.current) { return; } @@ -62,7 +51,7 @@ export function useThreadSettingsSheetPresentation(input: { clearDismissRestoreTimer(); restorePendingRef.current = false; restoreFocusAfterDismissRef.current = - phase === "restoring" || input.isEditorFocused || KeyboardController.isVisible(); + phase === "restoring" || isEditorFocusedRef.current || KeyboardController.isVisible(); setPhase("opening"); const openingId = openingIdRef.current + 1; @@ -70,24 +59,30 @@ export function useThreadSettingsSheetPresentation(input: { // Dismiss the keyboard before blur, then present next frame so native transitions overlap. void KeyboardController.dismiss({ animated: true }); - input.editorRef.current?.blur(); + editorRef.current?.blur(); requestAnimationFrame(() => { - if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { + // A composer unmounted during the frame takes its editor with it; present nothing. + if ( + !isActiveRef.current || + openingIdRef.current !== openingId || + editorRef.current === null + ) { return; } setPhase("visible"); + onPresent(); }); - }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused, phase]); + }; - const restoreEditorFocus = useCallback(() => { + const restoreEditorFocus = () => { const focusRestoreId = focusRestoreIdRef.current + 1; focusRestoreIdRef.current = focusRestoreId; let attemptsRemaining = 20; // Retry focus restoration if UIKit briefly refuses first responder at the dismissal boundary. const restoreFocus = () => { - if (!isMountedRef.current || focusRestoreIdRef.current !== focusRestoreId) { + if (focusRestoreIdRef.current !== focusRestoreId) { return; } if (isEditorFocusedRef.current || attemptsRemaining <= 0) { @@ -96,25 +91,25 @@ export function useThreadSettingsSheetPresentation(input: { } attemptsRemaining -= 1; - input.editorRef.current?.focus(); + editorRef.current?.focus(); setTimeout(restoreFocus, 50); }; requestAnimationFrame(restoreFocus); - }, [input.editorRef]); + }; - const runPendingDismissalRestore = useCallback(() => { + const runPendingDismissalRestore = () => { if (!restorePendingRef.current) { return; } restorePendingRef.current = false; clearDismissRestoreTimer(); - if (!isMountedRef.current || isActiveRef.current) { + if (isActiveRef.current) { return; } restoreEditorFocus(); - }, [clearDismissRestoreTimer, restoreEditorFocus]); + }; - const onDismissed = useCallback(() => { + const onDismissed = () => { isActiveRef.current = false; if (!restoreFocusAfterDismissRef.current) { @@ -134,19 +129,35 @@ export function useThreadSettingsSheetPresentation(input: { dismissRestoreTimerRef.current = null; runPendingDismissalRestore(); }, SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS); - }, [clearDismissRestoreTimer, runPendingDismissalRestore]); + }; - const onStackTransitionsFinished = useCallback(() => { + const onStackTransitionsFinished = () => { lastStackTransitionFinishedAtRef.current = Date.now(); runPendingDismissalRestore(); - }, [runPendingDismissalRestore]); + }; + + const onEditorFocusChange = (focused: boolean) => { + isEditorFocusedRef.current = focused; + }; + + // Called when the composer unmounts: cancels a queued presentation frame, the + // dismissal restore timer, and any focus-restore retries still in flight. + const dispose = () => { + isActiveRef.current = false; + openingIdRef.current += 1; + focusRestoreIdRef.current += 1; + restorePendingRef.current = false; + clearDismissRestoreTimer(); + }; return { isActive: phase === "opening" || phase === "visible", keepsComposerExpanded: phase !== "closed", isVisible: phase === "visible", open, + onEditorFocusChange, onDismissed, onStackTransitionsFinished, + dispose, } as const; } diff --git a/apps/mobile/src/features/threads/use-visible-second-clock.ts b/apps/mobile/src/features/threads/use-visible-second-clock.ts index 9abddf5ffcbf5..e48ab5a986948 100644 --- a/apps/mobile/src/features/threads/use-visible-second-clock.ts +++ b/apps/mobile/src/features/threads/use-visible-second-clock.ts @@ -1,27 +1,24 @@ import { useIsFocused } from "@react-navigation/native"; -import { useEffect, useLayoutEffect, useState } from "react"; -import { AppState } from "react-native"; +import { secondTick } from "@t3tools/client-runtime/clock"; +import { useSyncExternalStore } from "react"; -/** Hidden retained routes stay mounted; gate intervals to avoid background re-renders. */ +import { useAppActive } from "../../lib/appState"; + +const subscribeNever = () => () => undefined; + +/** + * Retained routes stay mounted when hidden, so an ungated tick would keep + * re-rendering them. Every visible label shares one module-level tick. + */ export function useVisibleSecondClock(enabled: boolean): number { const focused = useIsFocused(); - const [appActive, setAppActive] = useState(() => AppState.currentState === "active"); - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - const subscription = AppState.addEventListener("change", (state) => - setAppActive(state === "active"), - ); - // oxlint-disable-next-line react/set-state-in-effect -- Syncs state the listener missed. - setAppActive(AppState.currentState === "active"); - return () => subscription.remove(); - }, []); + const appActive = useAppActive(); const ticking = enabled && focused && appActive; - useLayoutEffect(() => { - if (!ticking) return; - // oxlint-disable-next-line react/set-state-in-effect -- Resuming reads the clock that stood still. - setNowMs(Date.now()); - const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(intervalId); - }, [ticking]); - return nowMs; + // While paused the snapshot still reads the shared tick, so resuming shows + // the current second rather than the one the clock stopped on. + return useSyncExternalStore( + ticking ? secondTick.subscribe : subscribeNever, + secondTick.getSnapshot, + secondTick.getSnapshot, + ); } diff --git a/apps/mobile/src/features/threads/use-worktree-setup.ts b/apps/mobile/src/features/threads/use-worktree-setup.ts index 0dd8d0f7028ea..e888701d30edb 100644 --- a/apps/mobile/src/features/threads/use-worktree-setup.ts +++ b/apps/mobile/src/features/threads/use-worktree-setup.ts @@ -1,6 +1,6 @@ import type { EnvironmentId, ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; import { resolveVisibleWorktreeSetup } from "@t3tools/client-runtime/worktree-setup"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useEnvironmentQuery } from "../../state/query"; import { vcsEnvironment } from "../../state/vcs"; import { resolveWorktreeSetupSnapshot } from "./worktree-setup-state"; @@ -26,9 +26,9 @@ export function useWorktreeSetup(input: { : null, ); const snapshot = resolveWorktreeSetupSnapshot(input.threadId, query.data, live); - useEffect(() => { - if (snapshot && snapshot !== live) setHeld({ key, snapshot }); - }, [key, live, snapshot]); + // Hold the newest snapshot so the query can stop once setup finishes. The + // resolver returns one of its inputs, so this settles after one pass. + if (snapshot && snapshot !== live) setHeld({ key, snapshot }); return { snapshot, visible: resolveVisibleWorktreeSetup({ diff --git a/apps/mobile/src/features/threads/useFileChipShare.ts b/apps/mobile/src/features/threads/useFileChipShare.ts index 7a57446271ca2..cff5703159b83 100644 --- a/apps/mobile/src/features/threads/useFileChipShare.ts +++ b/apps/mobile/src/features/threads/useFileChipShare.ts @@ -2,7 +2,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { assetEnvironment } from "../../state/assets"; @@ -21,49 +21,40 @@ export function useFileChipShare( refresh: true, reportFailure: false, }); - const connectionRef = useRef(httpBaseUrl); - useLayoutEffect(() => { - connectionRef.current = httpBaseUrl; - }, [httpBaseUrl]); const requestRef = useRef(null); useEffect(() => () => requestRef.current?.abort(), []); - const share = useCallback( - (target: FileChipTarget) => { - const source = fileChipShareSource(target, threadId); - if (!source || requestRef.current) return; - const request = new AbortController(); - requestRef.current = request; - const httpBaseUrl = connectionRef.current; - void (async () => { - if (httpBaseUrl === null) throw new Error("Reconnect to the environment and try again."); - const result = await createUrl({ environmentId, input: { resource: source.resource } }); - if (request.signal.aborted) return; - const url = - result._tag === "Success" ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) : null; - if (url === null) throw new Error("The file could not be loaded. Reconnect and try again."); - await downloadAndShareAttachment({ - url, - attachment: source, - signal: request.signal, - sourceIdentifier, - }); - })() - .catch((error: unknown) => { - if (!request.signal.aborted) { - Dialog.alert( - "Could not share file", - error instanceof Error ? error.message : "Try again.", - ); - } - }) - .finally(() => { - if (requestRef.current === request) { - requestRef.current = null; - } - }); - }, - [createUrl, environmentId, sourceIdentifier, threadId], - ); - return share; + return (target: FileChipTarget) => { + const source = fileChipShareSource(target, threadId); + if (!source || requestRef.current) return; + const request = new AbortController(); + requestRef.current = request; + void (async () => { + if (httpBaseUrl === null) throw new Error("Reconnect to the environment and try again."); + const result = await createUrl({ environmentId, input: { resource: source.resource } }); + if (request.signal.aborted) return; + const url = + result._tag === "Success" ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) : null; + if (url === null) throw new Error("The file could not be loaded. Reconnect and try again."); + await downloadAndShareAttachment({ + url, + attachment: source, + signal: request.signal, + sourceIdentifier, + }); + })() + .catch((error: unknown) => { + if (!request.signal.aborted) { + Dialog.alert( + "Could not share file", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (requestRef.current === request) { + requestRef.current = null; + } + }); + }; } diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index 73c95d01d8eb1..882659bbea324 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -1,8 +1,9 @@ +import { AppState } from "react-native"; import { describe, expect, it, vi } from "vite-plus/test"; import { createAppUpdateDeferral, - createAppUpdateLaunchCheck, + installAppUpdateChecks, runAppUpdateCheck, type AppUpdateCheckState, type AppUpdateClient, @@ -234,10 +235,9 @@ describe("runAppUpdateCheck", () => { const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(() => checkResult), }); - const checkOnLaunch = createAppUpdateLaunchCheck(client); const manualStates: AppUpdateCheckState[] = []; - const launchCheck = checkOnLaunch(); + const launchCheck = runAppUpdateCheck({ client }); const manualCheck = runAppUpdateCheck({ client, onStateChange: (state) => manualStates.push(state), @@ -303,16 +303,16 @@ describe("runAppUpdateCheck", () => { }); }); -describe("createAppUpdateLaunchCheck", () => { - it("checks at most once for each JavaScript launch", async () => { +describe("installAppUpdateChecks", () => { + it("checks once at launch and stops listening for foregrounds when disposed", () => { + const remove = vi.fn(); + vi.mocked(AppState.addEventListener).mockReturnValueOnce({ remove }); const client = makeUpdateClient(); - const checkOnLaunch = createAppUpdateLaunchCheck(client); - const first = checkOnLaunch(); - const second = checkOnLaunch(); - await first; - - expect(second).toBeUndefined(); + const dispose = installAppUpdateChecks(client); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + dispose(); + + expect(remove).toHaveBeenCalledOnce(); }); }); diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 87111109ebb8a..2d418f028f3de 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -466,20 +466,6 @@ function isAppUpdateUnavailableError(error: unknown): boolean { return typeof code === "string" && UPDATE_CHECK_UNAVAILABLE_ERROR_CODES.has(code); } -export function createAppUpdateLaunchCheck( - client: AppUpdateClient = Updates, -): () => Promise | undefined { - let started = false; - - return () => { - if (started || !isAppUpdateCheckAvailable(client)) return undefined; - started = true; - return runAppUpdateCheck({ client }); - }; -} - -export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck(); - export const FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS = 15 * 60 * 1000; export function shouldRecheckAppUpdateOnForeground( @@ -494,31 +480,30 @@ export function shouldRecheckAppUpdateOnForeground( ); } -export function createAppUpdateForegroundRecheck( +/** + * App-lifetime update checks: one at launch, then again when the app returns + * from a long stay in the background. + */ +export function installAppUpdateChecks( client: AppUpdateClient = Updates, deferral: AppUpdateDeferral = appUpdateDeferral, ): () => void { - let started = false; - - return () => { - if (started || !isAppUpdateCheckAvailable(client)) return; - started = true; - let backgroundedAtMs: number | null = null; - AppState.addEventListener("change", (state) => { - if (state === "background") { - backgroundedAtMs = Date.now(); - return; - } - if (state !== "active") return; - const shouldCheck = shouldRecheckAppUpdateOnForeground( - backgroundedAtMs, - Date.now(), - deferral.pendingInstall, - ); - backgroundedAtMs = null; - if (shouldCheck) void runAppUpdateCheck({ client, deferral }); - }); - }; + if (!isAppUpdateCheckAvailable(client)) return () => undefined; + void runAppUpdateCheck({ client }); + let backgroundedAtMs: number | null = null; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "background") { + backgroundedAtMs = Date.now(); + return; + } + if (state !== "active") return; + const shouldCheck = shouldRecheckAppUpdateOnForeground( + backgroundedAtMs, + Date.now(), + deferral.pendingInstall, + ); + backgroundedAtMs = null; + if (shouldCheck) void runAppUpdateCheck({ client, deferral }); + }); + return () => subscription.remove(); } - -export const startAppUpdateForegroundRecheck = createAppUpdateForegroundRecheck(); diff --git a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx index b1b0d2b69ac9b..76e175e4c96e1 100644 --- a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx +++ b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx @@ -1,12 +1,12 @@ import type { VoiceInputPhase, VoiceInputState } from "@t3tools/client-runtime/voice-input"; -import { memo, useCallback, useLayoutEffect, useState, type ReactNode } from "react"; +import { memo, useCallback, useState, type ReactNode } from "react"; import { Linking, Pressable, Text as SystemText, View, type LayoutChangeEvent } from "react-native"; import Animated, { Easing, ReduceMotion, useAnimatedStyle, + useDerivedValue, useReducedMotion, - useSharedValue, withTiming, type EntryExitAnimationFunction, type SharedValue, @@ -88,10 +88,8 @@ export function ComposerDictationDraftContent(props: { readonly compact: boolean; readonly hidden: boolean; }) { - const rotation = useSharedValue(props.hidden ? 1 : 0); - useLayoutEffect(() => { - rotation.value = withTiming(props.hidden ? 1 : 0, TOOLBAR_FLIP_TIMING); - }, [props.hidden, rotation]); + const hidden = props.hidden; + const rotation = useDerivedValue(() => withTiming(hidden ? 1 : 0, TOOLBAR_FLIP_TIMING)); const compact = props.compact; const animatedStyle = useAnimatedStyle(() => ({ opacity: compact ? 1 - rotation.value : 1, @@ -146,7 +144,7 @@ export function ComposerDictationToolbar(props: { } const WaveformBar = memo(function WaveformBar(props: { - readonly audioLevels: SharedValue; + readonly audioLevels: SharedValue>; readonly sampleIndex: number; readonly height?: number; }) { @@ -179,7 +177,7 @@ const COMPACT_WAVEFORM_BAR_COUNT = 8; const COMPACT_WAVEFORM_BAR_HEIGHT = 14; export const CompactVoiceWaveform = memo(function CompactVoiceWaveform(props: { - readonly audioLevels: SharedValue; + readonly audioLevels: SharedValue>; }) { return ( ; + readonly audioLevels: SharedValue>; }) { const [barCount, setBarCount] = useState(0); const handleLayout = useCallback((event: LayoutChangeEvent) => { @@ -261,11 +259,10 @@ function VoiceActionButton(props: { readonly variant?: "plain" | "primary"; }) { const variant = props.variant ?? "plain"; - const loadingVisibility = useSharedValue(props.loading ? 1 : 0); - useLayoutEffect(() => { - loadingVisibility.value = withTiming(props.loading ? 1 : 0, DICTATION_TIMING); - }, [loadingVisibility, props.loading]); - const primaryStyle = useAnimatedStyle(() => ({ opacity: 1 - loadingVisibility.value })); + const loading = props.loading === true; + const primaryStyle = useAnimatedStyle(() => ({ + opacity: withTiming(loading ? 0 : 1, DICTATION_TIMING), + })); return ( ; + readonly audioLevels: SharedValue>; readonly elapsedSeconds: number; readonly phase: VoiceInputPhase; readonly presentation: VoiceComposerPresentation; readonly onDismissError: () => void; }) { - const recordingVisibility = useSharedValue(props.phase === "recording" ? 1 : 0); - useLayoutEffect(() => { - recordingVisibility.value = withTiming(props.phase === "recording" ? 1 : 0, DICTATION_TIMING); - }, [props.phase, recordingVisibility]); + const recording = props.phase === "recording"; const waveformStyle = useAnimatedStyle(() => ({ - opacity: recordingVisibility.value, + opacity: withTiming(recording ? 1 : 0, DICTATION_TIMING), })); const labelStyle = useAnimatedStyle(() => ({ - opacity: 1 - recordingVisibility.value, + opacity: withTiming(recording ? 0 : 1, DICTATION_TIMING), })); if (!props.presentation.statusLabel) return null; diff --git a/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx b/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx index 9b212a86732c3..85e10972189ba 100644 --- a/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx +++ b/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx @@ -1,5 +1,5 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; -import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { useState, type ReactNode } from "react"; import { Linking, Platform, Pressable, useWindowDimensions, View } from "react-native"; import Animated, { Easing, @@ -8,7 +8,7 @@ import Animated, { interpolate, ReduceMotion, useAnimatedStyle, - useSharedValue, + useDerivedValue, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -35,18 +35,22 @@ const ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); export function GlobalVoiceInputControl(props: { readonly children: ReactNode }) { const voice = useGlobalVoiceInput(); - const collapseRef = useRef<(() => void) | null>(null); + const [expanded, setExpanded] = useState(false); const presentation = resolveVoiceComposerPresentation(voice.state, voice.elapsedSeconds); const visible = presentation.statusLabel !== null && !(voice.ownerKey && voice.focusedOwners.has(voice.ownerKey)); - const pill = visible ? : null; + // A pill that reappears starts collapsed. + if (!visible && expanded) setExpanded(false); + const pill = visible ? ( + + ) : null; return ( <> { - collapseRef.current?.(); + if (expanded) setExpanded(false); return false; }} > @@ -57,24 +61,16 @@ export function GlobalVoiceInputControl(props: { readonly children: ReactNode }) ); } -function EdgeDictationPill(props: { readonly collapseRef: RefObject<(() => void) | null> }) { +function EdgeDictationPill(props: { + readonly expanded: boolean; + readonly onExpandedChange: (expanded: boolean) => void; +}) { 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 { expanded, onExpandedChange: setExpanded } = props; + const progress = useDerivedValue(() => withTiming(expanded ? 1 : 0, MORPH_TIMING)); const expandedWidth = Math.min(width - insets.left - insets.right - 32, EXPANDED_MAX_WIDTH); const pillStyle = useAnimatedStyle(() => ({ diff --git a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx index a1660bb6f1f84..e740b118b2027 100644 --- a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx +++ b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx @@ -1,4 +1,4 @@ -import { RegistryContext, useAtomMount } from "@effect/atom-react"; +import { useAtomValue } from "@effect/atom-react"; import { AudioModule, RecordingPresets, @@ -10,20 +10,12 @@ import { } 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 { useSyncExternalStore } from "react"; import { AppState, Platform } from "react-native"; -import { AsyncResult } from "effect/reactivity"; -import { useSharedValue } from "react-native-reanimated"; +import { Atom, AsyncResult } from "effect/reactivity"; +import { makeMutable } from "react-native-reanimated"; +import { subscribeAppState } from "../../lib/appState"; import { microphoneOrder, preferredMicrophoneInput, @@ -32,17 +24,11 @@ import { import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; -import { - VOICE_RECORDING_LIMIT_SECONDS, - voiceInputBlocksSubmission, - type VoiceInputState, -} from "@t3tools/client-runtime/voice-input"; -import { createLazyVoiceRecorder, type LazyVoiceRecorder } from "./lazyVoiceRecorder"; -import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; +import { voiceInputBlocksSubmission } from "@t3tools/client-runtime/voice-input"; +import { createLazyVoiceRecorder } from "./lazyVoiceRecorder"; +import { 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; // The native constructor expects platform-flattened options, as built by useAudioRecorder. const { ios: IOS_RECORDING_OPTIONS, android: ANDROID_RECORDING_OPTIONS } = RecordingPresets.HIGH_QUALITY; @@ -81,181 +67,97 @@ async function configureVoiceRecordingAudio(): Promise { } } -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 keepScreenAwake(tag: string): () => void { + const activation = activateKeepAwakeAsync(tag); + void activation.catch(() => {}); + // Release after activation settles, even if the recording ends immediately. + return () => void activation.then(() => deactivateKeepAwake(tag)).catch(() => {}); } -function useVoiceInputRuntime() { - const registry = use(RegistryContext); - useAtomMount(mobilePreferencesAtom); - useAtomMount(updateMobilePreferencesAtom); - 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 recorderRef = useRef | null>(null); - - if (!sessionRef.current || !recorderRef.current) { - const recorder = createLazyVoiceRecorder({ - create: () => new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS), - onStatus: (status: RecordingStatus) => { - sessionRef.current?.controller.handleRecorderStatus({ - isFinished: status.isFinished, - hasError: status.hasError || status.mediaServicesDidReset === true, - error: status.error, - url: status.url, +/** + * The app has one microphone and one dictation at a time, so the session + * lives for the registry's lifetime rather than any screen's. + */ +const voiceInputAtom = Atom.make((get) => { + const registry = get.registry; + // Mounted so the recorder can read and remember microphones synchronously. + get.mount(mobilePreferencesAtom); + get.mount(updateMobilePreferencesAtom); + const audioLevels = makeMutable>( + Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0), + ); + let keepAwakeSequence = 0; + const recorder = createLazyVoiceRecorder({ + create: () => new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS), + onStatus: (status: RecordingStatus) => { + session.controller.handleRecorderStatus({ + isFinished: status.isFinished, + hasError: status.hasError || status.mediaServicesDidReset === true, + error: status.error, + url: status.url, + }); + }, + // Read when recording starts so preference changes do not re-render every composer. + selectInput: (inputs) => { + const preferences = registry.get(mobilePreferencesAtom); + if (!AsyncResult.isSuccess(preferences)) return null; + const known = microphoneOrder(preferences.value.microphones); + const order = rememberMicrophones(known, inputs); + if (order !== known) { + registry.set(updateMobilePreferencesAtom, { + transform: (current) => ({ + microphones: rememberMicrophones(microphoneOrder(current.microphones), inputs), + }), }); - }, - // Read when recording starts so preference changes do not re-render every composer. - selectInput: (inputs) => { - const preferences = registry.get(mobilePreferencesAtom); - if (!AsyncResult.isSuccess(preferences)) return null; - const known = microphoneOrder(preferences.value.microphones); - const order = rememberMicrophones(known, inputs); - if (order !== known) { - registry.set(updateMobilePreferencesAtom, { - transform: (current) => ({ - microphones: rememberMicrophones(microphoneOrder(current.microphones), inputs), - }), - }); - } - return preferredMicrophoneInput(inputs, order); - }, - }); - recorderRef.current = recorder; - 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; - const recorder = recorderRef.current; - - useEffect(() => { - const subscription = AppState.addEventListener("change", (nextState) => { - // iOS permission dialogs report inactive; cancel preparation only on background. Recorder status handles capture interruptions. - if (nextState === "background") controller.appMovedToBackground(); - }); - return () => subscription.remove(); - }, [controller]); - - useEffect( - () => () => { - controller.dispose(); - recorder.release(); + } + return preferredMicrophoneInput(inputs, order); }, - [controller, recorder], - ); - - 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 session: VoiceInputSession = 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(), + sampleRecording: () => { 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 status?.isRecording + ? { durationMillis: status.durationMillis, metering: status.metering } + : null; + }, + publishAudioLevels: (levels) => { + audioLevels.value = levels; + }, + keepAwake: () => keepScreenAwake(`voice-input:${++keepAwakeSequence}`), + }); + // 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. + const unsubscribeAppState = subscribeAppState(() => { + if (AppState.currentState === "background") void session.controller.appMovedToBackground(); + }); + get.addFinalizer(() => { + unsubscribeAppState(); + session.dispose(); + recorder.release(); + }); + return { session, audioLevels }; +}).pipe(Atom.keepAlive, Atom.withLabel("mobile:voice-input")); +export function useGlobalVoiceInput() { + const { session, audioLevels } = useAtomValue(voiceInputAtom); + const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot); return { + ...snapshot, isAvailable: getLocalVoiceTranscriber() !== null || getNativeShowcaseScene() !== null, - state, audioLevels, - elapsedSeconds, - isBusy: voiceInputBlocksSubmission(state), - ownerKey, - label, - focusedOwners, - setOwnerFocused, + isBusy: voiceInputBlocksSubmission(snapshot.state), session, - stop, - cancel, + stop: () => session.controller.stop(), + cancel: () => session.controller.cancel(), }; } diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts index 153de8a334240..af392842fa5e1 100644 --- a/apps/mobile/src/features/voice-input/useVoiceInputController.ts +++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts @@ -1,5 +1,4 @@ import { useFocusEffect } from "@react-navigation/native"; -import { useCallback, useEffect, useRef } from "react"; import { voiceInputBlocksSubmission, type VoiceInputState, @@ -22,45 +21,34 @@ export function useVoiceInputController(input: { readonly onChangeSelection: (selection: ComposerEditorSelection) => void; }) { const global = useGlobalVoiceInput(); - const { setOwnerFocused, session } = global; - const latestInput = useRef(input); - latestInput.current = input; - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); + const { session } = global; + const ownerKey = input.ownerKey; - useFocusEffect( - useCallback(() => { - const ownerKey = input.ownerKey; - if (!ownerKey) return; - setOwnerFocused(ownerKey, true); - return () => setOwnerFocused(ownerKey, false); - }, [input.ownerKey, setOwnerFocused]), - ); + useFocusEffect(() => { + if (!ownerKey) return; + session.setOwnerFocused(ownerKey, true); + return () => session.setOwnerFocused(ownerKey, false); + }); - const start = useCallback(() => { - const captured = latestInput.current; - if (!captured.ownerKey || captured.disabled) return; + const start = () => { + if (!ownerKey || input.disabled) return; + const { onChangeDraftMessage, onChangeSelection } = input; void session.start({ ...createVoiceInputTarget( - captured.ownerKey, - captured.readDraftMessage, + ownerKey, + input.readDraftMessage, + // Both sinks are keyed by owner, so a late commit cannot move the + // cursor of a draft this screen switched to meanwhile. (text, selection) => { - captured.onChangeDraftMessage(text); - if (mounted.current && latestInput.current.ownerKey === captured.ownerKey) { - latestInput.current.onChangeSelection(selection); - } + onChangeDraftMessage(text); + onChangeSelection(selection); }, - captured.selection, - captured.subscribeToDraftChanges, + input.selection, + input.subscribeToDraftChanges, ), - label: captured.label, + label: input.label, }); - }, [session]); + }; const state = global.ownerKey === input.ownerKey ? global.state : IDLE_STATE; const isBusy = voiceInputBlocksSubmission(state); return { diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts index b57aa0916c687..65e4fbf20d791 100644 --- a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts +++ b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts @@ -7,6 +7,7 @@ import { createVoiceInputTarget, VoiceInputSession, type VoiceInputTarget, + type VoiceRecordingSample, } from "./voiceInputSession"; function createTarget( @@ -18,7 +19,10 @@ function createTarget( return createVoiceInputTarget(ownerKey, readText, commit, selection, () => () => {}); } -function createSession() { +function createSession(sampleRecording: () => VoiceRecordingSample | null = () => null) { + const releaseKeepAwake = vi.fn(); + const keepAwake = vi.fn(() => releaseKeepAwake); + const publishAudioLevels = vi.fn(); const recorder = { uri: "file:///voice.m4a", prepareToRecordAsync: vi.fn(async () => {}), @@ -36,9 +40,11 @@ function createSession() { configureRecording: async () => {}, releaseRecording: vi.fn(async () => {}), deleteRecording: vi.fn(), - onStateChange: vi.fn(), + sampleRecording, + publishAudioLevels, + keepAwake, }); - return { session, recorder, prepare }; + return { session, recorder, prepare, keepAwake, releaseKeepAwake, publishAudioLevels }; } describe("global voice input", () => { @@ -253,4 +259,59 @@ describe("global voice input", () => { expect(oldCommit).not.toHaveBeenCalled(); expect(nextCommit).toHaveBeenCalledWith("second spoken text", { start: 18, end: 18 }); }); + + it("holds the screen awake and meters only while recording", async () => { + vi.useFakeTimers(); + try { + let sample: VoiceRecordingSample = { durationMillis: 0, metering: -100 }; + const { session, keepAwake, releaseKeepAwake, publishAudioLevels } = createSession( + () => sample, + ); + const snapshots: number[] = []; + session.subscribe(() => snapshots.push(session.getSnapshot().elapsedSeconds)); + + await session.start(createTarget("first", () => "first", vi.fn(), { start: 5, end: 5 })); + expect(session.getSnapshot().state.phase).toBe("recording"); + expect(keepAwake).toHaveBeenCalledTimes(1); + // Silence on a silent history publishes nothing. + expect(publishAudioLevels).not.toHaveBeenCalled(); + + sample = { durationMillis: 2_400, metering: 0 }; + vi.advanceTimersByTime(80); + expect(session.getSnapshot().elapsedSeconds).toBe(2); + expect(publishAudioLevels).toHaveBeenLastCalledWith(expect.arrayContaining([1])); + + await session.controller.stop(); + expect(releaseKeepAwake).toHaveBeenCalledTimes(1); + const publishedLevels = publishAudioLevels.mock.calls.length; + const publishedSnapshots = snapshots.length; + vi.advanceTimersByTime(800); + expect(publishAudioLevels).toHaveBeenCalledTimes(publishedLevels); + expect(snapshots).toHaveLength(publishedSnapshots); + // The finished recording's time stays readable until the next one starts. + expect(session.getSnapshot().elapsedSeconds).toBe(2); + + sample = { durationMillis: 0, metering: -100 }; + await session.start(createTarget("first", () => "first", vi.fn(), { start: 5, end: 5 })); + expect(session.getSnapshot().elapsedSeconds).toBe(0); + expect(publishAudioLevels).toHaveBeenLastCalledWith(expect.not.arrayContaining([1])); + expect(keepAwake).toHaveBeenCalledTimes(2); + session.dispose(); + expect(releaseKeepAwake).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("publishes which owners are focused", () => { + const { session } = createSession(); + const listener = vi.fn(); + session.subscribe(listener); + session.setOwnerFocused("first", true); + session.setOwnerFocused("first", true); + expect(session.getSnapshot().focusedOwners).toEqual(new Set(["first"])); + expect(listener).toHaveBeenCalledTimes(1); + session.setOwnerFocused("first", false); + expect(session.getSnapshot().focusedOwners.size).toBe(0); + }); }); diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.ts b/apps/mobile/src/features/voice-input/voiceInputSession.ts index 372c1081d6747..5bf9395daf59c 100644 --- a/apps/mobile/src/features/voice-input/voiceInputSession.ts +++ b/apps/mobile/src/features/voice-input/voiceInputSession.ts @@ -1,10 +1,19 @@ import { + VOICE_RECORDING_LIMIT_SECONDS, VoiceInputController, voiceInputBlocksSubmission, type VoiceDraftSnapshot, type VoiceInputControllerDependencies, + type VoiceInputPhase, + type VoiceInputState, } from "@t3tools/client-runtime/voice-input"; +import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; + +const VOICE_METERING_INTERVAL_MS = 80; +const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; +const SILENT_LEVELS: ReadonlyArray = Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0); + export type VoiceInputTarget = { readonly ownerKey: string; readonly label?: string; @@ -13,6 +22,31 @@ export type VoiceInputTarget = { readonly subscribe: () => () => void; }; +export type VoiceInputSnapshot = { + readonly state: VoiceInputState; + readonly ownerKey: string | null; + readonly label: string | null; + readonly elapsedSeconds: number; + readonly focusedOwners: ReadonlySet; +}; + +export type VoiceRecordingSample = { + readonly durationMillis: number; + readonly metering: number | undefined; +}; + +export type VoiceInputSessionDependencies = Omit< + VoiceInputControllerDependencies, + "readDraft" | "commitDraft" | "onStateChange" +> & { + /** Reads the live recorder, or null when it is not capturing. */ + readonly sampleRecording: () => VoiceRecordingSample | null; + /** Levels change every metering tick, so they bypass React state. */ + readonly publishAudioLevels: (levels: ReadonlyArray) => void; + /** Holds the screen awake while recording; returns the release. */ + readonly keepAwake: () => () => void; +}; + export function createVoiceInputTarget( ownerKey: string, readText: () => string | null, @@ -42,21 +76,32 @@ export function createVoiceInputTarget( }; } +/** + * The app's single voice input: it owns the controller, the recording-only + * side effects (keep-awake, metering) and publishes what the UI renders. + */ export class VoiceInputSession { readonly controller: VoiceInputController; + private readonly dependencies: VoiceInputSessionDependencies; private target: VoiceInputTarget | null = null; private unsubscribe: (() => void) | null = null; + private snapshot: VoiceInputSnapshot = { + state: IDLE_STATE, + ownerKey: null, + label: null, + elapsedSeconds: 0, + focusedOwners: new Set(), + }; + private readonly listeners = new Set<() => void>(); + private audioLevels = SILENT_LEVELS; + private releaseKeepAwake: (() => void) | null = null; + private meteringInterval: ReturnType | null = null; - constructor(dependencies: Omit) { + constructor(dependencies: VoiceInputSessionDependencies) { + this.dependencies = dependencies; this.controller = new VoiceInputController({ ...dependencies, - onStateChange: (state) => { - if (!voiceInputBlocksSubmission(state)) { - this.unsubscribe?.(); - this.unsubscribe = null; - } - dependencies.onStateChange(state); - }, + onStateChange: (state) => this.handleStateChange(state), readDraft: () => this.target?.readDraft() ?? null, commitDraft: (text, selection) => this.target?.commitDraft(text, selection), }); @@ -70,6 +115,21 @@ export class VoiceInputSession { return this.target?.label ?? null; } + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + readonly getSnapshot = (): VoiceInputSnapshot => this.snapshot; + + setOwnerFocused(ownerKey: string, focused: boolean): void { + if (this.snapshot.focusedOwners.has(ownerKey) === focused) return; + const focusedOwners = new Set(this.snapshot.focusedOwners); + if (focused) focusedOwners.add(ownerKey); + else focusedOwners.delete(ownerKey); + this.publish({ ...this.snapshot, focusedOwners }); + } + cancel(ownerKey: string | null): void { if (ownerKey !== null && this.ownerKey === ownerKey) this.controller.cancel(); } @@ -84,4 +144,84 @@ export class VoiceInputSession { this.unsubscribe = target.subscribe(); return this.controller.start(); } + + dispose(): void { + this.controller.dispose(); + this.stopRecordingActivity(); + this.unsubscribe?.(); + this.unsubscribe = null; + this.listeners.clear(); + } + + private handleStateChange(state: VoiceInputState): void { + if (!voiceInputBlocksSubmission(state)) { + this.unsubscribe?.(); + this.unsubscribe = null; + } + const previousPhase = this.snapshot.state.phase; + let elapsedSeconds = this.snapshot.elapsedSeconds; + if (state.phase !== previousPhase) { + if (previousPhase === "recording") this.stopRecordingActivity(); + if (capturesAudio(state.phase)) { + if (this.audioLevels.some((level) => level !== 0)) this.setAudioLevels(SILENT_LEVELS); + elapsedSeconds = 0; + } + } + this.publish({ + ...this.snapshot, + state, + ownerKey: this.ownerKey, + label: this.label, + elapsedSeconds, + }); + if (state.phase === "recording" && previousPhase !== "recording") { + this.startRecordingActivity(); + } + } + + private startRecordingActivity(): void { + this.releaseKeepAwake = this.dependencies.keepAwake(); + this.sampleRecording(); + this.meteringInterval = setInterval(() => this.sampleRecording(), VOICE_METERING_INTERVAL_MS); + } + + private stopRecordingActivity(): void { + if (this.meteringInterval !== null) clearInterval(this.meteringInterval); + this.meteringInterval = null; + this.releaseKeepAwake?.(); + this.releaseKeepAwake = null; + } + + private sampleRecording(): void { + if (this.controller.currentState.phase !== "recording") return; + const sample = this.dependencies.sampleRecording(); + if (!sample) return; + + const level = normalizeVoiceInputDecibels(sample.metering); + if (level !== 0 || this.audioLevels.some((sample) => sample !== 0)) { + this.setAudioLevels([...this.audioLevels.slice(1), level]); + } + + const elapsedSeconds = Math.min( + VOICE_RECORDING_LIMIT_SECONDS, + Math.max(0, Math.floor(sample.durationMillis / 1_000)), + ); + if (elapsedSeconds !== this.snapshot.elapsedSeconds) { + this.publish({ ...this.snapshot, elapsedSeconds }); + } + } + + private setAudioLevels(levels: ReadonlyArray): void { + this.audioLevels = levels; + this.dependencies.publishAudioLevels(levels); + } + + private publish(snapshot: VoiceInputSnapshot): void { + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } +} + +function capturesAudio(phase: VoiceInputPhase): boolean { + return phase === "preparing" || phase === "recording"; } diff --git a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts b/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts index 59d1ac1fd8dcd..87f2414b73890 100644 --- a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts +++ b/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; import { startOpeningEnvironmentThread } from "../../state/threads"; +import { withOpenedNewTaskDraft } from "../threads/open-new-task-draft"; import { selectWorkspaceDetail, type WorkspaceDetail, @@ -32,7 +33,14 @@ export function useSelectWorkspaceDetail() { selectDetail ?? ((detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => { startOpeningDetail(detail); - navigation.dispatch(selectWorkspaceDetail(detail, sheet)); + navigation.dispatch( + selectWorkspaceDetail( + detail.name === "Thread" + ? { ...detail, params: withOpenedNewTaskDraft(detail.params) } + : detail, + sheet, + ), + ); }) ); } diff --git a/apps/mobile/src/lib/appLinking.test.ts b/apps/mobile/src/lib/appLinking.test.ts index 480f58e0daaa8..b155800789234 100644 --- a/apps/mobile/src/lib/appLinking.test.ts +++ b/apps/mobile/src/lib/appLinking.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from "vite-plus/test"; -import { shouldHandleAppLink, restoreDraftLinkState } from "./appLinking"; +import { + findLinkedRoute, + restoreDraftLinkState, + shouldHandleAppLink, + updateLinkedRoute, +} from "./appLinking"; describe("shouldHandleAppLink", () => { it.each(["t3code://", "t3code:///", "t3code-dev://", "t3code-preview://"])( "ignores scheme-only URL %s", (url) => { - expect(shouldHandleAppLink(url)).toBe(false); + expect(shouldHandleAppLink(url, true)).toBe(false); }, ); @@ -15,13 +20,32 @@ describe("shouldHandleAppLink", () => { "t3code://pair?pairingUrl=x", "t3code-dev://settings/usage?tab=limits", ])("handles path-bearing URL %s", (url) => { - expect(shouldHandleAppLink(url)).toBe(true); + expect(shouldHandleAppLink(url, true)).toBe(true); }); it.each(["t3code://expo-development-client/?url=x", "t3code://expo-sharing/anything"])( "ignores lifecycle URL %s", (url) => { - expect(shouldHandleAppLink(url)).toBe(false); + expect(shouldHandleAppLink(url, true)).toBe(false); + }, + ); +}); + +describe("cloud-only deep links", () => { + it.each([ + "t3code://connect-onboarding", + "t3code-dev://connect-onboarding/?source=x", + "t3code://settings/auth", + "exp://192.168.1.2:8081/--/settings/auth", + ])("ignores %s without cloud config", (url) => { + expect(shouldHandleAppLink(url, false)).toBe(false); + expect(shouldHandleAppLink(url, true)).toBe(true); + }); + + it.each(["t3code://settings", "t3code://settings/authors", "t3code://pair?next=settings/auth"])( + "still handles %s without cloud config", + (url) => { + expect(shouldHandleAppLink(url, false)).toBe(true); }, ); }); @@ -65,3 +89,40 @@ describe("draft deep links", () => { expect(restoreDraftLinkState(undefined, "/new/draft")).toBeUndefined(); }); }); + +describe("nested link routes", () => { + const settingsLink = { + routes: [ + { name: "Home" }, + { + name: "SettingsSheet", + state: { + routes: [ + { name: "Settings" }, + { name: "SettingsEnvironmentNew", params: { pairingUrl: "x", autoConnect: "1" } }, + ], + }, + }, + ], + }; + + it("finds a route the link opens inside a nested navigator", () => { + expect(findLinkedRoute(settingsLink, ["SettingsEnvironmentNew"])?.params).toEqual({ + pairingUrl: "x", + autoConnect: "1", + }); + expect(findLinkedRoute(settingsLink, ["ConnectionsNew"])).toBeUndefined(); + }); + + it("rewrites a nested route without touching the rest of the state", () => { + const next = updateLinkedRoute(settingsLink, ["SettingsEnvironmentNew"], (route) => ({ + ...route, + params: { pairingUrl: "x" }, + })); + expect(findLinkedRoute(next, ["SettingsEnvironmentNew"])?.params).toEqual({ pairingUrl: "x" }); + expect(next?.routes[0]).toBe(settingsLink.routes[0]); + expect(updateLinkedRoute(settingsLink, ["ConnectionsNew"], (route) => route)).toBe( + settingsLink, + ); + }); +}); diff --git a/apps/mobile/src/lib/appLinking.ts b/apps/mobile/src/lib/appLinking.ts index 08263bb94d287..934a68f0677cc 100644 --- a/apps/mobile/src/lib/appLinking.ts +++ b/apps/mobile/src/lib/appLinking.ts @@ -6,14 +6,21 @@ import type { NavigationState, PartialState } from "@react-navigation/native"; * the launcher, not app navigation. Without this filter it falls through * to the NotFound wildcard route on every dev launch. */ -export function shouldHandleAppLink(url: string): boolean { +export function shouldHandleAppLink(url: string, cloudConfigured: boolean): boolean { return ( !url.includes("expo-development-client") && !url.includes("://expo-sharing") && - !/^t3code(-dev|-preview)?:\/*$/.test(url) + !/^t3code(-dev|-preview)?:\/*$/.test(url) && + (cloudConfigured || !CLOUD_ONLY_LINK_PATH.test(url.split(/[?#]/)[0] ?? "")) ); } +/** + * Screens that only exist with T3 Connect configured. A build without it has + * no entry points to them, so a link must not open one empty. + */ +const CLOUD_ONLY_LINK_PATH = /(?:^[a-z][\w+.-]*:\/*|\/)(?:connect-onboarding|settings\/auth)\/*$/i; + /** Keep the published draft URLs while using the root thread screen. */ export function restoreDraftLinkState( state: PartialState | undefined, @@ -29,3 +36,46 @@ export function restoreDraftLinkState( if (pathname !== "new/draft") routes.push(target); return { ...state, index: routes.length - 1, routes }; } + +type LinkState = PartialState; +type LinkRoute = LinkState["routes"][number]; + +function isLinkState(value: unknown): value is LinkState { + return typeof value === "object" && value !== null && Array.isArray((value as LinkState).routes); +} + +/** The first route named one of `names`, at any nesting depth of a parsed link. */ +export function findLinkedRoute( + state: LinkState | undefined, + names: ReadonlyArray, +): LinkRoute | undefined { + for (const route of state?.routes ?? []) { + if (names.includes(route.name)) return route; + const nested = isLinkState(route.state) ? findLinkedRoute(route.state, names) : undefined; + if (nested) return nested; + } + return undefined; +} + +/** Rewrites the first route named one of `names`, at any nesting depth of a parsed link. */ +export function updateLinkedRoute( + state: LinkState | undefined, + names: ReadonlyArray, + update: (route: LinkRoute) => LinkRoute, +): LinkState | undefined { + if (!state) return state; + for (const [index, route] of state.routes.entries()) { + let next: LinkRoute | undefined; + if (names.includes(route.name)) next = update(route); + else if (isLinkState(route.state)) { + const nested = updateLinkedRoute(route.state, names, update); + if (nested !== route.state) next = { ...route, state: nested }; + } + if (next) { + const routes = state.routes.slice(); + routes[index] = next; + return { ...state, routes }; + } + } + return state; +} diff --git a/apps/mobile/src/lib/composerImagePreview.test.ts b/apps/mobile/src/lib/composerImagePreview.test.ts new file mode 100644 index 0000000000000..0859cdcbb028d --- /dev/null +++ b/apps/mobile/src/lib/composerImagePreview.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ write: vi.fn(async () => undefined) })); + +vi.mock("expo-file-system", () => ({ + Paths: { cache: "file:///cache" }, + Directory: class { + constructor( + readonly parent: string, + readonly name: string, + ) {} + create() {} + }, + File: class { + readonly uri: string; + constructor(directory: { parent: string; name: string }, name: string) { + this.uri = `${directory.parent}/${directory.name}/${name}`; + } + get exists() { + return false; + } + create() {} + write = mocks.write; + }, +})); + +import type { EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult, AtomRegistry } from "effect/reactivity"; + +import type { DraftComposerImageAttachment } from "./composerImages"; +import { MaterializedPreviewKey, materializedPreviewAtom } from "./composerImagePreview"; + +describe("materializedPreviewAtom", () => { + it("keeps the materialized preview when the upload reference lands", async () => { + const registry = AtomRegistry.make(); + const attachment: DraftComposerImageAttachment = { + id: "image-1", + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + previewUri: "data:image/png;base64,AAAA", + }; + const atom = materializedPreviewAtom(new MaterializedPreviewKey(attachment)); + const unmount = registry.mount(atom); + await vi.waitFor(() => expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true)); + + const uploaded = materializedPreviewAtom( + new MaterializedPreviewKey({ + ...attachment, + uploadedAttachmentId: "uploaded-1", + uploadEnvironmentId: "env-1" as EnvironmentId, + }), + ); + + expect(uploaded).toBe(atom); + const result = registry.get(uploaded); + expect(AsyncResult.isSuccess(result) && result.value).toBe( + "file:///cache/t3-composer-previews/image-1.png", + ); + expect(mocks.write).toHaveBeenCalledTimes(1); + unmount(); + }); +}); diff --git a/apps/mobile/src/lib/composerImagePreview.ts b/apps/mobile/src/lib/composerImagePreview.ts new file mode 100644 index 0000000000000..76ff225f26327 --- /dev/null +++ b/apps/mobile/src/lib/composerImagePreview.ts @@ -0,0 +1,52 @@ +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Hash from "effect/Hash"; +import { Atom } from "effect/reactivity"; + +import type { DraftComposerImageAttachment } from "./composerImages"; + +const PREVIEW_CACHE_DIRECTORY = "t3-composer-previews"; + +async function materializeDataUrlPreview(id: string, dataUrl: string): Promise { + const comma = dataUrl.indexOf(","); + if (comma < 0) return null; + const { Directory, File, Paths } = await import("expo-file-system"); + const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/jpeg"; + const extension = (mimeType.split("/")[1] ?? "jpg").replace("jpeg", "jpg"); + const directory = new Directory(Paths.cache, PREVIEW_CACHE_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const file = new File(directory, `${id}.${extension}`); + if (!file.exists) { + file.create(); + await file.write(dataUrl.slice(comma + 1), { encoding: "base64" }); + } + return file.uri; +} + +/** + * Keyed by attachment id only: the cached file is named by id, and an upload + * reference landing on the same draft must not restart materialization and + * blank the preview. Hashing the id also avoids rehashing a multi-megabyte data URL. + */ +export class MaterializedPreviewKey implements Equal.Equal { + constructor(readonly attachment: DraftComposerImageAttachment) {} + [Equal.symbol](that: unknown): boolean { + return that instanceof MaterializedPreviewKey && that.attachment.id === this.attachment.id; + } + [Hash.symbol](): number { + return Hash.string(this.attachment.id); + } +} + +export const materializedPreviewAtom = Atom.family(({ attachment }: MaterializedPreviewKey) => + Atom.make( + Effect.tryPromise(() => materializeDataUrlPreview(attachment.id, attachment.previewUri)).pipe( + Effect.catch((error) => + Effect.sync(() => { + console.warn("[composer-attachments] could not cache an image preview", error); + return null; + }), + ), + ), + ).pipe(Atom.withLabel(`composer-image-preview:${attachment.id}`)), +); diff --git a/apps/mobile/src/lib/useUiRuntimeMemoryWarningGc.ts b/apps/mobile/src/lib/useUiRuntimeMemoryWarningGc.ts index 3932e1a97ee27..66a437791dff6 100644 --- a/apps/mobile/src/lib/useUiRuntimeMemoryWarningGc.ts +++ b/apps/mobile/src/lib/useUiRuntimeMemoryWarningGc.ts @@ -1,4 +1,3 @@ -import { useEffect } from "react"; import { AppState } from "react-native"; import { scheduleOnUI } from "react-native-worklets"; @@ -9,11 +8,9 @@ function collectUiRuntimeGarbage() { } /** iOS memory warnings collect only RN's heap; collect worklets too to release shadow nodes and serializables. */ -export function useUiRuntimeMemoryWarningGc() { - useEffect(() => { - const subscription = AppState.addEventListener("memoryWarning", () => { - scheduleOnUI(collectUiRuntimeGarbage); - }); - return () => subscription.remove(); - }, []); +export function installUiRuntimeMemoryWarningGc(): () => void { + const subscription = AppState.addEventListener("memoryWarning", () => { + scheduleOnUI(collectUiRuntimeGarbage); + }); + return () => subscription.remove(); } diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index dbab05ecd5846..58c762f24bcc1 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -3,15 +3,7 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { composerContextEditorTokens, composerSkillLabels } from "../lib/composerContext"; import { requireNativeView } from "expo"; -import { - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, - type Ref, -} from "react"; +import { useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; import { StyleSheet } from "react-native"; @@ -28,13 +20,12 @@ import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; import { FONT_FAMILIES } from "../lib/fontFamilies"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { - acknowledgeComposerNativeEvent, - assumeComposerControlledState, - isComposerNativeEcho, - pruneAcknowledgedComposerNativeEvents, - resolveComposerControlledEventCount, - type ComposerNativeEventSnapshot, -} from "./composerEditorRevision"; + acceptComposerNativeEvent, + assumeComposerControlledDocument, + createComposerEditorEcho, + pruneComposerEditorEcho, + resolveComposerControlledDocument, +} from "./t3ComposerEditorEcho"; import { DEFAULT_COMPOSER_ENTER_BEHAVIOR } from "../lib/composerEnterBehavior"; import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; @@ -134,11 +125,9 @@ export function ComposerEditor({ ...props }: ComposerEditorProps) { const nativeRef = useRef(null); - const mostRecentEventCountRef = useRef(0); + const echoRef = useRef(createComposerEditorEcho()); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); const [, forceNativeEventRender] = useState(0); - // Start history empty so restored drafts or recycled views receive a non-echo controlled payload. - const nativeEventSnapshotsRef = useRef([]); const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); const bodyText = useScaledTextRole("body"); const theme = useUniwindTheme(); @@ -195,67 +184,33 @@ export function ComposerEditor({ }), ); }, [props.value, props.context, skillLabels, theme, chipTextColor]); - // Every render resolves against the snapshot history, so a render whose - // (value, selection) lags the acknowledged native state is stamped behind - // the native revision and rejected by the editor instead of re-applying a - // stale caret or stale text mid-typing. - const controlledEventCount = resolveComposerControlledEventCount( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ); - const acknowledgesLatestNativeEvent = isComposerNativeEcho( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ); - const isNativeEcho = - controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; - const controlledDocumentJson = JSON.stringify({ + const { + controlledDocumentJson, + controlledEventCount, + acknowledgesLatestNativeEvent, + isNativeEcho, + } = resolveComposerControlledDocument(echoRef.current, { value: props.value, - selection: isNativeEcho ? null : (selection ?? null), + selection: selection ?? null, tokensJson, - mostRecentEventCount: controlledEventCount, - isNativeEcho, + renderedEventCount: mostRecentEventCount, }); useEffect(() => { - if (!acknowledgesLatestNativeEvent) return; - nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( - nativeEventSnapshotsRef.current, - mostRecentEventCount, - ); + if (acknowledgesLatestNativeEvent) + pruneComposerEditorEcho(echoRef.current, mostRecentEventCount); }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); const assumedValue = props.value; useEffect(() => { - // Newer native events make this payload stale; do not record it as applied. - if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return; - nativeEventSnapshotsRef.current = assumeComposerControlledState( - nativeEventSnapshotsRef.current, - controlledEventCount, - assumedValue, - ); + if (!isNativeEcho) + assumeComposerControlledDocument(echoRef.current, controlledEventCount, assumedValue); }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); - const acceptNativeEvent = useCallback( - (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { - const acknowledgedEventCount = acknowledgeComposerNativeEvent( - mostRecentEventCountRef.current, - eventCount, - ); - if (acknowledgedEventCount === null) { - return false; - } - mostRecentEventCountRef.current = acknowledgedEventCount; - nativeEventSnapshotsRef.current.push({ - eventCount: acknowledgedEventCount, - value, - selection: nextSelection, - }); - return acknowledgedEventCount; - }, - [], - ); + const acceptNativeEvent = ( + eventCount: number, + value: string, + nextSelection: ComposerEditorSelection, + ) => + acceptComposerNativeEvent(echoRef.current, { eventCount, value, selection: nextSelection }) ?? + false; const themeJson = JSON.stringify(createNativeComposerTheme(theme)); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; return ( diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index fb039ed5c8a85..7763a1951b8ac 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -4,15 +4,7 @@ import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineToken import { composerContextEditorTokens, composerSkillLabels } from "../lib/composerContext"; import { requireNativeView } from "expo"; import { TextInputWrapper } from "expo-paste-input"; -import { - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, - type Ref, -} from "react"; +import { useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { Image, Platform, StyleSheet } from "react-native"; @@ -31,13 +23,12 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear import { useUniwindTheme } from "../lib/useUniwindTheme"; import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; import { - acknowledgeComposerNativeEvent, - assumeComposerControlledState, - isComposerNativeEcho, - pruneAcknowledgedComposerNativeEvents, - resolveComposerControlledEventCount, - type ComposerNativeEventSnapshot, -} from "./composerEditorRevision"; + acceptComposerNativeEvent, + assumeComposerControlledDocument, + createComposerEditorEcho, + pruneComposerEditorEcho, + resolveComposerControlledDocument, +} from "./t3ComposerEditorEcho"; import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; const NATIVE_MODULE_NAME = "T3ComposerEditor"; @@ -132,11 +123,9 @@ export function ComposerEditor({ ...props }: ComposerEditorProps) { const nativeRef = useRef(null); - const mostRecentEventCountRef = useRef(0); + const echoRef = useRef(createComposerEditorEcho()); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); const [, forceNativeEventRender] = useState(0); - // Start history empty so restored drafts or recycled views receive a non-echo controlled payload. - const nativeEventSnapshotsRef = useRef([]); const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); const confirmedTokensRef = useRef(initialConfirmedTokens); const theme = useUniwindTheme(); @@ -193,67 +182,33 @@ export function ComposerEditor({ }), ); }, [props.value, props.context, skillLabels, theme, chipTextColor]); - // Every render resolves against the snapshot history, so a render whose - // (value, selection) lags the acknowledged native state is stamped behind - // the native revision and rejected by the editor instead of re-applying a - // stale caret or stale text mid-typing. - const controlledEventCount = resolveComposerControlledEventCount( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ); - const acknowledgesLatestNativeEvent = isComposerNativeEcho( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ); - const isNativeEcho = - controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; - const controlledDocumentJson = JSON.stringify({ + const { + controlledDocumentJson, + controlledEventCount, + acknowledgesLatestNativeEvent, + isNativeEcho, + } = resolveComposerControlledDocument(echoRef.current, { value: props.value, - selection: isNativeEcho ? null : (selection ?? null), + selection: selection ?? null, tokensJson, - mostRecentEventCount: controlledEventCount, - isNativeEcho, + renderedEventCount: mostRecentEventCount, }); useEffect(() => { - if (!acknowledgesLatestNativeEvent) return; - nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( - nativeEventSnapshotsRef.current, - mostRecentEventCount, - ); + if (acknowledgesLatestNativeEvent) + pruneComposerEditorEcho(echoRef.current, mostRecentEventCount); }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); const assumedValue = props.value; useEffect(() => { - // Newer native events make this payload stale; do not record it as applied. - if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return; - nativeEventSnapshotsRef.current = assumeComposerControlledState( - nativeEventSnapshotsRef.current, - controlledEventCount, - assumedValue, - ); + if (!isNativeEcho) + assumeComposerControlledDocument(echoRef.current, controlledEventCount, assumedValue); }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); - const acceptNativeEvent = useCallback( - (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { - const acknowledgedEventCount = acknowledgeComposerNativeEvent( - mostRecentEventCountRef.current, - eventCount, - ); - if (acknowledgedEventCount === null) { - return false; - } - mostRecentEventCountRef.current = acknowledgedEventCount; - nativeEventSnapshotsRef.current.push({ - eventCount: acknowledgedEventCount, - value, - selection: nextSelection, - }); - return acknowledgedEventCount; - }, - [], - ); + const acceptNativeEvent = ( + eventCount: number, + value: string, + nextSelection: ComposerEditorSelection, + ) => + acceptComposerNativeEvent(echoRef.current, { eventCount, value, selection: nextSelection }) ?? + false; const { systemColorsActive } = useAppearancePreferences(); const themeJson = JSON.stringify({ ...createNativeComposerTheme(theme), diff --git a/apps/mobile/src/native/t3ComposerEditorEcho.ts b/apps/mobile/src/native/t3ComposerEditorEcho.ts new file mode 100644 index 0000000000000..8aa31697bce48 --- /dev/null +++ b/apps/mobile/src/native/t3ComposerEditorEcho.ts @@ -0,0 +1,111 @@ +import { + acknowledgeComposerNativeEvent, + assumeComposerControlledState, + isComposerNativeEcho, + pruneAcknowledgedComposerNativeEvents, + resolveComposerControlledEventCount, + type ComposerNativeEventSnapshot, +} from "./composerEditorRevision"; +import type { ComposerEditorSelection } from "./T3ComposerEditor.types"; + +/** + * Per-editor bookkeeping for the controlled native editor protocol, shared by the iOS and + * Android editors. Native events land here synchronously, ahead of the React render that + * echoes them back, so this is mutable and lives in a ref rather than in state. + */ +export interface ComposerEditorEcho { + /** Newest native revision accepted, which can run ahead of the rendered one. */ + latestEventCount: number; + snapshots: ComposerNativeEventSnapshot[]; +} + +export function createComposerEditorEcho(): ComposerEditorEcho { + // The native editor mounts empty, so the snapshot history starts empty: the + // first controlled payload must be a non-echo so a restored draft (or a + // recycled native view) is applied rather than skipped. + return { latestEventCount: 0, snapshots: [] }; +} + +/** Accepts a native event, or returns null when it is older than one already accepted. */ +export function acceptComposerNativeEvent( + echo: ComposerEditorEcho, + event: { + readonly eventCount: number; + readonly value: string; + readonly selection: ComposerEditorSelection; + }, +): number | null { + const acknowledgedEventCount = acknowledgeComposerNativeEvent( + echo.latestEventCount, + event.eventCount, + ); + if (acknowledgedEventCount === null) return null; + echo.latestEventCount = acknowledgedEventCount; + echo.snapshots.push({ + eventCount: acknowledgedEventCount, + value: event.value, + selection: event.selection, + }); + return acknowledgedEventCount; +} + +export function resolveComposerControlledDocument( + echo: ComposerEditorEcho, + input: { + readonly value: string; + readonly selection: ComposerEditorSelection | null; + readonly tokensJson: string; + /** The acknowledged revision this render was scheduled for. */ + readonly renderedEventCount: number; + }, +) { + // Every render resolves against the snapshot history, so a render whose + // (value, selection) lags the acknowledged native state is stamped behind + // the native revision and rejected by the editor instead of re-applying a + // stale caret or stale text mid-typing. + const controlledEventCount = resolveComposerControlledEventCount( + input.value, + input.selection, + input.renderedEventCount, + echo.snapshots, + ); + const acknowledgesLatestNativeEvent = isComposerNativeEcho( + input.value, + input.selection, + input.renderedEventCount, + echo.snapshots, + ); + const isNativeEcho = + controlledEventCount === input.renderedEventCount && acknowledgesLatestNativeEvent; + const controlledDocumentJson = JSON.stringify({ + value: input.value, + selection: isNativeEcho ? null : input.selection, + tokensJson: input.tokensJson, + mostRecentEventCount: controlledEventCount, + isNativeEcho, + }); + return { + controlledDocumentJson, + controlledEventCount, + acknowledgesLatestNativeEvent, + isNativeEcho, + }; +} + +/** Commit phase only: a render that never commits must not drop history it acknowledged. */ +export function pruneComposerEditorEcho(echo: ComposerEditorEcho, renderedEventCount: number) { + echo.snapshots = pruneAcknowledgedComposerNativeEvents(echo.snapshots, renderedEventCount); +} + +/** Commit phase only: records a non-echo payload sent to native as the state it now holds. */ +export function assumeComposerControlledDocument( + echo: ComposerEditorEcho, + controlledEventCount: number, + value: string, +) { + // A native event that arrived after this render was committed moves the + // acknowledged revision forward; the editor rejects this payload, so the + // snapshot history must not assume it applied. + if (controlledEventCount !== echo.latestEventCount) return; + echo.snapshots = assumeComposerControlledState(echo.snapshots, controlledEventCount, value); +} diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index 4e405cdb0f23f..3662b6a788a75 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -14,14 +14,10 @@ const metroConfig: Promise<{ transformer: { babelTransformerPath: string } }> = // Remove bailout entries and manual memoization when compilation starts; this check rejects stale entries. const KNOWN_BAILOUTS: Record = { - "src/components/ComposerAttachmentStrip.tsx": "unsupported syntax", "src/components/ui/Menu.tsx": "ref read during render", "src/features/diffs/nativeReviewDiffSurface.ts": "ref read during render", "src/features/home/thread-swipe-actions.tsx": "ref read during render", "src/features/threads/PendingUserInputCard.tsx": "mutates a frozen value", - "src/features/threads/ThreadArrangementSheet.tsx": "ref read during render", - "src/features/voice-input/VoiceInputProvider.tsx": "ref read during render", - "src/features/voice-input/useVoiceInputController.ts": "ref read during render", "src/native/T3ComposerEditor.ios.tsx": "ref read during render", "src/native/T3ComposerEditor.native.tsx": "ref read during render", }; @@ -185,6 +181,14 @@ describe("React Compiler coverage", () => { ).toEqual([true]); }); + // A fresh ref callback each render re-subscribes BackHandler on every commit + // and lifts the menu's handler above ones registered after it opened. + it("caches the anchored menu's back handler ref", () => { + expect( + compilerCachedCalls("src/components/ui/AndroidAnchoredMenu.tsx", "backHandlerRef"), + ).toEqual([true]); + }); + it("lists only files that exist", () => { expect(Object.keys(KNOWN_BAILOUTS).filter((file) => !sourceFiles.includes(file))).toEqual([]); }); diff --git a/apps/mobile/src/state/acknowledged-thread-messages.ts b/apps/mobile/src/state/acknowledged-thread-messages.ts index d455c01cafcc1..ab9f26dd7d412 100644 --- a/apps/mobile/src/state/acknowledged-thread-messages.ts +++ b/apps/mobile/src/state/acknowledged-thread-messages.ts @@ -1,16 +1,42 @@ +import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "./atom-registry"; import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { environmentThreadDetails } from "./threads"; // Keep acknowledged rows until their subscribed projection arrives; command receipts can precede messages. +// Render hides rows the projection has echoed, so dropping them here only frees memory. export const acknowledgedThreadMessagesAtom = Atom.make>( [], ).pipe(Atom.keepAlive); -export function retainAcknowledgedThreadMessage(message: QueuedThreadMessage) { +// Peeks at a thread only when something already has it in the registry: reading +// it through the registry would open a snapshot load and a live stream of its own. +function isEchoedInMountedThread(message: QueuedThreadMessage): boolean { + const threadAtom = environmentThreadDetails.threadAtom({ + environmentId: message.environmentId, + threadId: message.threadId, + }); + const node = appAtomRegistry.getNodes().get(threadAtom); + if (node === undefined) return false; + const loaded: Option.Option> = node.valueOption(); + const thread = Option.getOrNull(loaded); + return thread?.projection.messages.some((entry) => entry.id === message.messageId) ?? false; +} + +function writeAcknowledgedThreadMessages( + update: (current: ReadonlyArray) => ReadonlyArray, +) { const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); - appAtomRegistry.set( - acknowledgedThreadMessagesAtom, + const next = update(current).filter((entry) => !isEchoedInMountedThread(entry)); + if (next.length === current.length && next.every((entry, index) => entry === current[index])) { + return; + } + appAtomRegistry.set(acknowledgedThreadMessagesAtom, next); +} + +export function retainAcknowledgedThreadMessage(message: QueuedThreadMessage) { + writeAcknowledgedThreadMessages((current) => current.some((entry) => entry.messageId === message.messageId) ? current : [...current, message], @@ -18,9 +44,7 @@ export function retainAcknowledgedThreadMessage(message: QueuedThreadMessage) { } export function forgetAcknowledgedThreadMessage(message: QueuedThreadMessage) { - const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); - appAtomRegistry.set( - acknowledgedThreadMessagesAtom, + writeAcknowledgedThreadMessages((current) => current.filter((entry) => entry.messageId !== message.messageId), ); } diff --git a/apps/mobile/src/state/composer-attachment-uploads.test.ts b/apps/mobile/src/state/composer-attachment-uploads.test.ts index fc7a5e47bac53..e549858b00840 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.test.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.test.ts @@ -19,20 +19,9 @@ const native = vi.hoisted(() => ({ remove: Symbol("remove"), transfer: vi.fn<(uri: string, url: string, options: UploadOptions) => Promise<{ status: number }>>(), - effects: [] as Array<() => void | (() => void)>, - queueRef: { - current: null as ReturnType< - typeof import("../lib/composerAttachmentUploadQueue").createComposerAttachmentUploadQueue - > | null, - }, })); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); -vi.mock("react", async (importOriginal) => ({ - ...(await importOriginal()), - useEffect: (effect: () => void | (() => void)) => native.effects.push(effect), - useRef: () => native.queueRef, -})); vi.mock("@effect/atom-react", () => ({ useAtomValue: (atom: Parameters[0]) => appAtomRegistry.get(atom), })); @@ -111,6 +100,8 @@ vi.mock("./entities", () => ({ useServerConfigs: () => new Map([["environment-1", config]]), useThreadShells: () => [], })); +vi.mock("./projects", () => ({ environmentProjects: {} })); +vi.mock("./workspace", () => ({ workspaceConnections: {} })); vi.mock("./server", async () => { const { Atom } = await import("effect/reactivity"); return { serverEnvironment: { configValueAtom: Atom.family(() => Atom.make(null)) } }; @@ -173,14 +164,9 @@ vi.mock("./use-thread-outbox", async () => { dispatchingQueuedMessageIdAtom: Atom.make>({}).pipe(Atom.keepAlive), useThreadOutboxMessages: () => appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), - useThreadOutboxShellStatuses: () => new Map(), + threadOutboxShellStatusesAtom: Atom.make(new Map()), }; }); -vi.mock("./use-remote-environment-registry", () => ({ - useRemoteConnectionStatus: () => ({ - connectedEnvironments: [{ environmentId: "environment-1", connectionState: "connected" }], - }), -})); import { composerAttachmentUploadBlockReason, @@ -188,10 +174,12 @@ import { composerAttachmentUploadKey, } from "../lib/composerAttachmentUploadQueue"; import type { DraftComposerAttachment } from "../lib/composerImages"; +import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "./atom-registry"; import { + composerAttachmentUploadRequests, composerAttachmentUploadsAtom, - useComposerAttachmentUploadWorker, + createComposerAttachmentUploadWorker, } from "./composer-attachment-uploads"; import { enqueueThreadOutboxMessage, @@ -200,7 +188,7 @@ import { } from "./thread-outbox"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import * as drafts from "./use-composer-drafts"; -import { prepareQueuedMessageAttachments } from "./use-thread-outbox-drain"; +import { prepareQueuedMessageAttachments } from "./threadOutboxDelivery"; const environmentId = EnvironmentId.make("environment-1"); const draftKey = "environment-1:thread-1"; @@ -217,12 +205,19 @@ const image: DraftComposerAttachment = { }; let disposeWorker: (() => void) | undefined; +const requestsAtom = Atom.make((get) => + composerAttachmentUploadRequests({ + drafts: get(drafts.composerDraftUploadSourcesAtom), + queuedMessagesByThreadKey: get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + connectedEnvironmentIds: new Set([environmentId]), + operableEnvironmentIds: new Set([environmentId]), + serverConfigs: new Map([[environmentId, config]]), + }), +); + function worker() { - native.effects = []; - useComposerAttachmentUploadWorker(); - disposeWorker = native.effects[0]!() ?? undefined; - const queue = native.queueRef.current!; - native.effects[1]!(); + const { queue, dispose } = createComposerAttachmentUploadWorker(requestsAtom); + disposeWorker = dispose; return queue; } diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 0e543ffe18349..7bf7747fef2c7 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -2,11 +2,13 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts"; import { Atom, AsyncResult } from "effect/reactivity"; -import { useEffect, useRef } from "react"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; -import { isFileBackedComposerAttachment } from "../lib/composerImages"; +import { + isFileBackedComposerAttachment, + type DraftComposerAttachment, +} from "../lib/composerImages"; import { composerAttachmentUploadKey, composerDraftEnvironmentId, @@ -15,19 +17,19 @@ import { type ComposerAttachmentUploadState, } from "../lib/composerAttachmentUploadQueue"; import { appAtomRegistry } from "./atom-registry"; -import { useServerConfigs } from "./entities"; +import { environmentServerConfigsAtom } from "./server"; import { flattenQueuedThreadMessages, threadOutboxManager } from "./thread-outbox"; -import { useThreadOutboxMessages } from "./use-thread-outbox"; import { ComposerDraftPersistenceError, + type ComposerDraft, composerDraftsAtom, composerDraftUploadSourcesAtom, ensureComposerDraftsLoaded, flushComposerDrafts, setComposerDraftAttachmentUpload, } from "./use-composer-drafts"; -import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; -import { readEnvironmentScope, useEnvironmentsWithScope } from "./session"; +import { environmentSession, readEnvironmentScope, sessionHasScope } from "./session"; +import { workspaceConnections } from "./workspace"; export { composerAttachmentUploadBlockReason, @@ -56,91 +58,130 @@ export function retryComposerAttachmentUpload(environmentId: EnvironmentId, atta uploadQueue?.retry(environmentId, attachmentId); } -export function useComposerAttachmentUploadWorker() { - const drafts = useAtomValue(composerDraftUploadSourcesAtom); - const queuedMessages = useThreadOutboxMessages(); - const serverConfigs = useServerConfigs(); - const { connectedEnvironments } = useRemoteConnectionStatus(); - const operableEnvironments = useEnvironmentsWithScope( - connectedEnvironments, - AuthOrchestrationOperateScope, +type ComposerAttachmentUploadRequest = { + readonly environmentId: EnvironmentId; + readonly attachment: DraftComposerAttachment; +}; + +export function composerAttachmentUploadRequests(input: { + readonly drafts: Readonly>>; + readonly queuedMessagesByThreadKey: Parameters[0]; + readonly connectedEnvironmentIds: ReadonlySet; + readonly operableEnvironmentIds: ReadonlySet; + readonly serverConfigs: ReadonlyMap< + EnvironmentId, + Parameters[1] + >; +}): ReadonlyArray { + const queued = flattenQueuedThreadMessages(input.queuedMessagesByThreadKey); + return Object.entries(input.drafts).flatMap(([key, draft]) => { + const environmentId = composerDraftEnvironmentId(key, queued, draft); + if ( + environmentId === null || + !input.connectedEnvironmentIds.has(environmentId) || + !input.operableEnvironmentIds.has(environmentId) + ) + return []; + return draft.attachments + .filter((attachment) => + canUploadComposerAttachment(attachment, input.serverConfigs.get(environmentId)), + ) + .map((attachment) => ({ environmentId, attachment })); + }); +} + +// Each connected environment's session is read here so a granted or revoked +// operate scope re-syncs the queue. +const composerAttachmentUploadRequestsAtom = Atom.make((get) => { + const connected = get(workspaceConnections.environmentsAtom).filter( + (environment) => environment.connectionState === "connected", ); - const queueRef = useRef | null>(null); + const operableEnvironmentIds = new Set(); + for (const { environmentId } of connected) { + if ( + sessionHasScope( + get(environmentSession.sessionStateAtom(environmentId)), + AuthOrchestrationOperateScope, + ) + ) { + operableEnvironmentIds.add(environmentId); + } + } + return composerAttachmentUploadRequests({ + drafts: get(composerDraftUploadSourcesAtom), + queuedMessagesByThreadKey: get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + connectedEnvironmentIds: new Set(connected.map((environment) => environment.environmentId)), + operableEnvironmentIds, + serverConfigs: get(environmentServerConfigsAtom), + }); +}).pipe(Atom.withLabel("mobile:composer-attachment-upload-requests")); - useEffect(() => { - ensureComposerDraftsLoaded(); - const queue = createComposerAttachmentUploadQueue({ - onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), - upload: async ({ environmentId, attachment }, signal, onProgress) => { - const release = isFileBackedComposerAttachment(attachment) - ? retainComposerAttachmentFileForPreview(attachment) - : undefined; - const prepared = await settlePromise(() => - prepareTurnAttachments({ - environmentId, - attachments: [attachment], - supportsImageUploads: true, - signal, - onUploadProgress: (_, progress) => onProgress(progress), - persistUploadedReferences: async ([uploaded]) => { - if (signal.aborted || !uploaded) return "abandon"; - const queued = flattenQueuedThreadMessages( - appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), - ); - let retained = false; - for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) { - if ( - composerDraftEnvironmentId(key, queued, draft) === environmentId && - draft.attachments.some((candidate) => candidate.id === attachment.id) - ) { - retained = setComposerDraftAttachmentUpload(key, uploaded) || retained; - } +/** + * Uploads draft attachments to their environment as soon as it is connected and + * operable. The queue follows the requests atom for its whole lifetime. + */ +export function createComposerAttachmentUploadWorker( + requestsAtom: Atom.Atom< + ReadonlyArray + > = composerAttachmentUploadRequestsAtom, +) { + ensureComposerDraftsLoaded(); + const queue = createComposerAttachmentUploadQueue({ + onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), + upload: async ({ environmentId, attachment }, signal, onProgress) => { + const release = isFileBackedComposerAttachment(attachment) + ? retainComposerAttachmentFileForPreview(attachment) + : undefined; + const prepared = await settlePromise(() => + prepareTurnAttachments({ + environmentId, + attachments: [attachment], + supportsImageUploads: true, + signal, + onUploadProgress: (_, progress) => onProgress(progress), + persistUploadedReferences: async ([uploaded]) => { + if (signal.aborted || !uploaded) return "abandon"; + const queued = flattenQueuedThreadMessages( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ); + let retained = false; + for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) { + if ( + composerDraftEnvironmentId(key, queued, draft) === environmentId && + draft.attachments.some((candidate) => candidate.id === attachment.id) + ) { + retained = setComposerDraftAttachmentUpload(key, uploaded) || retained; } - if (!retained) return "abandon"; - // Draft-save failure only costs re-upload after restart; the outbox saves references independently, so preserve accepted uploads. - await flushComposerDrafts().catch((error: unknown) => { - if (!(error instanceof ComposerDraftPersistenceError)) throw error; - console.warn("[composer-attachments] could not save the uploaded reference", error); - }); - return "persisted"; - }, - }), - ); - release?.(); - if (AsyncResult.isFailure(prepared)) throw squashAtomCommandFailure(prepared); - return prepared.value.status === "ready"; - }, - }); - queueRef.current = queue; - uploadQueue = queue; - return () => { + } + if (!retained) return "abandon"; + // Draft-save failure only costs re-upload after restart; the outbox saves references independently, so preserve accepted uploads. + await flushComposerDrafts().catch((error: unknown) => { + if (!(error instanceof ComposerDraftPersistenceError)) throw error; + console.warn("[composer-attachments] could not save the uploaded reference", error); + }); + return "persisted"; + }, + }), + ); + release?.(); + if (AsyncResult.isFailure(prepared)) throw squashAtomCommandFailure(prepared); + return prepared.value.status === "ready"; + }, + }); + uploadQueue = queue; + const unsubscribe = appAtomRegistry.subscribe(requestsAtom, (requests) => queue.sync(requests), { + immediate: true, + }); + return { + queue, + dispose: () => { + unsubscribe(); queue.dispose(); if (uploadQueue === queue) uploadQueue = null; - queueRef.current = null; - }; - }, []); + }, + }; +} - useEffect(() => { - const queued = flattenQueuedThreadMessages(queuedMessages); - const connected = new Set( - connectedEnvironments - .filter((environment) => environment.connectionState === "connected") - .map((environment) => environment.environmentId), - ); - const requests = Object.entries(drafts).flatMap(([key, draft]) => { - const environmentId = composerDraftEnvironmentId(key, queued, draft); - if ( - environmentId === null || - !connected.has(environmentId) || - !operableEnvironments.has(environmentId) - ) - return []; - return draft.attachments - .filter((attachment) => - canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)), - ) - .map((attachment) => ({ environmentId, attachment })); - }); - queueRef.current?.sync(requests); - }, [connectedEnvironments, drafts, operableEnvironments, queuedMessages, serverConfigs]); +export function installComposerAttachmentUploads(): () => void { + return createComposerAttachmentUploadWorker().dispose; } diff --git a/apps/mobile/src/state/composerDraftOperations.test.ts b/apps/mobile/src/state/composerDraftOperations.test.ts new file mode 100644 index 0000000000000..5e99f718a09fb --- /dev/null +++ b/apps/mobile/src/state/composerDraftOperations.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + abortComposerDraftOperations, + startComposerDraftOperation, +} from "./composerDraftOperations"; + +describe("composer draft operations", () => { + it("aborts every running operation for the draft and leaves other drafts alone", () => { + const first = startComposerDraftOperation("thread-a"); + const second = startComposerDraftOperation("thread-a"); + const other = startComposerDraftOperation("thread-b"); + + abortComposerDraftOperations("thread-a"); + + expect(first.signal.aborted).toBe(true); + expect(second.signal.aborted).toBe(true); + expect(other.signal.aborted).toBe(false); + other.finish(); + }); + + it("does not abort an operation that finished before the draft switched", () => { + const finished = startComposerDraftOperation("thread-c"); + finished.finish(); + const running = startComposerDraftOperation("thread-c"); + + abortComposerDraftOperations("thread-c"); + + expect(finished.signal.aborted).toBe(false); + expect(running.signal.aborted).toBe(true); + }); + + it("keeps tracking an operation started after an abort when an older one finishes", () => { + const before = startComposerDraftOperation("thread-d"); + abortComposerDraftOperations("thread-d"); + const after = startComposerDraftOperation("thread-d"); + before.finish(); + abortComposerDraftOperations("thread-d"); + + expect(after.signal.aborted).toBe(true); + }); +}); diff --git a/apps/mobile/src/state/composerDraftOperations.ts b/apps/mobile/src/state/composerDraftOperations.ts new file mode 100644 index 0000000000000..b62c5e1fb0df2 --- /dev/null +++ b/apps/mobile/src/state/composerDraftOperations.ts @@ -0,0 +1,38 @@ +const operationsByDraftKey = new Map>(); + +/** + * Async work that writes into one draft (a context import, a picker) starts + * here. Whoever discards that draft or moves the composer off it aborts the + * work, so a late result never lands in a draft the user has left. + */ +export function startComposerDraftOperation(draftKey: string): { + readonly signal: AbortSignal; + readonly finish: () => void; +} { + const controller = new AbortController(); + const operations = operationsByDraftKey.get(draftKey) ?? new Set(); + operations.add(controller); + operationsByDraftKey.set(draftKey, operations); + return { + signal: controller.signal, + finish: () => { + operations.delete(controller); + if (operations.size === 0 && operationsByDraftKey.get(draftKey) === operations) { + operationsByDraftKey.delete(draftKey); + } + }, + }; +} + +export function abortComposerDraftOperations(draftKey: string): void { + const operations = operationsByDraftKey.get(draftKey); + if (!operations) return; + operationsByDraftKey.delete(draftKey); + for (const controller of operations) controller.abort(); +} + +export function abortComposerDraftOperationsWhere(matches: (draftKey: string) => boolean): void { + for (const draftKey of [...operationsByDraftKey.keys()]) { + if (matches(draftKey)) abortComposerDraftOperations(draftKey); + } +} diff --git a/apps/mobile/src/state/device.ts b/apps/mobile/src/state/device.ts index 9183f8effdfb4..4364053207e4f 100644 --- a/apps/mobile/src/state/device.ts +++ b/apps/mobile/src/state/device.ts @@ -7,32 +7,32 @@ import { Atom } from "effect/reactivity"; import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; -import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { environmentSession, usePreparedConnection } from "./session"; export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRuntime); +// Hub tickets are single use, so each viewer resolves its own access: a new +// viewer (a reload, a device switch, a return to the screen) never reuses a +// ticket an earlier connection already spent. const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) => - connectionAtomRuntime - .atom((get) => { - const prepared = Option.getOrNull( - get(environmentSession.preparedConnectionValueAtom(environmentId)), - ); - return prepared === null - ? Effect.never - : resolveDeviceHubAccess({ prepared, hubBasePath: "/api/device-hub" }); - }) - .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-device-hub-access:${environmentId}`)), + Atom.family((_viewer: string) => + connectionAtomRuntime + .atom((get) => { + const prepared = Option.getOrNull( + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); + return prepared === null + ? Effect.never + : resolveDeviceHubAccess({ prepared, hubBasePath: "/api/device-hub" }); + }) + .pipe(Atom.withLabel(`mobile-device-hub-access:${environmentId}`)), + ), ); -export function refreshDeviceHubAccess(environmentId: EnvironmentId) { - appAtomRegistry.refresh(deviceHubAccessAtom(environmentId)); -} - -export function useDeviceHubAccess(environmentId: EnvironmentId, hostId: string) { +export function useDeviceHubAccess(environmentId: EnvironmentId, hostId: string, viewer: string) { const prepared = usePreparedConnection(environmentId); - const query = useEnvironmentQuery(deviceHubAccessAtom(environmentId)); + const query = useEnvironmentQuery(deviceHubAccessAtom(environmentId)(viewer)); const access = useMemo( () => query.data && query.error === null && Option.isSome(prepared) diff --git a/apps/mobile/src/state/legacy-plan-mode.ts b/apps/mobile/src/state/legacy-plan-mode.ts index d768d4dac351c..f4ab2f8c681c2 100644 --- a/apps/mobile/src/state/legacy-plan-mode.ts +++ b/apps/mobile/src/state/legacy-plan-mode.ts @@ -23,20 +23,12 @@ export function resolveLegacyPlanModeEnabled(input: { } export function resolvePendingTaskInteractionMode(input: { - readonly preferenceLoaded: boolean; readonly planModeEnabled: boolean; readonly draftInteractionMode: ProviderInteractionMode | undefined; - readonly queuedInteractionMode: ProviderInteractionMode | undefined; readonly provider?: InteractionModeProvider | null; }): ProviderInteractionMode { - if (input.provider?.showInteractionModeToggle === false) { + if (input.provider?.showInteractionModeToggle === false || !input.planModeEnabled) { return DEFAULT_PROVIDER_INTERACTION_MODE; } - if (input.planModeEnabled) { - return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; - } - if (!input.preferenceLoaded) { - return input.queuedInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; - } - return DEFAULT_PROVIDER_INTERACTION_MODE; + return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; } diff --git a/apps/mobile/src/state/pending-task-editor-writes.test.ts b/apps/mobile/src/state/pending-task-editor-writes.test.ts deleted file mode 100644 index 8cdfc006a815e..0000000000000 --- a/apps/mobile/src/state/pending-task-editor-writes.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import type { QueuedThreadMessage } from "./thread-outbox-model"; - -const harness = vi.hoisted(() => ({ - manager: null as unknown as ReturnType< - typeof import("./thread-outbox-manager").createThreadOutboxManager - >, - writeGates: [] as Array<{ - readonly promise: Promise; - readonly started: (message: QueuedThreadMessage) => void; - }>, -})); - -vi.mock("./thread-outbox", async () => { - const { createThreadOutboxManager } = await import("./thread-outbox-manager"); - const { appAtomRegistry } = await import("./atom-registry"); - harness.manager = createThreadOutboxManager({ - registry: appAtomRegistry, - storage: { - load: async () => ({ messages: [], errors: [] }), - write: async (message) => { - const pending = harness.writeGates.shift(); - if (pending) { - pending.started(message); - await pending.promise; - } - }, - remove: async () => undefined, - }, - }); - const manager = harness.manager; - return { - threadOutboxManager: manager, - flushThreadOutbox: async () => undefined, - threadOutboxRevision: (messageId: QueuedThreadMessage["messageId"]) => - manager.revisionOf(messageId), - updateThreadOutboxMessage: (message: QueuedThreadMessage, expectedRevision?: number) => - manager.update(message, expectedRevision), - }; -}); - -import { appAtomRegistry } from "./atom-registry"; -import { - capturePendingTaskEditorWriteBaseline, - flushPendingTaskEditorWrite, -} from "./pending-task-editor-writes"; -import { - composerDraftsAtom, - getComposerDraftSnapshot, - type ComposerDraft, -} from "./use-composer-drafts"; - -function queuedMessage(messageId: string, text: string): QueuedThreadMessage { - return { - environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make(messageId), - commandId: CommandId.make(`command-${messageId}`), - text, - attachments: [], - createdAt: "2026-08-28T12:00:00.000Z", - }; -} - -function draft(text: string): ComposerDraft { - return { - text, - attachments: [], - runtimeMode: "full-access", - }; -} - -function setDraft(draftKey: string, value: ComposerDraft): void { - appAtomRegistry.set(composerDraftsAtom, { [draftKey]: value }); -} - -function queuedMessageText(messageId: QueuedThreadMessage["messageId"]): string | null { - const messages = Object.values( - appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom), - ).flat(); - return messages.find((message) => message.messageId === messageId)?.text ?? null; -} - -function blockNextWrite() { - let resolveWrite!: () => void; - let rejectWrite!: (error: Error) => void; - let markStarted!: (message: QueuedThreadMessage) => void; - const promise = new Promise((resolve, reject) => { - resolveWrite = resolve; - rejectWrite = reject; - }); - const started = new Promise((resolve) => { - markStarted = resolve; - }); - harness.writeGates.push({ promise, started: markStarted }); - return { - started, - resolve: resolveWrite, - reject: rejectWrite, - }; -} - -beforeEach(() => { - harness.writeGates.length = 0; - appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {}); - appAtomRegistry.set(composerDraftsAtom, {}); -}); - -describe("pending task editor writes", () => { - it("chains a reopened editor that closes before the previous save finishes", async () => { - const original = queuedMessage("message-close-before-save", "original"); - const firstEdit = queuedMessage("message-close-before-save", "first edit"); - const secondEdit = queuedMessage("message-close-before-save", "second edit"); - const draftKey = "pending-task:message-close-before-save"; - await harness.manager.enqueue(original); - - setDraft(draftKey, draft(firstEdit.text)); - const firstBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); - const firstWriteGate = blockNextWrite(); - const firstSave = flushPendingTaskEditorWrite({ - message: firstEdit, - baseline: firstBaseline, - draftKey, - }); - await firstWriteGate.started; - - const secondBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); - setDraft(draftKey, draft(secondEdit.text)); - const secondWriteGate = blockNextWrite(); - const secondSave = flushPendingTaskEditorWrite({ - message: secondEdit, - baseline: secondBaseline, - draftKey, - }); - - firstWriteGate.resolve(); - await expect(firstSave).resolves.toBe(false); - await expect(secondWriteGate.started).resolves.toMatchObject({ text: secondEdit.text }); - secondWriteGate.resolve(); - - await expect(secondSave).resolves.toBe(true); - expect(queuedMessageText(original.messageId)).toBe(secondEdit.text); - }); - - it("keeps a captured predecessor after that predecessor finishes", async () => { - const original = queuedMessage("message-finished-predecessor", "original"); - const firstEdit = queuedMessage("message-finished-predecessor", "first edit"); - const secondEdit = queuedMessage("message-finished-predecessor", "second edit"); - const draftKey = "pending-task:message-finished-predecessor"; - await harness.manager.enqueue(original); - - setDraft(draftKey, draft(firstEdit.text)); - const firstWriteGate = blockNextWrite(); - const firstSave = flushPendingTaskEditorWrite({ - message: firstEdit, - baseline: capturePendingTaskEditorWriteBaseline(original.messageId), - draftKey, - }); - await firstWriteGate.started; - const secondBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); - - firstWriteGate.resolve(); - await expect(firstSave).resolves.toBe(true); - - setDraft(draftKey, draft(secondEdit.text)); - const secondWriteGate = blockNextWrite(); - const secondSave = flushPendingTaskEditorWrite({ - message: secondEdit, - baseline: secondBaseline, - draftKey, - }); - await secondWriteGate.started; - secondWriteGate.resolve(); - - await expect(secondSave).resolves.toBe(true); - expect(queuedMessageText(original.messageId)).toBe(secondEdit.text); - }); - - it("keeps the handed-off revision when the middle editor write fails", async () => { - const original = queuedMessage("message-middle-failure", "original"); - const firstEdit = queuedMessage("message-middle-failure", "first edit"); - const failedEdit = queuedMessage("message-middle-failure", "failed edit"); - const finalEdit = queuedMessage("message-middle-failure", "final edit"); - const draftKey = "pending-task:message-middle-failure"; - await harness.manager.enqueue(original); - - setDraft(draftKey, draft(firstEdit.text)); - const firstWriteGate = blockNextWrite(); - const firstSave = flushPendingTaskEditorWrite({ - message: firstEdit, - baseline: capturePendingTaskEditorWriteBaseline(original.messageId), - draftKey, - }); - await firstWriteGate.started; - - const failedBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); - setDraft(draftKey, draft(failedEdit.text)); - const failedWriteGate = blockNextWrite(); - const failedSave = flushPendingTaskEditorWrite({ - message: failedEdit, - baseline: failedBaseline, - draftKey, - }); - - const finalBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); - setDraft(draftKey, draft(finalEdit.text)); - const finalWriteGate = blockNextWrite(); - const finalSave = flushPendingTaskEditorWrite({ - message: finalEdit, - baseline: finalBaseline, - draftKey, - }); - - firstWriteGate.resolve(); - await expect(firstSave).resolves.toBe(false); - await failedWriteGate.started; - failedWriteGate.reject(new Error("disk full")); - await expect(failedSave).rejects.toMatchObject({ _tag: "ThreadOutboxManagerError" }); - await finalWriteGate.started; - finalWriteGate.resolve(); - - await expect(finalSave).resolves.toBe(true); - expect(queuedMessageText(original.messageId)).toBe(finalEdit.text); - }); - - it("does not overwrite an unrelated update accepted after capture", async () => { - const original = queuedMessage("message-unrelated-update", "original"); - const editorEdit = queuedMessage("message-unrelated-update", "editor edit"); - const unrelatedEdit = queuedMessage("message-unrelated-update", "unrelated edit"); - const draftKey = "pending-task:message-unrelated-update"; - await harness.manager.enqueue(original); - - const editorBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); - const revision = harness.manager.revisionOf(original.messageId); - await expect(harness.manager.update(unrelatedEdit, revision)).resolves.toBe(true); - setDraft(draftKey, draft(editorEdit.text)); - - await expect( - flushPendingTaskEditorWrite({ - message: editorEdit, - baseline: editorBaseline, - draftKey, - }), - ).resolves.toBe(false); - expect(queuedMessageText(original.messageId)).toBe(unrelatedEdit.text); - }); - - it("does not permit cleanup after a newer editor makes the draft unsendable", async () => { - const original = queuedMessage("message-unsendable-draft", "original"); - const editorEdit = queuedMessage("message-unsendable-draft", "saved edit"); - const draftKey = "pending-task:message-unsendable-draft"; - await harness.manager.enqueue(original); - - setDraft(draftKey, draft(editorEdit.text)); - const writeGate = blockNextWrite(); - const save = flushPendingTaskEditorWrite({ - message: editorEdit, - baseline: capturePendingTaskEditorWriteBaseline(original.messageId), - draftKey, - }); - await writeGate.started; - - setDraft(draftKey, draft("")); - writeGate.resolve(); - - await expect(save).resolves.toBe(false); - expect(getComposerDraftSnapshot(draftKey).text).toBe(""); - expect(queuedMessageText(original.messageId)).toBe(editorEdit.text); - }); -}); diff --git a/apps/mobile/src/state/pending-task-editor-writes.ts b/apps/mobile/src/state/pending-task-editor-writes.ts deleted file mode 100644 index 7fe61c30e4c3c..0000000000000 --- a/apps/mobile/src/state/pending-task-editor-writes.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { QueuedThreadMessage } from "./thread-outbox"; -import { threadOutboxRevision, updateThreadOutboxMessage } from "./thread-outbox"; -import { getComposerDraftSnapshot, sameComposerDraftState } from "./use-composer-drafts"; - -type PendingTaskEditorWriteResult = - | { - readonly status: "complete"; - readonly updated: boolean; - readonly nextRevision: number; - } - | { - readonly status: "failed"; - readonly error: unknown; - readonly nextRevision: number; - }; - -const pendingWrites = new Map< - QueuedThreadMessage["messageId"], - Promise ->(); - -/** Captures the editor revision and predecessor save; the promise survives clearing its map entry. */ -export function capturePendingTaskEditorWriteBaseline( - messageId: QueuedThreadMessage["messageId"], -): Promise { - const capturedRevision = threadOutboxRevision(messageId); - const predecessor = pendingWrites.get(messageId); - if (!predecessor) { - return Promise.resolve(capturedRevision); - } - return predecessor.then( - ({ nextRevision }) => Math.max(capturedRevision, nextRevision), - () => capturedRevision, - ); -} - -export function flushPendingTaskEditorWrite(input: { - readonly message: QueuedThreadMessage; - readonly baseline: Promise; - readonly draftKey: string; -}): Promise { - const { message } = input; - const draftSnapshot = getComposerDraftSnapshot(input.draftKey); - const write = input.baseline.then( - async (expectedRevision): Promise => { - try { - const updated = await updateThreadOutboxMessage(message, expectedRevision); - return { - status: "complete", - updated, - nextRevision: expectedRevision + (updated ? 1 : 0), - }; - } catch (error) { - // Failed writes do not advance the outbox; hand their expected revision to later editor saves. - return { - status: "failed", - error, - nextRevision: expectedRevision, - }; - } - }, - ); - - pendingWrites.set(message.messageId, write); - const removeWrite = (): void => { - if (pendingWrites.get(message.messageId) === write) { - pendingWrites.delete(message.messageId); - } - }; - void write.then(removeWrite, removeWrite); - - return write.then((result) => { - if (result.status === "failed") { - throw result.error; - } - return ( - result.updated && - sameComposerDraftState(draftSnapshot, getComposerDraftSnapshot(input.draftKey)) - ); - }); -} diff --git a/apps/mobile/src/state/preview.ts b/apps/mobile/src/state/preview.ts index f1a6f77298cd6..28ec6fe8f0c2d 100644 --- a/apps/mobile/src/state/preview.ts +++ b/apps/mobile/src/state/preview.ts @@ -16,6 +16,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { environmentSession, usePreparedConnection } from "./session"; @@ -142,22 +143,49 @@ export function useThreadServerBrowserTabs(input: { return { tabs: sessions, loaded: tabs.listed }; } +interface PreviewStreamRetry { + readonly attempt: number; + readonly notBefore: number; +} + +const previewStreamRetryAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make({ attempt: 0, notBefore: 0 }).pipe( + Atom.withLabel(`mobile-preview-stream-retry:${environmentId}`), + ), +); + const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => connectionAtomRuntime .atom((get) => { const prepared = Option.getOrNull( get(environmentSession.preparedConnectionValueAtom(environmentId)), ); - return prepared === null - ? Effect.never - : resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH }); + const { notBefore } = get(previewStreamRetryAtom(environmentId)); + if (prepared === null) return Effect.never; + const resolve = resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH }); + // A recompute interrupts the wait, so only the newest retry resolves. + const wait = notBefore - Date.now(); + return wait > 0 ? Effect.andThen(Effect.sleep(wait), resolve) : resolve; }) .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-preview-stream-access:${environmentId}`)), ); +/** Resolves fresh stream access once `delayMs` has passed. */ +function retryPreviewStreamAccess(environmentId: EnvironmentId, delayMs: number) { + appAtomRegistry.update(previewStreamRetryAtom(environmentId), (current) => ({ + attempt: current.attempt + 1, + notBefore: Date.now() + delayMs, + })); +} + export function usePreviewStreamAccess(environmentId: EnvironmentId) { const prepared = usePreparedConnection(environmentId); const query = useEnvironmentQuery(previewStreamAccessAtom(environmentId)); const access = query.data && query.error === null && Option.isSome(prepared) ? query.data : null; - return { access, error: query.error, refresh: query.refresh }; + return { + access, + error: query.error, + refresh: query.refresh, + retryAfter: (delayMs: number) => retryPreviewStreamAccess(environmentId, delayMs), + }; } diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index a59dcd25e4566..2084c0053028d 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -28,7 +28,9 @@ import * as Cause from "effect/Cause"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect, useState } from "react"; +import { useState } from "react"; + +import { useDeadlinePassed } from "../lib/clock"; import { appAtomRegistry } from "./atom-registry"; import { orchestrationEnvironment } from "./orchestration"; @@ -70,18 +72,15 @@ export interface ComposerPathSearchTarget { } export function useDebouncedValue(value: A, delayMs: number): A { - const [debounced, setDebounced] = useState(value); - - useEffect(() => { - const timer = setTimeout(() => { - setDebounced(value); - }, delayMs); - return () => { - clearTimeout(timer); - }; - }, [delayMs, value]); - - return debounced; + const [latest, setLatest] = useState<{ + readonly value: A; + readonly settled: A; + readonly changedAt: number | null; + }>({ value, settled: value, changedAt: null }); + const passed = useDeadlinePassed(latest.changedAt === null ? null : latest.changedAt + delayMs); + const settled = passed ? latest.value : latest.settled; + if (!Object.is(value, latest.value)) setLatest({ value, settled, changedAt: Date.now() }); + return settled; } export function useComposerPullRequestSearch(input: { @@ -224,6 +223,19 @@ export function useBranches(input: { ); } +/** Re-reads the first unfiltered page `usePaginatedBranches` renders for this checkout. */ +export function refreshBranchList(target: { + readonly environmentId: EnvironmentId; + readonly cwd: string; +}): void { + appAtomRegistry.refresh( + vcsEnvironment.listRefs({ + environmentId: target.environmentId, + input: { cwd: target.cwd, limit: VCS_REF_LIST_LIMIT }, + }), + ); +} + export function usePaginatedBranches(target: VcsRefTarget) { const query = target.query?.trim() ?? ""; const targetKey = diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index ba6c2937f09c8..78f49c058437d 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -5,14 +5,22 @@ import type { RunId, UploadChatAttachment, } from "@t3tools/contracts"; -import { Atom } from "effect/reactivity"; import { useAtomValue } from "@effect/atom-react"; +import { + makeQueuedRunEditStore, + resolveQueuedEditExit, + type QueuedRunsAtom, +} from "@t3tools/client-runtime/queued-run-edit"; import { uploadedComposerContext } from "../lib/composerContext"; import { appAtomRegistry } from "./atom-registry"; +import { abortComposerDraftOperations } from "./composerDraftOperations"; import { queuedEditDraftKey } from "./queued-edit-draft-key"; +import { setThreadComposerError } from "./thread-composer-error"; import { clearComposerDraft, + getComposerDraftSnapshot, + mergeComposerDraftContent, setComposerDraftContext, setComposerDraftText, } from "./use-composer-drafts"; @@ -25,56 +33,78 @@ export interface QueuedRunEdit { readonly context?: OrchestrationMessageContext; } -export const queuedRunEditsAtom = Atom.make>>({}).pipe( - Atom.keepAlive, - Atom.withLabel("mobile-queued-run-edits"), -); +const queuedRunEdits = makeQueuedRunEditStore({ + registry: appAtomRegistry, + label: "mobile-queued-run-edits", +}); export { isQueuedEditDraftKey, queuedEditDraftKey } from "./queued-edit-draft-key"; -export function getQueuedRunEdit(threadKey: string | null): QueuedRunEdit | null { - if (threadKey === null) return null; - return appAtomRegistry.get(queuedRunEditsAtom)[threadKey] ?? null; -} +export const getQueuedRunEdit = queuedRunEdits.get; export function useQueuedRunEdit(threadKey: string | null): QueuedRunEdit | null { - const edits = useAtomValue(queuedRunEditsAtom); + const edits = useAtomValue(queuedRunEdits.atom); return threadKey === null ? null : (edits[threadKey] ?? null); } -function setQueuedRunEdit(threadKey: string, edit: QueuedRunEdit | null): void { - const current = appAtomRegistry.get(queuedRunEditsAtom); - if (edit === null) { - if (!current[threadKey]) return; - const next = { ...current }; - delete next[threadKey]; - appAtomRegistry.set(queuedRunEditsAtom, next); - return; +/** + * The run started or was cancelled elsewhere. A changed edit goes back into + * the thread's composer when that is empty; otherwise it is dropped. Either + * way the thread says so, wherever the user is when it happens. + */ +function exitQueuedRunEdit(threadKey: string, edit: QueuedRunEdit): void { + const editDraftKey = queuedEditDraftKey(threadKey, edit.runId); + const editDraft = getComposerDraftSnapshot(editDraftKey); + const threadDraft = getComposerDraftSnapshot(threadKey); + const recovery = resolveQueuedEditExit({ + editChanged: + (editDraft.text.trim().length > 0 || editDraft.attachments.length > 0) && + (editDraft.text !== edit.originalText || editDraft.attachments.length > 0), + threadDraftEmpty: threadDraft.text.trim().length === 0 && threadDraft.attachments.length === 0, + }); + if (recovery === "kept") { + void mergeComposerDraftContent(threadKey, { + text: editDraft.text, + attachments: editDraft.attachments, + ...(editDraft.context ? { context: editDraft.context } : {}), + }); } - appAtomRegistry.set(queuedRunEditsAtom, { ...current, [threadKey]: edit }); + clearComposerDraft(editDraftKey, { deferAttachmentCleanup: recovery === "kept" }); + if (recovery === "clean") return; + setThreadComposerError( + threadKey, + recovery === "kept" + ? "That message already started. Your edit is back in the composer." + : "That message already started, so the edit was discarded.", + ); } -export function beginQueuedRunEdit(threadKey: string, edit: QueuedRunEdit): void { +/** `runs` is the thread's runs; the edit ends when its run leaves the queue. */ +export function beginQueuedRunEdit( + threadKey: string, + edit: QueuedRunEdit, + runs: QueuedRunsAtom, +): void { const previous = getQueuedRunEdit(threadKey); if (previous !== null && previous.runId !== edit.runId) { clearComposerDraft(queuedEditDraftKey(threadKey, previous.runId)); } + // The composer moves off the thread draft, so a paste or pick still running for it is dropped. + abortComposerDraftOperations(threadKey); const draftKey = queuedEditDraftKey(threadKey, edit.runId); clearComposerDraft(draftKey); setComposerDraftText(draftKey, edit.originalText); setComposerDraftContext(draftKey, edit.context); - setQueuedRunEdit(threadKey, edit); + queuedRunEdits.begin(threadKey, edit, { runs, onRunLeftQueue: exitQueuedRunEdit }); } export function removeQueuedRunEditAttachment(threadKey: string, attachmentId: string): void { - const edit = getQueuedRunEdit(threadKey); - if (edit === null) return; - setQueuedRunEdit(threadKey, { + queuedRunEdits.update(threadKey, (edit) => ({ ...edit, existingAttachments: edit.existingAttachments.filter( (attachment) => attachment.id !== attachmentId, ), - }); + })); } export function endQueuedRunEdit( @@ -84,9 +114,13 @@ export function endQueuedRunEdit( const edit = getQueuedRunEdit(threadKey); if (edit === null) return; clearComposerDraft(queuedEditDraftKey(threadKey, edit.runId), options); - setQueuedRunEdit(threadKey, null); + queuedRunEdits.end(threadKey); } +/** While a save is in flight, its result decides the edit's fate. */ +export const suspendQueuedRunEditExit = queuedRunEdits.suspendExitWatch; +export const resumeQueuedRunEditExit = queuedRunEdits.resumeExitWatch; + /** Attachments replace wholesale; resend kept server attachments with uploads in composer order. */ export function resolveQueuedEditPayload(input: { readonly edit: QueuedRunEdit; diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index ead11793f6a42..010325dfe722a 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -17,7 +17,7 @@ export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRu const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial()); -function sessionHasScope( +export function sessionHasScope( result: AsyncResult.AsyncResult, scope: AuthEnvironmentScope, ): boolean { diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts index cf5a59b052b67..6c8bec1454b9b 100644 --- a/apps/mobile/src/state/thread-order.test.ts +++ b/apps/mobile/src/state/thread-order.test.ts @@ -11,6 +11,7 @@ import { getPendingThreadOrder, pendingThreadOrderAtom, } from "./thread-order"; +import { environmentServerConfigsAtom } from "./server"; import { environmentThreadShells } from "./threads"; vi.mock("./atom-registry", async () => { @@ -30,6 +31,9 @@ vi.mock("./use-thread-outbox", async () => { return { queuedThreadKeysAtom: Atom.make(new Set()).pipe(Atom.keepAlive) }; }); +type ServerConfigs = Atom.Type; +type ServerConfig = ServerConfigs extends ReadonlyMap ? Config : never; +const configsAtom = environmentServerConfigsAtom as Atom.Writable; const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< readonly EnvironmentThreadShell[], readonly EnvironmentThreadShell[] @@ -113,4 +117,51 @@ describe("shared mobile pending move", () => { appAtomRegistry.set(shellsAtom, rows); expect(getPendingThreadOrder()).toBeNull(); }); + + it("retires the hold when a snooze expiry changes the section without a shell event", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-01T03:00:00.000Z")); + const { rows, start } = fixture(); + // Only the snooze capability matters to the section filter; afterEach's + // registry reset restores the empty configs. + const config = { environment: { capabilities: { threadSnooze: true } } } as ServerConfig; + appAtomRegistry.set(configsAtom, new Map([[EnvironmentId.make("env"), config]])); + const snoozed = makeThreadShellFixture({ + id: ThreadId.make("c"), + environmentId: EnvironmentId.make("env"), + createdAt: "2026-06-01T00:00:00.000Z", + snoozedUntil: "2026-06-01T03:00:05.000Z", + }); + appAtomRegistry.set(shellsAtom, [...rows, snoozed]); + start(); + expect(getPendingThreadOrder()).not.toBeNull(); + + vi.advanceTimersByTime(5_000); + + expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("tears down a superseded hold's clocks when another hold begins", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-01T03:00:00.000Z")); + const { rows, start } = fixture(); + const snoozed = makeThreadShellFixture({ + id: ThreadId.make("c"), + environmentId: EnvironmentId.make("env"), + createdAt: "2026-06-01T00:00:00.000Z", + snoozedUntil: "2026-06-01T04:00:00.000Z", + }); + appAtomRegistry.set(shellsAtom, [...rows, snoozed]); + start(); + start().cancel(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts index 88ed737717d97..730797dac1b88 100644 --- a/apps/mobile/src/state/thread-order.ts +++ b/apps/mobile/src/state/thread-order.ts @@ -1,12 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect } from "react"; +import { deadlineStore, minuteTick } from "@t3tools/client-runtime/clock"; import { Atom } from "effect/reactivity"; import { reconcilePendingThreadOrder, type PendingThreadOrder, } from "../features/threads/threadOrder"; -import { getThreadListV2OrderedSection } from "../features/threads/threadListV2"; +import { + getThreadListV2OrderedSection, + nextSnoozeWakeAfter, +} from "../features/threads/threadListV2"; import { appAtomRegistry } from "./atom-registry"; import { environmentServerConfigsAtom } from "./server"; import { environmentThreadShells } from "./threads"; @@ -20,16 +23,14 @@ export const pendingThreadOrderAtom = Atom.make(null) Atom.keepAlive, ); -export function usePendingThreadOrder(nowMinute: string, snoozeWakeTick: number) { - const pending = useAtomValue(pendingThreadOrderAtom); - // Clock-driven wakes can change membership without shell events; retire order holds and re-enable moves then. - useEffect(() => { - getPendingThreadOrder(); - }, [nowMinute, snoozeWakeTick]); - return pending; +export function usePendingThreadOrder() { + return useAtomValue(pendingThreadOrderAtom); } let refreshPendingOrder: (() => void) | undefined; +// Only one hold is live at a time; starting another tears down the previous +// hold's subscriptions so its clocks and listeners do not outlive it. +let teardownPendingOrder: (() => void) | undefined; export function getPendingThreadOrder(): PendingThreadOrder | null { refreshPendingOrder?.(); @@ -38,10 +39,24 @@ export function getPendingThreadOrder(): PendingThreadOrder | null { export function beginPendingThreadOrder(pending: PendingThreadOrder) { const unsubscribers: (() => void)[] = []; + // A timed wake can change section membership without a shell event, so the + // hold also re-checks on each minute and at the next snooze expiry. + let wake: { readonly atMs: number | null; readonly unsubscribe: () => void } | null = null; + const armWake = (atMs: number | null) => { + if (wake?.atMs === atMs) return; + wake?.unsubscribe(); + wake = { atMs, unsubscribe: deadlineStore(atMs).subscribe(refresh) }; + }; + const teardown = () => { + for (const unsubscribe of unsubscribers.splice(0)) unsubscribe(); + wake?.unsubscribe(); + wake = null; + }; const cancel = () => { if (refreshPendingOrder !== refresh) return; refreshPendingOrder = undefined; - for (const unsubscribe of unsubscribers) unsubscribe(); + teardownPendingOrder = undefined; + teardown(); appAtomRegistry.set(pendingThreadOrderAtom, null); }; const refresh = () => { @@ -49,10 +64,12 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { const current = appAtomRegistry.get(pendingThreadOrderAtom); if (current === null) return; const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const nowMs = Date.now(); const ordered = getThreadListV2OrderedSection({ - threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + threads, section: current.section, - now: new Date().toISOString(), + now: new Date(nowMs).toISOString(), queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), settlementEnvironmentIds: new Set( [...configs].flatMap(([id, config]) => @@ -66,15 +83,25 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { ), }); const next = reconcilePendingThreadOrder(current, ordered); - if (next === null) cancel(); - else if (next !== current) appAtomRegistry.set(pendingThreadOrderAtom, next); + if (next === null) { + cancel(); + return; + } + if (next !== current) appAtomRegistry.set(pendingThreadOrderAtom, next); + armWake(nextSnoozeWakeAfter(threads, nowMs)); }; + teardownPendingOrder?.(); refreshPendingOrder = refresh; + teardownPendingOrder = teardown; appAtomRegistry.set(pendingThreadOrderAtom, pending); unsubscribers.push( appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh), appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh), + minuteTick.subscribe(refresh), + ); + armWake( + nextSnoozeWakeAfter(appAtomRegistry.get(environmentThreadShells.threadShellsAtom), Date.now()), ); return { isPending: () => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts similarity index 78% rename from apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts rename to apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 3bdab5f7a6223..a07e6bcb4d9fe 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -13,9 +13,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import type { QueuedThreadMessage } from "./thread-outbox-model"; const state = vi.hoisted(() => ({ - grantedEnvironments: new Set(), draftText: "Keep this draft separate", cleanups: [] as Array<() => void>, + delivery: null as { + readonly drain: () => void; + readonly dispose: () => void; + } | null, start: vi.fn(), metadata: vi.fn(), runtime: vi.fn(), @@ -38,34 +41,43 @@ const state = vi.hoisted(() => ({ environment: { capabilities: {} }, }, })); -vi.mock("react", () => ({ - useCallback: (callback: A) => callback, - useEffect: (effect: () => void | (() => void)) => { - const cleanup = effect(); - if (cleanup) state.cleanups.push(cleanup); - }, - useRef: (value: A) => ({ current: value }), - useState: (initial: A) => [initial, vi.fn()], -})); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); -vi.mock("@effect/atom-react", async () => { +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + runAtomCommand: (_registry: unknown, command: (input: unknown) => unknown, input: unknown) => + command(input), +})); +vi.mock("./session", async () => { + const { Atom } = await import("effect/reactivity"); const { appAtomRegistry } = await import("./atom-registry"); - return { useAtomValue: (atom: Atom.Atom) => appAtomRegistry.get(atom) }; + // A session's state is reduced to whether it grants the operate scope. + const sessionStateAtom = Atom.family((_id: string) => Atom.make(false).pipe(Atom.keepAlive)); + const sessionHasScope = (granted: boolean, scope: string) => + granted && scope === AuthOrchestrationOperateScope; + return { + environmentSession: { sessionStateAtom }, + sessionHasScope, + readEnvironmentScope: (environmentId: string, scope: string) => + sessionHasScope(appAtomRegistry.get(sessionStateAtom(environmentId)), scope), + }; +}); +vi.mock("./projects", async () => { + const { Atom } = await import("effect/reactivity"); + return { environmentProjects: { projectsAtom: Atom.make([]) } }; +}); +vi.mock("./workspace", async () => { + const { Atom } = await import("effect/reactivity"); + return { + workspaceConnections: { + environmentsAtom: Atom.make([{ environmentId: "secondary", connectionState: "connected" }]), + }, + }; }); -vi.mock("./session", () => ({ - readEnvironmentScope: (environmentId: string, scope: string) => - scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId), - useEnvironmentsWithScope: () => state.grantedEnvironments, -})); -vi.mock("./entities", () => ({ - useProjects: () => [], - useThreadShells: () => [state.thread], - useServerConfigs: () => new Map([["secondary", state.config]]), -})); vi.mock("./server", async () => { const { Atom } = await import("effect/reactivity"); return { serverEnvironment: { configValueAtom: Atom.family((_id: string) => Atom.make(state.config)) }, + environmentServerConfigsAtom: Atom.make(new Map([["secondary", state.config]])), }; }); vi.mock("./threads", async () => { @@ -78,19 +90,13 @@ vi.mock("./threads", async () => { setInteractionMode: state.interaction, }, environmentThreadShells: { threadShellsAtom: Atom.make([state.thread]) }, + environmentThreadDetails: { threadAtom: () => Atom.make(null) }, }; }); -vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: A) => command })); vi.mock("./git", () => ({ gitEnvironment: { preparePullRequestThread: state.checkout } })); vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "abcd" })); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: state.prepare })); -vi.mock("./use-remote-environment-registry", () => ({ - setPendingConnectionError: vi.fn(), - useRemoteConnectionStatus: () => ({ - connectedEnvironments: [{ environmentId: "secondary", connectionState: "connected" }], - }), -})); vi.mock("./thread-outbox", async () => { const { createThreadOutboxManager } = await import("./thread-outbox-manager"); const { appAtomRegistry } = await import("./atom-registry"); @@ -117,13 +123,10 @@ vi.mock("./thread-outbox-removal", () => ({ })); vi.mock("./use-thread-outbox", async () => { const { Atom } = await import("effect/reactivity"); - const { appAtomRegistry } = await import("./atom-registry"); - await import("./thread-outbox"); return { editingQueuedMessageIdsAtom: Atom.make({}).pipe(Atom.keepAlive), dispatchingQueuedMessageIdAtom: Atom.make(null).pipe(Atom.keepAlive), - useThreadOutboxMessages: () => appAtomRegistry.get(state.manager.queuedMessagesByThreadKeyAtom), - useThreadOutboxShellStatuses: () => new Map([["secondary", "live"]]), + threadOutboxShellStatusesAtom: Atom.make(new Map([["secondary", "live"]])), }; }); vi.mock("./use-composer-drafts", async () => { @@ -145,8 +148,9 @@ vi.mock("./use-composer-drafts", async () => { }); import { appAtomRegistry } from "./atom-registry"; +import { environmentSession } from "./session"; import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox"; -import { useThreadOutboxDrain } from "./use-thread-outbox-drain"; +import { createThreadOutboxDelivery } from "./threadOutboxDelivery"; const message = (overrides: Partial = {}): QueuedThreadMessage => ({ environmentId: EnvironmentId.make("secondary"), @@ -161,17 +165,38 @@ const message = (overrides: Partial = {}): QueuedThreadMess const remaining = () => Object.values(appAtomRegistry.get(state.manager.queuedMessagesByThreadKeyAtom)).flat(); +function setOperateGrant(environmentId: string, granted: boolean): void { + // The mocked session atom holds the grant itself (see the ./session mock). + const sessionAtom = environmentSession.sessionStateAtom(EnvironmentId.make(environmentId)); + appAtomRegistry.set(sessionAtom as unknown as Atom.Writable, granted); +} + function runDrain() { const settled = Promise.withResolvers(); const release = appAtomRegistry.subscribe(dispatchingQueuedMessageIdAtom, (id) => { if (id === null) settled.resolve(); }); - useThreadOutboxDrain(); + startDelivery(); return settled.promise.finally(release); } +/** Starts the service once per test; later calls run another drain pass. */ +function startDelivery(): void { + if (state.delivery !== null) { + state.delivery.drain(); + return; + } + const delivery = createThreadOutboxDelivery(); + state.delivery = delivery; + state.cleanups.push(() => { + delivery.dispose(); + state.delivery = null; + }); +} + beforeEach(() => { - state.grantedEnvironments = new Set(["primary"]); + setOperateGrant("primary", true); + setOperateGrant("secondary", false); state.draftText = "Keep this draft separate"; appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {}); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null); @@ -213,7 +238,7 @@ describe("queued PR checkout", () => { }; it("waits for the provider's PR checkout and launches in that worktree", async () => { - state.grantedEnvironments.add("secondary"); + setOperateGrant("secondary", true); await state.manager.enqueue(queuedPullRequest()); const started = Promise.withResolvers(); const checkout = @@ -242,7 +267,7 @@ describe("queued PR checkout", () => { }); it("keeps the PR source queued on a transport failure without starting the agent", async () => { - state.grantedEnvironments.add("secondary"); + setOperateGrant("secondary", true); const queued = queuedPullRequest(); await state.manager.enqueue(queued); state.checkout.mockResolvedValueOnce( @@ -254,11 +279,11 @@ describe("queued PR checkout", () => { }); it("stops launch if operation access is revoked while checking out the PR", async () => { - state.grantedEnvironments.add("secondary"); + setOperateGrant("secondary", true); const queued = queuedPullRequest(); await state.manager.enqueue(queued); state.checkout.mockImplementationOnce(async () => { - state.grantedEnvironments.delete("secondary"); + setOperateGrant("secondary", false); return AsyncResult.success(preparedCheckout); }); await runDrain(); @@ -267,7 +292,7 @@ describe("queued PR checkout", () => { }); it("does not launch a stale task edited while the PR checkout was running", async () => { - state.grantedEnvironments.add("secondary"); + setOperateGrant("secondary", true); const queued = queuedPullRequest(); await state.manager.enqueue(queued); const edited = { ...queued, text: "Updated instructions" }; @@ -288,18 +313,31 @@ describe("queued task operation access", () => { it("parks a connected read-only target, then delivers its unchanged queue after a grant", async () => { const queued = message(); await state.manager.enqueue(queued); - useThreadOutboxDrain(); + startDelivery(); expect(state.start).not.toHaveBeenCalled(); expect(state.prepare).not.toHaveBeenCalled(); expect(remaining()).toEqual([queued]); - state.grantedEnvironments.add("secondary"); + setOperateGrant("secondary", true); await runDrain(); expect(state.start).toHaveBeenCalledOnce(); expect(remaining()).toEqual([]); }); + it("backs off instead of redelivering at once when a delivery throws", async () => { + setOperateGrant("secondary", true); + const queued = message(); + await state.manager.enqueue(queued); + state.start.mockImplementationOnce(() => { + throw new Error("Unexpected adapter defect"); + }); + await runDrain(); + expect(state.start).toHaveBeenCalledOnce(); + expect(appAtomRegistry.get(dispatchingQueuedMessageIdAtom)).toBeNull(); + expect(remaining()).toEqual([queued]); + }); + it("stops the remaining settings and turn commands when an earlier update loses access", async () => { - state.grantedEnvironments.add("secondary"); + setOperateGrant("secondary", true); const queued = message({ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "different-model" }, runtimeMode: "approval-required", @@ -307,7 +345,7 @@ describe("queued task operation access", () => { }); await state.manager.enqueue(queued); state.runtime.mockImplementationOnce(async () => { - state.grantedEnvironments.delete("secondary"); + setOperateGrant("secondary", false); return AsyncResult.success(undefined); }); await runDrain(); @@ -324,7 +362,7 @@ describe("queued task operation access", () => { ])( "preserves a rejected queue but removes an accepted turn after access loss (new task: $isCreation, accepted: $accepted)", async ({ isCreation, accepted }) => { - state.grantedEnvironments.add("secondary"); + setOperateGrant("secondary", true); const queued = message( isCreation ? { @@ -349,7 +387,7 @@ describe("queued task operation access", () => { }); const drained = runDrain(); await started.promise; - state.grantedEnvironments.delete("secondary"); + setOperateGrant("secondary", false); delivery.resolve( accepted ? AsyncResult.success(undefined) @@ -360,7 +398,7 @@ describe("queued task operation access", () => { expect(remaining()).toEqual(accepted ? [] : [queued]); expect(state.draftText).toBe("Keep this draft separate"); if (!accepted) { - state.grantedEnvironments.add("secondary"); + setOperateGrant("secondary", true); await runDrain(); expect(state.start).toHaveBeenCalledTimes(2); expect(remaining()).toEqual([]); @@ -377,7 +415,7 @@ describe("queued task operation access", () => { ])( "keeps the message queued after upload revocation (new task: $isCreation, upload fails: $uploadFails)", async ({ isCreation, uploadFails }) => { - state.grantedEnvironments.add("secondary"); + setOperateGrant("secondary", true); const queued = message( isCreation ? { @@ -395,7 +433,7 @@ describe("queued task operation access", () => { ); await state.manager.enqueue(queued); state.prepare.mockImplementationOnce(async ({ attachments }) => { - state.grantedEnvironments.delete("secondary"); + setOperateGrant("secondary", false); if (uploadFails) throw new Error("This connection cannot upload attachments."); return { status: "ready", diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/threadOutboxDelivery.test.ts similarity index 93% rename from apps/mobile/src/state/use-thread-outbox-drain.test.ts rename to apps/mobile/src/state/threadOutboxDelivery.test.ts index f62686baf2363..901ffaa9e8291 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.test.ts @@ -13,6 +13,9 @@ import type { PreparedTurnAttachments } from "../lib/attachmentUpload"; const harness = vi.hoisted(() => ({ canOperate: true, + threadProjection: null as unknown as import("effect/reactivity").Atom.Writable<{ + readonly projection: { readonly messages: ReadonlyArray<{ readonly id: string }> }; + } | null>, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager >, @@ -76,11 +79,9 @@ vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: harness.prepareTurnAttachments, })); -vi.mock("./entities", () => ({ - useProjects: () => [], - useServerConfigs: () => new Map(), - useThreadShells: () => [], -})); +vi.mock("./projects", () => ({ environmentProjects: {} })); + +vi.mock("./workspace", () => ({ workspaceConnections: {} })); vi.mock("./server", async () => { const { Atom } = await import("effect/reactivity"); @@ -89,32 +90,32 @@ vi.mock("./server", async () => { vi.mock("./git", () => ({ gitEnvironment: {} })); -vi.mock("./threads", () => ({ - threadEnvironment: {}, -})); - -vi.mock("./use-atom-command", () => ({ - useAtomCommand: () => async () => undefined, -})); +vi.mock("./threads", async () => { + const { Atom } = await import("effect/reactivity"); + harness.threadProjection = Atom.make<{ + readonly projection: { readonly messages: ReadonlyArray<{ readonly id: string }> }; + } | null>(null).pipe(Atom.keepAlive); + return { + threadEnvironment: {}, + environmentThreadDetails: { threadAtom: () => harness.threadProjection }, + }; +}); vi.mock("./session", () => ({ + environmentSession: {}, readEnvironmentScope: () => harness.canOperate, - useEnvironmentsWithScope: () => new Set(), + sessionHasScope: () => harness.canOperate, })); vi.mock("./use-thread-outbox", async () => { const { Atom } = await import("effect/reactivity"); return { editingQueuedMessageIdsAtom: Atom.make>({}).pipe(Atom.keepAlive), - useThreadOutboxMessages: () => ({}), - useThreadOutboxShellStatuses: () => new Map(), + dispatchingQueuedMessageIdAtom: Atom.make(null).pipe(Atom.keepAlive), + threadOutboxShellStatusesAtom: Atom.make(new Map()), }; }); -vi.mock("./use-remote-environment-registry", () => ({ - useRemoteConnectionStatus: () => ({ connectedEnvironments: [] }), -})); - vi.mock("./thread-outbox", async () => { const { createThreadOutboxManager } = await import("./thread-outbox-manager"); const { appAtomRegistry } = await import("./atom-registry"); @@ -150,7 +151,7 @@ import { recoverEditedCreationAfterDelivery, removeAcknowledgedExistingThreadMessage, restoreRejectedQueuedMessage, -} from "./use-thread-outbox-drain"; +} from "./threadOutboxDelivery"; function queuedMessage(input: { readonly messageId: string; @@ -203,6 +204,7 @@ function remainingMessages(): ReadonlyArray { beforeEach(() => { appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); + appAtomRegistry.set(harness.threadProjection, null); harness.canOperate = true; harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); }); @@ -328,6 +330,28 @@ describe("thread outbox attachment preparation", () => { }); describe("thread outbox drain delivery cleanup", () => { + it("drops echoed acknowledged rows at the next delivery without subscribing to the thread", async () => { + const first = queuedMessage({ messageId: "awaiting-echo", text: "Delivered" }); + const second = queuedMessage({ messageId: "next-delivery", text: "Also delivered" }); + await harness.manager.enqueue(first); + await harness.manager.enqueue(second); + + await expect( + completeQueuedMessageDelivery(first, harness.manager.revisionOf(first.messageId)), + ).resolves.toBe("removed"); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([first]); + + appAtomRegistry.set(harness.threadProjection, { + projection: { messages: [{ id: "unrelated" }, { id: first.messageId }] }, + }); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([first]); + + await expect( + completeQueuedMessageDelivery(second, harness.manager.revisionOf(second.messageId)), + ).resolves.toBe("removed"); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([second]); + }); + it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => { const message = queuedMessage({ messageId: "archive-write-failure", text: "Delivered" }); await harness.manager.enqueue(message); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/threadOutboxDelivery.ts similarity index 55% rename from apps/mobile/src/state/use-thread-outbox-drain.ts rename to apps/mobile/src/state/threadOutboxDelivery.ts index f18bbadd5dcc7..6bdbd1fdb19c8 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -1,12 +1,15 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import * as Dialog from "../components/ui/Dialog"; -import { useAtomValue } from "@effect/atom-react"; import { threadRuntimeIsActive, type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { + type AtomCommand, + type AtomCommandResult, + runAtomCommand, +} from "@t3tools/client-runtime/state/runtime"; import { threadFamilyRead, type ThreadFamilyRead } from "@t3tools/client-runtime/state/threads"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { @@ -15,12 +18,12 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + type EnvironmentId, type MessageId, } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/reactivity"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { AsyncResult, Atom } from "effect/reactivity"; import { createDebugLogger } from "../lib/debugLog"; import { scopedThreadKey } from "../lib/scopedEntities"; @@ -38,15 +41,15 @@ import { } from "./acknowledged-thread-messages"; import { appAtomRegistry } from "./atom-registry"; import { restoredNewTaskDraftKey } from "./new-task-draft-key"; -import { useProjects, useServerConfigs, useThreadShells } from "./entities"; import { clearPendingThreadCreationOutcome, pendingThreadCreationOutcomesAtom, recordPendingThreadCreationOutcome, } from "./pending-thread-creation"; -import { serverEnvironment } from "./server"; +import { environmentProjects } from "./projects"; +import { environmentServerConfigsAtom, serverEnvironment } from "./server"; import { gitEnvironment } from "./git"; -import { readEnvironmentScope, useEnvironmentsWithScope } from "./session"; +import { environmentSession, readEnvironmentScope, sessionHasScope } from "./session"; import { confirmThreadOutboxMessageQueued, threadOutboxManager, @@ -81,15 +84,13 @@ import { updateComposerDraftSettings, waitForComposerDraftsLoaded, } from "./use-composer-drafts"; -import { useAtomCommand } from "./use-atom-command"; import { dispatchingQueuedMessageIdAtom, editingQueuedMessageIdsAtom, - useThreadOutboxMessages, - useThreadOutboxShellStatuses, + threadOutboxShellStatusesAtom, } from "./use-thread-outbox"; import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error"; -import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; +import { workspaceConnections } from "./workspace"; const threadOutboxDebug = createDebugLogger("thread-outbox"); @@ -571,459 +572,308 @@ async function preserveUploadedAttachmentsForEditor( } } -export function useThreadOutboxDrain(): void { - const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); - const preparePullRequest = useAtomCommand(gitEnvironment.preparePullRequestThread, { - reportFailure: false, - }); - const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { - reportFailure: false, - }); - const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { - reportFailure: false, - }); - const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); - const editingQueuedMessageIds = useAtomValue(editingQueuedMessageIdsAtom); - const queuedMessagesByThreadKey = useThreadOutboxMessages(); - const shellStatuses = useThreadOutboxShellStatuses(); - const threads = useThreadShells(); - const creationOutcomes = useAtomValue(pendingThreadCreationOutcomesAtom); - const projects = useProjects(); - const serverConfigs = useServerConfigs(); - const { connectedEnvironments } = useRemoteConnectionStatus(); - const operableEnvironments = useEnvironmentsWithScope( +// Everything a drain pass decides from. Each queued environment's session is +// read here so a granted or revoked operate scope re-runs the drain. +const deliveryInputsAtom = Atom.make((get) => { + const connectedEnvironments = get(workspaceConnections.environmentsAtom); + const operableEnvironments = new Set(); + for (const { environmentId } of connectedEnvironments) { + if ( + sessionHasScope( + get(environmentSession.sessionStateAtom(environmentId)), + AuthOrchestrationOperateScope, + ) + ) { + operableEnvironments.add(environmentId); + } + } + return { + dispatchingQueuedMessageId: get(dispatchingQueuedMessageIdAtom), + editingQueuedMessageIds: get(editingQueuedMessageIdsAtom), + queuedMessagesByThreadKey: get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + shellStatuses: get(threadOutboxShellStatusesAtom), + threads: get(environmentThreadShells.threadShellsAtom), + projects: get(environmentProjects.projectsAtom), + serverConfigs: get(environmentServerConfigsAtom), connectedEnvironments, - AuthOrchestrationOperateScope, - ); - const [retryTick, setRetryTick] = useState(0); - const retryAttemptRef = useRef(new Map()); - const retryNotBeforeRef = useRef(new Map()); - const retryTimersRef = useRef(new Map>()); - const acknowledgedExistingThreadMessageIdsRef = useRef(new Set()); - // Targets the shell may have left out hold their thread family until they leave the queue, so - // the row stays readable through dispatch. - const familyMountsRef = useRef(new Map void>()); - useEffect( - () => () => { - for (const release of familyMountsRef.current.values()) release(); - familyMountsRef.current.clear(); - }, - [], - ); - const blockedRecoverySubscriptionsRef = useRef( - new Map< - MessageId, - { readonly message: QueuedThreadMessage; readonly unsubscribe: () => void } - >(), - ); + operableEnvironments, + }; +}).pipe(Atom.withLabel("mobile:thread-outbox:delivery-inputs")); - const scheduleQueuedMessageRetry = useCallback((messageId: MessageId) => { - const retryAttempt = (retryAttemptRef.current.get(messageId) ?? 0) + 1; - retryAttemptRef.current.set(messageId, retryAttempt); - const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); - retryNotBeforeRef.current.set(messageId, Date.now() + retryDelayMs); - const pendingTimer = retryTimersRef.current.get(messageId); - if (pendingTimer !== undefined) { - clearTimeout(pendingTimer); - } - const retryTimer = setTimeout(() => { - retryTimersRef.current.delete(messageId); - setRetryTick((current) => current + 1); - }, retryDelayMs); - retryTimersRef.current.set(messageId, retryTimer); - }, []); +// Read through the shell atom, not once: the shell often lands before the +// outcome is recorded, and that entry must still be collected when it does. +const settledCreationOutcomeKeysAtom = Atom.make((get): ReadonlyArray => { + const outcomes = get(pendingThreadCreationOutcomesAtom); + const threads = get(environmentThreadShells.threadShellsAtom); + return Object.entries(outcomes).flatMap(([threadKey, outcome]) => + outcome.kind === "delivered" && + threads.some( + (thread) => + scopedThreadKey(thread.environmentId, thread.id) === threadKey && + (thread.latestRun !== null || + thread.runtime?.status === "failed" || + thread.runtime?.status === "cancelled" || + thread.runtime?.status === "interrupted"), + ) + ? [threadKey] + : [], + ); +}).pipe(Atom.withLabel("mobile:thread-outbox:settled-creation-outcomes")); - const restoreQueuedMessage = useCallback( - async (queuedMessage: QueuedThreadMessage, message: string): Promise => { - const result = await restoreRejectedQueuedMessage(queuedMessage, message); - if (result !== "blocked") { - return result !== "retry"; - } +const runDeliveryCommand = ( + command: AtomCommand, + input: W, +): Promise> => + runAtomCommand(appAtomRegistry, command, input, { reportFailure: false }); - if (!blockedRecoverySubscriptionsRef.current.has(queuedMessage.messageId)) { - const draftKey = recoveryDraftKey(queuedMessage); - const editorDraftKey = queuedMessage.creation - ? `pending-task:${queuedMessage.messageId}` - : null; - const currentDrafts = appAtomRegistry.get(composerDraftsAtom); - const blockedAttachments = currentDrafts[draftKey]?.attachments; - const editorAttachments = - editorDraftKey === null ? undefined : currentDrafts[editorDraftKey]?.attachments; - const unsubscribe = appAtomRegistry.subscribe(composerDraftsAtom, (drafts) => { - if ( - drafts[draftKey]?.attachments === blockedAttachments && - (editorDraftKey === null || drafts[editorDraftKey]?.attachments === editorAttachments) - ) { - return; - } - const active = blockedRecoverySubscriptionsRef.current.get(queuedMessage.messageId); - if (!active) { - return; - } - blockedRecoverySubscriptionsRef.current.delete(queuedMessage.messageId); - active.unsubscribe(); - setRetryTick((current) => current + 1); - }); - blockedRecoverySubscriptionsRef.current.set(queuedMessage.messageId, { - message: queuedMessage, - unsubscribe, - }); - } - return true; +function reportCommandFailure( + queuedMessage: QueuedThreadMessage, + commandResult: AtomCommandResult, + stage: ThreadOutboxCommandStage, +): { readonly action: "retry" | "restore"; readonly message: string } | null { + if (!AsyncResult.isFailure(commandResult)) { + return null; + } + const error = Cause.squash(commandResult.cause); + const action = logThreadOutboxDeliveryFailure({ + stage, + error, + interrupted: Cause.hasInterruptsOnly(commandResult.cause), + context: { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + cause: commandResult.cause, }, - [], - ); + }); + return { + action, + message: error instanceof Error ? error.message : "The message could not be sent.", + }; +} - useEffect(() => { - let mounted = true; - const load = async () => { - if ((await threadOutboxManager.load()) || !mounted) return; - Dialog.alert( - "Some queued messages could not be loaded", - "Unreadable records and attachment files are still saved. Other messages can still be sent.", - [ - { text: "Dismiss", style: "cancel" }, - { text: "Retry", onPress: () => void load() }, - ], - ); - }; - void load(); - return () => { - mounted = false; - for (const timer of retryTimersRef.current.values()) { - clearTimeout(timer); - } - retryTimersRef.current.clear(); - for (const blocked of blockedRecoverySubscriptionsRef.current.values()) { - blocked.unsubscribe(); - } - blockedRecoverySubscriptionsRef.current.clear(); - }; - }, []); +interface ThreadOutboxRetry { + readonly attempt: number; + readonly notBefore: number; + readonly timer: ReturnType; +} - const makeDeliveryHelpers = useCallback((queuedMessage: QueuedThreadMessage) => { - const reportFailure = ( - commandResult: AtomCommandResult, - stage: ThreadOutboxCommandStage, - ): { readonly action: "retry" | "restore"; readonly message: string } | null => { - if (!AsyncResult.isFailure(commandResult)) { - return null; - } - const error = Cause.squash(commandResult.cause); - const action = logThreadOutboxDeliveryFailure({ - stage, - error, - interrupted: Cause.hasInterruptsOnly(commandResult.cause), - context: { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - cause: commandResult.cause, - }, - }); - return { - action, - message: error instanceof Error ? error.message : "The message could not be sent.", - }; - }; - return { reportFailure }; - }, []); +/** + * Delivers queued thread messages one at a time. A drain pass runs whenever its inputs + * change (outbox, locks, shells, projects, configs, connections, scopes) and when a retry + * backoff expires. `dispatchingQueuedMessageIdAtom` is the single in-flight slot; clearing + * it re-runs the drain for the next message. + */ +export function createThreadOutboxDelivery(): { + readonly drain: () => void; + readonly dispose: () => void; +} { + let disposed = false; + const retries = new Map(); + // Existing-thread turns the server accepted but whose outbox removal has + // not landed; the next pass retries only the removal, never the send. + const acknowledgedExistingThreadMessageIds = new Set(); + // Targets the shell may have left out hold their thread family until they leave the queue, so + // the row stays readable through dispatch. + const familyMounts = new Map void>(); + const blockedRecoveries = new Map< + MessageId, + { readonly message: QueuedThreadMessage; readonly unsubscribe: () => void } + >(); - const sendQueuedMessage = useCallback( - async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { - const hasAccess = () => - readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope); - if (!hasAccess()) return true; - const serverConfig = appAtomRegistry.get( - serverEnvironment.configValueAtom(queuedMessage.environmentId), - ); - if (!serverConfig) return false; - const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); - if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) { - const { title, detail } = describeUnavailableModelSelection( - serverConfig, - settings.modelSelection, - thread.projectId, - ); - return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); - } - const { reportFailure } = makeDeliveryHelpers(queuedMessage); + const retryNotBefore = (messageId: MessageId): number => retries.get(messageId)?.notBefore ?? 0; - if (settings.runtimeMode !== thread.runtimeMode) { - if (!hasAccess()) return true; - const runtimeResult = await setThreadRuntimeMode({ - environmentId: queuedMessage.environmentId, - input: { - commandId: settingsCommandId(queuedMessage, "runtime-mode"), - threadId: queuedMessage.threadId, - runtimeMode: settings.runtimeMode, - createdAt: queuedMessage.createdAt, - }, - }); - if (AsyncResult.isFailure(runtimeResult)) { - reportFailure(runtimeResult, "settings-sync"); - return false; - } - } + const scheduleRetry = (messageId: MessageId): void => { + if (disposed) return; + const previous = retries.get(messageId); + if (previous !== undefined) clearTimeout(previous.timer); + const attempt = (previous?.attempt ?? 0) + 1; + const delayMs = threadOutboxRetryDelayMs(attempt); + retries.set(messageId, { + attempt, + notBefore: Date.now() + delayMs, + timer: setTimeout(drain, delayMs), + }); + }; - if (settings.interactionMode !== thread.interactionMode) { - if (!hasAccess()) return true; - const interactionResult = await setThreadInteractionMode({ - environmentId: queuedMessage.environmentId, - input: { - commandId: settingsCommandId(queuedMessage, "interaction-mode"), - threadId: queuedMessage.threadId, - interactionMode: settings.interactionMode, - createdAt: queuedMessage.createdAt, - }, - }); - if (AsyncResult.isFailure(interactionResult)) { - reportFailure(interactionResult, "settings-sync"); - return false; - } - } + const clearRetry = (messageId: MessageId): void => { + const retry = retries.get(messageId); + if (retry === undefined) return; + clearTimeout(retry.timer); + retries.delete(messageId); + }; - let prepared: PreparedTurnAttachments; - let persistedMessage: QueuedThreadMessage; - let deliveryRevision: number; - if (!hasAccess()) return true; - try { - const preparedResult = await prepareQueuedMessageAttachments( - queuedMessage, - serverConfig.environment.capabilities.attachmentUploads === true, - ); - if (preparedResult.status === "abandoned") { - return true; - } - prepared = preparedResult.prepared; - persistedMessage = preparedResult.persistedMessage; - deliveryRevision = preparedResult.deliveryRevision; - if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - await preserveUploadedAttachmentsForEditor( - queuedMessage, - preparedResult.persistedMessage, - ); - return true; - } - } catch (error) { - if (!hasAccess()) return true; - logThreadOutboxUploadFailure(queuedMessage, error); - if (!shouldRetryThreadOutboxDelivery(error)) { - return restoreQueuedMessage( - queuedMessage, - error instanceof Error ? error.message : "An attachment could not upload.", - ); - } - return false; - } - if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { - return true; + // A delivery that throws backs off like any failure. Releasing the slot + // without a retry would re-run the drain straight into the same message. + const settle = (messageId: MessageId, delivery: Promise): void => { + void delivery + .then( + (sent) => (sent ? clearRetry(messageId) : scheduleRetry(messageId)), + (error: unknown) => { + console.warn("[thread-outbox] queued message delivery threw", { messageId, error }); + scheduleRetry(messageId); + }, + ) + .finally(() => finishDispatchingQueuedMessage(messageId)); + }; + + // An over-cap draft blocks recovery until the user changes its attachments; + // parking on the draft avoids hammering the restore with backoff retries. + const restoreQueuedMessage = async ( + queuedMessage: QueuedThreadMessage, + message: string, + ): Promise => { + const result = await restoreRejectedQueuedMessage(queuedMessage, message); + if (result !== "blocked") { + return result !== "retry"; + } + if (disposed || blockedRecoveries.has(queuedMessage.messageId)) { + return true; + } + const draftKey = recoveryDraftKey(queuedMessage); + const editorDraftKey = queuedMessage.creation + ? `pending-task:${queuedMessage.messageId}` + : null; + const currentDrafts = appAtomRegistry.get(composerDraftsAtom); + const blockedAttachments = currentDrafts[draftKey]?.attachments; + const editorAttachments = + editorDraftKey === null ? undefined : currentDrafts[editorDraftKey]?.attachments; + const unsubscribe = appAtomRegistry.subscribe(composerDraftsAtom, (drafts) => { + if ( + drafts[draftKey]?.attachments === blockedAttachments && + (editorDraftKey === null || drafts[editorDraftKey]?.attachments === editorAttachments) + ) { + return; } - const currentConfig = appAtomRegistry.get( - serverEnvironment.configValueAtom(queuedMessage.environmentId), - ); - if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) { - const { title, detail } = describeUnavailableModelSelection( - currentConfig, - settings.modelSelection, - thread.projectId, - ); - return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); + const active = blockedRecoveries.get(queuedMessage.messageId); + if (!active) { + return; } - const sendSettings = resolveQueuedThreadSettings( - queuedMessage, - settings, - currentConfig.providers, + blockedRecoveries.delete(queuedMessage.messageId); + active.unsubscribe(); + drain(); + }); + blockedRecoveries.set(queuedMessage.messageId, { message: queuedMessage, unsubscribe }); + return true; + }; + + const sendQueuedMessage = async ( + queuedMessage: QueuedThreadMessage, + thread: EnvironmentThreadShell, + ): Promise => { + const hasAccess = () => + readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope); + if (!hasAccess()) return true; + const serverConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(queuedMessage.environmentId), + ); + if (!serverConfig) return false; + const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); + if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) { + const { title, detail } = describeUnavailableModelSelection( + serverConfig, + settings.modelSelection, + thread.projectId, ); + return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); + } + + if (settings.runtimeMode !== thread.runtimeMode) { if (!hasAccess()) return true; - const deliveryResult = await startTurn({ + const runtimeResult = await runDeliveryCommand(threadEnvironment.setRuntimeMode, { environmentId: queuedMessage.environmentId, input: { - commandId: queuedMessage.commandId, - creationSource: "mobile", + commandId: settingsCommandId(queuedMessage, "runtime-mode"), threadId: queuedMessage.threadId, - message: { - messageId: queuedMessage.messageId, - role: "user", - ...serializeComposerMessageForServer( - queuedMessage.text, - uploadedComposerContext( - queuedMessage.context, - queuedMessage.attachments, - prepared.attachments, - ), - currentConfig.environment.capabilities.inlineMessageContext === true, - ), - attachments: prepared.attachments, - }, - modelSelection: sendSettings.modelSelection, - titleSeed: deriveThreadTitleSeed({ - text: queuedMessage.text, - attachments: queuedMessage.attachments, - }), - runtimeMode: sendSettings.runtimeMode, - interactionMode: sendSettings.interactionMode, + runtimeMode: settings.runtimeMode, createdAt: queuedMessage.createdAt, - dispatchMode: queuedMessage.dispatchMode ?? "start", }, }); - if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; - const failure = reportFailure(deliveryResult, "start-turn"); - if (failure?.action === "retry") { + if (AsyncResult.isFailure(runtimeResult)) { + reportCommandFailure(queuedMessage, runtimeResult, "settings-sync"); return false; } - if (failure?.action === "restore") { - return restoreQueuedMessage(persistedMessage, failure.message); - } - acknowledgedExistingThreadMessageIdsRef.current.add(persistedMessage.messageId); - const delivered = - (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; - if (delivered) { - acknowledgedExistingThreadMessageIdsRef.current.delete(persistedMessage.messageId); - } - return delivered; - }, - [ - makeDeliveryHelpers, - setThreadInteractionMode, - setThreadRuntimeMode, - startTurn, - restoreQueuedMessage, - ], - ); + } - const sendQueuedCreation = useCallback( - async ( - queuedMessage: QueuedThreadMessage, - creation: QueuedThreadCreation, - projectCwd: string, - ) => { - const hasAccess = () => - readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope); + if (settings.interactionMode !== thread.interactionMode) { if (!hasAccess()) return true; - const modelSelection = queuedMessage.modelSelection; - if (modelSelection === undefined) { + const interactionResult = await runDeliveryCommand(threadEnvironment.setInteractionMode, { + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "interaction-mode"), + threadId: queuedMessage.threadId, + interactionMode: settings.interactionMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(interactionResult)) { + reportCommandFailure(queuedMessage, interactionResult, "settings-sync"); return false; } - const serverConfig = appAtomRegistry.get( - serverEnvironment.configValueAtom(queuedMessage.environmentId), - ); - if (!serverConfig) return false; - const settings = resolveQueuedThreadSettings( + } + + let prepared: PreparedTurnAttachments; + let persistedMessage: QueuedThreadMessage; + let deliveryRevision: number; + if (!hasAccess()) return true; + try { + const preparedResult = await prepareQueuedMessageAttachments( queuedMessage, - { - modelSelection, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - }, - serverConfig.providers, + serverConfig.environment.capabilities.attachmentUploads === true, ); - if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, creation.projectId)) { - const { title, detail } = describeUnavailableModelSelection( - serverConfig, - settings.modelSelection, - creation.projectId, - ); - return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); - } - let prepared: PreparedTurnAttachments; - let persistedMessage: QueuedThreadMessage; - let deliveryRevision: number; - try { - const preparedResult = await prepareQueuedMessageAttachments( - queuedMessage, - serverConfig.environment.capabilities.attachmentUploads === true, - ); - if (preparedResult.status === "abandoned") { - return true; - } - prepared = preparedResult.prepared; - persistedMessage = preparedResult.persistedMessage; - deliveryRevision = preparedResult.deliveryRevision; - if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - await preserveUploadedAttachmentsForEditor( - queuedMessage, - preparedResult.persistedMessage, - ); - return true; - } - } catch (error) { - if (!hasAccess()) return true; - logThreadOutboxUploadFailure(queuedMessage, error); - if (!shouldRetryThreadOutboxDelivery(error)) { - return restoreQueuedMessage( - queuedMessage, - error instanceof Error ? error.message : "An attachment could not upload.", - ); - } - return false; + if (preparedResult.status === "abandoned") { + return true; } - if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { + prepared = preparedResult.prepared; + persistedMessage = preparedResult.persistedMessage; + deliveryRevision = preparedResult.deliveryRevision; + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + await preserveUploadedAttachmentsForEditor(queuedMessage, preparedResult.persistedMessage); return true; } - const currentConfig = appAtomRegistry.get( - serverEnvironment.configValueAtom(queuedMessage.environmentId), - ); - if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, creation.projectId)) { - const { title, detail } = describeUnavailableModelSelection( - currentConfig, - settings.modelSelection, - creation.projectId, + } catch (error) { + if (!hasAccess()) return true; + logThreadOutboxUploadFailure(queuedMessage, error); + if (!shouldRetryThreadOutboxDelivery(error)) { + return restoreQueuedMessage( + queuedMessage, + error instanceof Error ? error.message : "An attachment could not upload.", ); - return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); } - const sendSettings = resolveQueuedThreadSettings( - queuedMessage, - settings, - currentConfig.providers, + return false; + } + if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { + return true; + } + const currentConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(queuedMessage.environmentId), + ); + if (!currentConfig) return false; + if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) { + const { title, detail } = describeUnavailableModelSelection( + currentConfig, + settings.modelSelection, + thread.projectId, ); - if (!hasAccess()) return true; - const { reportFailure } = makeDeliveryHelpers(queuedMessage); - let workspace = creation; - if (creation.pullRequestReference) { - const checkout = await preparePullRequest({ - environmentId: queuedMessage.environmentId, - input: { cwd: projectCwd, reference: creation.pullRequestReference, mode: "worktree" }, - }); - if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision) || !hasAccess()) - return true; - const failure = reportFailure(checkout, "start-turn"); - if (failure?.action === "retry") return false; - if (failure?.action === "restore") - return restoreQueuedMessage(persistedMessage, failure.message); - if (!AsyncResult.isSuccess(checkout)) return false; - if (!checkout.value.worktreePath || !checkout.value.isOnPullRequestHead) { - const { shortLabel } = ( - sourceControlClients.findByChangeRequestUrl(creation.pullRequestReference) ?? - sourceControlClients.get(undefined) - ).changeRequest; - return restoreQueuedMessage( - persistedMessage, - `The ${shortLabel} worktree has local changes and could not update to the ${shortLabel} head. Resolve them before trying again.`, - ); - } - workspace = { - ...creation, - workspaceMode: "local", - branch: checkout.value.branch, - worktreePath: checkout.value.worktreePath, - startFromOrigin: false, - }; - } - const deliveryResult = await startTurn({ - environmentId: queuedMessage.environmentId, - input: buildProjectThreadStartTurnInput({ - projectId: creation.projectId, - projectCwd, - threadId: queuedMessage.threadId, - commandId: queuedMessage.commandId, + return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); + } + const sendSettings = resolveQueuedThreadSettings( + queuedMessage, + settings, + currentConfig.providers, + ); + if (!hasAccess()) return true; + const deliveryResult = await runDeliveryCommand(threadEnvironment.startTurn, { + environmentId: queuedMessage.environmentId, + input: { + commandId: queuedMessage.commandId, + creationSource: "mobile", + threadId: queuedMessage.threadId, + message: { messageId: queuedMessage.messageId, - createdAt: queuedMessage.createdAt, - titleText: queuedMessage.text, + role: "user", ...serializeComposerMessageForServer( - queuedMessage.text.trim(), + queuedMessage.text, uploadedComposerContext( queuedMessage.context, queuedMessage.attachments, @@ -1031,58 +881,212 @@ export function useThreadOutboxDrain(): void { ), currentConfig.environment.capabilities.inlineMessageContext === true, ), - uploadedAttachments: prepared.attachments, - modelSelection: sendSettings.modelSelection, - runtimeMode: sendSettings.runtimeMode, - interactionMode: sendSettings.interactionMode, - workspaceMode: workspace.workspaceMode, - branch: workspace.branch, - worktreePath: workspace.worktreePath, - startFromOrigin: workspace.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + attachments: prepared.attachments, + }, + modelSelection: sendSettings.modelSelection, + titleSeed: deriveThreadTitleSeed({ + text: queuedMessage.text, + attachments: queuedMessage.attachments, }), - }); - if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; - const failure = reportFailure(deliveryResult, "start-turn"); - if (failure?.action === "retry") { - return false; + runtimeMode: sendSettings.runtimeMode, + interactionMode: sendSettings.interactionMode, + createdAt: queuedMessage.createdAt, + dispatchMode: queuedMessage.dispatchMode ?? "start", + }, + }); + if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; + const failure = reportCommandFailure(queuedMessage, deliveryResult, "start-turn"); + if (failure?.action === "retry") { + return false; + } + if (failure?.action === "restore") { + return restoreQueuedMessage(persistedMessage, failure.message); + } + acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId); + const delivered = + (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; + if (delivered) { + acknowledgedExistingThreadMessageIds.delete(persistedMessage.messageId); + } + return delivered; + }; + + const sendQueuedCreation = async ( + queuedMessage: QueuedThreadMessage, + creation: QueuedThreadCreation, + projectCwd: string, + ): Promise => { + const hasAccess = () => + readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope); + if (!hasAccess()) return true; + const modelSelection = queuedMessage.modelSelection; + if (modelSelection === undefined) { + return false; + } + const serverConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(queuedMessage.environmentId), + ); + if (!serverConfig) return false; + const settings = resolveQueuedThreadSettings( + queuedMessage, + { + modelSelection, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + }, + serverConfig.providers, + ); + if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, creation.projectId)) { + const { title, detail } = describeUnavailableModelSelection( + serverConfig, + settings.modelSelection, + creation.projectId, + ); + return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); + } + let prepared: PreparedTurnAttachments; + let persistedMessage: QueuedThreadMessage; + let deliveryRevision: number; + try { + const preparedResult = await prepareQueuedMessageAttachments( + queuedMessage, + serverConfig.environment.capabilities.attachmentUploads === true, + ); + if (preparedResult.status === "abandoned") { + return true; } - if (failure?.action === "restore") { - return restoreQueuedMessage(persistedMessage, failure.message); + prepared = preparedResult.prepared; + persistedMessage = preparedResult.persistedMessage; + deliveryRevision = preparedResult.deliveryRevision; + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + await preserveUploadedAttachmentsForEditor(queuedMessage, preparedResult.persistedMessage); + return true; } - recordPendingThreadCreationOutcome({ kind: "delivered", message: persistedMessage }); - const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); - if (outcome === "edited") { - if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - return true; - } - return recoverEditedCreationAfterDelivery(persistedMessage); + } catch (error) { + if (!hasAccess()) return true; + logThreadOutboxUploadFailure(queuedMessage, error); + if (!shouldRetryThreadOutboxDelivery(error)) { + return restoreQueuedMessage( + queuedMessage, + error instanceof Error ? error.message : "An attachment could not upload.", + ); } - return outcome === "removed"; - }, - [makeDeliveryHelpers, preparePullRequest, restoreQueuedMessage, startTurn], - ); - - // Subscribe to outcomes; they may arrive after the last shell update, leaving read-once entries uncollected. - useEffect(() => { - for (const [threadKey, outcome] of Object.entries(creationOutcomes)) { - if ( - outcome.kind === "delivered" && - threads.some( - (thread) => - scopedThreadKey(thread.environmentId, thread.id) === threadKey && - (thread.latestRun !== null || - thread.runtime?.status === "failed" || - thread.runtime?.status === "cancelled" || - thread.runtime?.status === "interrupted"), - ) - ) { - clearPendingThreadCreationOutcome(threadKey); + return false; + } + if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { + return true; + } + const currentConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(queuedMessage.environmentId), + ); + if (!currentConfig) return false; + if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, creation.projectId)) { + const { title, detail } = describeUnavailableModelSelection( + currentConfig, + settings.modelSelection, + creation.projectId, + ); + return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); + } + const sendSettings = resolveQueuedThreadSettings( + queuedMessage, + settings, + currentConfig.providers, + ); + if (!hasAccess()) return true; + let workspace = creation; + if (creation.pullRequestReference) { + const checkout = await runDeliveryCommand(gitEnvironment.preparePullRequestThread, { + environmentId: queuedMessage.environmentId, + input: { cwd: projectCwd, reference: creation.pullRequestReference, mode: "worktree" }, + }); + if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision) || !hasAccess()) + return true; + const failure = reportCommandFailure(queuedMessage, checkout, "start-turn"); + if (failure?.action === "retry") return false; + if (failure?.action === "restore") + return restoreQueuedMessage(persistedMessage, failure.message); + if (!AsyncResult.isSuccess(checkout)) return false; + if (!checkout.value.worktreePath || !checkout.value.isOnPullRequestHead) { + const { shortLabel } = ( + sourceControlClients.findByChangeRequestUrl(creation.pullRequestReference) ?? + sourceControlClients.get(undefined) + ).changeRequest; + return restoreQueuedMessage( + persistedMessage, + `The ${shortLabel} worktree has local changes and could not update to the ${shortLabel} head. Resolve them before trying again.`, + ); } + workspace = { + ...creation, + workspaceMode: "local", + branch: checkout.value.branch, + worktreePath: checkout.value.worktreePath, + startFromOrigin: false, + }; } - }, [creationOutcomes, threads]); + const deliveryResult = await runDeliveryCommand(threadEnvironment.startTurn, { + environmentId: queuedMessage.environmentId, + input: buildProjectThreadStartTurnInput({ + projectId: creation.projectId, + projectCwd, + threadId: queuedMessage.threadId, + commandId: queuedMessage.commandId, + messageId: queuedMessage.messageId, + createdAt: queuedMessage.createdAt, + titleText: queuedMessage.text, + ...serializeComposerMessageForServer( + queuedMessage.text.trim(), + uploadedComposerContext( + queuedMessage.context, + queuedMessage.attachments, + prepared.attachments, + ), + currentConfig.environment.capabilities.inlineMessageContext === true, + ), + uploadedAttachments: prepared.attachments, + modelSelection: sendSettings.modelSelection, + runtimeMode: sendSettings.runtimeMode, + interactionMode: sendSettings.interactionMode, + workspaceMode: workspace.workspaceMode, + branch: workspace.branch, + worktreePath: workspace.worktreePath, + startFromOrigin: workspace.startFromOrigin ?? false, + worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + }), + }); + if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; + const failure = reportCommandFailure(queuedMessage, deliveryResult, "start-turn"); + if (failure?.action === "retry") { + return false; + } + if (failure?.action === "restore") { + return restoreQueuedMessage(persistedMessage, failure.message); + } + recordPendingThreadCreationOutcome({ kind: "delivered", message: persistedMessage }); + const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); + if (outcome === "edited") { + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + return true; + } + return recoverEditedCreationAfterDelivery(persistedMessage); + } + return outcome === "removed"; + }; - useEffect(() => { + function drain(): void { + if (disposed) return; + const { + dispatchingQueuedMessageId, + editingQueuedMessageIds, + queuedMessagesByThreadKey, + shellStatuses, + threads, + projects, + serverConfigs, + connectedEnvironments, + operableEnvironments, + } = appAtomRegistry.get(deliveryInputsAtom); if (dispatchingQueuedMessageId !== null) { return; } @@ -1092,15 +1096,15 @@ export function useThreadOutboxDrain(): void { .flat() .map((message) => message.messageId), ); - for (const messageId of acknowledgedExistingThreadMessageIdsRef.current) { + for (const messageId of acknowledgedExistingThreadMessageIds) { if (!queuedMessageIds.has(messageId)) { - acknowledgedExistingThreadMessageIdsRef.current.delete(messageId); + acknowledgedExistingThreadMessageIds.delete(messageId); } } - for (const [messageId, release] of familyMountsRef.current) { + for (const [messageId, release] of familyMounts) { if (!queuedMessageIds.has(messageId)) { release(); - familyMountsRef.current.delete(messageId); + familyMounts.delete(messageId); } } @@ -1109,48 +1113,36 @@ export function useThreadOutboxDrain(): void { if (!nextQueuedMessage) { continue; } + const messageId = nextQueuedMessage.messageId; if ( nextQueuedMessage.creation === undefined && - acknowledgedExistingThreadMessageIdsRef.current.has(nextQueuedMessage.messageId) + acknowledgedExistingThreadMessageIds.has(messageId) ) { - if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { + if (retryNotBefore(messageId) > Date.now()) { continue; } - beginDispatchingQueuedMessage(nextQueuedMessage.messageId); - void removeAcknowledgedExistingThreadMessage( - nextQueuedMessage, - acknowledgedExistingThreadMessageIdsRef.current, - ) - .then((removed) => { - if (!removed) { - scheduleQueuedMessageRetry(nextQueuedMessage.messageId); - return; - } - retryAttemptRef.current.delete(nextQueuedMessage.messageId); - retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); - const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); - if (pendingTimer !== undefined) { - clearTimeout(pendingTimer); - retryTimersRef.current.delete(nextQueuedMessage.messageId); - } - }) - .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId)); + beginDispatchingQueuedMessage(messageId); + settle( + messageId, + removeAcknowledgedExistingThreadMessage( + nextQueuedMessage, + acknowledgedExistingThreadMessageIds, + ), + ); return; } - if (editingQueuedMessageIds[nextQueuedMessage.messageId]) { + if (editingQueuedMessageIds[messageId]) { continue; } - const blockedRecovery = blockedRecoverySubscriptionsRef.current.get( - nextQueuedMessage.messageId, - ); + const blockedRecovery = blockedRecoveries.get(messageId); if (blockedRecovery) { if (blockedRecovery.message === nextQueuedMessage) { continue; } - blockedRecoverySubscriptionsRef.current.delete(nextQueuedMessage.messageId); + blockedRecoveries.delete(messageId); blockedRecovery.unsubscribe(); } - if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { + if (retryNotBefore(messageId) > Date.now()) { continue; } @@ -1173,8 +1165,8 @@ export function useThreadOutboxDrain(): void { ) { // The family keeps the row readable through dispatch once it loads; the current shell // generation's read decides whether the thread is gone. - if (!familyMountsRef.current.has(nextQueuedMessage.messageId)) { - familyMountsRef.current.set( + if (!familyMounts.has(nextQueuedMessage.messageId)) { + familyMounts.set( nextQueuedMessage.messageId, threadFamilies.mount( appAtomRegistry, @@ -1189,8 +1181,8 @@ export function useThreadOutboxDrain(): void { ), ); if (familyRead === "settled") { - // The rows from this render can lag the family that just settled; decide from the - // merged rows as they are now. + // The rows read at the start of this pass can lag the family that just settled; + // decide from the merged rows as they are now. thread = findThread( appAtomRegistry.get(environmentThreadShells.threadShellsAtom), nextQueuedMessage, @@ -1218,7 +1210,7 @@ export function useThreadOutboxDrain(): void { familyRead !== "settled" ) { // An empty family read changes no merged row, so nothing else would wake the drain. - scheduleQueuedMessageRetry(nextQueuedMessage.messageId); + scheduleRetry(messageId); continue; } if (deliveryAction === "send" && !operableEnvironments.has(nextQueuedMessage.environmentId)) { @@ -1240,28 +1232,25 @@ export function useThreadOutboxDrain(): void { continue; } if (dispatchStep.step === "retry") { - scheduleQueuedMessageRetry(nextQueuedMessage.messageId); + scheduleRetry(messageId); continue; } if (dispatchStep.step === "restore") { const attachmentError = dispatchStep.reason; - beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + beginDispatchingQueuedMessage(messageId); void confirmThreadOutboxMessageQueued(nextQueuedMessage) .then((queued) => { - if ( - !queued || - appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId] - ) { + if (!queued || appAtomRegistry.get(editingQueuedMessageIdsAtom)[messageId]) { return true; } return restoreQueuedMessage(nextQueuedMessage, attachmentError); }) .then((restored) => { if (!restored) { - scheduleQueuedMessageRetry(nextQueuedMessage.messageId); + scheduleRetry(messageId); } }) - .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId)); + .finally(() => finishDispatchingQueuedMessage(messageId)); return; } const creationProjectCwd = @@ -1279,7 +1268,7 @@ export function useThreadOutboxDrain(): void { } } - beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + beginDispatchingQueuedMessage(messageId); const removeQueuedMessage = (warning: string) => removeThreadOutboxMessage(nextQueuedMessage).then( () => true, @@ -1287,7 +1276,7 @@ export function useThreadOutboxDrain(): void { console.warn(warning, { environmentId: nextQueuedMessage.environmentId, threadId: nextQueuedMessage.threadId, - messageId: nextQueuedMessage.messageId, + messageId, error, }); return false; @@ -1299,7 +1288,7 @@ export function useThreadOutboxDrain(): void { return true; } // Re-check editor ownership after confirmation; return true to defer without failure backoff. - if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) { + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[messageId]) { return true; } if (deliveryAction === "send") { @@ -1307,13 +1296,12 @@ export function useThreadOutboxDrain(): void { appAtomRegistry.get(environmentThreadShells.threadShellsAtom), nextQueuedMessage, ); - const liveThreadBusy = threadRuntimeIsActive(liveThread?.runtime); const liveDeliveryAction = resolveThreadOutboxDeliveryAction({ isCreation: creation !== undefined, threadExists: liveThread !== undefined, shellStatus, environmentConnected: environment?.connectionState === "connected", - threadBusy: liveThreadBusy, + threadBusy: threadRuntimeIsActive(liveThread?.runtime), }); if (liveDeliveryAction !== "send") { return true; @@ -1332,40 +1320,57 @@ export function useThreadOutboxDrain(): void { ? sendQueuedMessage(nextQueuedMessage, thread) : Promise.resolve(false); }); - void delivery - .then((sent) => { - if (sent) { - retryAttemptRef.current.delete(nextQueuedMessage.messageId); - retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); - const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); - if (pendingTimer !== undefined) { - clearTimeout(pendingTimer); - retryTimersRef.current.delete(nextQueuedMessage.messageId); - } - return; - } - - scheduleQueuedMessageRetry(nextQueuedMessage.messageId); - }) - .finally(() => { - finishDispatchingQueuedMessage(nextQueuedMessage.messageId); - }); + settle(messageId, delivery); return; } - }, [ - connectedEnvironments, - dispatchingQueuedMessageId, - editingQueuedMessageIds, - operableEnvironments, - projects, - queuedMessagesByThreadKey, - retryTick, - restoreQueuedMessage, - scheduleQueuedMessageRetry, - sendQueuedCreation, - sendQueuedMessage, - serverConfigs, - shellStatuses, - threads, - ]); + } + + const stopDraining = appAtomRegistry.subscribe(deliveryInputsAtom, drain, { immediate: true }); + const stopCollectingOutcomes = appAtomRegistry.subscribe( + settledCreationOutcomeKeysAtom, + (threadKeys) => { + for (const threadKey of threadKeys) clearPendingThreadCreationOutcome(threadKey); + }, + { immediate: true }, + ); + + return { + drain, + dispose: () => { + disposed = true; + stopDraining(); + stopCollectingOutcomes(); + for (const retry of retries.values()) clearTimeout(retry.timer); + retries.clear(); + for (const blocked of blockedRecoveries.values()) blocked.unsubscribe(); + blockedRecoveries.clear(); + for (const release of familyMounts.values()) release(); + familyMounts.clear(); + }, + }; +} + +function loadThreadOutbox(isActive: () => boolean): void { + void threadOutboxManager.load().then((loaded) => { + if (loaded || !isActive()) return; + Dialog.alert( + "Some queued messages could not be loaded", + "Unreadable records and attachment files are still saved. Other messages can still be sent.", + [ + { text: "Dismiss", style: "cancel" }, + { text: "Retry", onPress: () => loadThreadOutbox(isActive) }, + ], + ); + }); +} + +/** App-lifetime: loads the persisted outbox and delivers queued messages. */ +export function installThreadOutboxDelivery(): () => void { + let active = true; + loadThreadOutbox(() => active); + const delivery = createThreadOutboxDelivery(); + return () => { + active = false; + delivery.dispose(); + }; } diff --git a/apps/mobile/src/state/use-composer-attachment-actions.ts b/apps/mobile/src/state/use-composer-attachment-actions.ts index 52fb05754ba96..681d37e0e6fa3 100644 --- a/apps/mobile/src/state/use-composer-attachment-actions.ts +++ b/apps/mobile/src/state/use-composer-attachment-actions.ts @@ -1,5 +1,5 @@ import * as Dialog from "../components/ui/Dialog"; -import { useLayoutEffect, useRef } from "react"; +import { useRef } from "react"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type ServerConfig } from "@t3tools/contracts"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { nextPastedTextFileName } from "@t3tools/client-runtime/text-paste"; @@ -19,28 +19,22 @@ import { getComposerDraftSnapshot, type ComposerDraftInsertion, } from "./use-composer-drafts"; +import { startComposerDraftOperation } from "./composerDraftOperations"; export function useComposerAttachmentActions(props: { readonly draftKey: string | null; readonly serverConfig: ServerConfig | null | undefined; readonly locked?: boolean; }) { - const currentKey = useRef(props.draftKey); const reservedNames = useRef(new Map>()); - useLayoutEffect(() => { - currentKey.current = props.draftKey; - return () => { - currentKey.current = null; - }; - }, [props.draftKey]); const capabilities = props.serverConfig?.environment.capabilities; const append = async ( key: string, + signal: AbortSignal, attachments: ReadonlyArray, insertion: ComposerDraftInsertion, ) => { - // A picker may finish after selecting another draft or sending this one. - if (currentKey.current !== key) { + if (signal.aborted) { await Promise.all( attachments.flatMap((item) => item.fileUri ? [removePersistedComposerAttachmentFile(item.fileUri)] : [], @@ -50,6 +44,11 @@ export function useComposerAttachmentActions(props: { } return appendComposerDraftAttachments(key, attachments, { appendReference: true, insertion }); }; + // A picker may finish after its draft was discarded or the composer moved off it. + const withDraftOperation = async (key: string, work: (signal: AbortSignal) => Promise) => { + const operation = startComposerDraftOperation(key); + await work(operation.signal).finally(operation.finish); + }; const report = (title: string, error: string | null | undefined, rejected: number) => { const problems = [ ...(error ? [error] : []), @@ -63,17 +62,19 @@ export function useComposerAttachmentActions(props: { const key = props.draftKey; if (!key || props.locked) return; const insertion = captureComposerDraftInsertion(key); - const result = await pickComposerMedia({ - existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), - maxVideoBytes: capabilities?.attachmentUploads - ? capabilities.fileAttachments?.maxUploadBytes - : undefined, + await withDraftOperation(key, async (signal) => { + const result = await pickComposerMedia({ + existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), + maxVideoBytes: capabilities?.attachmentUploads + ? capabilities.fileAttachments?.maxUploadBytes + : undefined, + }); + report( + "Could not attach photo or video", + result.error, + await append(key, signal, result.attachments, insertion), + ); }); - report( - "Could not attach photo or video", - result.error, - await append(key, result.attachments, insertion), - ); }; const onPickDraftFiles = async () => { const key = props.draftKey; @@ -84,25 +85,36 @@ export function useComposerAttachmentActions(props: { return; } const insertion = captureComposerDraftInsertion(key); - const result = await pickComposerFiles({ - existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), - maxBytes, + await withDraftOperation(key, async (signal) => { + const result = await pickComposerFiles({ + existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), + maxBytes, + }); + report( + "Could not attach file", + result.error, + await append(key, signal, result.files, insertion), + ); }); - report("Could not attach file", result.error, await append(key, result.files, insertion)); }; const onNativePasteImages = async (uris: ReadonlyArray) => { const key = props.draftKey; if (!key || props.locked || !uris.length) return; const insertion = captureComposerDraftInsertion(key); - try { - const images = await convertPastedImagesToAttachments({ - uris, - existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), - }); - report("Could not paste images", null, await append(key, images, insertion)); - } catch (error) { - Dialog.alert("Could not paste images", error instanceof Error ? error.message : "Try again."); - } + await withDraftOperation(key, async (signal) => { + try { + const images = await convertPastedImagesToAttachments({ + uris, + existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), + }); + report("Could not paste images", null, await append(key, signal, images, insertion)); + } catch (error) { + Dialog.alert( + "Could not paste images", + error instanceof Error ? error.message : "Try again.", + ); + } + }); }; const onNativePasteText = async (paste: ComposerTextPaste) => { const key = props.draftKey; @@ -116,21 +128,26 @@ export function useComposerAttachmentActions(props: { for (const attachment of getComposerDraftSnapshot(key).attachments) names.add(attachment.name); const name = nextPastedTextFileName([...names]); names.add(name); - try { - const attachment = await createPastedTextComposerAttachment({ - text: paste.text, - name, - maxBytes: clampFileAttachmentUploadBytes(maxBytes), - }); - const rejected = await append(key, [attachment], { text: paste.value, ...paste.selection }); - if (rejected) await removePersistedComposerAttachmentFile(attachment.fileUri); - report("Could not attach pasted text", null, rejected); - } catch (error) { - Dialog.alert( - "Could not attach pasted text", - error instanceof Error ? error.message : "Try again.", - ); - } + await withDraftOperation(key, async (signal) => { + try { + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name, + maxBytes: clampFileAttachmentUploadBytes(maxBytes), + }); + const rejected = await append(key, signal, [attachment], { + text: paste.value, + ...paste.selection, + }); + if (rejected) await removePersistedComposerAttachmentFile(attachment.fileUri); + report("Could not attach pasted text", null, rejected); + } catch (error) { + Dialog.alert( + "Could not attach pasted text", + error instanceof Error ? error.message : "Try again.", + ); + } + }); }; return { onPickDraftMedia, onPickDraftFiles, onNativePasteImages, onNativePasteText }; } diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 02d68745d9e79..157e7d8669877 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -171,6 +171,7 @@ import { formatComposerContextReference, } from "@t3tools/shared/composerContextReferences"; import { appAtomRegistry } from "./atom-registry"; +import { startComposerDraftOperation } from "./composerDraftOperations"; import { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, @@ -2276,6 +2277,32 @@ describe("mobile composer drafts", () => { }); }); + it("aborts operations running for drafts an environment removal drops", async () => { + const environmentId = EnvironmentId.make("environment-removed"); + composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); + appAtomRegistry.set(composerDraftsAtom, { + "new-task:removed-draft": { + ...DRAFT, + project: { + environmentId, + projectId: ProjectId.make("project-removed"), + createdAt: "2026-09-05T00:00:00.000Z", + }, + }, + "environment-kept:thread-kept": DRAFT, + }); + const newTask = startComposerDraftOperation("new-task:removed-draft"); + const emptyThread = startComposerDraftOperation(`${environmentId}:thread-empty`); + const kept = startComposerDraftOperation("environment-kept:thread-kept"); + + await clearComposerDraftsEnvironment(environmentId); + + expect(newTask.signal.aborted).toBe(true); + expect(emptyThread.signal.aborted).toBe(true); + expect(kept.signal.aborted).toBe(false); + kept.finish(); + }); + it("lands a still-debounced draft write when flushed", async () => { const draftKey = "environment-1:thread-1"; setComposerDraftText(draftKey, "typed right before the restart"); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 75fb98c8dcf70..9b6f0dac642b6 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -21,7 +21,6 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { useEffect } from "react"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import * as Option from "effect/Option"; import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; @@ -54,6 +53,10 @@ import { import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; +import { + abortComposerDraftOperations, + abortComposerDraftOperationsWhere, +} from "./composerDraftOperations"; import { isNewTaskDraftKey, newTaskDraftKey, @@ -84,15 +87,36 @@ export function setComposerContextImporting(draftKey: string, importing: boolean appAtomRegistry.set(composerContextImportsAtom, next); } -let lastComposerSelection: { draftKey: string; text: string; start: number; end: number } | null = - null; +interface ComposerDraftSelection { + readonly text: string; + readonly start: number; + readonly end: number; +} + +// Keyed by draft so a caret move in one composer only notifies that draft's readers. +// Kept alive so the caret survives the composer unmounting, like the draft itself. +const composerDraftSelectionAtom = Atom.family((_draftKey: string) => + Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:composer-draft-selection"), + ), +); +const NO_COMPOSER_DRAFT_SELECTION_ATOM = Atom.make(null); export function rememberComposerDraftSelection( draftKey: string, text: string, selection: { start: number; end: number }, ): void { - lastComposerSelection = { draftKey, text, ...selection }; + appAtomRegistry.set(composerDraftSelectionAtom(draftKey), { text, ...selection }); +} + +function matchingComposerDraftSelection( + selection: ComposerDraftSelection | null, + text: string, +): { start: number; end: number } | null { + if (selection?.text !== text) return null; + return { start: selection.start, end: selection.end }; } /** Read the post-chip-insertion selection or the editor restores the pre-insert caret before the chip. */ @@ -100,10 +124,20 @@ export function readComposerDraftSelection( draftKey: string, text: string, ): { start: number; end: number } | null { - if (lastComposerSelection?.draftKey !== draftKey || lastComposerSelection.text !== text) { - return null; - } - return { start: lastComposerSelection.start, end: lastComposerSelection.end }; + return matchingComposerDraftSelection( + appAtomRegistry.get(composerDraftSelectionAtom(draftKey)), + text, + ); +} + +export function useComposerDraftSelection( + draftKey: string | null, + text: string, +): { start: number; end: number } | null { + const selection = useAtomValue( + draftKey === null ? NO_COMPOSER_DRAFT_SELECTION_ATOM : composerDraftSelectionAtom(draftKey), + ); + return draftKey === null ? null : matchingComposerDraftSelection(selection, text); } export interface ComposerDraftInsertion { @@ -129,8 +163,7 @@ function contextInsertionRange( draft: ComposerDraft, target?: ComposerDraftInsertion, ) { - const captured = - target ?? (lastComposerSelection?.draftKey === draftKey ? lastComposerSelection : null); + const captured = target ?? appAtomRegistry.get(composerDraftSelectionAtom(draftKey)); // Append after text edits during writes; caret-only moves preserve the captured insertion range. const selection = captured?.text === draft.text ? captured : null; const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length)); @@ -303,12 +336,10 @@ function draftWithInsertedContext( for (const record of content.context.records) records.set(record.contextId, record); const context = referencedComposerContext(text, { version: 1, records: [...records.values()] }); if ((context?.records.length ?? 0) > COMPOSER_CONTEXT_MAX_RECORDS) return null; - lastComposerSelection = { - draftKey, - text, + rememberComposerDraftSelection(draftKey, text, { start: start + insertion.length, end: start + insertion.length, - }; + }); return withReferencedContextFiles(draft, text, context); } @@ -357,6 +388,8 @@ export interface ComposerDraftWorkspaceSelection { readonly worktreePath: string | null; readonly startFromOrigin?: boolean; readonly pullRequestReference?: string; + /** Local mode chosen before refs named the current branch; the branch follows it. */ + readonly followsCurrentCheckout?: boolean; } export type ComposerDraftSettingsUpdate = Pick< @@ -370,6 +403,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean), pullRequestReference: Schema.optional(Schema.String), + followsCurrentCheckout: Schema.optional(Schema.Boolean), }); const ComposerDraftProjectSchema = Schema.Struct({ @@ -432,6 +466,17 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.withLabel("mobile:composer-drafts"), ); +const composerDraftsHydratedAtom = Atom.make(false).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:composer-drafts-hydrated"), +); + +/** Whether persisted drafts have merged in; reading it starts hydration. */ +export const composerDraftsLoadedAtom = Atom.make((get) => { + ensureComposerDraftsLoaded(); + return get(composerDraftsHydratedAtom); +}).pipe(Atom.withLabel("mobile:composer-drafts-loaded")); + export const stickyComposerModelSelectionAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-model-selection"), @@ -468,6 +513,7 @@ const persistenceQueue = new SerializedAsyncQueue(); export function resetComposerDraftsLoadState(): void { loadPromise = null; + appAtomRegistry.set(composerDraftsHydratedAtom, false); persistRetryNeeded = false; } @@ -1000,6 +1046,7 @@ export function ensureComposerDraftsLoaded(): void { ), }); } + appAtomRegistry.set(composerDraftsHydratedAtom, true); }); loadPromise = loading; void loading.catch((cause) => { @@ -1700,7 +1747,7 @@ export async function restoreComposerDraftSnapshot( ); } -export function sameComposerDraftState(a: ComposerDraft, b: ComposerDraft): boolean { +function sameComposerDraftState(a: ComposerDraft, b: ComposerDraft): boolean { return ( a.text === b.text && a.attachments === b.attachments && @@ -1804,6 +1851,7 @@ export function clearComposerDraft( draftKey: string, options?: { readonly deferAttachmentCleanup?: boolean }, ): void { + abortComposerDraftOperations(draftKey); const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { if (!current[draftKey]) { @@ -1848,6 +1896,31 @@ export function createNewTaskDraft(project: { return draftKey; } +/** + * Stamps a new-task draft with its project unless it already has one, and + * reports whether it did. A route's draft key is derived before anything is + * written, so whoever opens the route stamps it; an existing stamp (and the + * draft's content) is left alone. + */ +export function ensureNewTaskDraft( + draftKey: string, + project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, +): boolean { + let stamped = false; + updateComposerDrafts((current) => { + const existing = current[draftKey]; + if (existing?.project !== undefined) return current; + stamped = true; + const stamp: ComposerDraftProject = { + environmentId: project.environmentId, + projectId: project.projectId, + createdAt: new Date().toISOString(), + }; + return { ...current, [draftKey]: { ...normalizeDraft(existing), project: stamp } }; + }); + return stamped; +} + export function retargetNewTaskDraft( draftKey: string, project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, @@ -1913,6 +1986,13 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI const removedAttachments = Object.entries(current) .filter(([draftKey]) => next[draftKey] === undefined) .flatMap(([, draft]) => draft.attachments); + // Keys with no stored draft yet (an empty thread composer) can still have a pick running. + const environmentPrefix = `${environmentId}:`; + abortComposerDraftOperationsWhere( + (draftKey) => + draftKey.startsWith(environmentPrefix) || + (current[draftKey] !== undefined && next[draftKey] === undefined), + ); if (persistTimer !== null) { clearTimeout(persistTimer); @@ -1925,10 +2005,21 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI await releaseUnusedComposerAttachmentFiles(removedAttachments); } +// Reading drafts from a component hydrates them from disk the first time. const composerDraftAtom = Atom.family((draftKey: string) => - Atom.make((get) => get(composerDraftsAtom)[draftKey] ?? EMPTY_DRAFT), + Atom.make((get) => { + ensureComposerDraftsLoaded(); + return get(composerDraftsAtom)[draftKey] ?? EMPTY_DRAFT; + }), ); -const EMPTY_DRAFT_ATOM = Atom.make(EMPTY_DRAFT); +const EMPTY_DRAFT_ATOM = Atom.make(() => { + ensureComposerDraftsLoaded(); + return EMPTY_DRAFT; +}); +const stickyComposerModelSelectionReaderAtom = Atom.make((get) => { + ensureComposerDraftsLoaded(); + return get(stickyComposerModelSelectionAtom); +}); type ComposerDraftsByKey = Readonly>; const EMPTY_DRAFTS: ComposerDraftsByKey = {}; @@ -1996,30 +2087,22 @@ const composerDraftSettingsAtom = Atom.family((draftKey: string) => ), ), ); -const EMPTY_DRAFT_SETTINGS_ATOM = Atom.make(EMPTY_DRAFT_SETTINGS); +const EMPTY_DRAFT_SETTINGS_ATOM = Atom.make((get) => { + get(EMPTY_DRAFT_ATOM); + return EMPTY_DRAFT_SETTINGS; +}); export function useComposerDraftSettings(draftKey: string | null): ComposerDraftSettings { const settings = useAtomValue( draftKey ? composerDraftSettingsAtom(draftKey) : EMPTY_DRAFT_SETTINGS_ATOM, ); - useEffect(() => { - ensureComposerDraftsLoaded(); - }, []); return settings; } export function useComposerDraft(draftKey: string | null): ComposerDraft { - const draft = useAtomValue(draftKey ? composerDraftAtom(draftKey) : EMPTY_DRAFT_ATOM); - useEffect(() => { - ensureComposerDraftsLoaded(); - }, []); - return draft; + return useAtomValue(draftKey ? composerDraftAtom(draftKey) : EMPTY_DRAFT_ATOM); } export function useStickyComposerModelSelection(): ModelSelection | null { - const selection = useAtomValue(stickyComposerModelSelectionAtom); - useEffect(() => { - ensureComposerDraftsLoaded(); - }, []); - return selection; + return useAtomValue(stickyComposerModelSelectionReaderAtom); } diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 472c1a266c6cd..a327636d0e997 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -5,6 +5,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; import { useCallback } from "react"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { useConnectionController } from "../features/connection/useConnectionController"; import { environmentPresentations } from "./presentation"; @@ -16,6 +17,7 @@ import { environmentSession } from "./session"; import { environmentCatalog } from "../connection/catalog"; import { createRemoteEnvironmentProjectionAtoms } from "./remote-environment-projections"; import { serverEnvironment } from "./server"; +import { connectPairingUrl as connectPairingUrlCommand } from "../connection/onboarding"; const connectionPairingUrlAtom = Atom.make("").pipe( Atom.keepAlive, @@ -31,12 +33,55 @@ export function setPendingConnectionError(message: string | null): void { appAtomRegistry.set(pendingConnectionErrorAtom, message); } +export interface PairingOutcome { + readonly ok: boolean; + /** Shown beside the pairing form; null when the failure got its own dialog. */ + readonly error: string | null; +} + +/** Pairs with the environment behind a pairing link and saves it. */ +export async function pairEnvironment( + pairingUrl: string, + expectedEnvironmentId?: EnvironmentId, +): Promise { + const result = await runAtomCommand( + appAtomRegistry, + connectPairingUrlCommand, + { pairingUrl, ...(expectedEnvironmentId === undefined ? {} : { expectedEnvironmentId }) }, + { reportFailure: false }, + ); + if (AsyncResult.isSuccess(result)) { + appAtomRegistry.set(connectionPairingUrlAtom, ""); + return { ok: true, error: null }; + } + const error = Cause.squash(result.cause); + const message = error instanceof Error ? error.message : "Failed to pair with the environment."; + if ( + error !== null && + typeof error === "object" && + "reason" in error && + error.reason === "unsupported" + ) { + Dialog.alert("Client not supported", message); + return { ok: false, error: null }; + } + return { ok: false, error: message }; +} + const remoteEnvironmentProjections = createRemoteEnvironmentProjectionAtoms({ presentationAtom: environmentPresentations.presentationAtom, preparedConnectionAtom: environmentSession.preparedConnectionValueAtom, serverConfigAtom: serverEnvironment.configValueAtom, }); +export const environmentConnectedAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get) => + get(remoteEnvironmentProjections.runtimeStateAtom(environmentId))?.connectionState === + "connected", + ).pipe(Atom.withLabel(`mobile:environment-connected:${environmentId}`)), +); + const EMPTY_SAVED_CONNECTION_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile:saved-connection:empty"), ); @@ -102,7 +147,6 @@ export function useRemoteConnections() { const controller = useConnectionController(); const navigation = useNavigation(); const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); - const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); const onChangeConnectionPairingUrl = useCallback((pairingUrl: string) => { @@ -110,30 +154,9 @@ export function useRemoteConnections() { }, []); const onConnectPress = useCallback( - async (pairingUrl?: string, expectedEnvironmentId?: EnvironmentId) => { - const nextPairingUrl = pairingUrl ?? connectionPairingUrl; - setPendingConnectionError(null); - const result = await controller.connectPairingUrl(nextPairingUrl, expectedEnvironmentId); - if (AsyncResult.isFailure(result)) { - const error = Cause.squash(result.cause); - const message = - error instanceof Error ? error.message : "Failed to pair with the environment."; - if ( - error !== null && - typeof error === "object" && - "reason" in error && - error.reason === "unsupported" - ) { - Dialog.alert("Client not supported", message); - } else { - setPendingConnectionError(message); - } - } else { - appAtomRegistry.set(connectionPairingUrlAtom, ""); - } - return result; - }, - [connectionPairingUrl, controller], + (pairingUrl?: string, expectedEnvironmentId?: EnvironmentId) => + pairEnvironment(pairingUrl ?? connectionPairingUrl, expectedEnvironmentId), + [connectionPairingUrl], ); const onReconnectEnvironment = useCallback( @@ -196,7 +219,6 @@ export function useRemoteConnections() { connectionPairingUrl, connectionState, connectionError, - pairingConnectionError: pendingConnectionError, connectedEnvironments, connectedEnvironmentCount: connectedEnvironments.length, onChangeConnectionPairingUrl, diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts index 8b662c1257d9a..ce3f8a83f2c13 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -25,9 +25,8 @@ const state = vi.hoisted(() => ({ vi.mock("react", () => ({ useCallback: (callback: unknown) => callback, useMemo: (factory: () => unknown) => factory(), - useEffect: () => {}, - useEffectEvent: (callback: unknown) => callback, })); +vi.mock("@effect/atom-react", () => ({ useAtomMount: () => {} })); vi.mock("./session", () => ({ useEnvironmentScope: (environmentId: unknown, scope: string) => (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope), @@ -51,7 +50,10 @@ vi.mock("./queries", () => ({ })); vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); -vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} })); +vi.mock("./use-remote-environment-registry", () => ({ + environmentConnectedAtom: () => null, + setPendingConnectionError: () => {}, +})); vi.mock("./use-vcs-action-state", () => ({ showGitActionResult: (result: { type: string; description?: string }) => state.results.push(result), diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx deleted file mode 100644 index cfd1dd4052fec..0000000000000 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { act, useEffect, useReducer } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { AsyncResult } from "effect/reactivity"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const harness = vi.hoisted(() => ({ - selection: { - selectedThread: { environmentId: "environment-1", id: "thread-1" } as unknown, - selectedThreadProject: { workspaceRoot: "/repo" } as unknown, - selectedEnvironmentRuntime: { connectionState: "connected" } as unknown, - }, - worktree: { selectedThreadCwd: "/repo" as string | null, selectedThreadWorktreePath: null }, - refreshStatusCalls: [] as Array, - renderCount: 0, - onRefreshSettled: () => {}, -})); - -vi.mock("./use-atom-command", () => ({ - useAtomCommand: (command: unknown) => - command === "refreshStatus" - ? async (input: unknown) => { - harness.refreshStatusCalls.push(input); - harness.onRefreshSettled(); - return AsyncResult.success({}); - } - : async () => AsyncResult.success({}), -})); -vi.mock("./vcs", () => ({ - vcsEnvironment: { refreshStatus: "refreshStatus" }, - vcsActionManager: { runStackedAction: () => "runStackedAction", track: vi.fn() }, -})); -vi.mock("./threads", () => ({ threadEnvironment: { updateMetadata: "updateMetadata" } })); -vi.mock("./queries", () => ({ useBranches: () => ({ data: null, refresh: () => {} }) })); -vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); -vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); -vi.mock("./session", () => ({ - readEnvironmentScope: () => true, - useEnvironmentScope: () => true, -})); -vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} })); -vi.mock("./use-vcs-action-state", () => ({ showGitActionResult: () => {} })); -vi.mock("./use-thread-selection", () => ({ - useThreadSelection: () => { - harness.renderCount += 1; - if (harness.renderCount > 50) { - throw new Error("Consumer rerendered more than 50 times"); - } - return harness.selection; - }, -})); -vi.mock("./use-selected-thread-worktree", () => ({ - useSelectedThreadWorktree: () => harness.worktree, -})); - -import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions"; - -let root: Root | null = null; -let rerender: () => void = () => {}; - -function Consumer() { - const [, bump] = useReducer((count: number) => count + 1, 0); - useEffect(() => { - rerender = bump; - }); - useSelectedThreadGitActions(); - return null; -} - -async function flush() { - for (let index = 0; index < 5; index += 1) { - await act(async () => { - await Promise.resolve(); - }); - } -} - -beforeEach(() => { - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - harness.selection = { - selectedThread: { environmentId: "environment-1", id: "thread-1" }, - selectedThreadProject: { workspaceRoot: "/repo" }, - selectedEnvironmentRuntime: { connectionState: "connected" }, - }; - harness.worktree = { selectedThreadCwd: "/repo", selectedThreadWorktreePath: null }; - harness.refreshStatusCalls = []; - harness.renderCount = 0; - harness.onRefreshSettled = () => rerender(); -}); - -afterEach(() => { - act(() => root?.unmount()); - root = null; -}); - -describe("useSelectedThreadGitActions", () => { - it("refreshes once on mount and does not loop when refreshes rerender the consumer", async () => { - root = createRoot(document.createElement("div")); - act(() => root!.render()); - await flush(); - - harness.selection = { - selectedThread: { environmentId: "environment-1", id: "thread-1" }, - selectedThreadProject: { workspaceRoot: "/repo" }, - selectedEnvironmentRuntime: { connectionState: "connected" }, - }; - act(() => rerender()); - await flush(); - - expect(harness.refreshStatusCalls).toEqual([ - { environmentId: "environment-1", input: { cwd: "/repo" } }, - ]); - }); -}); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 7cbb0a5e24a32..a5a35c47f2c4d 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -1,8 +1,10 @@ -import { useCallback, useEffect, useEffectEvent, useMemo } from "react"; +import { useAtomMount } from "@effect/atom-react"; +import { useCallback, useMemo } from "react"; import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { + createVcsStatusRevalidationAtomFamily, type GitActionRequestInput, type VcsActionOperation, type VcsRef, @@ -26,12 +28,31 @@ import { vcsActionManager, vcsEnvironment } from "../state/vcs"; import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { readEnvironmentScope, useEnvironmentScope } from "./session"; -import { setPendingConnectionError } from "./use-remote-environment-registry"; +import { + environmentConnectedAtom, + setPendingConnectionError, +} from "./use-remote-environment-registry"; import { useAtomCommand } from "./use-atom-command"; import { showGitActionResult } from "./use-vcs-action-state"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; +function refreshFailureMessage(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : "Failed to refresh git status."; +} + +// Selecting a thread or reconnecting its environment refreshes the checkout's +// status once, shared by every sheet that shows it. +const selectedThreadStatusRevalidation = createVcsStatusRevalidationAtomFamily({ + refreshStatus: vcsEnvironment.refreshStatus, + connected: environmentConnectedAtom, + onRefreshed: (result) => + setPendingConnectionError( + AsyncResult.isFailure(result) ? refreshFailureMessage(result.cause) : null, + ), +}); + export function useSelectedThreadGitActions() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -41,8 +62,7 @@ export function useSelectedThreadGitActions() { const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false }); const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); - const { selectedThread, selectedThreadProject, selectedEnvironmentRuntime } = - useThreadSelection(); + const { selectedThread, selectedThreadProject } = useThreadSelection(); const canWriteSourceControl = useEnvironmentScope( selectedThread?.environmentId ?? null, AuthSourceControlWriteScope, @@ -130,9 +150,7 @@ export function useSelectedThreadGitActions() { execute, ); if (AsyncResult.isFailure(result)) { - const error = Cause.squash(result.cause); - const message = error instanceof Error ? error.message : "Failed to refresh git status."; - setPendingConnectionError(message); + setPendingConnectionError(refreshFailureMessage(result.cause)); return null; } setPendingConnectionError(null); @@ -141,32 +159,13 @@ export function useSelectedThreadGitActions() { [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject], ); - // Key refreshes on primitives; streaming frequently replaces the selected thread object. - const selectedEnvironmentId = selectedThread?.environmentId ?? null; - const selectedThreadId = selectedThread?.id ?? null; - const hasSelectedThreadProject = selectedThreadProject !== null; - const isEnvironmentConnected = selectedEnvironmentRuntime?.connectionState === "connected"; - const refreshOnSelection = useEffectEvent(() => { - void refreshSelectedThreadGitStatus({ quiet: true }); - }); - useEffect(() => { - if ( - selectedEnvironmentId === null || - selectedThreadId === null || - !hasSelectedThreadProject || - selectedThreadCwd === null || - !isEnvironmentConnected - ) { - return; - } - refreshOnSelection(); - }, [ - selectedEnvironmentId, - selectedThreadId, - hasSelectedThreadProject, - selectedThreadCwd, - isEnvironmentConnected, - ]); + useAtomMount( + selectedThreadStatusRevalidation( + selectedThread && selectedThreadProject && selectedThreadCwd + ? { environmentId: selectedThread.environmentId, cwd: selectedThreadCwd } + : null, + ), + ); const runSelectedThreadGitMutation = useCallback( async ( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 5b86b88650921..d2648913ae13d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -25,6 +25,7 @@ import { type ModelSelection, type ProviderInteractionMode, type RuntimeMode, + type ServerProvider, type ThreadId, } from "@t3tools/contracts"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; @@ -54,7 +55,10 @@ import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed, createThreadFeedCache } from "../lib/threadActivity"; import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; -import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; +import { + appendPendingThreadMessages, + selectPendingAcknowledgedMessages, +} from "../features/threads/pending-thread-feed"; import { threadAllowsProviderSwitch } from "./thread-provider-switching"; import { appAtomRegistry } from "../state/atom-registry"; import { pendingThreadCreationMessage } from "./pending-thread-creation"; @@ -70,7 +74,6 @@ import { insertComposerDraftContext, clearComposerDraftContent, composerContextImportsAtom, - ensureComposerDraftsLoaded, getComposerDraftSnapshot, mergeComposerDraftContent, removeComposerDraftAttachment, @@ -97,6 +100,8 @@ import { queuedEditDraftKey, removeQueuedRunEditAttachment, resolveQueuedEditPayload, + resumeQueuedRunEditExit, + suspendQueuedRunEditExit, useQueuedRunEdit, } from "./queued-run-edit"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; @@ -171,6 +176,21 @@ export function useThreadDraftForThread(input: { }; } +/** A provider without the interaction-mode toggle runs in the default mode. */ +export function updateThreadDraftModelSelection( + threadKey: string, + value: ModelSelection, + providers: ReadonlyArray, +): void { + const provider = providers.find((candidate) => candidate.instanceId === value.instanceId); + updateComposerDraftSettings(threadKey, { + modelSelection: value, + ...(provider?.showInteractionModeToggle === false + ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE } + : {}), + }); +} + export function useThreadComposerState() { const { selectedThread: selectedThreadShell, @@ -210,10 +230,6 @@ export function useThreadComposerState() { return nextName; }; - useEffect(() => { - ensureComposerDraftsLoaded(); - }, []); - const selectedThreadKey = selectedThreadShell ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) : null; @@ -259,27 +275,16 @@ export function useThreadComposerState() { checkpointSummaries, cache: feedCache, }); - const pendingAcknowledgments = acknowledgedMessages.filter( - (message) => - scopedThreadKey(message.environmentId, message.threadId) === selectedThreadKey && - !selectedThreadQueuedMessages.some((queued) => queued.messageId === message.messageId), - ); + const pendingAcknowledgments = selectPendingAcknowledgedMessages(acknowledgedMessages, { + threadKey: selectedThreadKey, + queuedMessages: selectedThreadQueuedMessages, + projectedMessages: selectedThreadMessages, + }); if (pendingAcknowledgments.length === 0) return feed; return appendPendingThreadMessages(feed, feed, pendingAcknowledgments).map((entry) => entry.pendingMessage ? { ...entry, acknowledged: true } : entry, ); })(); - useEffect(() => { - const echoedIds = new Set(selectedThreadMessages?.map((message) => message.id)); - if (acknowledgedMessages.some((message) => echoedIds.has(message.messageId))) { - appAtomRegistry.set( - acknowledgedThreadMessagesAtom, - appAtomRegistry - .get(acknowledgedThreadMessagesAtom) - .filter((message) => !echoedIds.has(message.messageId)), - ); - } - }, [acknowledgedMessages, selectedThreadMessages]); const preferencesResult = useAtomValue(mobilePreferencesAtom); const followUpBehavior = AsyncResult.isSuccess(preferencesResult) @@ -392,40 +397,6 @@ export function useThreadComposerState() { ? deriveProviderSubagentStatus(selectedThreadProjection.projection) : null; - const selectedThreadRuns = selectedThreadProjection?.projection.runs; - const editedRunId = queuedRunEdit?.runId ?? null; - useEffect(() => { - if (selectedThreadKey === null || editedRunId === null || selectedThreadRuns === undefined) { - return; - } - if (savingQueuedEditRef.current) return; - const stillQueued = selectedThreadRuns.some( - (run) => run.id === editedRunId && run.status === "queued", - ); - if (stillQueued) return; - const editDraftKey = queuedEditDraftKey(selectedThreadKey, editedRunId); - const editDraft = getComposerDraftSnapshot(editDraftKey); - const threadDraft = getComposerDraftSnapshot(selectedThreadKey); - const keepable = - editDraft.text.trim().length > 0 && - threadDraft.text.trim().length === 0 && - threadDraft.attachments.length === 0; - if (keepable) { - void mergeComposerDraftContent(selectedThreadKey, { - text: editDraft.text, - attachments: editDraft.attachments, - ...(editDraft.context ? { context: editDraft.context } : {}), - }); - } - endQueuedRunEdit(selectedThreadKey, { deferAttachmentCleanup: keepable }); - setThreadComposerError( - selectedThreadKey, - keepable - ? "That message already started. Your edit is back in the composer." - : "That message already started, so the edit was discarded.", - ); - }, [editedRunId, selectedThreadKey, selectedThreadRuns]); - const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime) ? (selectedThreadRuntime?.activeRunId ?? null) @@ -465,6 +436,7 @@ export function useThreadComposerState() { } savingQueuedEditRef.current = true; setIsSavingQueuedEdit(true); + suspendQueuedRunEditExit(threadKey); const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; const prepared = await settlePromise(() => prepareTurnAttachments({ @@ -476,6 +448,7 @@ export function useThreadComposerState() { if (AsyncResult.isFailure(prepared)) { savingQueuedEditRef.current = false; setIsSavingQueuedEdit(false); + resumeQueuedRunEditExit(threadKey); const error = squashAtomCommandFailure(prepared); Dialog.alert( "Could not save the queued message", @@ -486,6 +459,7 @@ export function useThreadComposerState() { if (prepared.value.status !== "ready") { savingQueuedEditRef.current = false; setIsSavingQueuedEdit(false); + resumeQueuedRunEditExit(threadKey); return; } const payload = resolveQueuedEditPayload({ @@ -510,6 +484,7 @@ export function useThreadComposerState() { savingQueuedEditRef.current = false; setIsSavingQueuedEdit(false); if (result._tag !== "Success") { + resumeQueuedRunEditExit(threadKey); Dialog.alert( "Could not save the queued message", "It may have already started. Your edit is still in the composer.", @@ -837,28 +812,6 @@ export function useThreadComposerState() { removeComposerDraftAttachment(threadKey, imageId); }; - const onUpdateModelSelection = (value: ModelSelection) => { - if (!selectedThreadKey) { - return; - } - const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( - (candidate) => candidate.instanceId === value.instanceId, - ); - updateComposerDraftSettings(selectedThreadKey, { - modelSelection: value, - ...(provider?.showInteractionModeToggle === false - ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE } - : {}), - }); - }; - - const onUpdateRuntimeMode = (value: RuntimeMode) => { - if (!selectedThreadKey) { - return; - } - updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); - }; - const onUpdateInteractionMode = (value: ProviderInteractionMode) => { if (!selectedThreadKey) { return; @@ -908,8 +861,6 @@ export function useThreadComposerState() { onNativePasteText, onRemoveDraftImage, onSendMessage, - onUpdateModelSelection, - onUpdateRuntimeMode, onUpdateInteractionMode, }; } diff --git a/apps/mobile/src/state/use-thread-outbox.ts b/apps/mobile/src/state/use-thread-outbox.ts index 8339472a99617..a1128cbd08b30 100644 --- a/apps/mobile/src/state/use-thread-outbox.ts +++ b/apps/mobile/src/state/use-thread-outbox.ts @@ -7,7 +7,7 @@ import { appAtomRegistry } from "./atom-registry"; import { environmentShell } from "./shell"; import { threadOutboxManager } from "./thread-outbox"; -const threadOutboxShellStatusesAtom = Atom.make( +export const threadOutboxShellStatusesAtom = Atom.make( (get): ReadonlyMap => { const statuses = new Map(); for (const queue of Object.values(get(threadOutboxManager.queuedMessagesByThreadKeyAtom))) { @@ -68,7 +68,3 @@ export const queuedThreadKeysAtom = Atom.make((get): ReadonlySet => { export function useQueuedThreadKeys(): ReadonlySet { return useAtomValue(queuedThreadKeysAtom); } - -export function useThreadOutboxShellStatuses() { - return useAtomValue(threadOutboxShellStatusesAtom); -} diff --git a/apps/mobile/src/state/use-vcs-action-state.ts b/apps/mobile/src/state/use-vcs-action-state.ts index a3929b1f3cbde..85528385f5ee3 100644 --- a/apps/mobile/src/state/use-vcs-action-state.ts +++ b/apps/mobile/src/state/use-vcs-action-state.ts @@ -1,8 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; import { type VcsActionState, type VcsActionTarget } from "@t3tools/client-runtime/state/vcs"; import { Atom } from "effect/reactivity"; -import { useEffect, useState } from "react"; +import * as Haptics from "expo-haptics"; +import { useVisibleSecondClock } from "../features/threads/use-visible-second-clock"; import { appAtomRegistry } from "./atom-registry"; import { vcsActionManager } from "./vcs"; @@ -32,6 +33,11 @@ function broadcast(result: GitActionResultNotification | null): void { export function showGitActionResult(result: GitActionResultNotification): void { if (dismissTimer) clearTimeout(dismissTimer); broadcast(result); + void Haptics.notificationAsync( + result.type === "success" + ? Haptics.NotificationFeedbackType.Success + : Haptics.NotificationFeedbackType.Error, + ); dismissTimer = setTimeout(() => { dismissTimer = null; broadcast(null); @@ -78,14 +84,7 @@ export function useGitActionProgress(target: VcsActionTarget): GitActionProgress const actionState = useVcsActionState(target); const { result } = useGitActionResultNotification(); - // Keep clock state; the compiler caches render-time Date.now() and would freeze the label. - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - if (!actionState.isRunning) return; - setNowMs(Date.now()); - const interval = setInterval(() => setNowMs(Date.now()), 1000); - return () => clearInterval(interval); - }, [actionState.isRunning]); + const nowMs = useVisibleSecondClock(actionState.isRunning); if (actionState.isRunning) { const description = diff --git a/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx index a51395e66f410..21d01109b3812 100644 --- a/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx @@ -1,13 +1,18 @@ -import { useAtomValue } from "@effect/atom-react"; -import { Atom } from "effect/reactivity"; +import { Atom, AsyncResult } from "effect/reactivity"; import * as Linking from "expo-linking"; -import { useEffect } from "react"; -import { Platform } from "react-native"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { Appearance, Platform } from "react-native"; + +import { + resolveThemeVariablesByAppearance, + systemColorPalettesAtom, +} from "../features/settings/appearance/AppearancePreferencesProvider"; import { environmentCatalog } from "../connection/catalog"; +import { normalizeMobileThemeMode, resolveMobileThemeIds } from "../lib/mobileTheme"; +import { appAtomRegistry } from "../state/atom-registry"; +import { mobilePreferencesAtom } from "../state/preferences"; import { environmentPresentations } from "../state/presentation"; import { publishSubscriptionUsage } from "./publishSubscriptionUsage"; -import { useSubscriptionUsage } from "./useSubscriptionUsage"; +import { installSubscriptionUsageRefresh } from "./useSubscriptionUsage"; import { buildSubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; import { createWidgetTheme } from "./widgetTheme"; @@ -20,37 +25,52 @@ const snapshotAtom = Atom.make((get) => ), ).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); -export function SubscriptionUsageCoordinator() { - const { - themeMode, - themeAppearance, - themeVariablesByAppearance, - isReady: appearanceReady, - } = useAppearancePreferences(); - const catalog = useAtomValue(environmentCatalog.catalogValueAtom); - const snapshot = useAtomValue(snapshotAtom); - useSubscriptionUsage(catalog.isReady); - const androidAppearance = Platform.OS === "android" ? themeAppearance : null; - useEffect(() => { - if (!catalog.isReady || !appearanceReady) return; - void Promise.resolve() - .then(() => - publishSubscriptionUsage({ - ...snapshot, - theme: createWidgetTheme(themeMode, themeVariablesByAppearance), - }), - ) - .catch((error: unknown) => { - console.warn("Could not update subscription usage widget", error); - }); - }, [ - catalog.isReady, - appearanceReady, - snapshot, - themeMode, - themeVariablesByAppearance, - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Android stores resolved colors; System appearance changes must republish them. - androidAppearance, - ]); - return null; +const systemColorSchemeAtom = Atom.make((get) => { + const subscription = Appearance.addChangeListener(({ colorScheme }) => + get.setSelf(colorScheme ?? null), + ); + get.addFinalizer(() => subscription.remove()); + return Appearance.getColorScheme() ?? null; +}); + +const widgetPayloadAtom = Atom.make((get) => { + if (!get(environmentCatalog.catalogValueAtom).isReady) return null; + const preferences = get(mobilePreferencesAtom); + if (!AsyncResult.isSuccess(preferences)) return null; + const themeMode = normalizeMobileThemeMode(preferences.value.themeMode); + const themeIds = resolveMobileThemeIds(preferences.value); + return { + ...get(snapshotAtom), + theme: createWidgetTheme( + themeMode, + resolveThemeVariablesByAppearance( + { light: themeIds.light, dark: themeIds.dark }, + get(systemColorPalettesAtom), + ), + ), + // Android stores resolved colors; System appearance changes must republish them. + androidAppearance: Platform.OS === "android" ? get(systemColorSchemeAtom) : null, + }; +}).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); + +/** App-lifetime: keeps the home-screen usage widget's quotas and theme current. */ +export function installSubscriptionUsageWidget(): () => void { + const stopRefresh = installSubscriptionUsageRefresh(); + const unsubscribe = appAtomRegistry.subscribe( + widgetPayloadAtom, + (payload) => { + if (payload === null) return; + const { androidAppearance: _androidAppearance, ...usage } = payload; + void Promise.resolve() + .then(() => publishSubscriptionUsage(usage)) + .catch((error: unknown) => { + console.warn("Could not update subscription usage widget", error); + }); + }, + { immediate: true }, + ); + return () => { + stopRefresh(); + unsubscribe(); + }; } diff --git a/apps/mobile/src/widgets/useSubscriptionUsage.ts b/apps/mobile/src/widgets/useSubscriptionUsage.ts index 1f90a5ebbe7cc..0ba5ad1f5107d 100644 --- a/apps/mobile/src/widgets/useSubscriptionUsage.ts +++ b/apps/mobile/src/widgets/useSubscriptionUsage.ts @@ -1,40 +1,47 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useMemo } from "react"; -import { AppState } from "react-native"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { environmentCatalog } from "../connection/catalog"; +import { isAppActive, subscribeAppState } from "../lib/appState"; +import { appAtomRegistry } from "../state/atom-registry"; import { environmentPresentations } from "../state/presentation"; import { serverEnvironment } from "../state/server"; -import { useAtomCommand } from "../state/use-atom-command"; import { createWidgetRefresher, WIDGET_REFRESH_INTERVAL } from "./subscriptionUsageSnapshot"; -export function useSubscriptionUsage(enabled = true) { - const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - const refresh = useMemo( - () => - createWidgetRefresher( - (environmentId: Parameters[0]["environmentId"]) => - refreshProviders({ environmentId, input: {} }), - ), - [refreshProviders], +/** + * App-lifetime worker that keeps provider quotas fresh for the usage widget: + * on resume, on every connection change, and on a fixed interval while active. + * Each environment is refreshed at most once per interval. + */ +export function installSubscriptionUsageRefresh(): () => void { + const refresh = createWidgetRefresher((environmentId: EnvironmentId) => + runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshProviders, + { environmentId, input: {} }, + { label: serverEnvironment.refreshProviders.label, reportFailure: false }, + ), ); + const update = () => { + if (!isAppActive() || !appAtomRegistry.get(environmentCatalog.catalogValueAtom).isReady) return; + const connected = [...appAtomRegistry.get(environmentPresentations.presentationsAtom)] + .filter(([, presentation]) => presentation.connection.phase === "connected") + .map(([id]) => id); + void refresh(connected, Date.now()); + }; - useEffect(() => { - const update = () => { - if (!enabled || AppState.currentState !== "active") return; - const connected = [...presentations] - .filter(([, presentation]) => presentation.connection.phase === "connected") - .map(([id]) => id); - void refresh(connected, Date.now()); - }; - update(); - const subscription = AppState.addEventListener("change", update); - const timer = setInterval(update, WIDGET_REFRESH_INTERVAL); - return () => { - subscription.remove(); - clearInterval(timer); - }; - }, [enabled, presentations, refresh]); + const unsubscribeCatalog = appAtomRegistry.subscribe(environmentCatalog.catalogValueAtom, update); + const unsubscribePresentations = appAtomRegistry.subscribe( + environmentPresentations.presentationsAtom, + update, + { immediate: true }, + ); + const unsubscribeAppState = subscribeAppState(update); + const timer = setInterval(update, WIDGET_REFRESH_INTERVAL); + return () => { + unsubscribeCatalog(); + unsubscribePresentations(); + unsubscribeAppState(); + clearInterval(timer); + }; } diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 890b37887bde1..88dbfbc225810 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -105,8 +105,7 @@ export function hasCompleteProviderWorkspaceSnapshot( return Boolean(snapshot && !snapshot.slashCommandsPending); } -// Compat shim: still exported for mobile use-composer-command-menu until the mobile-threads layer. -export function hasCurrentProviderWorkspaceSnapshot( +function hasCurrentProviderWorkspaceSnapshot( provider: ServerProvider | null | undefined, cwd: string | null | undefined, nowMs: number,