1import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
2
3import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
4import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model";
5import { isNewTaskDraftKey } from "./new-task-draft-key";
6import type { ComposerDraft } from "./use-composer-drafts";
7
8/**
9 * Unsent work that will become a thread, shaped for thread-list presentation.
10 * A `pending` task sits in the outbox and sends itself when its environment
11 * reconnects; a `draft` is new-task composer content, which only sends when
12 * the user submits it. Both share the list slot so the user can find
13 * everything they have written but not yet started in one place.
14 */
15export type PendingNewTask = PendingQueuedTask | PendingDraftTask;
16
17export interface PendingQueuedTask {
18 readonly kind: "pending";
19 readonly key: string;
20 readonly environmentId: EnvironmentId;
21 readonly projectId: ProjectId;
22 readonly projectTitle: string | undefined;
23 readonly projectCwd: string | undefined;
24 readonly branch: string | null;
25 readonly title: string;
26 readonly createdAt: string;
27 readonly message: QueuedThreadMessage;
28 readonly creation: QueuedThreadCreation;
29}
30
31export interface PendingDraftTask {
32 readonly kind: "draft";
33 readonly key: string;
34 readonly environmentId: EnvironmentId;
35 readonly projectId: ProjectId;
36 readonly projectTitle: undefined;
37 readonly projectCwd: undefined;
38 readonly branch: string | null;
39 readonly title: string;
40 readonly createdAt: string;
41 readonly draftKey: string;
42 readonly draft: ComposerDraft;
43}
44
45/**
46 * Settings-only drafts (a model pick with no text) are not work the user
47 * would look for in the list; only text or attachments make a draft visible.
48 */
49export function composerDraftHasUserContent(draft: ComposerDraft): boolean {
50 return draft.text.trim().length > 0 || draft.attachments.length > 0;
51}
52
53function draftTitle(draft: ComposerDraft): string {
54 if (draft.text.trim().length > 0) {
55 return deriveThreadTitleFromPrompt(draft.text);
56 }
57 const count = draft.attachments.length;
58 return count === 1 ? "1 attachment" : `${count} attachments`;
59}
60
61export function buildPendingNewTasks(input: {
62 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
63 readonly drafts: Readonly<Record<string, ComposerDraft>>;
64}): ReadonlyArray<PendingNewTask> {
65 const tasks: PendingNewTask[] = [];
66 for (const message of input.queuedMessages) {
67 if (!message.creation) {
68 continue;
69 }
70 tasks.push({
71 kind: "pending",
72 key: `pending-task:${message.messageId}`,
73 environmentId: message.environmentId,
74 projectId: message.creation.projectId,
75 projectTitle: message.creation.projectTitle,
76 projectCwd: message.creation.projectCwd,
77 branch: message.creation.branch,
78 title: deriveThreadTitleFromPrompt(message.text),
79 createdAt: message.createdAt,
80 message,
81 creation: message.creation,
82 });
83 }
84 for (const [draftKey, draft] of Object.entries(input.drafts)) {
85 if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) {
86 continue;
87 }
88 tasks.push({
89 kind: "draft",
90 key: `draft-task:${draftKey}`,
91 environmentId: draft.project.environmentId,
92 projectId: draft.project.projectId,
93 projectTitle: undefined,
94 projectCwd: undefined,
95 branch: draft.workspaceSelection?.branch ?? null,
96 title: draftTitle(draft),
97 createdAt: draft.project.createdAt,
98 draftKey,
99 draft,
100 });
101 }
102 // Drafts are what the user is writing now, so they lead; within each kind,
103 // newest first.
104 tasks.sort((left, right) => {
105 if (left.kind !== right.kind) {
106 return left.kind === "draft" ? -1 : 1;
107 }
108 return right.createdAt.localeCompare(left.createdAt) || left.key.localeCompare(right.key);
109 });
110 return tasks;
111}