Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/mobile/src/features/threads/ThreadAgentsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/unstable/reactivity";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Platform, Pressable, ScrollView, View } from "react-native";
Expand All @@ -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<ThreadTurnSubagents | null>(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<AgentsTarget>) {
Expand Down
21 changes: 9 additions & 12 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
ProviderInteractionMode,
RuntimeMode,
RuntimeRequestId,
ScopedThreadRef,
ServerConfig as T3ServerConfig,
ThreadId,
UsageLimitsReport,
Expand Down Expand Up @@ -144,6 +145,11 @@ export interface ThreadDetailScreenProps {
readonly worktreeSetup?: WorktreeSetupCardProps | null;
readonly setupWorkingStartedAt?: string | null;
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;
Expand Down Expand Up @@ -312,10 +318,7 @@ const USER_INPUT_TOGGLE_TIMING = {
export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
const navigation = useNavigation();
const { session: voiceInputSession } = useGlobalVoiceInput();
const reportedModelSelection = useThreadReportedModelSelection({
environmentId: props.environmentId,
threadId: props.selectedThread.id,
});
const reportedModelSelection = useThreadReportedModelSelection(props.selectedThreadDetailRef);
const deviceState = useEnvironmentQuery(
deviceEnvironment.state({ environmentId: props.environmentId, input: {} }),
);
Expand Down Expand Up @@ -369,14 +372,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<ComposerEditorHandle>(null);
// A provider-native subagent shows status instead of a composer.
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/features/threads/ThreadQueueControl.tsx
Original file line number Diff line number Diff line change
@@ -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/unstable/reactivity";
import { Image } from "expo-image";
import * as Haptics from "expo-haptics";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
Expand Down Expand Up @@ -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";
Expand All @@ -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<QueueTarget>) {
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ function ThreadRouteContent(
const {
selectedThread,
selectedThreadCreation,
selectedThreadDetailRef,
selectedThreadProject,
selectedEnvironmentConnection,
} = useThreadSelection();
Expand Down Expand Up @@ -990,6 +991,7 @@ function ThreadRouteContent(
<View className="flex-1 bg-screen android:overflow-hidden android:rounded-t-[28px] android:bg-thread-canvas">
<ThreadDetailScreen
selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
selectedThreadDetailRef={selectedThreadDetailRef}
contentPresentation={contentPresentation}
screenTone={connectionTone(routeConnectionState)}
connectionError={routeConnectionError}
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/state/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p
const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
Atom.withLabel("mobile-server-config:empty"),
);
const EMPTY_THREAD_ATOM = Atom.make<EnvironmentThread | null>(null).pipe(
Atom.withLabel("mobile-thread:empty"),
);

/** Resolves when the project event reaches the live client store. */
export function waitForProject(
Expand Down Expand Up @@ -93,6 +96,9 @@ export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
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,
);
}
8 changes: 3 additions & 5 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export function useThreadComposerState() {
const {
selectedThread: selectedThreadShell,
selectedThreadCreation,
selectedThreadDetailRef,
selectedEnvironmentRuntime,
} = useThreadSelection();
const selectedThreadProjection = useSelectedThreadProjection();
Expand Down Expand Up @@ -305,12 +306,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);
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/state/use-thread-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,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 ||
Expand Down
Loading