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 {
214214 buildPendingUserInputAnswers,
215215 carryDisplacedCustomAnswerIntoPrompt,
216216 derivePendingUserInputProgress,
217 projectPendingUserInputAnswer,
217218 setPendingUserInputCustomAnswer,
218219 togglePendingUserInputOptionSelection,
219220 type PendingUserInputDraftAnswer,
......@@ -10239,6 +10240,7 @@ export default function ChatView(props: ChatViewProps) {
1023910240 string,
1024010241 import("@t3tools/contracts").UserInputAttachments[string]
1024110242 >();
10243 const providerAnswers = { ...answers };
1024210244 for (const question of pendingInput.questions) {
1024310245 const target = questionAttachmentDraftId(
1024410246 environmentId,
......@@ -10248,6 +10250,10 @@ export default function ChatView(props: ChatViewProps) {
1024810250 );
1024910251 if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
1025010252 const draft = useComposerDraftStore.getState().getComposerDraft(target);
10253 providerAnswers[question.id] = projectPendingUserInputAnswer(
10254 answers[question.id],
10255 draft?.threadContexts ?? [],
10256 );
1025110257 const attachments = draft ? [...draft.images, ...draft.files] : [];
1025210258 if (attachments.length === 0) continue;
1025310259 const uploaded = getUploadedAttachments({ environmentId, images: attachments });
......@@ -10273,7 +10279,7 @@ export default function ChatView(props: ChatViewProps) {
1027310279 input: {
1027410280 threadId: activeThreadId,
1027510281 requestId,
10276 answers,
10282 answers: providerAnswers,
1027710283 ...(attachmentsByQuestionId.size > 0
1027810284 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
1027910285 : {}),
......@@ -10346,6 +10352,20 @@ export default function ChatView(props: ChatViewProps) {
1034610352 const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer);
1034710353 if (nextPrompt !== currentPrompt) {
1034810354 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 }
1034910369 }
1035010370 setPendingUserInputAnswersByRequestId((existing) => {
1035110371 const question =
......@@ -10376,8 +10396,10 @@ export default function ChatView(props: ChatViewProps) {
1037610396 activePendingProgress?.activeQuestion,
1037710397 activePendingUserInput,
1037810398 activePendingRequestKey,
10399 activeThreadId,
1037910400 composerDraftTarget,
1038010401 composerRef,
10402 environmentId,
1038110403 pendingUserInputAnswersByRequestId,
1038210404 setComposerDraftPrompt,
1038310405 ],
apps/web/src/components/chat/ChatComposer.tsx+58-5
......@@ -1897,7 +1897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18971897 const composerTerminalContexts = composerDraft.terminalContexts;
18981898 const composerPreviewAnnotations = composerDraft.previewAnnotations;
18991899 const composerReviewComments = composerDraft.reviewComments;
1900 const composerThreadContexts = composerDraft.threadContexts;
1900 const composerThreadContexts = attachmentDraft.threadContexts;
19011901 const pendingSnapShotAnimations = useSyncExternalStore(
19021902 subscribeToPendingSnapShotAnimations,
19031903 getPendingSnapShotAnimations,
......@@ -3163,6 +3163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31633163 ...composerTerminalContexts
31643164 .filter((c) => wanted.has(terminalContextReference(c).contextId))
31653165 .map(terminalContextRecord),
3166 ...composerThreadContexts.filter((record) => wanted.has(record.contextId)),
31663167 ...composerReviewComments
31673168 .filter((c) => wanted.has(reviewCommentContextId(c.id)))
31683169 .map(reviewCommentContextRecord),
......@@ -3201,6 +3202,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
32013202 composerPreviewAnnotations,
32023203 composerReviewComments,
32033204 composerTerminalContexts,
3205 composerThreadContexts,
32043206 environmentId,
32053207 uploadsByImageId,
32063208 ],
......@@ -3394,7 +3396,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
33943396 case "thread": {
33953397 // The agent can only read threads on its own server; a pasted foreign one is dropped.
33963398 if (record.environmentId !== environmentId) break;
3397 addComposerDraftThreadContexts(composerDraftTarget, [record], {
3399 addComposerDraftThreadContexts(attachmentDraftTarget, [record], {
33983400 appendReference: false,
33993401 });
34003402 rewritten.set(record.contextId, record.contextId);
......@@ -3427,6 +3429,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34273429 addComposerDraftTerminalContexts,
34283430 addComposerDraftThreadContexts,
34293431 composerContextRecords,
3432 attachmentDraftTarget,
34303433 composerDraftTarget,
34313434 environmentId,
34323435 importAttachmentRecord,
......@@ -4157,7 +4160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41574160 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
41584161 );
41594162 if (applied) {
4160 addComposerDraftThreadContexts(composerDraftTarget, [record], {
4163 addComposerDraftThreadContexts(attachmentDraftTarget, [record], {
41614164 appendReference: false,
41624165 });
41634166 setComposerHighlightedItemId(null);
......@@ -4169,6 +4172,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41694172 addComposerDraftReviewComment,
41704173 addComposerDraftThreadContexts,
41714174 applyPromptReplacement,
4175 attachmentDraftTarget,
41724176 composerDraftTarget,
41734177 handleInteractionModeChange,
41744178 planModeUiEnabled,
......@@ -6300,11 +6304,60 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
63006304 const shell = readThreadShell(ref);
63016305 return shell ? [threadContextRecord(ref, shell.title)] : [];
63026306 });
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 }
63046343 };
63056344 form.addEventListener(THREAD_CONTEXT_DROP_EVENT, onThreadDrop);
63066345 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
63096362 const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLFormElement>) => {
63106363 if (!dataTransferHasComposerMention(event.dataTransfer.types)) return;
apps/web/src/pendingUserInput.test.ts+31
......@@ -1,4 +1,7 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
12import { describe, expect, it } from "vite-plus/test";
3import { formatInlineContextReference } from "./lib/composerContextReferences";
4import { threadContextRecord, threadContextReference } from "./lib/composerContextRecords";
25
36import {
47 buildPendingUserInputAnswers,
......@@ -6,11 +9,39 @@ import {
69 countAnsweredPendingUserInputQuestions,
710 derivePendingUserInputProgress,
811 findFirstUnansweredPendingUserInputQuestionIndex,
12 projectPendingUserInputAnswer,
913 resolvePendingUserInputAnswer,
1014 setPendingUserInputCustomAnswer,
1115 togglePendingUserInputOptionSelection,
1216} 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
1445const singleSelectQuestion = {
1546 id: "scope",
1647 header: "Scope",
apps/web/src/pendingUserInput.ts+16-1
......@@ -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
318export interface PendingUserInputDraftAnswer {
419 selectedOptionValues?: string[];