Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions apps/web/src/components/chat/usePendingThreadRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ export function usePendingThreadRequests(input: {
if (userInputResponsesInFlight.current.has(responseKey)) return;
const attachmentsByQuestionId = new Map<string, UserInputAttachments[string]>();
const providerAnswers = { ...answers };
const composerDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget);
for (const question of pendingInput.questions) {
const target = questionAttachmentDraftId(
environmentId,
Expand All @@ -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(
Expand All @@ -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);

Expand Down
45 changes: 41 additions & 4 deletions apps/web/src/pendingUserInput.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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",
);
});
});

Expand Down
54 changes: 48 additions & 6 deletions apps/web/src/pendingUserInput.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<ThreadContextRecord>,
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.
Expand Down
Loading