import { useAtomValue } from "@effect/atom-react";import { useRoute, type RouteProp } from "@react-navigation/native";import { useMemo, useRef, useState } from "react";import { EnvironmentId, type OrchestrationThread, ThreadId, type ScopedProjectRef, type ScopedThreadRef,} from "@t3tools/contracts";import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";import * as Option from "effect/Option"; import { scopedThreadKey } from "../lib/scopedEntities";import { useProject, useThreadShell } from "../state/entities";import { useEnvironmentThread } from "../state/threads";import { resolvePendingThreadCreation, pendingThreadCreationOutcomesAtom, pendingThreadCreationShell, type PendingThreadCreation,} from "./pending-thread-creation";import { useRemoteEnvironmentRuntime, useSavedRemoteConnection,} from "./use-remote-environment-registry";import { useThreadOutboxMessages } from "./use-thread-outbox";type ThreadSelectionRouteParams = { readonly environmentId?: string | string[]; readonly threadId?: string | string[];}; function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { return value[0] ?? null; } return value ?? null;} function latestUserMessageAt(thread: OrchestrationThread): OrchestrationThread["updatedAt"] | null { for (let index = thread.messages.length - 1; index >= 0; index -= 1) { const message = thread.messages[index]; if (message?.role === "user") { return message.createdAt; } } return null;} function threadDetailToShell( environmentId: EnvironmentId, thread: OrchestrationThread,): EnvironmentThreadShell { return { environmentId, id: thread.id, projectId: thread.projectId, title: thread.title, modelSelection: thread.modelSelection, runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest ?? null, pullRequests: thread.pullRequests, branchPullRequest: thread.branchPullRequest ?? null, latestTurn: thread.latestTurn, createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, settledOverride: thread.settledOverride, settledAt: thread.settledAt, unsettledAt: thread.unsettledAt, activeOrderKey: thread.activeOrderKey, pinnedAt: thread.pinnedAt, pinOrderKey: thread.pinOrderKey, snoozedUntil: thread.snoozedUntil ?? null, snoozedAt: thread.snoozedAt ?? null, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, };} function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefined) { const routeParams = params ?? {}; const routeThreadRef = useMemo<ScopedThreadRef | null>(() => { const environmentId = firstRouteParam(routeParams.environmentId); const threadId = firstRouteParam(routeParams.threadId); if (!environmentId || !threadId) { return null; } return { environmentId: EnvironmentId.make(environmentId), threadId: ThreadId.make(threadId), }; }, [routeParams.environmentId, routeParams.threadId]); const lastRouteThreadRef = useRef<ScopedThreadRef | null>(null); if (routeThreadRef !== null) { lastRouteThreadRef.current = routeThreadRef; } const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current; const selectedThreadShell = useThreadShell(selectedThreadRef); const selectedThreadKey = selectedThreadRef === null ? null : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom); // A creation the outbox still holds or just delivered: the thread screen // opened before the server made the thread, so present a stand-in shell. const pendingCreation = useMemo<PendingThreadCreation | null>(() => { if (selectedThreadKey === null) { return null; } const queued = queuedMessagesByThreadKey[selectedThreadKey]?.find( (message) => message.creation !== undefined, ); const outcome = creationOutcome[selectedThreadKey] ?? null; const message = queued ?? outcome?.message ?? null; 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. const selectedThreadDetailRef = selectedThreadShell !== null || pendingCreation === null || pendingCreation.outcome?.kind === "delivered" ? selectedThreadRef : null; const selectedThreadDetailState = useEnvironmentThread( selectedThreadDetailRef?.environmentId ?? null, selectedThreadDetailRef?.threadId ?? null, ); const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); const selectedThread = useMemo( () => selectedThreadShell ?? (selectedThreadRef !== null && selectedThreadDetail !== null ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail) : pendingCreation !== null ? pendingThreadCreationShell(pendingCreation.message) : null), [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell], ); const [previousCreation, setPreviousCreation] = useState<PendingThreadCreation | null>(null); const selectedThreadCreation = resolvePendingThreadCreation({ threadKey: selectedThreadKey, pending: pendingCreation, previous: previousCreation, detail: selectedThreadDetail, }); if (previousCreation !== selectedThreadCreation) { setPreviousCreation(selectedThreadCreation); } const selectedProjectRef = useMemo<ScopedProjectRef | null>( () => selectedThread === null ? null : { environmentId: selectedThread.environmentId, projectId: selectedThread.projectId, }, [selectedThread], ); const selectedThreadProject = useProject(selectedProjectRef); const selectedEnvironmentId = selectedThread?.environmentId ?? null; const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); return useMemo( () => ({ selectedThreadRef, selectedThread, selectedThreadCreation, selectedThreadDetailState, selectedThreadProject, selectedEnvironmentConnection, selectedEnvironmentRuntime, }), [ selectedEnvironmentConnection, selectedEnvironmentRuntime, selectedThread, selectedThreadCreation, selectedThreadDetailState, selectedThreadProject, selectedThreadRef, ], );} type ThreadSelectionState = ReturnType<typeof useResolvedThreadSelection>; export function useThreadSelection(): ThreadSelectionState { const route = useRoute<RouteProp<Record<string, ThreadSelectionRouteParams | undefined>>>(); return useResolvedThreadSelection(route.params);}
import { useAtomValue } from "@effect/atom-react";
import { useRoute, type RouteProp } from "@react-navigation/native";
import { useMemo, useRef, useState } from "react";
import {
EnvironmentId,
type OrchestrationThread,
ThreadId,
type ScopedProjectRef,
type ScopedThreadRef,
} from "@t3tools/contracts";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import * as Option from "effect/Option";
import { scopedThreadKey } from "../lib/scopedEntities";
import { useProject, useThreadShell } from "../state/entities";
import { useEnvironmentThread } from "../state/threads";
import {
resolvePendingThreadCreation,
pendingThreadCreationOutcomesAtom,
pendingThreadCreationShell,
type PendingThreadCreation,
} from "./pending-thread-creation";
import {
useRemoteEnvironmentRuntime,
useSavedRemoteConnection,
} from "./use-remote-environment-registry";
import { useThreadOutboxMessages } from "./use-thread-outbox";
type ThreadSelectionRouteParams = {
readonly environmentId?: string | string[];
readonly threadId?: string | string[];
};
function firstRouteParam(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
}
function latestUserMessageAt(thread: OrchestrationThread): OrchestrationThread["updatedAt"] | null {
for (let index = thread.messages.length - 1; index >= 0; index -= 1) {
const message = thread.messages[index];
if (message?.role === "user") {
return message.createdAt;
}
}
return null;
}
function threadDetailToShell(
environmentId: EnvironmentId,
thread: OrchestrationThread,
): EnvironmentThreadShell {
return {
environmentId,
id: thread.id,
projectId: thread.projectId,
title: thread.title,
modelSelection: thread.modelSelection,
runtimeMode: thread.runtimeMode,
interactionMode: thread.interactionMode,
branch: thread.branch,
worktreePath: thread.worktreePath,
linkedPullRequest: thread.linkedPullRequest ?? null,
pullRequests: thread.pullRequests,
branchPullRequest: thread.branchPullRequest ?? null,
latestTurn: thread.latestTurn,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
archivedAt: thread.archivedAt,
settledOverride: thread.settledOverride,
settledAt: thread.settledAt,
unsettledAt: thread.unsettledAt,
activeOrderKey: thread.activeOrderKey,
pinnedAt: thread.pinnedAt,
pinOrderKey: thread.pinOrderKey,
snoozedUntil: thread.snoozedUntil ?? null,
snoozedAt: thread.snoozedAt ?? null,
session: thread.session,
latestUserMessageAt: latestUserMessageAt(thread),
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
};
}
function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefined) {
const routeParams = params ?? {};
const routeThreadRef = useMemo<ScopedThreadRef | null>(() => {
const environmentId = firstRouteParam(routeParams.environmentId);
const threadId = firstRouteParam(routeParams.threadId);
if (!environmentId || !threadId) {
return null;
}
return {
environmentId: EnvironmentId.make(environmentId),
threadId: ThreadId.make(threadId),
};
}, [routeParams.environmentId, routeParams.threadId]);
const lastRouteThreadRef = useRef<ScopedThreadRef | null>(null);
if (routeThreadRef !== null) {
lastRouteThreadRef.current = routeThreadRef;
}
const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current;
const selectedThreadShell = useThreadShell(selectedThreadRef);
const selectedThreadKey =
selectedThreadRef === null
? null
: scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId);
const queuedMessagesByThreadKey = useThreadOutboxMessages();
const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom);
// A creation the outbox still holds or just delivered: the thread screen
// opened before the server made the thread, so present a stand-in shell.
const pendingCreation = useMemo<PendingThreadCreation | null>(() => {
if (selectedThreadKey === null) {
return null;
}
const queued = queuedMessagesByThreadKey[selectedThreadKey]?.find(
(message) => message.creation !== undefined,
);
const outcome = creationOutcome[selectedThreadKey] ?? null;
const message = queued ?? outcome?.message ?? null;
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.
const selectedThreadDetailRef =
selectedThreadShell !== null ||
pendingCreation === null ||
pendingCreation.outcome?.kind === "delivered"
? selectedThreadRef
: null;
const selectedThreadDetailState = useEnvironmentThread(
selectedThreadDetailRef?.environmentId ?? null,
selectedThreadDetailRef?.threadId ?? null,
);
const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
const selectedThread = useMemo(
() =>
selectedThreadShell ??
(selectedThreadRef !== null && selectedThreadDetail !== null
? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail)
: pendingCreation !== null
? pendingThreadCreationShell(pendingCreation.message)
: null),
[pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell],
);
const [previousCreation, setPreviousCreation] = useState<PendingThreadCreation | null>(null);
const selectedThreadCreation = resolvePendingThreadCreation({
threadKey: selectedThreadKey,
pending: pendingCreation,
previous: previousCreation,
detail: selectedThreadDetail,
});
if (previousCreation !== selectedThreadCreation) {
setPreviousCreation(selectedThreadCreation);
}
const selectedProjectRef = useMemo<ScopedProjectRef | null>(
() =>
selectedThread === null
? null
: {
environmentId: selectedThread.environmentId,
projectId: selectedThread.projectId,
},
[selectedThread],
);
const selectedThreadProject = useProject(selectedProjectRef);
const selectedEnvironmentId = selectedThread?.environmentId ?? null;
const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId);
const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId);
return useMemo(
() => ({
selectedThreadRef,
selectedThread,
selectedThreadCreation,
selectedThreadDetailState,
selectedThreadProject,
selectedEnvironmentConnection,
selectedEnvironmentRuntime,
}),
[
selectedEnvironmentConnection,
selectedEnvironmentRuntime,
selectedThread,
selectedThreadCreation,
selectedThreadDetailState,
selectedThreadProject,
selectedThreadRef,
],
);
}
type ThreadSelectionState = ReturnType<typeof useResolvedThreadSelection>;
export function useThreadSelection(): ThreadSelectionState {
const route = useRoute<RouteProp<Record<string, ThreadSelectionRouteParams | undefined>>>();
return useResolvedThreadSelection(route.params);
}