diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 8a07b86b5eece..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, @@ -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,15 @@ export function usePendingThreadRequests(input: { ); return; } - attachmentsByQuestionId.set(question.id, uploaded as UserInputAttachments[string]); + providerAnswers[question.id] = projectPendingUserInputAnswer( + answers[question.id], + draft, + uploaded, + composerDraft, + ); + 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..e520e17ff90ac 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -1,7 +1,10 @@ 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 { + reviewCommentContextReference, + threadContextReference, +} from "./lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { @@ -19,19 +22,53 @@ 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, [], null)).toBe( "Compare [Reference work](t3-thread://v2/env-answer/reference-thread)", ); }); + // 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, null, [], { + ...draft, + reviewComments: [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"]; - expect(projectPendingUserInputAnswer(options, [record])).toBe(options); - expect(projectPendingUserInputAnswer(" Edited answer\n", [record])).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 17998eaf8010c..51cfd8b0407dd 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,5 +1,8 @@ import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + projectComposerContextForProvider, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { buildPendingUserInputAnswers, @@ -9,18 +12,57 @@ 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. +// Question attachments and thread links live separately from the composer's other context. export function projectPendingUserInputAnswer( answer: unknown, - threadContexts: ReadonlyArray, + questionDraft: PendingAnswerDraft | null, + uploaded: ReadonlyArray<{ readonly id: string }>, + composerDraft: PendingAnswerDraft | null, ) { - 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") return answer; + const records = + buildMessageContext({ + 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) => + 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.