1import type {
2 ReviewCommentContextRecord,
3 ThreadContextRecord,
4 UserInputQuestion,
5} from "@t3tools/contracts";
6import {
7 projectComposerContextForProvider,
8 replaceComposerContextReferences,
9} from "@t3tools/shared/composerContextReferences";
10import { formatThreadLink } from "@t3tools/shared/threadLinks";
11import {
12 buildPendingUserInputAnswers,
13 normalizeSelectedOptionValues,
14 resolvePendingUserInputAnswer,
15 type PendingUserInputAnswer,
16 type PendingUserInputDraftAnswer,
17} from "@t3tools/client-runtime/state/pending-user-input";
18
19import { collectInlineContextIds } from "./lib/composerContextReferences";
20
21export function projectPendingUserInputAnswer(
22 answer: unknown,
23 records: ReadonlyArray<ThreadContextRecord | ReviewCommentContextRecord>,
24) {
25 if (typeof answer !== "string" || records.length === 0) return answer;
26 const recordsById = new Map(records.map((record) => [record.contextId, record]));
27 return replaceComposerContextReferences(answer, (reference) => {
28 const record = recordsById.get(reference.contextId);
29 if (record?.kind !== reference.kind) return reference.source;
30 return record.kind === "thread"
31 ? formatThreadLink(record, reference.label)
32 : projectComposerContextForProvider({ text: reference.source, records: [record] });
33 });
34}
35
36// Question drafts retain deleted chip records for undo; only transfer referenced records.
37export function threadContextsReferencedByAnswer(
38 answer: string,
39 threadContexts: ReadonlyArray<ThreadContextRecord>,
40) {
41 const referencedIds = new Set(collectInlineContextIds(answer));
42 return threadContexts.filter((record) => referencedIds.has(record.contextId));
43}
44
45export interface PendingUserInputProgress<Q extends UserInputQuestion = UserInputQuestion> {
46 questionIndex: number;
47 activeQuestion: Q | null;
48 activeDraft: PendingUserInputDraftAnswer | undefined;
49 selectedOptionValues: ReadonlyArray<string>;
50 customAnswer: string;
51 resolvedAnswer: PendingUserInputAnswer | null;
52 usingCustomAnswer: boolean;
53 answeredQuestionCount: number;
54 isLastQuestion: boolean;
55 isComplete: boolean;
56 canAdvance: boolean;
57}
58
59const DISPLACED_ANSWER_SEPARATOR = "\n\n";
60
61export function carryDisplacedCustomAnswerIntoPrompt(
62 prompt: string,
63 customAnswer: string | undefined,
64): string {
65 const displaced = customAnswer?.trim() ?? "";
66 if (displaced.length === 0) {
67 return prompt;
68 }
69 if (prompt.trim().length === 0) {
70 return displaced;
71 }
72 return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`;
73}
74
75/**
76 * Takes set-aside answer text back out of the thread draft once it is the answer again.
77 * `laterDisplaced` is what was set aside after it, in order: together they locate this copy,
78 * so equal text inside another answer or the user's own draft is never the one removed.
79 */
80export function withdrawDisplacedCustomAnswerFromPrompt(
81 prompt: string,
82 displaced: string,
83 laterDisplaced: ReadonlyArray<string> = [],
84): string {
85 const text = displaced.trim();
86 if (text.length === 0) return prompt;
87 const later = laterDisplaced.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
88 const suffix = [text, ...later].join(DISPLACED_ANSWER_SEPARATOR);
89 if (!prompt.endsWith(suffix)) return prompt;
90 const before = prompt.slice(0, prompt.length - suffix.length);
91 const after = later.join(DISPLACED_ANSWER_SEPARATOR);
92 if (before.length === 0) return after;
93 if (!before.endsWith(DISPLACED_ANSWER_SEPARATOR)) return prompt;
94 return after.length === 0 ? before.slice(0, -DISPLACED_ANSWER_SEPARATOR.length) : before + after;
95}
96
97function countAnsweredPendingUserInputQuestions(
98 questions: ReadonlyArray<UserInputQuestion>,
99 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
100): number {
101 return questions.reduce((count, question) => {
102 return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) !== null
103 ? count + 1
104 : count;
105 }, 0);
106}
107
108export function derivePendingUserInputProgress<Q extends UserInputQuestion>(
109 questions: ReadonlyArray<Q>,
110 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
111 questionIndex: number,
112): PendingUserInputProgress<Q> {
113 const normalizedQuestionIndex =
114 questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1));
115 const activeQuestion = questions[normalizedQuestionIndex] ?? null;
116 const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;
117 const resolvedAnswer = activeQuestion
118 ? resolvePendingUserInputAnswer(activeQuestion, activeDraft)
119 : null;
120 const customAnswer =
121 activeQuestion?.allowCustomAnswer === false ? "" : (activeDraft?.customAnswer ?? "");
122 const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);
123 const isLastQuestion =
124 questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1;
125
126 return {
127 questionIndex: normalizedQuestionIndex,
128 activeQuestion,
129 activeDraft,
130 selectedOptionValues: activeQuestion
131 ? normalizeSelectedOptionValues(activeQuestion, activeDraft?.selectedOptionValues)
132 : [],
133 customAnswer,
134 resolvedAnswer,
135 usingCustomAnswer:
136 (activeQuestion?.initialAnswer !== undefined ? customAnswer : customAnswer.trim()).length > 0,
137 answeredQuestionCount,
138 isLastQuestion,
139 isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null,
140 canAdvance: resolvedAnswer !== null,
141 };
142}