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 { Atom } from "effect/reactivity";
8import * as DateTime from "effect/DateTime";
9
10import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
11import { scopedThreadKey } from "../lib/scopedEntities";
12import { appAtomRegistry } from "./atom-registry";
13import type { QueuedThreadMessage } from "./thread-outbox-model";
14
15/**
16 * A new task navigates to its thread screen the moment it is queued, before the
17 * server has created the thread. Until the shell arrives the screen renders a
18 * stand-in built from the queued creation. The outcome recorded by the outbox
19 * drain covers the two windows that stand-in cannot: the gap between delivery
20 * and the first turn (keep showing setup) and a rejected creation
21 * (the drain restored the content into the project draft; offer to reopen it).
22 */
23export type PendingThreadCreationOutcome =
24 | { readonly kind: "delivered"; readonly message: QueuedThreadMessage }
25 | { readonly kind: "failed"; readonly message: QueuedThreadMessage; readonly reason: string };
26
27export type PendingThreadCreation = {
28 readonly message: QueuedThreadMessage;
29 readonly outcome: PendingThreadCreationOutcome | null;
30};
31
32/** Keep the screen's creation state until its detail can take over the pill. */
33export function resolvePendingThreadCreation(input: {
34 readonly threadKey: string | null;
35 readonly pending: PendingThreadCreation | null;
36 readonly previous: PendingThreadCreation | null;
37 readonly detail: {
38 readonly messages: ReadonlyArray<{ readonly id: string }>;
39 readonly runs?: ReadonlyArray<{ readonly status: string }>;
40 readonly latestTurn?: { readonly turnId: string } | null;
41 readonly session?: { readonly status: string } | null;
42 } | null;
43}): PendingThreadCreation | null {
44 const creation = input.pending ?? input.previous;
45 if (
46 creation === null ||
47 scopedThreadKey(creation.message.environmentId, creation.message.threadId) !== input.threadKey
48 ) {
49 return null;
50 }
51 if (creation.outcome?.kind === "failed") return creation;
52 const detail = input.detail;
53 const latestRun = detail?.runs?.at(-1);
54 const terminalStatus = latestRun?.status ?? detail?.session?.status;
55 if (
56 terminalStatus === "failed" ||
57 terminalStatus === "error" ||
58 terminalStatus === "cancelled" ||
59 terminalStatus === "stopped" ||
60 terminalStatus === "interrupted"
61 )
62 return null;
63 // Message delivery and turn startup are separate events. The prompt alone
64 // cannot replace the preparing pill; wait for the turn's timing too. Retain
65 // the local creation if the outbox has already collected its shell outcome.
66 if (
67 detail !== null &&
68 (latestRun !== undefined || detail.latestTurn != null) &&
69 !isPendingThreadCreationVisible({
70 creationMessageId: creation.message.messageId,
71 loadedMessageIds: detail.messages.map((message) => message.id),
72 })
73 ) {
74 return null;
75 }
76 return creation;
77}
78
79export const pendingThreadCreationOutcomesAtom = Atom.make<
80 Readonly<Record<string, PendingThreadCreationOutcome>>
81>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:pending-thread-creation:outcomes"));
82
83export function recordPendingThreadCreationOutcome(outcome: PendingThreadCreationOutcome): void {
84 const key = scopedThreadKey(outcome.message.environmentId, outcome.message.threadId);
85 appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {
86 ...appAtomRegistry.get(pendingThreadCreationOutcomesAtom),
87 [key]: outcome,
88 });
89}
90
91export function clearPendingThreadCreationOutcome(threadKey: string): void {
92 const current = appAtomRegistry.get(pendingThreadCreationOutcomesAtom);
93 if (!current[threadKey]) {
94 return;
95 }
96 const next = { ...current };
97 delete next[threadKey];
98 appAtomRegistry.set(pendingThreadCreationOutcomesAtom, next);
99}
100
101/**
102 * Whether the queued prompt still has to stand in for the real message.
103 *
104 * The server creates the thread, then builds the worktree, and only then
105 * starts the turn, so the thread shell and an empty detail arrive seconds
106 * ahead of the prompt. Keying this on the shell's arrival left the thread
107 * showing "No conversation yet" for that whole window. The queued message id
108 * is reused as the delivered message id, so its presence is the exact signal.
109 */
110export function isPendingThreadCreationVisible(input: {
111 readonly creationMessageId: string;
112 /** Null while no detail has loaded; empty during a worktree checkout. */
113 readonly loadedMessageIds: ReadonlyArray<string> | null;
114}): boolean {
115 return !input.loadedMessageIds?.includes(input.creationMessageId);
116}
117
118export function pendingThreadCreationMessage(message: QueuedThreadMessage): LocalThreadMessage {
119 return {
120 id: message.messageId,
121 role: "user",
122 text: message.text,
123 context: message.context,
124 // Deliberately no attachments. Their ids are local draft ids the server
125 // cannot resolve, so the feed's attachment rows would sit on a spinner
126 // that only ends when the real message arrives — and never, if the
127 // creation is rejected. The delivered message renders them moments later.
128 streaming: false,
129 createdAt: message.createdAt,
130 updatedAt: message.createdAt,
131 };
132}
133
134/**
135 * Thread shell shaped from a queued creation. `modelSelection` is required on
136 * the shell; a creation is only sendable with one, so the fallback never sends.
137 */
138export function pendingThreadCreationShell(
139 message: QueuedThreadMessage,
140): EnvironmentThreadShell | null {
141 const creation = message.creation;
142 if (!creation || !message.modelSelection) {
143 return null;
144 }
145 const timestamp = DateTime.makeUnsafe(message.createdAt);
146 return presentThreadShell(message.environmentId, {
147 id: message.threadId,
148 projectId: creation.projectId,
149 title: deriveThreadTitleFromPrompt(message.text),
150 providerInstanceId: message.modelSelection.instanceId,
151 modelSelection: message.modelSelection,
152 runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE,
153 interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
154 branch: creation.branch,
155 pullRequests: [],
156 worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath,
157 linkedPullRequest: null,
158 branchPullRequest: null,
159 lineage: { rootThreadId: message.threadId, parentThreadId: null, relationshipToParent: null },
160 forkedFrom: null,
161 createdBy: "user",
162 creationSource: "mobile",
163 activeProviderThreadId: null,
164 latestRunId: null,
165 activeRunId: null,
166 status: "preparing",
167 pendingRuntimeRequest: null,
168 latestVisibleMessage: null,
169 latestUserMessageAt: timestamp,
170 hasActionableProposedPlan: false,
171 itemCount: 0,
172 visibleItemCount: 0,
173 createdAt: timestamp,
174 updatedAt: timestamp,
175 archivedAt: null,
176 settledOverride: null,
177 settledAt: null,
178 unsettledAt: null,
179 snoozedUntil: null,
180 snoozedAt: null,
181 pinnedAt: null,
182 pinOrderKey: null,
183 activeOrderKey: null,
184 deletedAt: null,
185 });
186}