Skip to content
Source
Sign inSign up

pending-new-tasks-model.ts

169 lines6.0 KB

1import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
2import type { EnvironmentId, MessageId, ProjectId } from "@t3tools/contracts";
3​
4import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
5import {
6 isQueuedThreadCreationSendable,
7 resolveThreadOutboxDeliveryAction,
8 type QueuedThreadCreation,
9 type QueuedThreadMessage,
10} from "./thread-outbox-model";
11import { isNewTaskDraftKey } from "./new-task-draft-key";
12import type { ComposerDraft } from "./use-composer-drafts";
13import { resolveNewTaskDraftWorkspace } from "../features/threads/new-task-context-presentation";
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;
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 title: string;
39 readonly createdAt: string;
40 readonly draftKey: string;
41 readonly draft: ComposerDraft;
43​
44function composerDraftHasUserContent(draft: ComposerDraft): boolean {
45 return draft.text.trim().length > 0 || draft.attachments.length > 0;
47​
48function draftTitle(draft: ComposerDraft): string {
49 if (draft.text.trim().length > 0) {
50 return deriveThreadTitleFromPrompt(draft.text);
51 }
52 const count = draft.attachments.length;
53 return count === 1 ? "1 attachment" : `${count} attachments`;
55​
56/** A project's new-task defaults, as useNewTaskProjectDefaults resolves them. */
57export interface PendingDraftProjectDefaults {
58 readonly canChooseWorkspace: boolean;
59 readonly defaultMode: "local" | "worktree";
60 readonly defaultModeSettled: boolean;
62​
63/**
64 * True when the draft's branch is derived from refs rather than stored, so the list must load
65 * refs to show it.
66 */
67export function pendingDraftBranchNeedsRefs(
68 draft: ComposerDraft,
69 defaults: PendingDraftProjectDefaults,
70): boolean {
71 const selection = defaults.canChooseWorkspace ? draft.workspaceSelection : undefined;
72 return selection === undefined ? defaults.defaultMode === "worktree" : selection.branch === null;
74​
75/** The branch a draft will start on, derived the same way the new-task flow and send path do. */
76export function resolvePendingDraftBranch(input: {
77 readonly draft: ComposerDraft;
78 readonly defaults: PendingDraftProjectDefaults;
79 readonly isGitRepo: boolean;
80 readonly branches: Parameters<typeof resolveNewTaskDraftWorkspace>[0]["branches"];
81 readonly projectCwd: string | null;
82}): string | null {
83 return resolveNewTaskDraftWorkspace({
84 selection: input.draft.workspaceSelection,
85 canChooseWorkspace: input.defaults.canChooseWorkspace,
86 defaultMode: input.defaults.defaultMode,
87 defaultModeSettled: input.defaults.defaultModeSettled,
88 isGitRepo: input.isGitRepo,
89 branches: input.branches,
90 projectCwd: input.projectCwd,
91 }).branch;
93​
94export interface ThreadOutboxSendGate {
95 readonly connectedEnvironmentIds: ReadonlySet<EnvironmentId>;
96 readonly operableEnvironmentIds: ReadonlySet<EnvironmentId>;
97 readonly shellStatuses: ReadonlyMap<EnvironmentId, EnvironmentShellStatus>;
98 readonly retryingMessageIds: Readonly<Partial<Record<MessageId, true>>>;
100​
101/** The outbox drain's gate for a creation: sendable, connected, shell live, operate granted, not backing off. */
102export function isQueuedCreationSending(
103 message: QueuedThreadMessage,
104 gate: ThreadOutboxSendGate,
105): boolean {
106 const environmentId = message.environmentId;
107 return (
108 isQueuedThreadCreationSendable(message) &&
109 gate.retryingMessageIds[message.messageId] !== true &&
110 gate.operableEnvironmentIds.has(environmentId) &&
111 resolveThreadOutboxDeliveryAction({
112 isCreation: true,
113 threadExists: false,
114 shellStatus: gate.shellStatuses.get(environmentId) ?? "empty",
115 environmentConnected: gate.connectedEnvironmentIds.has(environmentId),
116 }) === "send"
117 );
119​
120export function buildPendingNewTasks(input: {
121 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
122 readonly drafts: Readonly<Record<string, ComposerDraft>>;
123 readonly sendGate: ThreadOutboxSendGate;
124}): ReadonlyArray<PendingNewTask> {
125 const tasks: PendingNewTask[] = [];
126 for (const message of input.queuedMessages) {
127 // Listing a creation already being sent would flash an "Unsent" row.
128 if (!message.creation || isQueuedCreationSending(message, input.sendGate)) {
129 continue;
130 }
131 tasks.push({
132 kind: "pending",
133 key: `pending-task:${message.messageId}`,
134 environmentId: message.environmentId,
135 projectId: message.creation.projectId,
136 projectTitle: message.creation.projectTitle,
137 projectCwd: message.creation.projectCwd,
138 branch: message.creation.branch,
139 title: deriveThreadTitleFromPrompt(message.text),
140 createdAt: message.createdAt,
141 message,
142 creation: message.creation,
143 });
144 }
145 for (const [draftKey, draft] of Object.entries(input.drafts)) {
146 if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) {
147 continue;
148 }
149 tasks.push({
150 kind: "draft",
151 key: `draft-task:${draftKey}`,
152 environmentId: draft.project.environmentId,
153 projectId: draft.project.projectId,
154 projectTitle: undefined,
155 projectCwd: undefined,
156 title: draftTitle(draft),
157 createdAt: draft.project.createdAt,
158 draftKey,
159 draft,
160 });
161 }
162 tasks.sort((left, right) => {
163 if (left.kind !== right.kind) {
164 return left.kind === "draft" ? -1 : 1;
165 }
166 return right.createdAt.localeCompare(left.createdAt) || left.key.localeCompare(right.key);
167 });
168 return tasks;