1import {
2 presentThreadShell,
3 type EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import type { LocalThreadMessage } from "../lib/threadActivity";
6import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";
7import { threadKey } from "@t3tools/client-runtime/state/entities";
8import { asLaunchingThread } from "@t3tools/client-runtime/state/thread-inbox";
9import { Atom } from "effect/reactivity";
10import * as DateTime from "effect/DateTime";
11
12import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
13import { scopedThreadKey } from "../lib/scopedEntities";
14import { appAtomRegistry } from "./atom-registry";
15import type { QueuedThreadMessage } from "./thread-outbox-model";
16
17export type PendingThreadCreationOutcome =
18 | { readonly kind: "delivered"; readonly message: QueuedThreadMessage }
19 | { readonly kind: "failed"; readonly message: QueuedThreadMessage; readonly reason: string };
20
21export type PendingThreadCreation = {
22 readonly message: QueuedThreadMessage;
23 readonly outcome: PendingThreadCreationOutcome | null;
24};
25
26export function selectThreadCreationDetail<Ref>(input: {
27 readonly threadRef: Ref | null;
28 readonly threadKey: string | null;
29 readonly queuedMessagesByThreadKey: Readonly<
30 Record<string, ReadonlyArray<QueuedThreadMessage> | undefined>
31 >;
32 readonly outcomes: Readonly<Record<string, PendingThreadCreationOutcome | undefined>>;
33}): { readonly pendingCreation: PendingThreadCreation | null; readonly detailRef: Ref | null } {
34 const pendingCreation = ((): PendingThreadCreation | null => {
35 if (input.threadKey === null) {
36 return null;
37 }
38 const queued = input.queuedMessagesByThreadKey[input.threadKey]?.find(
39 (message) => message.creation !== undefined,
40 );
41 const outcome = input.outcomes[input.threadKey] ?? null;
42 const message = queued ?? outcome?.message ?? null;
43 return message === null ? null : { message, outcome };
44 })();
45 // The shell can arrive before creation is acknowledged. Wait for delivery
46 // before subscribing: an early not-found is cached as a deleted thread.
47 // Every detail reader must use this ref, not the route id.
48 const detailRef =
49 pendingCreation === null || pendingCreation.outcome?.kind === "delivered"
50 ? input.threadRef
51 : null;
52 return { pendingCreation, detailRef };
53}
54
55export function resolvePendingThreadCreation(input: {
56 readonly threadKey: string | null;
57 readonly pending: PendingThreadCreation | null;
58 readonly previous: PendingThreadCreation | null;
59 readonly detail: {
60 readonly messages: ReadonlyArray<{ readonly id: string }>;
61 readonly runs: ReadonlyArray<{ readonly status: string }>;
62 } | null;
63}): PendingThreadCreation | null {
64 const creation = input.pending ?? input.previous;
65 if (
66 creation === null ||
67 scopedThreadKey(creation.message.environmentId, creation.message.threadId) !== input.threadKey
68 ) {
69 return null;
70 }
71 if (creation.outcome?.kind === "failed") return creation;
72 const detail = input.detail;
73 const latestRun = detail?.runs.at(-1);
74 const terminalStatus = latestRun?.status;
75 if (
76 terminalStatus === "failed" ||
77 terminalStatus === "cancelled" ||
78 terminalStatus === "interrupted"
79 )
80 return null;
81 // Message delivery can precede startup; wait for turn timing before replacing the preparing pill.
82 if (
83 detail !== null &&
84 latestRun !== undefined &&
85 !isPendingThreadCreationVisible({
86 creationMessageId: creation.message.messageId,
87 loadedMessageIds: detail.messages.map((message) => message.id),
88 })
89 ) {
90 return null;
91 }
92 return creation;
93}
94
95export const pendingThreadCreationOutcomesAtom = Atom.make<
96 Readonly<Record<string, PendingThreadCreationOutcome>>
97>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:pending-thread-creation:outcomes"));
98
99export function recordPendingThreadCreationOutcome(outcome: PendingThreadCreationOutcome): void {
100 const key = scopedThreadKey(outcome.message.environmentId, outcome.message.threadId);
101 appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {
102 ...appAtomRegistry.get(pendingThreadCreationOutcomesAtom),
103 [key]: outcome,
104 });
105}
106
107export function clearPendingThreadCreationOutcome(threadKey: string): void {
108 const current = appAtomRegistry.get(pendingThreadCreationOutcomesAtom);
109 if (!current[threadKey]) {
110 return;
111 }
112 const next = { ...current };
113 delete next[threadKey];
114 appAtomRegistry.set(pendingThreadCreationOutcomesAtom, next);
115}
116
117/** Thread shell and empty detail precede the prompt: worktree setup runs between creation and turn start. */
118function isPendingThreadCreationVisible(input: {
119 readonly creationMessageId: string;
120 readonly loadedMessageIds: ReadonlyArray<string> | null;
121}): boolean {
122 return !input.loadedMessageIds?.includes(input.creationMessageId);
123}
124
125export function pendingThreadCreationMessage(message: QueuedThreadMessage): LocalThreadMessage {
126 return {
127 id: message.messageId,
128 role: "user",
129 text: message.text,
130 context: message.context,
131 // Local draft ids cannot resolve server-side and would spin forever on rejected creation.
132 streaming: false,
133 createdAt: message.createdAt,
134 updatedAt: message.createdAt,
135 };
136}
137
138/** Launches show as preparing from Send, before the server's shell exists, until their first run. */
139export function withLaunchingThreads(input: {
140 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
141 readonly launches: ReadonlyArray<QueuedThreadMessage>;
142 readonly hiddenThreadKeys: ReadonlySet<string>;
143}): ReadonlyArray<EnvironmentThreadShell> {
144 if (input.launches.length === 0) return input.threads;
145 const launchByKey = new Map<string, QueuedThreadMessage>();
146 for (const message of input.launches) {
147 if (!message.creation || input.hiddenThreadKeys.has(threadKey(message))) continue;
148 launchByKey.set(scopedThreadKey(message.environmentId, message.threadId), message);
149 }
150 if (launchByKey.size === 0) return input.threads;
151 const threads = input.threads.map((thread) => {
152 const key = scopedThreadKey(thread.environmentId, thread.id);
153 const launch = launchByKey.get(key);
154 if (!launch) return thread;
155 launchByKey.delete(key);
156 return asLaunchingThread(thread);
157 });
158 for (const launch of launchByKey.values()) {
159 const shell = pendingThreadCreationShell(launch);
160 if (shell) threads.push(asLaunchingThread(shell));
161 }
162 return threads;
163}
164
165export function pendingThreadCreationShell(
166 message: QueuedThreadMessage,
167): EnvironmentThreadShell | null {
168 const creation = message.creation;
169 if (!creation || !message.modelSelection) {
170 return null;
171 }
172 const timestamp = DateTime.makeUnsafe(message.createdAt);
173 return presentThreadShell(message.environmentId, {
174 id: message.threadId,
175 projectId: creation.projectId,
176 title: deriveThreadTitleFromPrompt(message.text),
177 providerInstanceId: message.modelSelection.instanceId,
178 modelSelection: message.modelSelection,
179 runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE,
180 interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
181 branch: creation.branch,
182 pullRequests: [],
183 worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath,
184 linkedPullRequest: null,
185 branchPullRequest: null,
186 lineage: { rootThreadId: message.threadId, parentThreadId: null, relationshipToParent: null },
187 forkedFrom: null,
188 createdBy: "user",
189 creationSource: "mobile",
190 activeProviderThreadId: null,
191 latestRunId: null,
192 activeRunId: null,
193 status: "preparing",
194 pendingRuntimeRequest: null,
195 latestVisibleMessage: null,
196 latestUserMessageAt: timestamp,
197 hasActionableProposedPlan: false,
198 itemCount: 0,
199 visibleItemCount: 0,
200 createdAt: timestamp,
201 updatedAt: timestamp,
202 archivedAt: null,
203 settledOverride: null,
204 settledAt: null,
205 unsettledAt: null,
206 snoozedUntil: null,
207 snoozedAt: null,
208 pinnedAt: null,
209 pinOrderKey: null,
210 activeOrderKey: null,
211 deletedAt: null,
212 });
213}