🌿 fix: Preserve Viewed Branch on Sibling-Tree Churn (#13732)

* 🌿 fix: Preserve Viewed Branch on Sibling-Tree Churn

Regenerating a message could snap the view to an unrelated newest branch.
MultiMessage reset siblingIdx to 0 (newest) on any messagesTree.length
change, but getRegenerateSubmissionMessages slices the flat message array
during a regenerate — the streaming handlers render a tree missing unrelated
sibling branches, then finalHandler restores the full set. That 2→1→2
child-count swing snapped unrelated forks to their newest sibling, so
regenerating the latest response on an older branch jumped to a previously
regenerated branch.

Replace the indiscriminate reset with per-fork branch memory: a 'seen' set
distinguishes a genuinely new sibling (submission/regeneration/edit here —
focus it) from one transiently dropped and restored (preserve the user's
branch). Decision extracted as the pure, unit-tested resolveSiblingSelection.

- client/src/utils/messages.ts: resolveSiblingSelection + tests
- MultiMessage: seen/selectedId refs, structural id-signature effect
- e2e: regenerate-latest-on-older-branch keeps the viewed branch (fails on
  the old reset, passes now)

* 🧪 test: Long-Thread Branch Preservation E2E

Add the user-reported scenario: in a multi-turn thread, regenerate an
earlier response (forking a root branch), switch back to the original, then
regenerate a later response on it — the original branch must stay intact.
Uses labeled prompts so each turn's unique reply is a reliable settle signal.
Verified it fails on the original MultiMessage and passes with the fix.

* 🎨 style: Fix import order in MultiMessage (react before recoil)

* 🌿 fix: Keep Unrelated Branches in Regenerate Optimistic Render

Regenerating a message used a flat `messages.slice(0, targetIndex)` for the
optimistic render, which also drops unrelated sibling branches that merely sit
later in the flat array. Mid-regenerate the thread briefly collapsed to a short
branch (visible flash) and the scroll jumped to the shrunken content and didn't
recover — the same flat-array root cause as the branch-reset bug.

Remove only the regenerated response and its descendants, keeping unrelated
branches. The thread (and scroll) stay put through the regenerate. This array
is render-only — the server regenerates from parentMessageId and createPayload
doesn't include it — so summing by subtree never affects the request.

Verified via a small-viewport scroll trace: old collapses 903->295px / 8->2
renders mid-stream; fixed stays 903px / 8 renders, scroll held at bottom.
Unit test covers the keep-unrelated-branches behavior (fails on the old slice).

* 🌿 fix: Let an Explicit Branch Selection Survive Streaming ID Churn

resolveSiblingSelection focused any unseen sibling id before checking the
committed selection. When an in-flight response's id is replaced mid-stream
(placeholder → server/run id, e.g. useStepHandler re-keys to runId) after the
user switched to a different sibling, that swap looked like a brand-new sibling
and stole focus back to the streaming branch.

Reorder: the committed selection wins while still present; only focus a fresh
sibling when the selection is gone (regenerated away, or its own placeholder id
was just replaced — that's how a regen/edit still takes focus, since the slice
removes the old response). Added unit tests for both churn directions.

* 🌿 fix: Only Focus a New Sibling When the Fork Actually Grew

The previous churn fix (selection-wins-first) was too aggressive: a genuinely
new sibling ADDED while the prior selection is still present — e.g. a follow-up
re-parented as a sibling after a generation-start failure — was no longer
focused, so its reply never rendered (broke message-tree generation-start
recovery e2e).

Gate new-sibling focus on actual growth: resolveSiblingSelection now takes
prevCount and only focuses a never-seen id when ids.length > prevCount. A
same-count placeholder→server id swap (churn) or a restored already-seen
sibling is not growth, so the committed selection still wins there. Covers
follow-up/new-branch focus, churn steal-prevention, and self-churn follow.

message-tree + chat e2e: 17 passed (incl. the recovered generation-start test).

* 🌿 refactor: Drop MultiMessage Branch-Memory in Favor of the Slice Fix

The regenerate-slice fix (keep unrelated branches in the optimistic render) is
the true root cause: with no spurious tree collapse, the original
setSiblingIdx(0)-on-length-change never misfires, so the branch-reset is fixed
without per-fork memory. The earlier MultiMessage rewrite (seen/selectedId/
prevCount + resolveSiblingSelection) was a symptom patch added before the root
cause was found, and its per-instance memory generated two edge-case findings
(placeholder→server id churn; divergence from external siblingIdx writes like
resume restore).

Revert MultiMessage to the simple upstream version and remove
resolveSiblingSelection (+ its tests). The slice fix + the existing branch e2e
(chat.spec: switch-back, regenerate-latest, long-thread) cover the behavior;
all 17 chat + message-tree branch specs pass with this version.

* 🌿 fix: Focus the Regenerated Response When Its Fork Count Is Unchanged

When a parent already has multiple sibling responses and the user switches to a
non-latest one and regenerates it, the optimistic slice drops the target but
keeps the other siblings, so the child count is unchanged. MultiMessage only
resets the (reversed) sibling index on a length change, so the stale index kept
pointing at the kept sibling and the regenerating response stayed hidden until
the server restored the dropped sibling at finalize (count bump → reset).

Explicitly focus the newest sibling (reversed index 0 = the appended response)
of the regenerated fork in createdHandler. Position-based, fires only on the
regenerate action, so it doesn't reintroduce the placeholder→server id churn or
external-write fragility that a per-render selection memory had.

E2E: new during-stream test (slow+counted reply marker) asserting the
regenerating response is visible before finalize; negatively verified
(fails without the focus call, passes with it).

* 🌿 fix: Eliminate Pre-Created Flash by Focusing at the Optimistic Render

The createdHandler focus removed the until-finalize bug, but a brief flash
remained between clicking regenerate and the `created` event: useChatFunctions
renders the optimistic placeholder first, and that render has the same
unchanged-count problem, so the kept sibling showed until createdHandler fired.

Extract the focus into a shared useFocusRegeneratedResponse hook and apply it at
the optimistic render too (useChatFunctions) and on `created`
(useEventHandlers). The placeholder is now focused from the first frame.

E2E: gated pre-created test — holds the SSE stream GET (the chat POST returns a
stream id; the stream is a separate GET) so `created` cannot arrive, leaving
only the optimistic render, then asserts the kept sibling is already gone. This
isolates the optimistic focus (createdHandler cannot mask it); negatively
verified (fails without the optimistic focus call).

* 🧪 test: Extend Store Mock for the Regenerate Focus Hook

useChatFunctions.regenerate.spec.tsx mocks ~/store and recoil partially; the new
useFocusRegeneratedResponse calls store.messagesSiblingIdxFamily via a recoil
`set`, neither of which the mock provided (TypeError on regenerate). Add
messagesSiblingIdxFamily to the store mock and `set` to the useRecoilCallback
mock. Test-only; production code unchanged.
This commit is contained in:
Danny Avila 2026-06-14 09:38:06 -04:00 committed by GitHub
parent db7011d567
commit 9618be6eb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 333 additions and 5 deletions

View file

@ -31,6 +31,7 @@ jest.mock('recoil', () => ({
snapshot: {
getLoadable: () => ({ state: 'hasValue', contents: [] }),
},
set: jest.fn(),
reset: jest.fn(),
}),
}));
@ -48,6 +49,7 @@ jest.mock('~/store', () => ({
isSubmittingFamily: () => 'isSubmitting',
showStopButtonByIndex: () => 'showStopButton',
pendingManualSkillsByConvoId: () => 'pendingManualSkills',
messagesSiblingIdxFamily: () => 'messagesSiblingIdx',
},
useGetEphemeralAgent: () => mockGetEphemeralAgent,
}));

