From 34f845832401cd9b8f9a740e1cefc3218deb63c2 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 12 Oct 2026 19:33:45 -0500 Subject: [PATCH] fix(web): attach dropped threads to pending answers (#18789) --- apps/web/src/components/ChatView.tsx | 24 ++++++- apps/web/src/components/chat/ChatComposer.tsx | 63 +++++++++++++++++-- apps/web/src/pendingUserInput.test.ts | 31 +++++++++ apps/web/src/pendingUserInput.ts | 17 ++++- 4 files changed, 128 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1fb029cc3484a2a3aeecd8ac1cbf7f630329f4d2..249ce9286e5e57b698857c65a8f1faf6b4463797 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -214,6 +214,7 @@ import { buildPendingUserInputAnswers, carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, + projectPendingUserInputAnswer, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, @@ -10239,6 +10240,7 @@ export default function ChatView(props: ChatViewProps) { string, import("@t3tools/contracts").UserInputAttachments[string] >(); + const providerAnswers = { ...answers }; for (const question of pendingInput.questions) { const target = questionAttachmentDraftId( environmentId, @@ -10248,6 +10250,10 @@ export default function ChatView(props: ChatViewProps) { ); if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; const draft = useComposerDraftStore.getState().getComposerDraft(target); + providerAnswers[question.id] = projectPendingUserInputAnswer( + answers[question.id], + draft?.threadContexts ?? [], + ); const attachments = draft ? [...draft.images, ...draft.files] : []; if (attachments.length === 0) continue; const uploaded = getUploadedAttachments({ environmentId, images: attachments }); @@ -10273,7 +10279,7 @@ export default function ChatView(props: ChatViewProps) { input: { threadId: activeThreadId, requestId, - answers, + answers: providerAnswers, ...(attachmentsByQuestionId.size > 0 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } : {}), @@ -10346,6 +10352,20 @@ export default function ChatView(props: ChatViewProps) { const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); if (nextPrompt !== currentPrompt) { setComposerDraftPrompt(composerDraftTarget, nextPrompt); + if (activeThreadId) { + const store = useComposerDraftStore.getState(); + const questionTarget = questionAttachmentDraftId( + environmentId, + activeThreadId, + activePendingUserInput.requestId, + questionId, + ); + store.addThreadContexts( + composerDraftTarget, + store.getComposerDraft(questionTarget)?.threadContexts ?? [], + { appendReference: false }, + ); + } } setPendingUserInputAnswersByRequestId((existing) => { const question = @@ -10376,8 +10396,10 @@ export default function ChatView(props: ChatViewProps) { activePendingProgress?.activeQuestion, activePendingUserInput, activePendingRequestKey, + activeThreadId, composerDraftTarget, composerRef, + environmentId, pendingUserInputAnswersByRequestId, setComposerDraftPrompt, ], diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9162c4b67e0d0c03b397c2b447984a8222731fac..64514530b29fb3fc927dd5d9770a46c6630cd2ba 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1897,7 +1897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerTerminalContexts = composerDraft.terminalContexts; const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; - const composerThreadContexts = composerDraft.threadContexts; + const composerThreadContexts = attachmentDraft.threadContexts; const pendingSnapShotAnimations = useSyncExternalStore( subscribeToPendingSnapShotAnimations, getPendingSnapShotAnimations, @@ -3163,6 +3163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...composerTerminalContexts .filter((c) => wanted.has(terminalContextReference(c).contextId)) .map(terminalContextRecord), + ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), ...composerReviewComments .filter((c) => wanted.has(reviewCommentContextId(c.id))) .map(reviewCommentContextRecord), @@ -3201,6 +3202,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerPreviewAnnotations, composerReviewComments, composerTerminalContexts, + composerThreadContexts, environmentId, uploadsByImageId, ], @@ -3394,7 +3396,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) case "thread": { // The agent can only read threads on its own server; a pasted foreign one is dropped. if (record.environmentId !== environmentId) break; - addComposerDraftThreadContexts(composerDraftTarget, [record], { + addComposerDraftThreadContexts(attachmentDraftTarget, [record], { appendReference: false, }); rewritten.set(record.contextId, record.contextId); @@ -3427,6 +3429,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftTerminalContexts, addComposerDraftThreadContexts, composerContextRecords, + attachmentDraftTarget, composerDraftTarget, environmentId, importAttachmentRecord, @@ -4157,7 +4160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, ); if (applied) { - addComposerDraftThreadContexts(composerDraftTarget, [record], { + addComposerDraftThreadContexts(attachmentDraftTarget, [record], { appendReference: false, }); setComposerHighlightedItemId(null); @@ -4169,6 +4172,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftReviewComment, addComposerDraftThreadContexts, applyPromptReplacement, + attachmentDraftTarget, composerDraftTarget, handleInteractionModeChange, planModeUiEnabled, @@ -6300,11 +6304,60 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shell = readThreadShell(ref); return shell ? [threadContextRecord(ref, shell.title)] : []; }); - if (records.length > 0) addComposerDraftThreadContexts(composerDraftTarget, records); + if (records.length === 0) return; + if (questionAttachmentTarget !== null) { + const canAnswer = + canOperateThread && + !isConnecting && + !isComposerApprovalState && + !activePendingIsResponding && + activePendingProgress?.activeQuestion?.allowCustomAnswer !== false; + const text = `${records + .map((record) => formatInlineContextReference(threadContextReference(record))) + .join(" ")} `; + const snapshot = readComposerSnapshot(); + const cursor = snapshot.expandedCursor; + const leadingSpace = cursor > 0 && !/\s/.test(snapshot.value[cursor - 1] ?? ""); + const inserted = + canAnswer && + applyPromptReplacement( + cursor, + extendReplacementRangeForTrailingSpace(snapshot.value, cursor, text), + leadingSpace ? ` ${text}` : text, + ); + if (!inserted) { + toastManager.add({ + type: "error", + title: "Unable to add thread", + description: "The composer is busy; try again once it is ready.", + }); + return; + } + addComposerDraftThreadContexts(questionAttachmentTarget, records, { + appendReference: false, + }); + if (isComposerCollapsedMobile) expandMobileComposer(); + } else { + addComposerDraftThreadContexts(composerDraftTarget, records); + } }; form.addEventListener(THREAD_CONTEXT_DROP_EVENT, onThreadDrop); return () => form.removeEventListener(THREAD_CONTEXT_DROP_EVENT, onThreadDrop); - }, [addComposerDraftThreadContexts, composerDraftTarget, environmentId]); + }, [ + activePendingIsResponding, + activePendingProgress?.activeQuestion?.allowCustomAnswer, + addComposerDraftThreadContexts, + applyPromptReplacement, + canOperateThread, + composerDraftTarget, + environmentId, + expandMobileComposer, + isComposerApprovalState, + isComposerCollapsedMobile, + isConnecting, + questionAttachmentTarget, + readComposerSnapshot, + ]); const onComposerMentionDragLeaveCapture = (event: React.DragEvent) => { if (!dataTransferHasComposerMention(event.dataTransfer.types)) return; diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index c536e0c377ae755d4bc112b2e6265b7dda7dc13f..d6d14b0c4cacb6c3a6c75f237a4fac1719934d10 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -1,4 +1,7 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import { formatInlineContextReference } from "./lib/composerContextReferences"; +import { threadContextRecord, threadContextReference } from "./lib/composerContextRecords"; import { buildPendingUserInputAnswers, @@ -6,11 +9,39 @@ import { countAnsweredPendingUserInputQuestions, derivePendingUserInputProgress, findFirstUnansweredPendingUserInputQuestionIndex, + projectPendingUserInputAnswer, resolvePendingUserInputAnswer, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, } from "./pendingUserInput"; +describe("projectPendingUserInputAnswer", () => { + const record = threadContextRecord( + { + environmentId: EnvironmentId.make("env-answer"), + threadId: ThreadId.make("reference-thread"), + }, + "Reference work", + ); + + it("sends thread chips as readable links with the actual thread ID", () => { + const answer = `Compare ${formatInlineContextReference(threadContextReference(record))}`; + expect(projectPendingUserInputAnswer(answer, [record])).toBe( + "Compare [Reference work](t3-thread://v1/reference-thread)", + ); + }); + + it("leaves plain answers unchanged when the draft has unreferenced thread records", () => { + expect(projectPendingUserInputAnswer("Plain answer", [record])).toBe("Plain answer"); + }); + + it("preserves option values and exact answer text", () => { + const options = [" first\t", "second"]; + expect(projectPendingUserInputAnswer(options, [record])).toBe(options); + expect(projectPendingUserInputAnswer(" Edited answer\n", [record])).toBe(" Edited answer\n"); + }); +}); + const singleSelectQuestion = { id: "scope", header: "Scope", diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 1f3e4d9ea0480b5873e1ffa8299716966c552ec5..9b5abf195c7a255faa172b40640fa1cf0f4c49b0 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,4 +1,19 @@ -import type { UserInputQuestion } from "@t3tools/contracts"; +import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; +import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; + +/** Question responses carry text, so send thread chips as links with the actual thread IDs. */ +export function projectPendingUserInputAnswer( + answer: unknown, + threadContexts: ReadonlyArray, +) { + if (typeof answer !== "string" || threadContexts.length === 0) return answer; + const recordsById = new Map(threadContexts.map((record) => [record.contextId, record])); + return replaceComposerContextReferences(answer, (reference) => { + const record = reference.kind === "thread" ? recordsById.get(reference.contextId) : undefined; + return record ? formatThreadLink(record.threadId, reference.label) : reference.source; + }); +} export interface PendingUserInputDraftAnswer { selectedOptionValues?: string[]; -- 2.54.0