authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-12 19:33:45-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 19:33:45-05:00
log34f845832401cd9b8f9a740e1cefc3218deb63c2
treed526cc5a8d53baffe323ec48e8a51fd5acc21500
parent979589d6d82d2757188f977dc4869813183ebb49
signature Signed by PGP key B5690EEEBB952194

fix(web): attach dropped threads to pending answers (#18789)


4 files changed, 128 insertions(+), 7 deletions(-)

apps/web/src/components/ChatView.tsx+23-1
...@@ -214,6 +214,7 @@ import {...@@ -214,6 +214,7 @@ import {
214 buildPendingUserInputAnswers,214 buildPendingUserInputAnswers,
215 carryDisplacedCustomAnswerIntoPrompt,215 carryDisplacedCustomAnswerIntoPrompt,
216 derivePendingUserInputProgress,216 derivePendingUserInputProgress,
217 projectPendingUserInputAnswer,
217 setPendingUserInputCustomAnswer,218 setPendingUserInputCustomAnswer,
218 togglePendingUserInputOptionSelection,219 togglePendingUserInputOptionSelection,
219 type PendingUserInputDraftAnswer,220 type PendingUserInputDraftAnswer,
...@@ -10239,6 +10240,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -10239,6 +10240,7 @@ export default function ChatView(props: ChatViewProps) {
10239 string,10240 string,
10240 import("@t3tools/contracts").UserInputAttachments[string]10241 import("@t3tools/contracts").UserInputAttachments[string]
10241 >();10242 >();
10243 const providerAnswers = { ...answers };
10242 for (const question of pendingInput.questions) {10244 for (const question of pendingInput.questions) {
10243 const target = questionAttachmentDraftId(10245 const target = questionAttachmentDraftId(
10244 environmentId,10246 environmentId,
...@@ -10248,6 +10250,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -10248,6 +10250,10 @@ export default function ChatView(props: ChatViewProps) {
10248 );10250 );
10249 if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;10251 if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
10250 const draft = useComposerDraftStore.getState().getComposerDraft(target);10252 const draft = useComposerDraftStore.getState().getComposerDraft(target);
10253 providerAnswers[question.id] = projectPendingUserInputAnswer(
10254 answers[question.id],
10255 draft?.threadContexts ?? [],
10256 );
10251 const attachments = draft ? [...draft.images, ...draft.files] : [];10257 const attachments = draft ? [...draft.images, ...draft.files] : [];
10252 if (attachments.length === 0) continue;10258 if (attachments.length === 0) continue;
10253 const uploaded = getUploadedAttachments({ environmentId, images: attachments });10259 const uploaded = getUploadedAttachments({ environmentId, images: attachments });
...@@ -10273,7 +10279,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -10273,7 +10279,7 @@ export default function ChatView(props: ChatViewProps) {
10273 input: {10279 input: {
10274 threadId: activeThreadId,10280 threadId: activeThreadId,
10275 requestId,10281 requestId,
10276 answers,10282 answers: providerAnswers,
10277 ...(attachmentsByQuestionId.size > 010283 ...(attachmentsByQuestionId.size > 0
10278 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }10284 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
10279 : {}),10285 : {}),
...@@ -10346,6 +10352,20 @@ export default function ChatView(props: ChatViewProps) {...@@ -10346,6 +10352,20 @@ export default function ChatView(props: ChatViewProps) {
10346 const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer);10352 const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer);
10347 if (nextPrompt !== currentPrompt) {10353 if (nextPrompt !== currentPrompt) {
10348 setComposerDraftPrompt(composerDraftTarget, nextPrompt);10354 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
10355 if (activeThreadId) {
10356 const store = useComposerDraftStore.getState();
10357 const questionTarget = questionAttachmentDraftId(
10358 environmentId,
10359 activeThreadId,
10360 activePendingUserInput.requestId,
10361 questionId,
10362 );
10363 store.addThreadContexts(
10364 composerDraftTarget,
10365 store.getComposerDraft(questionTarget)?.threadContexts ?? [],
10366 { appendReference: false },
10367 );
10368 }
10349 }10369 }
10350 setPendingUserInputAnswersByRequestId((existing) => {10370 setPendingUserInputAnswersByRequestId((existing) => {
10351 const question =10371 const question =
...@@ -10376,8 +10396,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -10376,8 +10396,10 @@ export default function ChatView(props: ChatViewProps) {
10376 activePendingProgress?.activeQuestion,10396 activePendingProgress?.activeQuestion,
10377 activePendingUserInput,10397 activePendingUserInput,
10378 activePendingRequestKey,10398 activePendingRequestKey,
10399 activeThreadId,
10379 composerDraftTarget,10400 composerDraftTarget,
10380 composerRef,10401 composerRef,
10402 environmentId,
10381 pendingUserInputAnswersByRequestId,10403 pendingUserInputAnswersByRequestId,
10382 setComposerDraftPrompt,10404 setComposerDraftPrompt,
10383 ],10405 ],
apps/web/src/components/chat/ChatComposer.tsx+58-5
...@@ -1897,7 +1897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1897,7 +1897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1897 const composerTerminalContexts = composerDraft.terminalContexts;1897 const composerTerminalContexts = composerDraft.terminalContexts;
1898 const composerPreviewAnnotations = composerDraft.previewAnnotations;1898 const composerPreviewAnnotations = composerDraft.previewAnnotations;
1899 const composerReviewComments = composerDraft.reviewComments;1899 const composerReviewComments = composerDraft.reviewComments;
1900 const composerThreadContexts = composerDraft.threadContexts;1900 const composerThreadContexts = attachmentDraft.threadContexts;
1901 const pendingSnapShotAnimations = useSyncExternalStore(1901 const pendingSnapShotAnimations = useSyncExternalStore(
1902 subscribeToPendingSnapShotAnimations,1902 subscribeToPendingSnapShotAnimations,
1903 getPendingSnapShotAnimations,1903 getPendingSnapShotAnimations,
...@@ -3163,6 +3163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3163,6 +3163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3163 ...composerTerminalContexts3163 ...composerTerminalContexts
3164 .filter((c) => wanted.has(terminalContextReference(c).contextId))3164 .filter((c) => wanted.has(terminalContextReference(c).contextId))
3165 .map(terminalContextRecord),3165 .map(terminalContextRecord),
3166 ...composerThreadContexts.filter((record) => wanted.has(record.contextId)),
3166 ...composerReviewComments3167 ...composerReviewComments
3167 .filter((c) => wanted.has(reviewCommentContextId(c.id)))3168 .filter((c) => wanted.has(reviewCommentContextId(c.id)))
3168 .map(reviewCommentContextRecord),3169 .map(reviewCommentContextRecord),
...@@ -3201,6 +3202,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3201,6 +3202,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3201 composerPreviewAnnotations,3202 composerPreviewAnnotations,
3202 composerReviewComments,3203 composerReviewComments,
3203 composerTerminalContexts,3204 composerTerminalContexts,
3205 composerThreadContexts,
3204 environmentId,3206 environmentId,
3205 uploadsByImageId,3207 uploadsByImageId,
3206 ],3208 ],
...@@ -3394,7 +3396,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3394,7 +3396,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3394 case "thread": {3396 case "thread": {
3395 // The agent can only read threads on its own server; a pasted foreign one is dropped.3397 // The agent can only read threads on its own server; a pasted foreign one is dropped.
3396 if (record.environmentId !== environmentId) break;3398 if (record.environmentId !== environmentId) break;
3397 addComposerDraftThreadContexts(composerDraftTarget, [record], {3399 addComposerDraftThreadContexts(attachmentDraftTarget, [record], {
3398 appendReference: false,3400 appendReference: false,
3399 });3401 });
3400 rewritten.set(record.contextId, record.contextId);3402 rewritten.set(record.contextId, record.contextId);
...@@ -3427,6 +3429,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3427,6 +3429,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3427 addComposerDraftTerminalContexts,3429 addComposerDraftTerminalContexts,
3428 addComposerDraftThreadContexts,3430 addComposerDraftThreadContexts,
3429 composerContextRecords,3431 composerContextRecords,
3432 attachmentDraftTarget,
3430 composerDraftTarget,3433 composerDraftTarget,
3431 environmentId,3434 environmentId,
3432 importAttachmentRecord,3435 importAttachmentRecord,
...@@ -4157,7 +4160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4157,7 +4160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4157 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },4160 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
4158 );4161 );
4159 if (applied) {4162 if (applied) {
4160 addComposerDraftThreadContexts(composerDraftTarget, [record], {4163 addComposerDraftThreadContexts(attachmentDraftTarget, [record], {
4161 appendReference: false,4164 appendReference: false,
4162 });4165 });
4163 setComposerHighlightedItemId(null);4166 setComposerHighlightedItemId(null);
...@@ -4169,6 +4172,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4169,6 +4172,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4169 addComposerDraftReviewComment,4172 addComposerDraftReviewComment,
4170 addComposerDraftThreadContexts,4173 addComposerDraftThreadContexts,
4171 applyPromptReplacement,4174 applyPromptReplacement,
4175 attachmentDraftTarget,
4172 composerDraftTarget,4176 composerDraftTarget,
4173 handleInteractionModeChange,4177 handleInteractionModeChange,
4174 planModeUiEnabled,4178 planModeUiEnabled,
...@@ -6300,11 +6304,60 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6300,11 +6304,60 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6300 const shell = readThreadShell(ref);6304 const shell = readThreadShell(ref);
6301 return shell ? [threadContextRecord(ref, shell.title)] : [];6305 return shell ? [threadContextRecord(ref, shell.title)] : [];
6302 });6306 });
6303 if (records.length > 0) addComposerDraftThreadContexts(composerDraftTarget, records);6307 if (records.length === 0) return;
6308 if (questionAttachmentTarget !== null) {
6309 const canAnswer =
6310 canOperateThread &&
6311 !isConnecting &&
6312 !isComposerApprovalState &&
6313 !activePendingIsResponding &&
6314 activePendingProgress?.activeQuestion?.allowCustomAnswer !== false;
6315 const text = `${records
6316 .map((record) => formatInlineContextReference(threadContextReference(record)))
6317 .join(" ")} `;
6318 const snapshot = readComposerSnapshot();
6319 const cursor = snapshot.expandedCursor;
6320 const leadingSpace = cursor > 0 && !/\s/.test(snapshot.value[cursor - 1] ?? "");
6321 const inserted =
6322 canAnswer &&
6323 applyPromptReplacement(
6324 cursor,
6325 extendReplacementRangeForTrailingSpace(snapshot.value, cursor, text),
6326 leadingSpace ? ` ${text}` : text,
6327 );
6328 if (!inserted) {
6329 toastManager.add({
6330 type: "error",
6331 title: "Unable to add thread",
6332 description: "The composer is busy; try again once it is ready.",
6333 });
6334 return;
6335 }
6336 addComposerDraftThreadContexts(questionAttachmentTarget, records, {
6337 appendReference: false,
6338 });
6339 if (isComposerCollapsedMobile) expandMobileComposer();
6340 } else {
6341 addComposerDraftThreadContexts(composerDraftTarget, records);
6342 }
6304 };6343 };
6305 form.addEventListener(THREAD_CONTEXT_DROP_EVENT, onThreadDrop);6344 form.addEventListener(THREAD_CONTEXT_DROP_EVENT, onThreadDrop);
6306 return () => form.removeEventListener(THREAD_CONTEXT_DROP_EVENT, onThreadDrop);6345 return () => form.removeEventListener(THREAD_CONTEXT_DROP_EVENT, onThreadDrop);
6307 }, [addComposerDraftThreadContexts, composerDraftTarget, environmentId]);6346 }, [
6347 activePendingIsResponding,
6348 activePendingProgress?.activeQuestion?.allowCustomAnswer,
6349 addComposerDraftThreadContexts,
6350 applyPromptReplacement,
6351 canOperateThread,
6352 composerDraftTarget,
6353 environmentId,
6354 expandMobileComposer,
6355 isComposerApprovalState,
6356 isComposerCollapsedMobile,
6357 isConnecting,
6358 questionAttachmentTarget,
6359 readComposerSnapshot,
6360 ]);
63086361
6309 const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLFormElement>) => {6362 const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLFormElement>) => {
6310 if (!dataTransferHasComposerMention(event.dataTransfer.types)) return;6363 if (!dataTransferHasComposerMention(event.dataTransfer.types)) return;
apps/web/src/pendingUserInput.test.ts+31
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
3import { formatInlineContextReference } from "./lib/composerContextReferences";
4import { threadContextRecord, threadContextReference } from "./lib/composerContextRecords";
25
3import {6import {
4 buildPendingUserInputAnswers,7 buildPendingUserInputAnswers,
...@@ -6,11 +9,39 @@ import {...@@ -6,11 +9,39 @@ import {
6 countAnsweredPendingUserInputQuestions,9 countAnsweredPendingUserInputQuestions,
7 derivePendingUserInputProgress,10 derivePendingUserInputProgress,
8 findFirstUnansweredPendingUserInputQuestionIndex,11 findFirstUnansweredPendingUserInputQuestionIndex,
12 projectPendingUserInputAnswer,
9 resolvePendingUserInputAnswer,13 resolvePendingUserInputAnswer,
10 setPendingUserInputCustomAnswer,14 setPendingUserInputCustomAnswer,
11 togglePendingUserInputOptionSelection,15 togglePendingUserInputOptionSelection,
12} from "./pendingUserInput";16} from "./pendingUserInput";
1317
18describe("projectPendingUserInputAnswer", () => {
19 const record = threadContextRecord(
20 {
21 environmentId: EnvironmentId.make("env-answer"),
22 threadId: ThreadId.make("reference-thread"),
23 },
24 "Reference work",
25 );
26
27 it("sends thread chips as readable links with the actual thread ID", () => {
28 const answer = `Compare ${formatInlineContextReference(threadContextReference(record))}`;
29 expect(projectPendingUserInputAnswer(answer, [record])).toBe(
30 "Compare [Reference work](t3-thread://v1/reference-thread)",
31 );
32 });
33
34 it("leaves plain answers unchanged when the draft has unreferenced thread records", () => {
35 expect(projectPendingUserInputAnswer("Plain answer", [record])).toBe("Plain answer");
36 });
37
38 it("preserves option values and exact answer text", () => {
39 const options = [" first\t", "second"];
40 expect(projectPendingUserInputAnswer(options, [record])).toBe(options);
41 expect(projectPendingUserInputAnswer(" Edited answer\n", [record])).toBe(" Edited answer\n");
42 });
43});
44
14const singleSelectQuestion = {45const singleSelectQuestion = {
15 id: "scope",46 id: "scope",
16 header: "Scope",47 header: "Scope",
apps/web/src/pendingUserInput.ts+16-1
...@@ -1,4 +1,19 @@...@@ -1,4 +1,19 @@
1import type { UserInputQuestion } from "@t3tools/contracts";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}
217
3export interface PendingUserInputDraftAnswer {18export interface PendingUserInputDraftAnswer {
4 selectedOptionValues?: string[];19 selectedOptionValues?: string[];