1import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts";
2import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
3import { formatThreadLink } from "@t3tools/shared/threadLinks";
4
5/** Question responses carry text, so send thread chips as links with the actual thread IDs. */
6export function projectPendingUserInputAnswer(
7 answer: unknown,
8 threadContexts: ReadonlyArray<ThreadContextRecord>,
9) {
10 if (typeof answer !== "string" || threadContexts.length === 0) return answer;
11 const recordsById = new Map(threadContexts.map((record) => [record.contextId, record]));
12 return replaceComposerContextReferences(answer, (reference) => {
13 const record = reference.kind === "thread" ? recordsById.get(reference.contextId) : undefined;
14 return record ? formatThreadLink(record.threadId, reference.label) : reference.source;
15 });
16}
17
18export interface PendingUserInputDraftAnswer {
19 selectedOptionValues?: string[];
20 customAnswer?: string;
21 attachmentCount?: number;
22 attachmentsBlocked?: boolean;
23}
24
25export interface PendingUserInputProgress {
26 questionIndex: number;
27 activeQuestion: UserInputQuestion | null;
28 activeDraft: PendingUserInputDraftAnswer | undefined;
29 selectedOptionValues: string[];
30 customAnswer: string;
31 resolvedAnswer: string | string[] | null;
32 usingCustomAnswer: boolean;
33 answeredQuestionCount: number;
34 isLastQuestion: boolean;
35 isComplete: boolean;
36 canAdvance: boolean;
37}
38
39function normalizeDraftAnswer(value: string | undefined): string | null {
40 if (typeof value !== "string") {
41 return null;
42 }
43
44 const trimmed = value.trim();
45 return trimmed.length > 0 ? trimmed : null;
46}
47
48function normalizeSelectedOptionValues(value: string[] | undefined): string[] {
49 if (!Array.isArray(value)) {
50 return [];
51 }
52
53 // Provider option IDs must stay unchanged, including whitespace.
54 return Array.from(new Set(value.filter((entry) => typeof entry === "string")));
55}
56
57export function resolvePendingUserInputAnswer(
58 question: UserInputQuestion,
59 draft: PendingUserInputDraftAnswer | undefined,
60): string | string[] | null {
61 if (draft?.attachmentsBlocked) return null;
62 const customAnswer =
63 question.allowCustomAnswer === false
64 ? null
65 : question.initialAnswer !== undefined
66 ? (draft?.customAnswer ?? null)
67 : normalizeDraftAnswer(draft?.customAnswer);
68 if (customAnswer !== null && customAnswer.length > 0) {
69 return customAnswer;
70 }
71
72 const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter(
73 (value) => question.options.some((option) => (option.value ?? option.label) === value),
74 );
75 if (question.multiSelect) {
76 return selectedOptionValues.length > 0
77 ? selectedOptionValues
78 : (customAnswer ??
79 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null));
80 }
81
82 return (
83 selectedOptionValues[0] ??
84 customAnswer ??
85 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
86 );
87}
88
89export function setPendingUserInputCustomAnswer(
90 draft: PendingUserInputDraftAnswer | undefined,
91 customAnswer: string,
92 question?: Pick<UserInputQuestion, "initialAnswer">,
93): PendingUserInputDraftAnswer {
94 const selectedOptionValues =
95 question?.initialAnswer !== undefined || customAnswer.trim().length > 0
96 ? undefined
97 : normalizeSelectedOptionValues(draft?.selectedOptionValues);
98
99 return {
100 customAnswer,
101 ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}),
102 };
103}
104
105const DISPLACED_ANSWER_SEPARATOR = "\n\n";
106
107/**
108 * Selecting an option replaces the custom answer, because a non-empty custom
109 * answer outranks selected options in `resolvePendingUserInputAnswer`. Text the
110 * user typed into the answer field must not vanish on that click: it moves back
111 * into the thread draft, after whatever was already waiting there.
112 */
113export function carryDisplacedCustomAnswerIntoPrompt(
114 prompt: string,
115 customAnswer: string | undefined,
116): string {
117 const displaced = customAnswer?.trim() ?? "";
118 if (displaced.length === 0) {
119 return prompt;
120 }
121 if (prompt.trim().length === 0) {
122 return displaced;
123 }
124 return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`;
125}
126
127export function togglePendingUserInputOptionSelection(
128 question: UserInputQuestion,
129 draft: PendingUserInputDraftAnswer | undefined,
130 optionValue: string,
131): PendingUserInputDraftAnswer {
132 if (question.multiSelect) {
133 const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues);
134 const nextSelectedOptionValues = selectedOptionValues.includes(optionValue)
135 ? selectedOptionValues.filter((value) => value !== optionValue)
136 : [...selectedOptionValues, optionValue];
137
138 return {
139 customAnswer: "",
140 ...(nextSelectedOptionValues.length > 0
141 ? { selectedOptionValues: nextSelectedOptionValues }
142 : {}),
143 };
144 }
145
146 return {
147 customAnswer: "",
148 selectedOptionValues: [optionValue],
149 };
150}
151
152export function buildPendingUserInputAnswers(
153 questions: ReadonlyArray<UserInputQuestion>,
154 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
155): Record<string, string | string[]> | null {
156 const answers: Record<string, string | string[]> = {};
157
158 for (const question of questions) {
159 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
160 if (answer === null) {
161 return null;
162 }
163 answers[question.id] = answer;
164 }
165
166 return answers;
167}
168
169export function countAnsweredPendingUserInputQuestions(
170 questions: ReadonlyArray<UserInputQuestion>,
171 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
172): number {
173 return questions.reduce((count, question) => {
174 return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) !== null
175 ? count + 1
176 : count;
177 }, 0);
178}
179
180export function findFirstUnansweredPendingUserInputQuestionIndex(
181 questions: ReadonlyArray<UserInputQuestion>,
182 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
183): number {
184 const unansweredIndex = questions.findIndex(
185 (question) => resolvePendingUserInputAnswer(question, draftAnswers[question.id]) === null,
186 );
187
188 return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex;
189}
190
191export function derivePendingUserInputProgress(
192 questions: ReadonlyArray<UserInputQuestion>,
193 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
194 questionIndex: number,
195): PendingUserInputProgress {
196 const normalizedQuestionIndex =
197 questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1));
198 const activeQuestion = questions[normalizedQuestionIndex] ?? null;
199 const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;
200 const resolvedAnswer = activeQuestion
201 ? resolvePendingUserInputAnswer(activeQuestion, activeDraft)
202 : null;
203 const customAnswer =
204 activeQuestion?.allowCustomAnswer === false ? "" : (activeDraft?.customAnswer ?? "");
205 const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);
206 const isLastQuestion =
207 questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1;
208
209 return {
210 questionIndex: normalizedQuestionIndex,
211 activeQuestion,
212 activeDraft,
213 selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues),
214 customAnswer,
215 resolvedAnswer,
216 usingCustomAnswer:
217 (activeQuestion?.initialAnswer !== undefined ? customAnswer : customAnswer.trim()).length > 0,
218 answeredQuestionCount,
219 isLastQuestion,
220 isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null,
221 canAdvance: resolvedAnswer !== null,
222 };
223}