| 1 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 2 | |
| 3 | import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; |
| 4 | import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model"; |
| 5 | import { isNewTaskDraftKey } from "./new-task-draft-key"; |
| 6 | import 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 | */ |
| 15 | export type PendingNewTask = PendingQueuedTask | PendingDraftTask; |
| 16 | |
| 17 | export 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 | |
| 31 | export 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 | */ |
| 49 | export function composerDraftHasUserContent(draft: ComposerDraft): boolean { |
| 50 | return draft.text.trim().length > 0 || draft.attachments.length > 0; |
| 51 | } |
| 52 | |
| 53 | function 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 | |
| 61 | export 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 | } |