1import { describe, expect, it } from "@effect/vitest";
2import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
3
4import type { QueuedThreadMessage } from "./thread-outbox-model";
5import type { ComposerDraft } from "./use-composer-drafts";
6import { buildPendingNewTasks } from "./pending-new-tasks-model";
7
8const environmentId = EnvironmentId.make("env-1");
9const projectId = ProjectId.make("project-1");
10
11function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
12 return {
13 environmentId,
14 threadId: ThreadId.make(`thread-${id}`),
15 messageId: MessageId.make(id),
16 commandId: CommandId.make(`command-${id}`),
17 text: `queued ${id}`,
18 attachments: [],
19 createdAt,
20 creation: {
21 projectId,
22 workspaceMode: "local",
23 branch: "main",
24 worktreePath: null,
25 },
26 };
27}
28
29function draft(
30 text: string,
31 createdAt: string,
32 overrides: Partial<ComposerDraft> = {},
33): ComposerDraft {
34 return {
35 text,
36 attachments: [],
37 project: { environmentId, projectId, createdAt },
38 ...overrides,
39 };
40}
41
42describe("buildPendingNewTasks", () => {
43 it("surfaces every new-task draft with content alongside queued creations", () => {
44 const tasks = buildPendingNewTasks({
45 queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")],
46 drafts: {
47 "new-task:draft-old": draft("first idea", "2026-09-05T09:00:00.000Z", {
48 workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null },
49 }),
50 "new-task:draft-new": draft("second idea", "2026-09-05T11:00:00.000Z"),
51 },
52 });
53
54 expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([
55 ["draft", "second idea", null],
56 ["draft", "first idea", "main"],
57 ["pending", "queued a", "main"],
58 ]);
59 expect(tasks[1]).toMatchObject({
60 key: "draft-task:new-task:draft-old",
61 environmentId,
62 projectId,
63 draftKey: "new-task:draft-old",
64 createdAt: "2026-09-05T09:00:00.000Z",
65 });
66 });
67
68 it("hides settings-only drafts, unstamped drafts, and drafts for other surfaces", () => {
69 const tasks = buildPendingNewTasks({
70 queuedMessages: [],
71 drafts: {
72 "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", {
73 modelSelection: { instanceId: "codex" as never, model: "gpt" },
74 }),
75 "new-task:blank": draft(" ", "2026-09-05T09:00:00.000Z"),
76 "new-task:unstamped": { text: "no project", attachments: [] },
77 [`${environmentId}:thread-1`]: { text: "thread composer text", attachments: [] },
78 "pending-task:message-1": { text: "editor copy of a queued task", attachments: [] },
79 },
80 });
81
82 expect(tasks).toEqual([]);
83 });
84
85 it("titles an attachment-only draft by its attachment count", () => {
86 const attachment = {
87 type: "image",
88 id: "image-1",
89 uri: "file:///image-1.png",
90 mimeType: "image/png",
91 name: "image-1.png",
92 width: 1,
93 height: 1,
94 sizeBytes: 1,
95 } as unknown as ComposerDraft["attachments"][number];
96 const tasks = buildPendingNewTasks({
97 queuedMessages: [],
98 drafts: {
99 "new-task:with-image": draft("", "2026-09-05T09:00:00.000Z", {
100 attachments: [attachment],
101 }),
102 },
103 });
104
105 expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]);
106 });
107
108 it("orders queued creations newest first and skips existing-thread messages", () => {
109 const tasks = buildPendingNewTasks({
110 queuedMessages: [
111 queuedCreation("old", "2026-09-05T08:00:00.000Z"),
112 { ...queuedCreation("follow-up", "2026-09-05T11:00:00.000Z"), creation: undefined },
113 queuedCreation("new", "2026-09-05T10:00:00.000Z"),
114 ],
115 drafts: {},
116 });
117
118 expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]);
119 });
120});