| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useRoute, type RouteProp } from "@react-navigation/native"; |
| 3 | import { useMemo, useRef, useState } from "react"; |
| 4 | import { |
| 5 | EnvironmentId, |
| 6 | ThreadId, |
| 7 | type OrchestrationV2ThreadProjection, |
| 8 | type OrchestrationV2ThreadShell, |
| 9 | type ScopedProjectRef, |
| 10 | type ScopedThreadRef, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import { |
| 13 | presentThreadShell, |
| 14 | type EnvironmentThreadShell, |
| 15 | } from "@t3tools/client-runtime/state/shell"; |
| 16 | import { |
| 17 | deriveLatestThreadRun, |
| 18 | deriveThreadRuntime, |
| 19 | } from "@t3tools/client-runtime/state/thread-execution"; |
| 20 | import * as Option from "effect/Option"; |
| 21 | |
| 22 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 23 | import { useProject, useThreadShell } from "../state/entities"; |
| 24 | import { useEnvironmentThread } from "../state/threads"; |
| 25 | import { |
| 26 | resolvePendingThreadCreation, |
| 27 | pendingThreadCreationOutcomesAtom, |
| 28 | pendingThreadCreationShell, |
| 29 | type PendingThreadCreation, |
| 30 | } from "./pending-thread-creation"; |
| 31 | import { |
| 32 | useRemoteEnvironmentRuntime, |
| 33 | useSavedRemoteConnection, |
| 34 | } from "./use-remote-environment-registry"; |
| 35 | import { useThreadOutboxMessages } from "./use-thread-outbox"; |
| 36 | type ThreadSelectionRouteParams = { |
| 37 | readonly environmentId?: string | string[]; |
| 38 | readonly threadId?: string | string[]; |
| 39 | }; |
| 40 | |
| 41 | // A blank param (a hand-typed deep link) is treated as missing, since branded |
| 42 | // IDs reject whitespace-only values. |
| 43 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 44 | const first = Array.isArray(value) ? value[0] : value; |
| 45 | return first === undefined || first.trim().length === 0 ? null : first; |
| 46 | } |
| 47 | |
| 48 | function latestUserMessageAt( |
| 49 | projection: OrchestrationV2ThreadProjection, |
| 50 | ): OrchestrationV2ThreadShell["latestUserMessageAt"] { |
| 51 | for (let index = projection.messages.length - 1; index >= 0; index -= 1) { |
| 52 | const message = projection.messages[index]; |
| 53 | if (message?.role === "user") { |
| 54 | return message.createdAt; |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | return null; |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * Builds an optimistic thread shell from the detail projection for the window |
| 63 | * where the shell list has not materialized the thread yet (e.g. a thread that |
| 64 | * was just created from this device). |
| 65 | */ |
| 66 | function threadDetailToShell( |
| 67 | environmentId: EnvironmentId, |
| 68 | projection: OrchestrationV2ThreadProjection, |
| 69 | ): EnvironmentThreadShell { |
| 70 | const thread = projection.thread; |
| 71 | const latestRun = deriveLatestThreadRun(projection); |
| 72 | const runtime = deriveThreadRuntime(projection); |
| 73 | const pendingRequest = |
| 74 | projection.runtimeRequests.find((request) => request.status === "pending") ?? null; |
| 75 | return presentThreadShell(environmentId, { |
| 76 | id: thread.id, |
| 77 | projectId: thread.projectId, |
| 78 | title: thread.title, |
| 79 | providerInstanceId: thread.providerInstanceId, |
| 80 | modelSelection: thread.modelSelection, |
| 81 | runtimeMode: thread.runtimeMode, |
| 82 | interactionMode: thread.interactionMode, |
| 83 | branch: thread.branch, |
| 84 | worktreePath: thread.worktreePath, |
| 85 | linkedPullRequest: thread.linkedPullRequest ?? null, |
| 86 | pullRequests: thread.pullRequests, |
| 87 | branchPullRequest: thread.branchPullRequest ?? null, |
| 88 | activeProviderThreadId: thread.activeProviderThreadId, |
| 89 | lineage: thread.lineage, |
| 90 | forkedFrom: thread.forkedFrom, |
| 91 | createdBy: thread.createdBy, |
| 92 | creationSource: thread.creationSource, |
| 93 | latestRunId: latestRun?.runId ?? null, |
| 94 | activeRunId: runtime?.activeRunId ?? null, |
| 95 | status: runtime?.status ?? "idle", |
| 96 | pendingRuntimeRequest: |
| 97 | pendingRequest === null |
| 98 | ? null |
| 99 | : { id: pendingRequest.id, kind: pendingRequest.kind, createdAt: pendingRequest.createdAt }, |
| 100 | latestVisibleMessage: null, |
| 101 | latestUserMessageAt: latestUserMessageAt(projection), |
| 102 | hasActionableProposedPlan: false, |
| 103 | itemCount: projection.turnItems.length, |
| 104 | visibleItemCount: projection.visibleTurnItems.length, |
| 105 | createdAt: thread.createdAt, |
| 106 | updatedAt: thread.updatedAt, |
| 107 | archivedAt: thread.archivedAt, |
| 108 | settledOverride: thread.settledOverride, |
| 109 | settledAt: thread.settledAt, |
| 110 | unsettledAt: thread.unsettledAt, |
| 111 | activeOrderKey: thread.activeOrderKey, |
| 112 | autoSettleDisabledAt: thread.autoSettleDisabledAt, |
| 113 | pinnedAt: thread.pinnedAt, |
| 114 | pinOrderKey: thread.pinOrderKey, |
| 115 | snoozedUntil: thread.snoozedUntil ?? null, |
| 116 | snoozedAt: thread.snoozedAt ?? null, |
| 117 | deletedAt: thread.deletedAt, |
| 118 | }); |
| 119 | } |
| 120 | |
| 121 | function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefined) { |
| 122 | const routeParams = params ?? {}; |
| 123 | const routeThreadRef = useMemo<ScopedThreadRef | null>(() => { |
| 124 | const environmentId = firstRouteParam(routeParams.environmentId); |
| 125 | const threadId = firstRouteParam(routeParams.threadId); |
| 126 | if (!environmentId || !threadId) { |
| 127 | return null; |
| 128 | } |
| 129 | |
| 130 | return { |
| 131 | environmentId: EnvironmentId.make(environmentId), |
| 132 | threadId: ThreadId.make(threadId), |
| 133 | }; |
| 134 | }, [routeParams.environmentId, routeParams.threadId]); |
| 135 | const lastRouteThreadRef = useRef<ScopedThreadRef | null>(null); |
| 136 | if (routeThreadRef !== null) { |
| 137 | lastRouteThreadRef.current = routeThreadRef; |
| 138 | } |
| 139 | const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current; |
| 140 | const selectedThreadShell = useThreadShell(selectedThreadRef); |
| 141 | const selectedThreadKey = |
| 142 | selectedThreadRef === null |
| 143 | ? null |
| 144 | : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId); |
| 145 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); |
| 146 | const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom); |
| 147 | // A creation the outbox still holds or just delivered: the thread screen |
| 148 | // opened before the server made the thread, so present a stand-in shell. |
| 149 | const pendingCreation = useMemo<PendingThreadCreation | null>(() => { |
| 150 | if (selectedThreadKey === null) { |
| 151 | return null; |
| 152 | } |
| 153 | const queued = queuedMessagesByThreadKey[selectedThreadKey]?.find( |
| 154 | (message) => message.creation !== undefined, |
| 155 | ); |
| 156 | const outcome = creationOutcome[selectedThreadKey] ?? null; |
| 157 | const message = queued ?? outcome?.message ?? null; |
| 158 | return message === null ? null : { message, outcome }; |
| 159 | }, [creationOutcome, queuedMessagesByThreadKey, selectedThreadKey]); |
| 160 | // Until the creation is delivered the server has no thread to subscribe |
| 161 | // to; subscribing anyway would retry "not found" for the whole setup. |
| 162 | const selectedThreadDetailRef = |
| 163 | selectedThreadShell !== null || |
| 164 | pendingCreation === null || |
| 165 | pendingCreation.outcome?.kind === "delivered" |
| 166 | ? selectedThreadRef |
| 167 | : null; |
| 168 | const [previousCreation, setPreviousCreation] = useState<PendingThreadCreation | null>(null); |
| 169 | // Normal selection is shell-only. Detail readers subscribe separately; only |
| 170 | // optimistic creation needs the projection here until its prompt arrives. |
| 171 | const needsDetail = |
| 172 | selectedThreadShell === null || pendingCreation !== null || previousCreation !== null; |
| 173 | const selectedThreadDetailState = useEnvironmentThread( |
| 174 | needsDetail ? (selectedThreadDetailRef?.environmentId ?? null) : null, |
| 175 | needsDetail ? (selectedThreadDetailRef?.threadId ?? null) : null, |
| 176 | ); |
| 177 | const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); |
| 178 | const selectedThread = useMemo( |
| 179 | () => |
| 180 | selectedThreadShell ?? |
| 181 | (selectedThreadRef !== null && selectedThreadDetail !== null |
| 182 | ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail) |
| 183 | : pendingCreation !== null |
| 184 | ? pendingThreadCreationShell(pendingCreation.message) |
| 185 | : null), |
| 186 | [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell], |
| 187 | ); |
| 188 | const selectedThreadCreation = resolvePendingThreadCreation({ |
| 189 | threadKey: selectedThreadKey, |
| 190 | pending: pendingCreation, |
| 191 | previous: previousCreation, |
| 192 | detail: selectedThreadDetail, |
| 193 | }); |
| 194 | if (previousCreation !== selectedThreadCreation) { |
| 195 | setPreviousCreation(selectedThreadCreation); |
| 196 | } |
| 197 | const selectedProjectRef = useMemo<ScopedProjectRef | null>( |
| 198 | () => |
| 199 | selectedThread === null |
| 200 | ? null |
| 201 | : { |
| 202 | environmentId: selectedThread.environmentId, |
| 203 | projectId: selectedThread.projectId, |
| 204 | }, |
| 205 | [selectedThread], |
| 206 | ); |
| 207 | const selectedThreadProject = useProject(selectedProjectRef); |
| 208 | const selectedEnvironmentId = selectedThread?.environmentId ?? null; |
| 209 | const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); |
| 210 | const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); |
| 211 | |
| 212 | return useMemo( |
| 213 | () => ({ |
| 214 | selectedThreadRef, |
| 215 | selectedThread, |
| 216 | selectedThreadCreation, |
| 217 | selectedThreadDetailRef, |
| 218 | selectedThreadProject, |
| 219 | selectedEnvironmentConnection, |
| 220 | selectedEnvironmentRuntime, |
| 221 | }), |
| 222 | [ |
| 223 | selectedEnvironmentConnection, |
| 224 | selectedEnvironmentRuntime, |
| 225 | selectedThread, |
| 226 | selectedThreadCreation, |
| 227 | selectedThreadDetailRef, |
| 228 | selectedThreadProject, |
| 229 | selectedThreadRef, |
| 230 | ], |
| 231 | ); |
| 232 | } |
| 233 | |
| 234 | type ThreadSelectionState = ReturnType<typeof useResolvedThreadSelection>; |
| 235 | |
| 236 | export function useThreadSelection(): ThreadSelectionState { |
| 237 | const route = useRoute<RouteProp<Record<string, ThreadSelectionRouteParams | undefined>>>(); |
| 238 | return useResolvedThreadSelection(route.params); |
| 239 | } |