diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index 1d082515172d..a56da99d568b 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -9,6 +9,7 @@ import { import { deriveSubagentElapsedMs, formatDuration } from "@t3tools/shared/orchestrationTiming"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; 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"; @@ -26,8 +27,16 @@ const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Pl type AgentsTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; -export function useThreadTurnSubagents(target: AgentsTarget): ThreadTurnSubagents | null { - return useAtomValue(environmentThreadDetails.turnSubagentsAtom(target)); +const EMPTY_TURN_SUBAGENTS_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-thread-turn-subagents:empty"), +); + +export function useThreadTurnSubagents(target: AgentsTarget | null): ThreadTurnSubagents | null { + return useAtomValue( + target === null + ? EMPTY_TURN_SUBAGENTS_ATOM + : environmentThreadDetails.turnSubagentsAtom(target), + ); } export function ThreadAgentsSheet({ route }: StaticScreenProps) { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9229c117d8da..2c74494a2f7d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -24,6 +24,7 @@ import type { ProviderInteractionMode, RuntimeMode, RuntimeRequestId, + ScopedThreadRef, ServerConfig as T3ServerConfig, ThreadId, UsageLimitsReport, @@ -155,6 +156,11 @@ export interface ThreadDetailScreenProps { readonly setupWorkingStartedAt?: string | null; readonly canOperateThread: boolean; readonly selectedThread: EnvironmentThreadShell; + /** + * Read thread details through this, not `selectedThread.id`: it stays null until + * a queued creation reaches the server; see `useThreadSelection`. + */ + readonly selectedThreadDetailRef: ScopedThreadRef | null; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; readonly connectionError: string | null; @@ -324,10 +330,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const navigation = useNavigation(); const { session: voiceInputSession } = useGlobalVoiceInput(); - const reportedModelSelection = useThreadReportedModelSelection({ - environmentId: props.environmentId, - threadId: props.selectedThread.id, - }); + const reportedModelSelection = useThreadReportedModelSelection(props.selectedThreadDetailRef); const deviceState = useEnvironmentQuery( deviceEnvironment.state({ environmentId: props.environmentId, input: {} }), ); @@ -401,14 +404,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; - const queuedCount = useThreadQueuedCount({ - environmentId: props.environmentId, - threadId: props.selectedThread.id, - }); - const turnSubagents = useThreadTurnSubagents({ - environmentId: props.environmentId, - threadId: props.selectedThread.id, - }); + const queuedCount = useThreadQueuedCount(props.selectedThreadDetailRef); + const turnSubagents = useThreadTurnSubagents(props.selectedThreadDetailRef); const agentsSegment = resolveSubagentPillSegment(turnSubagents); const composerEditorRef = useRef(null); // A provider-native subagent shows status instead of a composer. diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 5aa56b3d2723..9fa5d862b89b 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -1,6 +1,7 @@ import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; import { useAtomValue } from "@effect/atom-react"; import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; import { Image } from "expo-image"; import * as Haptics from "expo-haptics"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -37,6 +38,9 @@ import { threadDragGapOffset } from "./threadDragGap"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); const REMOVE_ACTION_WIDTH = 76; const THUMBNAIL_LIMIT = 3; +const EMPTY_QUEUED_COUNT_ATOM = Atom.make(0).pipe( + Atom.withLabel("mobile-thread-queue-count:empty"), +); type QueueTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; type QueueAction = "steer" | "edit" | "up" | "down" | "remove"; @@ -46,8 +50,10 @@ export function useThreadQueueWorkflow(target: QueueTarget) { return useAtomValue(environmentThreadDetails.queueWorkflowAtom(target)); } -export function useThreadQueuedCount(target: QueueTarget) { - return useAtomValue(environmentThreadDetails.queuedCountAtom(target)); +export function useThreadQueuedCount(target: QueueTarget | null) { + return useAtomValue( + target === null ? EMPTY_QUEUED_COUNT_ATOM : environmentThreadDetails.queuedCountAtom(target), + ); } export function ThreadQueueSheet({ route }: StaticScreenProps) { diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 8a579fc9a2f6..70a559521a04 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -226,6 +226,7 @@ function ThreadRouteContent( const { selectedThread, selectedThreadCreation, + selectedThreadDetailRef, selectedThreadProject, selectedEnvironmentConnection, } = useThreadSelection(); @@ -932,6 +933,7 @@ function ThreadRouteContent( (null).p const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-server-config:empty"), ); +const EMPTY_THREAD_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-thread:empty"), +); const EMPTY_THREAD_TITLES: ReadonlyMap = new Map(); const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe( Atom.withLabel("mobile-thread-titles:empty"), @@ -129,6 +132,9 @@ export function useServerConfigs(): ReadonlyMap { const selectReportedModelSelection = (thread: EnvironmentThread | null) => thread === null ? null : deriveReportedModelSelection(thread.projection); -export function useThreadReportedModelSelection(ref: ScopedThreadRef) { - return useAtomValue(environmentThreadDetails.threadAtom(ref), selectReportedModelSelection); +export function useThreadReportedModelSelection(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null ? EMPTY_THREAD_ATOM : environmentThreadDetails.threadAtom(ref), + selectReportedModelSelection, + ); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 12a9999c8a03..2c1d1c75121f 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -180,6 +180,7 @@ export function useThreadComposerState() { const { selectedThread: selectedThreadShell, selectedThreadCreation, + selectedThreadDetailRef, selectedEnvironmentRuntime, } = useThreadSelection(); const selectedThreadProjection = useSelectedThreadProjection(); @@ -307,12 +308,9 @@ export function useThreadComposerState() { // Steering needs a live provider turn the adapter can interrupt; the queue // workflow already derives that from the session's capabilities. const queueWorkflow = useAtomValue( - selectedThreadShell === null + selectedThreadDetailRef === null ? EMPTY_QUEUE_WORKFLOW_ATOM - : environmentThreadDetails.queueWorkflowAtom({ - environmentId: selectedThreadShell.environmentId, - threadId: selectedThreadShell.id, - }), + : environmentThreadDetails.queueWorkflowAtom(selectedThreadDetailRef), ); const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true; const queuedRunEdit = useQueuedRunEdit(selectedThreadKey); diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 3624caebb076..51b88c748e4a 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -158,7 +158,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin return message === null ? null : { message, outcome }; }, [creationOutcome, queuedMessagesByThreadKey, selectedThreadKey]); // Until the creation is delivered the server has no thread to subscribe - // to; subscribing anyway would retry "not found" for the whole setup. + // to. Every detail reader must use this ref, not the route id: an earlier + // read gets a definitive not-found, which the thread state keeps as deleted. const selectedThreadDetailRef = selectedThreadShell !== null || pendingCreation === null ||