From 40dda36ac2043b636faa924ab5455c60e724adf9 Mon Sep 17 00:00:00 2001 From: Umais-Adeed Date: Sat, 31 Oct 2026 13:25:00 +0500 Subject: [PATCH 1/3] fix(web): review comments in question answers reach the agent with their text Answers to pending questions only expanded thread chips, so a review comment chip reached the agent as a bare t3-context link. Expand every chip in an answer the same way normal messages are expanded. Fixes #28022 Co-Authored-By: Claude Opus 5.5 --- .../chat/usePendingThreadRequests.ts | 27 +++++++++++++---- apps/web/src/pendingUserInput.test.ts | 29 +++++++++++++++++- apps/web/src/pendingUserInput.ts | 30 ++++++++++++++----- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 8a07b86b5eece..21c8e2727546d 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -25,6 +25,7 @@ import { useShallow } from "zustand/react/shallow"; import { type DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; +import { buildMessageContext } from "../../lib/composerContextRecords"; import { buildPendingUserInputAnswers, setPendingUserInputCustomAnswer, @@ -315,12 +316,7 @@ export function usePendingThreadRequests(input: { ); 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 }); if (!uploaded) { setThreadError( @@ -329,7 +325,26 @@ export function usePendingThreadRequests(input: { ); return; } - attachmentsByQuestionId.set(question.id, uploaded as UserInputAttachments[string]); + const context = draft + ? buildMessageContext({ + terminalContexts: draft.terminalContexts, + reviewComments: draft.reviewComments, + previewAnnotations: draft.previewAnnotations, + threadContexts: draft.threadContexts, + environmentContexts: draft.environmentContexts, + attachments: attachments.flatMap((attachment, index) => { + const upload = uploaded[index]; + return upload ? [{ attachment, attachmentId: upload.id }] : []; + }), + }) + : undefined; + providerAnswers[question.id] = projectPendingUserInputAnswer( + answers[question.id], + context?.records ?? [], + ); + if (uploaded.length > 0) { + attachmentsByQuestionId.set(question.id, uploaded as UserInputAttachments[string]); + } } userInputResponsesInFlight.current.add(responseKey); diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 1178c91043052..722d52772ffea 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -1,7 +1,11 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { formatInlineContextReference } from "./lib/composerContextReferences"; -import { threadContextReference } from "./lib/composerContextRecords"; +import { + reviewCommentContextRecord, + reviewCommentContextReference, + threadContextReference, +} from "./lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { @@ -27,6 +31,29 @@ describe("projectPendingUserInputAnswer", () => { ); }); + // regression: #28022 review comment chips in answers reached the agent as bare links + it("sends review comment chips with their comment text and diff", () => { + const comment = { + id: "comment-1", + sectionId: "section-1", + sectionTitle: "Working tree", + filePath: "docs/file.md", + startIndex: 16, + endIndex: 16, + rangeLabel: "+16", + text: "Rename this heading", + diff: "+## Old heading", + }; + const answer = `Yes, and ${formatInlineContextReference(reviewCommentContextReference(comment))}`; + const projected = projectPendingUserInputAnswer(answer, [ + record, + reviewCommentContextRecord(comment), + ]); + expect(projected).not.toContain("t3-context://"); + expect(projected).toContain("Rename this heading"); + expect(projected).toContain("+## Old heading"); + }); + // regression: #18789 answers to pending questions lost exact text and option values it("preserves option values and exact answer text", () => { const options = [" first\t", "second"]; diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 17998eaf8010c..5c141aba35868 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,5 +1,12 @@ -import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import type { + ComposerContextRecord, + ThreadContextRecord, + UserInputQuestion, +} from "@t3tools/contracts"; +import { + projectComposerContextForProvider, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { buildPendingUserInputAnswers, @@ -11,16 +18,25 @@ import { import { collectInlineContextIds } from "./lib/composerContextReferences"; +// Answers bypass the server's message-context expansion, so chips are expanded here. export function projectPendingUserInputAnswer( answer: unknown, - threadContexts: ReadonlyArray, + records: 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; + if (typeof answer !== "string" || records.length === 0) return answer; + const threadsById = new Map( + records.flatMap((record) => + record.kind === "thread" && "threadId" in record ? [[record.contextId, record] as const] : [], + ), + ); + const linked = replaceComposerContextReferences(answer, (reference) => { + const record = threadsById.get(reference.contextId); return record ? formatThreadLink(record, reference.label) : reference.source; }); + return projectComposerContextForProvider({ + text: linked, + records: records.filter((record) => record.kind !== "thread"), + }); } // Question drafts retain deleted chip records for undo; only transfer referenced records. From cf21a7db5328733394f739cdd9054ed15842335a Mon Sep 17 00:00:00 2001 From: Umais-Adeed Date: Sat, 31 Oct 2026 13:43:22 +0500 Subject: [PATCH 2/3] test(web): cover the draft-to-answer handoff for question chips Move building an answer's context records into projectPendingUserInputAnswer so its unit test covers the draft fields the submit path hands over, not only the projection. Co-Authored-By: Claude Opus 5.5 --- .../chat/usePendingThreadRequests.ts | 17 ++-------- apps/web/src/pendingUserInput.test.ts | 25 +++++++++----- apps/web/src/pendingUserInput.ts | 33 +++++++++++++++---- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 21c8e2727546d..4296d6801c3b4 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -25,7 +25,6 @@ import { useShallow } from "zustand/react/shallow"; import { type DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; -import { buildMessageContext } from "../../lib/composerContextRecords"; import { buildPendingUserInputAnswers, setPendingUserInputCustomAnswer, @@ -325,22 +324,10 @@ export function usePendingThreadRequests(input: { ); return; } - const context = draft - ? buildMessageContext({ - terminalContexts: draft.terminalContexts, - reviewComments: draft.reviewComments, - previewAnnotations: draft.previewAnnotations, - threadContexts: draft.threadContexts, - environmentContexts: draft.environmentContexts, - attachments: attachments.flatMap((attachment, index) => { - const upload = uploaded[index]; - return upload ? [{ attachment, attachmentId: upload.id }] : []; - }), - }) - : undefined; providerAnswers[question.id] = projectPendingUserInputAnswer( answers[question.id], - context?.records ?? [], + draft, + uploaded, ); if (uploaded.length > 0) { attachmentsByQuestionId.set(question.id, uploaded as UserInputAttachments[string]); diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 722d52772ffea..9826ea443077e 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -2,7 +2,6 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { formatInlineContextReference } from "./lib/composerContextReferences"; import { - reviewCommentContextRecord, reviewCommentContextReference, threadContextReference, } from "./lib/composerContextRecords"; @@ -23,10 +22,19 @@ describe("projectPendingUserInputAnswer", () => { }, "Reference work", ); + const draft = { + images: [], + files: [], + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + threadContexts: [record], + environmentContexts: [], + }; it("sends thread chips as readable links with the actual thread ID", () => { const answer = `Compare ${formatInlineContextReference(threadContextReference(record))}`; - expect(projectPendingUserInputAnswer(answer, [record])).toBe( + expect(projectPendingUserInputAnswer(answer, draft, [])).toBe( "Compare [Reference work](t3-thread://v2/env-answer/reference-thread)", ); }); @@ -45,10 +53,11 @@ describe("projectPendingUserInputAnswer", () => { diff: "+## Old heading", }; const answer = `Yes, and ${formatInlineContextReference(reviewCommentContextReference(comment))}`; - const projected = projectPendingUserInputAnswer(answer, [ - record, - reviewCommentContextRecord(comment), - ]); + const projected = projectPendingUserInputAnswer( + answer, + { ...draft, reviewComments: [comment] }, + [], + ); expect(projected).not.toContain("t3-context://"); expect(projected).toContain("Rename this heading"); expect(projected).toContain("+## Old heading"); @@ -57,8 +66,8 @@ describe("projectPendingUserInputAnswer", () => { // regression: #18789 answers to pending questions lost exact text and option values 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"); + expect(projectPendingUserInputAnswer(options, draft, [])).toBe(options); + expect(projectPendingUserInputAnswer(" Edited answer\n", draft, [])).toBe(" Edited answer\n"); }); }); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 5c141aba35868..018eac2574a08 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,8 +1,4 @@ -import type { - ComposerContextRecord, - ThreadContextRecord, - UserInputQuestion, -} from "@t3tools/contracts"; +import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; import { projectComposerContextForProvider, replaceComposerContextReferences, @@ -16,14 +12,37 @@ import { type PendingUserInputDraftAnswer, } from "@t3tools/client-runtime/state/pending-user-input"; +import type { ComposerThreadDraftState } from "./composerDraftStore"; +import { buildMessageContext } from "./lib/composerContextRecords"; import { collectInlineContextIds } from "./lib/composerContextReferences"; +type PendingAnswerDraft = Pick< + ComposerThreadDraftState, + | "images" + | "files" + | "terminalContexts" + | "reviewComments" + | "previewAnnotations" + | "threadContexts" + | "environmentContexts" +>; + // Answers bypass the server's message-context expansion, so chips are expanded here. +// `uploaded` holds the server attachments for the draft's images then files, in order. export function projectPendingUserInputAnswer( answer: unknown, - records: ReadonlyArray, + draft: PendingAnswerDraft | null, + uploaded: ReadonlyArray<{ readonly id: string }>, ) { - if (typeof answer !== "string" || records.length === 0) return answer; + if (typeof answer !== "string" || !draft) return answer; + const records = + buildMessageContext({ + ...draft, + attachments: [...draft.images, ...draft.files].flatMap((attachment, index) => { + const upload = uploaded[index]; + return upload ? [{ attachment, attachmentId: upload.id }] : []; + }), + })?.records ?? []; const threadsById = new Map( records.flatMap((record) => record.kind === "thread" && "threadId" in record ? [[record.contextId, record] as const] : [], From d41712cb141734ea128ae0ee7588d41c68a9e9a1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 22:39:31 +0000 Subject: [PATCH 3/3] fix(web): include thread draft context in question answers --- .../chat/usePendingThreadRequests.ts | 2 ++ apps/web/src/pendingUserInput.test.ts | 17 +++++++------- apps/web/src/pendingUserInput.ts | 23 ++++++++++++------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 4296d6801c3b4..1ac81196c8b76 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -306,6 +306,7 @@ export function usePendingThreadRequests(input: { if (userInputResponsesInFlight.current.has(responseKey)) return; const attachmentsByQuestionId = new Map(); const providerAnswers = { ...answers }; + const composerDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); for (const question of pendingInput.questions) { const target = questionAttachmentDraftId( environmentId, @@ -328,6 +329,7 @@ export function usePendingThreadRequests(input: { answers[question.id], draft, uploaded, + composerDraft, ); if (uploaded.length > 0) { attachmentsByQuestionId.set(question.id, uploaded as UserInputAttachments[string]); diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 9826ea443077e..e520e17ff90ac 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -34,7 +34,7 @@ describe("projectPendingUserInputAnswer", () => { it("sends thread chips as readable links with the actual thread ID", () => { const answer = `Compare ${formatInlineContextReference(threadContextReference(record))}`; - expect(projectPendingUserInputAnswer(answer, draft, [])).toBe( + expect(projectPendingUserInputAnswer(answer, draft, [], null)).toBe( "Compare [Reference work](t3-thread://v2/env-answer/reference-thread)", ); }); @@ -53,11 +53,10 @@ describe("projectPendingUserInputAnswer", () => { diff: "+## Old heading", }; const answer = `Yes, and ${formatInlineContextReference(reviewCommentContextReference(comment))}`; - const projected = projectPendingUserInputAnswer( - answer, - { ...draft, reviewComments: [comment] }, - [], - ); + const projected = projectPendingUserInputAnswer(answer, null, [], { + ...draft, + reviewComments: [comment], + }); expect(projected).not.toContain("t3-context://"); expect(projected).toContain("Rename this heading"); expect(projected).toContain("+## Old heading"); @@ -66,8 +65,10 @@ describe("projectPendingUserInputAnswer", () => { // regression: #18789 answers to pending questions lost exact text and option values it("preserves option values and exact answer text", () => { const options = [" first\t", "second"]; - expect(projectPendingUserInputAnswer(options, draft, [])).toBe(options); - expect(projectPendingUserInputAnswer(" Edited answer\n", draft, [])).toBe(" Edited answer\n"); + expect(projectPendingUserInputAnswer(options, draft, [], null)).toBe(options); + expect(projectPendingUserInputAnswer(" Edited answer\n", draft, [], null)).toBe( + " Edited answer\n", + ); }); }); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 018eac2574a08..51cfd8b0407dd 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -28,20 +28,27 @@ type PendingAnswerDraft = Pick< >; // Answers bypass the server's message-context expansion, so chips are expanded here. -// `uploaded` holds the server attachments for the draft's images then files, in order. +// Question attachments and thread links live separately from the composer's other context. export function projectPendingUserInputAnswer( answer: unknown, - draft: PendingAnswerDraft | null, + questionDraft: PendingAnswerDraft | null, uploaded: ReadonlyArray<{ readonly id: string }>, + composerDraft: PendingAnswerDraft | null, ) { - if (typeof answer !== "string" || !draft) return answer; + if (typeof answer !== "string") return answer; const records = buildMessageContext({ - ...draft, - attachments: [...draft.images, ...draft.files].flatMap((attachment, index) => { - const upload = uploaded[index]; - return upload ? [{ attachment, attachmentId: upload.id }] : []; - }), + terminalContexts: composerDraft?.terminalContexts ?? [], + reviewComments: composerDraft?.reviewComments ?? [], + previewAnnotations: composerDraft?.previewAnnotations ?? [], + environmentContexts: composerDraft?.environmentContexts ?? [], + threadContexts: questionDraft?.threadContexts ?? [], + attachments: [...(questionDraft?.images ?? []), ...(questionDraft?.files ?? [])].flatMap( + (attachment, index) => { + const upload = uploaded[index]; + return upload ? [{ attachment, attachmentId: upload.id }] : []; + }, + ), })?.records ?? []; const threadsById = new Map( records.flatMap((record) =>