import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { OrchestrationThread } from "@t3tools/contracts"; import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; import { scopedThreadKey } from "../lib/scopedEntities"; import { appAtomRegistry } from "./atom-registry"; import type { QueuedThreadMessage } from "./thread-outbox-model"; /** * A new task navigates to its thread screen the moment it is queued, before the * server has created the thread. Until the shell arrives the screen renders a * stand-in built from the queued creation. The outcome recorded by the outbox * drain covers the two windows that stand-in cannot: the gap between delivery * and the first turn (keep showing setup) and a rejected creation * (the drain restored the content into the project draft; offer to reopen it). */ export type PendingThreadCreationOutcome = | { readonly kind: "delivered"; readonly message: QueuedThreadMessage } | { readonly kind: "failed"; readonly message: QueuedThreadMessage; readonly reason: string }; export type PendingThreadCreation = { readonly message: QueuedThreadMessage; readonly outcome: PendingThreadCreationOutcome | null; }; /** Keep the screen's creation state until its detail can take over the pill. */ export function resolvePendingThreadCreation(input: { readonly threadKey: string | null; readonly pending: PendingThreadCreation | null; readonly previous: PendingThreadCreation | null; readonly detail: { readonly messages: ReadonlyArray<{ readonly id: string }>; readonly latestTurn: { readonly turnId: string } | null; readonly session: { readonly status: string } | null; } | null; }): PendingThreadCreation | null { const creation = input.pending ?? input.previous; if ( creation === null || scopedThreadKey(creation.message.environmentId, creation.message.threadId) !== input.threadKey ) { return null; } if (creation.outcome?.kind === "failed") return creation; const detail = input.detail; if ( detail?.session?.status === "error" || detail?.session?.status === "stopped" || detail?.session?.status === "interrupted" ) return null; // Message delivery and turn startup are separate events. The prompt alone // cannot replace the preparing pill; wait for the turn's timing too. Retain // the local creation if the outbox has already collected its shell outcome. if ( detail !== null && detail.latestTurn !== null && !isPendingThreadCreationVisible({ creationMessageId: creation.message.messageId, loadedMessageIds: detail.messages.map((message) => message.id), }) ) { return null; } return creation; } export const pendingThreadCreationOutcomesAtom = Atom.make< Readonly> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:pending-thread-creation:outcomes")); export function recordPendingThreadCreationOutcome(outcome: PendingThreadCreationOutcome): void { const key = scopedThreadKey(outcome.message.environmentId, outcome.message.threadId); appAtomRegistry.set(pendingThreadCreationOutcomesAtom, { ...appAtomRegistry.get(pendingThreadCreationOutcomesAtom), [key]: outcome, }); } export function clearPendingThreadCreationOutcome(threadKey: string): void { const current = appAtomRegistry.get(pendingThreadCreationOutcomesAtom); if (!current[threadKey]) { return; } const next = { ...current }; delete next[threadKey]; appAtomRegistry.set(pendingThreadCreationOutcomesAtom, next); } /** * Whether the queued prompt still has to stand in for the real message. * * The server creates the thread, then builds the worktree, and only then * starts the turn, so the thread shell and an empty detail arrive seconds * ahead of the prompt. Keying this on the shell's arrival left the thread * showing "No conversation yet" for that whole window. The queued message id * is reused as the delivered message id, so its presence is the exact signal. */ export function isPendingThreadCreationVisible(input: { readonly creationMessageId: string; /** Null while no detail has loaded; empty during a worktree checkout. */ readonly loadedMessageIds: ReadonlyArray | null; }): boolean { return !input.loadedMessageIds?.includes(input.creationMessageId); } export function pendingThreadCreationMessage( message: QueuedThreadMessage, ): OrchestrationThread["messages"][number] { return { id: message.messageId, role: "user", text: message.text, // Deliberately no attachments. Their ids are local draft ids the server // cannot resolve, so the feed's attachment rows would sit on a spinner // that only ends when the real message arrives — and never, if the // creation is rejected. The delivered message renders them moments later. turnId: null, streaming: false, createdAt: message.createdAt, updatedAt: message.createdAt, }; } /** * Thread shell shaped from a queued creation. `modelSelection` is required on * the shell; a creation is only sendable with one, so the fallback never sends. */ export function pendingThreadCreationShell( message: QueuedThreadMessage, ): EnvironmentThreadShell | null { const creation = message.creation; if (!creation || !message.modelSelection) { return null; } return { environmentId: message.environmentId, id: message.threadId, projectId: creation.projectId, title: deriveThreadTitleFromPrompt(message.text), modelSelection: message.modelSelection, runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, branch: creation.branch, pullRequests: [], worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath, linkedPullRequest: null, latestTurn: null, createdAt: message.createdAt, updatedAt: message.createdAt, archivedAt: null, settledOverride: null, settledAt: null, snoozedUntil: null, snoozedAt: null, session: null, latestUserMessageAt: message.createdAt, hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, }; }