View file

@ -90,4 +90,40 @@ describe('regenerate response targeting', () => {
}).map((message) => message.messageId),
).toEqual(['user-1']);
});
it('keeps unrelated sibling branches when regenerating (no flat-array drop)', () => {
// user-1 has two responses: the original chain (assistant-1 -> ... ) and a
// regenerated sibling (assistant-1b) that sits LATER in the flat array.
const messages = [
userMessage('user-1'),
assistantMessage('assistant-1', 'user-1'),
userMessage('user-2', 'assistant-1'),
assistantMessage('assistant-2', 'user-2'),
assistantMessage('assistant-1b', 'user-1'),
];
// Regenerating the latest response on the original branch must drop only
// that response, NOT the unrelated assistant-1b branch.
expect(
getRegenerateSubmissionMessages({
messages,
targetResponseMessage: messages[3],
initialResponseId: 'assistant-2_',
})
.map((message) => message.messageId)
.sort(),
).toEqual(['assistant-1', 'assistant-1b', 'user-1', 'user-2']);
// Regenerating an earlier response drops its subtree (user-2, assistant-2)
// but still keeps the unrelated assistant-1b branch.
expect(
getRegenerateSubmissionMessages({
messages,
targetResponseMessage: messages[1],
initialResponseId: 'assistant-1_',
})
.map((message) => message.messageId)
.sort(),
).toEqual(['assistant-1b', 'user-1']);
});
});

