1import {
2 CommandId,
3 MessageId,
4 ThreadId,
5 type ChatAttachment,
6 type ModelSelection,
7 type OrchestrationMessageContext,
8 type ProjectId,
9 type ProviderInteractionMode,
10 type RuntimeMode,
11} from "@t3tools/contracts";
12import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
13import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
14
15import type { UploadedMobileAttachment } from "./attachmentUpload";
16
17export interface ProjectThreadStartTurnSpec {
18 readonly projectId: ProjectId;
19 readonly projectCwd: string;
20 readonly threadId: string;
21 readonly commandId: string;
22 readonly messageId: string;
23 readonly createdAt: string;
24 readonly text: string;
25 readonly context?: OrchestrationMessageContext;
26 /** New uploads or server-owned attachments from a cancelled setup. */
27 readonly uploadedAttachments: ReadonlyArray<UploadedMobileAttachment | ChatAttachment>;
28 readonly modelSelection: ModelSelection;
29 readonly runtimeMode: RuntimeMode;
30 readonly interactionMode: ProviderInteractionMode;
31 readonly workspaceMode: "local" | "worktree";
32 readonly branch: string | null;
33 readonly worktreePath: string | null;
34 readonly startFromOrigin: boolean;
35 /** Generated temp branch for worktree mode; unused for local mode. */
36 readonly worktreeBranchName: string;
37}
38
39/**
40 * Single source of the `thread.turn.start` bootstrap payload used to create a
41 * thread from a project draft — shared by the immediate send path and the
42 * offline outbox drain so both deliver identical commands.
43 */
44export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpec) {
45 const title = deriveThreadTitleSeed({ text: spec.text, attachments: spec.uploadedAttachments });
46 const isWorktree = spec.workspaceMode === "worktree";
47 return {
48 commandId: CommandId.make(spec.commandId),
49 creationSource: "mobile" as const,
50 threadId: ThreadId.make(spec.threadId),
51 message: {
52 messageId: MessageId.make(spec.messageId),
53 role: "user" as const,
54 text: spec.text,
55 ...(spec.context ? { context: spec.context } : {}),
56 attachments: spec.uploadedAttachments,
57 },
58 modelSelection: spec.modelSelection,
59 titleSeed: title,
60 runtimeMode: spec.runtimeMode,
61 interactionMode: spec.interactionMode,
62 bootstrap: {
63 createThread: {
64 projectId: spec.projectId,
65 title,
66 modelSelection: spec.modelSelection,
67 runtimeMode: spec.runtimeMode,
68 interactionMode: spec.interactionMode,
69 branch: spec.branch,
70 worktreePath: isWorktree ? null : spec.worktreePath,
71 createdAt: spec.createdAt,
72 },
73 ...(isWorktree
74 ? {
75 prepareWorktree: {
76 projectCwd: spec.projectCwd,
77 baseBranch: spec.branch!,
78 branch: spec.worktreeBranchName,
79 ...(spec.startFromOrigin ? { startFromOrigin: true } : {}),
80 },
81 runSetupScript: true,
82 }
83 : {}),
84 },
85 createdAt: spec.createdAt,
86 };
87}
88
89export function deriveThreadTitleFromPrompt(value: string): string {
90 const trimmed = assistantCitationsToPlainText(value).trim();
91 if (trimmed.length === 0) {
92 return "New thread";
93 }
94
95 const compact = trimmed.replace(/\s+/g, " ");
96 return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
97}