fix(client): let an output diff supersede a create preview, and guard two edges

A diff in the output now supersedes everything, including a create's authored
content. `create_file` overwriting an existing file returns a summary plus a
unified diff, and preferring the authored content there rendered that diff
twice: once through DiffView and again verbatim as the raw output section. This
restores the precedence the string-preview version had.

Give image tiles distinct React keys. Syndicated images reuse one thumbnail
across results that link to different pages, and grouped searches merge
attachments, so the source alone collided.

Require the composer to still hold the submitted text before restoring the
stashed ask message. The textarea stays editable while the resume is in flight,
so anything typed since is newer than both the stash and the answer.
This commit is contained in:
Marco Beretta 2026-08-29 01:14:43 +02:00
parent 885ab6f415
commit c670403b81
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 27 additions and 19 deletions

View file

@ -135,14 +135,19 @@ export default function FileAuthoringCall({
}
return !isCreate && editArgs.length > 0 ? buildEditPreviewDiff(editArgs) : null;
}, [outputIsDiff, output, isCreate, editArgs]);
/** Plain text for the non-diff pane, and the copy payload either way. */
/** Plain text for the non-diff pane, and the copy payload either way. A diff
* in the output supersedes everything, including a create's authored
* content: `create_file` overwriting an existing file returns a summary plus
* a unified diff, and preferring the authored content there rendered that
* diff twice, once through `DiffView` and again verbatim as the raw output
* section. */
const preview = useMemo(() => {
if (isCreate) {
return authoredContent || output;
}
if (outputIsDiff) {
return output;
}
if (isCreate) {
return authoredContent || output;
}
return parsedDiff ? diffPreviewText(parsedDiff) : output;
}, [isCreate, authoredContent, outputIsDiff, output, parsedDiff]);
const showOutputSection = !!output && preview !== output;

View file

@ -119,7 +119,10 @@ function ImageStrip({ images, label }: { images: ImageResult[]; label: string })
? `${image.thumbnailWidth} / ${image.thumbnailHeight}`
: '1 / 1';
return (
<li key={image.thumbnailUrl || image.imageUrl || i} className="shrink-0">
/** Syndicated images reuse one thumbnail across results that link to
* different pages, and grouped searches merge attachments, so the
* source alone collides. The target plus the index is unique. */
<li key={`${imageSource(image) ?? ''}|${href ?? ''}|${i}`} className="shrink-0">
<a
href={href}
target="_blank"

View file

@ -370,22 +370,22 @@ export default function useAskAnswerMode(conversationId?: string | null) {
});
/** A successful answer never goes through `collapse`, so the released
* text has to be handed back here too or the user's ordinary message
* dies with the question.
*
* Restored whenever one is stashed, independently of the reset
* condition below: only the free-text path sets
* dies with the question. Restored on EVERY submission path, not
* just the free-text one: only that path sets
* `consumedComposerText`, so gating on it discarded the stash on an
* option click or Skip. The stash also outranks whatever the
* composer currently holds, which is the answer draft this
* submission just consumed. */
* option click or Skip.
*
* Both writes require the composer to still hold what was
* submitted. The textarea stays editable while the resume is in
* flight, and anything typed since is newer than both the stash and
* the answer, so it wins. */
const released = releasedComposerTextRef.current[submittedActionId];
if (released) {
currentScope.formContext?.setValue('text', released);
} else if (
(consumedComposerText || (wasActive && saveDrafts)) &&
currentScope.formContext?.getValues('text') === submittedComposerText
) {
currentScope.formContext.reset();
if (currentScope.formContext?.getValues('text') === submittedComposerText) {
if (released) {
currentScope.formContext.setValue('text', released);
} else if (consumedComposerText || (wasActive && saveDrafts)) {
currentScope.formContext.reset();
}
}
setReleasedComposerText((current) => {
if (current[submittedActionId] == null) {