View file

@ -32,6 +32,7 @@ import {
createDualMessageContent,
getRouteChatProjectId,
} from '~/utils';
import useFocusRegeneratedResponse from '~/hooks/Chat/useFocusRegeneratedResponse';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
import store, { useGetEphemeralAgent } from '~/store';
@ -149,12 +150,32 @@ export function getRegenerateSubmissionMessages({
initialResponseId?: string | null;
}): TMessage[] {
if (targetResponseMessage?.messageId) {
const targetIndex = messages.findIndex(
(message) => message.messageId === targetResponseMessage.messageId,
);
if (targetIndex >= 0) {
return messages.slice(0, targetIndex);
/**
* Remove the response being regenerated and its descendants only NOT a
* flat `slice(0, targetIndex)`, which also drops unrelated sibling branches
* that merely sit later in the array. That collapse made the optimistic
* render briefly lose other branches mid-regenerate (visible flash, and the
* scroll jumping to the shrunken content). Keeping them holds the thread
* and scroll steady. This array is render-only; the server regenerates
* from `parentMessageId`, so removing by subtree never affects the payload.
*/
const removed = new Set<string>([targetResponseMessage.messageId]);
let grew = true;
while (grew) {
grew = false;
for (const message of messages) {
const parentMessageId = message.parentMessageId;
if (
parentMessageId != null &&
removed.has(parentMessageId) &&
!removed.has(message.messageId)
) {
removed.add(message.messageId);
grew = true;
}
}
}
return messages.filter((message) => !removed.has(message.messageId));
}
return messages.filter((msg) => msg.messageId !== initialResponseId);
@ -192,6 +213,7 @@ export default function useChatFunctions({
const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? '');
const setIsSubmitting = useSetRecoilState(store.isSubmittingFamily(index));
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const focusRegeneratedResponse = useFocusRegeneratedResponse();
/**
* Atomically read + reset the per-conversation queue of manually-invoked
@ -554,6 +576,7 @@ export default function useChatFunctions({
if (isRegenerate) {
setMessages([...submissionMessages, initialResponse]);
focusRegeneratedResponse(initialResponse.parentMessageId);
} else {
setMessages([...submissionMessages, currentMsg, initialResponse]);
}

View file

@ -0,0 +1,25 @@
import { useRecoilCallback } from 'recoil';
import store from '~/store';
/**
* Focus a regenerated response's fork on its newest sibling. A regeneration
* appends the new response as the newest child of its parent, but when the
* parent already had multiple siblings the child count is unchanged (the slice
* keeps unrelated branches), so MultiMessage's length-change reset never fires
* and the view would otherwise stay on the kept sibling. Selecting reversed
* index 0 (newest = the appended response) makes the regenerating branch
* visible applied at the optimistic render and reaffirmed on the `created`
* event so there is no flash on the kept sibling in between.
*/
export default function useFocusRegeneratedResponse() {
return useRecoilCallback(
({ set }) =>
(parentMessageId?: string | null) => {
if (parentMessageId == null) {
return;
}
set(store.messagesSiblingIdxFamily(parentMessageId), 0);
},
[],
);
}

View file

@ -38,6 +38,7 @@ import {
queueTitleGeneration,
markTitleGenerationProcessed,
} from '~/data-provider';
import useFocusRegeneratedResponse from '~/hooks/Chat/useFocusRegeneratedResponse';
import { shouldResetSubagentAtomsOnConversationChange } from './cleanup';
import useAttachmentHandler from '~/hooks/SSE/useAttachmentHandler';
import useContentHandler from '~/hooks/SSE/useContentHandler';
@ -488,6 +489,8 @@ export default function useEventHandlers({
[queryClient, setMessages, isAddedRequest, announcePolite, setConversation, setShowStopButton],
);
const focusRegeneratedResponse = useFocusRegeneratedResponse();
const createdHandler = useCallback(
(data: TResData, submission: EventSubmission) => {
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
@ -510,6 +513,7 @@ export default function useEventHandlers({
});
if (isRegenerate) {
setMessages([...messages, initialResponse]);
focusRegeneratedResponse(initialResponse.parentMessageId);
} else {
setMessages([...messages, userMessage, initialResponse]);
}
@ -580,6 +584,7 @@ export default function useEventHandlers({
announcePolite,
setConversation,
applyAgentTemplate,
focusRegeneratedResponse,
],
);