| author | |
| committer | |
| log | 34f845832401cd9b8f9a740e1cefc3218deb63c2 |
| tree | d526cc5a8d53baffe323ec48e8a51fd5acc21500 |
| parent | 979589d6d82d2757188f977dc4869813183ebb49 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 128 insertions(+), 7 deletions(-)
apps/web/src/components/ChatView.tsx+23-1| ... | ... | @@ -214,6 +214,7 @@ import { |
| 214 | 214 | buildPendingUserInputAnswers, |
| 215 | 215 | carryDisplacedCustomAnswerIntoPrompt, |
| 216 | 216 | derivePendingUserInputProgress, |
| 217 | projectPendingUserInputAnswer, | |
| 217 | 218 | setPendingUserInputCustomAnswer, |
| 218 | 219 | togglePendingUserInputOptionSelection, |
| 219 | 220 | type PendingUserInputDraftAnswer, |
| ... | ... | @@ -10239,6 +10240,7 @@ export default function ChatView(props: ChatViewProps) { |
| 10239 | 10240 | string, |
| 10240 | 10241 | import("@t3tools/contracts").UserInputAttachments[string] |
| 10241 | 10242 | >(); |
| 10243 | const providerAnswers = { ...answers }; | |
| 10242 | 10244 | for (const question of pendingInput.questions) { |
| 10243 | 10245 | const target = questionAttachmentDraftId( |
| 10244 | 10246 | environmentId, |
| ... | ... | @@ -10248,6 +10250,10 @@ export default function ChatView(props: ChatViewProps) { |
| 10248 | 10250 | ); |
| 10249 | 10251 | if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; |
| 10250 | 10252 | const draft = useComposerDraftStore.getState().getComposerDraft(target); |
| 10253 | providerAnswers[question.id] = projectPendingUserInputAnswer( | |
| 10254 | answers[question.id], | |
| 10255 | draft?.threadContexts ?? [], | |
| 10256 | ); | |
| 10251 | 10257 | const attachments = draft ? [...draft.images, ...draft.files] : []; |
| 10252 | 10258 | if (attachments.length === 0) continue; |
| 10253 | 10259 | const uploaded = getUploadedAttachments({ environmentId, images: attachments }); |
| ... | ... | @@ -10273,7 +10279,7 @@ export default function ChatView(props: ChatViewProps) { |
| 10273 | 10279 | input: { |
| 10274 | 10280 | threadId: activeThreadId, |
| 10275 | 10281 | requestId, |
| 10276 | answers, | |
| 10282 | answers: providerAnswers, | |
| 10277 | 10283 | ...(attachmentsByQuestionId.size > 0 |
| 10278 | 10284 | ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } |
| 10279 | 10285 | : {}), |
| ... | ... | @@ -10346,6 +10352,20 @@ export default function ChatView(props: ChatViewProps) { |
| 10346 | 10352 | const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); |
| 10347 | 10353 | if (nextPrompt !== currentPrompt) { |
| 10348 | 10354 | 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 | } | |
| 10349 | 10369 | } |
| 10350 | 10370 | setPendingUserInputAnswersByRequestId((existing) => { |
| 10351 | 10371 | const question = |
| ... | ... | @@ -10376,8 +10396,10 @@ export default function ChatView(props: ChatViewProps) { |
| 10376 | 10396 | activePendingProgress?.activeQuestion, |
| 10377 | 10397 | activePendingUserInput, |
| 10378 | 10398 | activePendingRequestKey, |
| 10399 | activeThreadId, | |
| 10379 | 10400 | composerDraftTarget, |
| 10380 | 10401 | composerRef, |
| 10402 | environmentId, | |
| 10381 | 10403 | pendingUserInputAnswersByRequestId, |
| 10382 | 10404 | setComposerDraftPrompt, |
| 10383 | 10405 | ], |
apps/web/src/components/chat/ChatComposer.tsx+58-5| ... | ... | @@ -1897,7 +1897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1897 | 1897 | const composerTerminalContexts = composerDraft.terminalContexts; |
| 1898 | 1898 | const composerPreviewAnnotations = composerDraft.previewAnnotations; |
| 1899 | 1899 | const composerReviewComments = composerDraft.reviewComments; |
| 1900 | const composerThreadContexts = composerDraft.threadContexts; | |
| 1900 | const composerThreadContexts = attachmentDraft.threadContexts; | |
| 1901 | 1901 | const pendingSnapShotAnimations = useSyncExternalStore( |
| 1902 | 1902 | subscribeToPendingSnapShotAnimations, |
| 1903 | 1903 | getPendingSnapShotAnimations, |
| ... | ... | @@ -3163,6 +3163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3163 | 3163 | ...composerTerminalContexts |
| 3164 | 3164 | .filter((c) => wanted.has(terminalContextReference(c).contextId)) |
| 3165 | 3165 | .map(terminalContextRecord), |
| 3166 | ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), | |
| 3166 | 3167 | ...composerReviewComments |
| 3167 | 3168 | .filter((c) => wanted.has(reviewCommentContextId(c.id))) |
| 3168 | 3169 | .map(reviewCommentContextRecord), |
| ... | ... | @@ -3201,6 +3202,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3201 | 3202 | composerPreviewAnnotations, |
| 3202 | 3203 | composerReviewComments, |
| 3203 | 3204 | composerTerminalContexts, |
| 3205 | composerThreadContexts, | |
| 3204 | 3206 | environmentId, |
| 3205 | 3207 | uploadsByImageId, |
| 3206 | 3208 | ], |
| ... | ... | @@ -3394,7 +3396,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3394 | 3396 | case "thread": { |
| 3395 | 3397 | // The agent can only read threads on its own server; a pasted foreign one is dropped. |
| 3396 | 3398 | if (record.environmentId !== environmentId) break; |
| 3397 | addComposerDraftThreadContexts(composerDraftTarget, [record], { | |
| 3399 | addComposerDraftThreadContexts(attachmentDraftTarget, [record], { | |
| 3398 | 3400 | appendReference: false, |
| 3399 | 3401 | }); |
| 3400 | 3402 | rewritten.set(record.contextId, record.contextId); |
| ... | ... | @@ -3427,6 +3429,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3427 | 3429 | addComposerDraftTerminalContexts, |
| 3428 | 3430 | addComposerDraftThreadContexts, |
| 3429 | 3431 | composerContextRecords, |
| 3432 | attachmentDraftTarget, | |
| 3430 | 3433 | composerDraftTarget, |
| 3431 | 3434 | environmentId, |
| 3432 | 3435 | importAttachmentRecord, |
| ... | ... | @@ -4157,7 +4160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4157 | 4160 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, |
| 4158 | 4161 | ); |
| 4159 | 4162 | if (applied) { |
| 4160 | addComposerDraftThreadContexts(composerDraftTarget, [record], { | |
| 4163 | addComposerDraftThreadContexts(attachmentDraftTarget, [record], { | |
| 4161 | 4164 | appendReference: false, |
| 4162 | 4165 | }); |
| 4163 | 4166 | setComposerHighlightedItemId(null); |
| ... | ... | @@ -4169,6 +4172,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4169 | 4172 | addComposerDraftReviewComment, |
| 4170 | 4173 | addComposerDraftThreadContexts, |
| 4171 | 4174 | applyPromptReplacement, |
| 4175 | attachmentDraftTarget, | |
| 4172 | 4176 | composerDraftTarget, |
| 4173 | 4177 | handleInteractionModeChange, |
| 4174 | 4178 | planModeUiEnabled, |
| ... | ... | @@ -6300,11 +6304,60 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6300 | 6304 | const shell = readThreadShell(ref); |
| 6301 | 6305 | return shell ? [threadContextRecord(ref, shell.title)] : []; |
| 6302 | 6306 | }); |
| 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 | } | |
| 6304 | 6343 | }; |
| 6305 | 6344 | form.addEventListener(THREAD_CONTEXT_DROP_EVENT, onThreadDrop); |
| 6306 | 6345 | 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 | ]); | |
| 6308 | 6361 | |
| 6309 | 6362 | const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLFormElement>) => { |
| 6310 | 6363 | if (!dataTransferHasComposerMention(event.dataTransfer.types)) return; |
apps/web/src/pendingUserInput.test.ts+31| ... | ... | @@ -1,4 +1,7 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { formatInlineContextReference } from "./lib/composerContextReferences"; | |
| 4 | import { threadContextRecord, threadContextReference } from "./lib/composerContextRecords"; | |
| 2 | 5 | |
| 3 | 6 | import { |
| 4 | 7 | buildPendingUserInputAnswers, |
| ... | ... | @@ -6,11 +9,39 @@ import { |
| 6 | 9 | countAnsweredPendingUserInputQuestions, |
| 7 | 10 | derivePendingUserInputProgress, |
| 8 | 11 | findFirstUnansweredPendingUserInputQuestionIndex, |
| 12 | projectPendingUserInputAnswer, | |
| 9 | 13 | resolvePendingUserInputAnswer, |
| 10 | 14 | setPendingUserInputCustomAnswer, |
| 11 | 15 | togglePendingUserInputOptionSelection, |
| 12 | 16 | } from "./pendingUserInput"; |
| 13 | 17 | |
| 18 | describe("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 | ||
| 14 | 45 | const singleSelectQuestion = { |
| 15 | 46 | id: "scope", |
| 16 | 47 | header: "Scope", |
apps/web/src/pendingUserInput.ts+16-1| ... | ... | @@ -1,4 +1,19 @@ |
| 1 | import type { UserInputQuestion } from "@t3tools/contracts"; | |
| 1 | import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; | |
| 2 | import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; | |
| 3 | import { formatThreadLink } from "@t3tools/shared/threadLinks"; | |
| 4 | ||
| 5 | /** Question responses carry text, so send thread chips as links with the actual thread IDs. */ | |
| 6 | export 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 | } | |
| 2 | 17 | |
| 3 | 18 | export interface PendingUserInputDraftAnswer { |
| 4 | 19 | selectedOptionValues?: string[]; |