| 1 | import { |
| 2 | presentThreadShell, |
| 3 | type EnvironmentThreadShell, |
| 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | import type { LocalThreadMessage } from "../lib/threadActivity"; |
| 6 | import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; |
| 7 | import { Atom } from "effect/reactivity"; |
| 8 | import * as DateTime from "effect/DateTime"; |
| 9 | |
| 10 | import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; |
| 11 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 12 | import { appAtomRegistry } from "./atom-registry"; |
| 13 | import 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 | */ |
| 23 | export type PendingThreadCreationOutcome = |
| 24 | | { readonly kind: "delivered"; readonly message: QueuedThreadMessage } |
| 25 | | { readonly kind: "failed"; readonly message: QueuedThreadMessage; readonly reason: string }; |
| 26 | |
| 27 | export 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. */ |
| 33 | export 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 | |
| 79 | export const pendingThreadCreationOutcomesAtom = Atom.make< |
| 80 | Readonly<Record<string, PendingThreadCreationOutcome>> |
| 81 | >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:pending-thread-creation:outcomes")); |
| 82 | |
| 83 | export 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 | |
| 91 | export 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 | */ |
| 110 | export 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 | |
| 118 | export 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 | */ |
| 138 | export 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 | } |