Skip to content

Commit 915ab47

Browse files
authored
feat(chat): implicit preferred-response selection for multi-model chat (#14067)
1 parent d4948a9 commit 915ab47

12 files changed

Lines changed: 460 additions & 132 deletions

web/src/app/app/message/MultiModelPanel.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface MultiModelPanelProps {
4848
errorDetails?: Record<string, any> | null;
4949
/** Whether any model is still streaming — disables preferred selection */
5050
isGenerating?: boolean;
51+
/** Whether a send is in flight, which disables preferred selection */
52+
selectionDisabled?: boolean;
5153
}
5254

5355
/**
@@ -79,10 +81,16 @@ export default function MultiModelPanel({
7981
errorStackTrace,
8082
errorDetails,
8183
isGenerating,
84+
selectionDisabled,
8285
}: MultiModelPanelProps) {
8386
const ModelIcon = getModelIcon(provider, modelName);
8487

85-
const canSelect = !isHidden && !isPreferred && !isGenerating && !readOnly;
88+
const canSelect =
89+
!isHidden &&
90+
!isPreferred &&
91+
!isGenerating &&
92+
!selectionDisabled &&
93+
!readOnly;
8694

8795
const handlePanelClick = useCallback(() => {
8896
if (canSelect) onSelect();

web/src/app/app/message/MultiModelResponseView.tsx

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { RegenerationFactory } from "@/app/app/message/messageComponents/AgentMe
1515
import MultiModelPanel from "@/app/app/message/MultiModelPanel";
1616
import { MultiModelResponse } from "@/app/app/message/interfaces";
1717
import { setPreferredResponse } from "@/app/app/services/lib";
18+
import { applyPreferredResponse } from "@/app/app/message/multiModel";
1819
import { useChatSessionStore } from "@/app/app/stores/useChatSessionStore";
1920
import { cn } from "@opal/utils";
2021

@@ -28,6 +29,9 @@ export interface MultiModelResponseViewProps {
2829
onMessageSelection?: (nodeId: number) => void;
2930
/** Called whenever the set of hidden panel indices changes */
3031
onHiddenPanelsChange?: (hidden: Set<number>) => void;
32+
// Blocks picking while a send is in flight, so an explicit pick can't race
33+
// the send's preference write and strand it off the backend mainline.
34+
selectionDisabled?: boolean;
3135
/**
3236
* Read-only mode for the shared view: every response stays equal-width and
3337
* fully visible (no selection carousel), select/hide interactions are
@@ -76,20 +80,30 @@ export default function MultiModelResponseView({
7680
otherMessagesCanSwitchTo,
7781
onMessageSelection,
7882
onHiddenPanelsChange,
83+
selectionDisabled = false,
7984
readOnly = false,
8085
}: MultiModelResponseViewProps) {
81-
// Initialize preferredIndex from the backend's preferred_response_id. When a
82-
// preferred response is picked the backend also points latest_child at it, so
83-
// this marks the response the flow continued through. A turn the user never
84-
// picked from (e.g. a final multi-model turn) has no preference and stays
85-
// unhighlighted.
86-
const [preferredIndex, setPreferredIndex] = useState<number | null>(() => {
86+
// preferredIndex mirrors the tree's preferred_response_id, which the backend
87+
// pairs with latest_child: it marks the response the flow continued through.
88+
// A turn never picked from (e.g. a final multi-model turn) stays unhighlighted.
89+
const preferredIndexFromTree = useMemo(() => {
8790
if (parentMessage?.preferredResponseId == null) return null;
8891
const match = responses.find(
8992
(r) => r.messageId === parentMessage.preferredResponseId
9093
);
9194
return match?.modelIndex ?? null;
92-
});
95+
}, [parentMessage?.preferredResponseId, responses]);
96+
const [preferredIndex, setPreferredIndex] = useState<number | null>(
97+
preferredIndexFromTree
98+
);
99+
// Re-sync when the preference lands after mount (session hydration, or the
100+
// implicit pick made at send time). Clearing is owned by the deselect flow's
101+
// animation, so only non-null values sync in.
102+
useEffect(() => {
103+
if (preferredIndexFromTree != null) {
104+
setPreferredIndex(preferredIndexFromTree);
105+
}
106+
}, [preferredIndexFromTree]);
93107
const [hiddenPanels, setHiddenPanels] = useState<Set<number>>(new Set());
94108
// Controls animation: false = panels at start position, true = panels at peek position
95109
const [selectionEntered, setSelectionEntered] = useState(
@@ -211,7 +225,7 @@ export default function MultiModelResponseView({
211225

212226
const handleSelectPreferred = useCallback(
213227
(modelIndex: number) => {
214-
if (isGenerating) return;
228+
if (isGenerating || selectionDisabled) return;
215229

216230
// Cancel any pending deselect animation so it doesn't overwrite this selection
217231
if (deselectTimeoutRef.current !== null) {
@@ -250,10 +264,6 @@ export default function MultiModelResponseView({
250264
const response = responses.find((r) => r.modelIndex === modelIndex);
251265
if (!response) return;
252266

253-
// Persist preferred response + sync `latestChildNodeId`. Backend's
254-
// `set_preferred_response` updates `latest_child_message_id`; if the
255-
// frontend chain walk disagrees, the next follow-up fails with
256-
// "not on the latest mainline".
257267
if (parentMessage?.messageId && response.messageId && currentSessionId) {
258268
setPreferredResponse(parentMessage.messageId, response.messageId).catch(
259269
(err) => console.error("Failed to persist preferred response:", err)
@@ -262,22 +272,16 @@ export default function MultiModelResponseView({
262272
const tree = useChatSessionStore
263273
.getState()
264274
.sessions.get(currentSessionId)?.messageTree;
265-
if (tree) {
266-
const userMsg = tree.get(parentMessage.nodeId);
267-
if (userMsg) {
268-
const updated = new Map(tree);
269-
updated.set(parentMessage.nodeId, {
270-
...userMsg,
271-
preferredResponseId: response.messageId,
272-
latestChildNodeId: response.nodeId,
273-
});
274-
updateSessionMessageTree(currentSessionId, updated);
275-
}
275+
const updated =
276+
tree && applyPreferredResponse(tree, parentMessage.nodeId, response);
277+
if (updated) {
278+
updateSessionMessageTree(currentSessionId, updated);
276279
}
277280
}
278281
},
279282
[
280283
isGenerating,
284+
selectionDisabled,
281285
responses,
282286
preferredIndex,
283287
parentMessage,
@@ -351,21 +355,16 @@ export default function MultiModelResponseView({
351355
restoreScroll();
352356
}
353357

354-
// Clear preferredResponseId in the local tree so input bar re-gates
358+
// Clear preferredResponseId in the local tree so the next send assumes
359+
// a preference again
355360
if (parentMessage && currentSessionId) {
356361
const tree = useChatSessionStore
357362
.getState()
358363
.sessions.get(currentSessionId)?.messageTree;
359-
if (tree) {
360-
const userMsg = tree.get(parentMessage.nodeId);
361-
if (userMsg) {
362-
const updated = new Map(tree);
363-
updated.set(parentMessage.nodeId, {
364-
...userMsg,
365-
preferredResponseId: undefined,
366-
});
367-
updateSessionMessageTree(currentSessionId, updated);
368-
}
364+
const updated =
365+
tree && applyPreferredResponse(tree, parentMessage.nodeId, null);
366+
if (updated) {
367+
updateSessionMessageTree(currentSessionId, updated);
369368
}
370369
}
371370
}, 450);
@@ -461,11 +460,13 @@ export default function MultiModelResponseView({
461460
errorStackTrace: response.errorStackTrace,
462461
errorDetails: response.errorDetails,
463462
isGenerating,
463+
selectionDisabled,
464464
}),
465465
[
466466
preferredIndex,
467467
hiddenPanels,
468468
readOnly,
469+
selectionDisabled,
469470
handleSelectPreferred,
470471
handleDeselectPreferred,
471472
toggleVisibility,
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { Message } from "@/app/app/interfaces";
2+
import {
3+
chooseImplicitPreferred,
4+
getMultiModelChildren,
5+
getUnresolvedMultiModelTurn,
6+
} from "@/app/app/message/multiModel";
7+
import { getLatestMessageChain } from "@/app/app/services/messageTree";
8+
9+
let nextNodeId = 1;
10+
11+
function buildMessage(overrides: Partial<Message>): Message {
12+
return {
13+
nodeId: nextNodeId++,
14+
message: "",
15+
type: "assistant",
16+
files: [],
17+
toolCall: null,
18+
parentNodeId: null,
19+
packets: [],
20+
...overrides,
21+
};
22+
}
23+
24+
// Builds a multi-model turn: one response per entry of `models`, in panel
25+
// layout order (first model last).
26+
function buildTurn(
27+
tree: Map<number, Message>,
28+
models: (string | { model: string; type: "error" })[],
29+
options: { parent?: Message; preferredModel?: string } = {}
30+
): { userMessage: Message; responses: Message[] } {
31+
const userMessage = buildMessage({
32+
type: "user",
33+
parentNodeId: options.parent?.nodeId ?? null,
34+
messageId: nextNodeId * 100,
35+
});
36+
const responses = models.map((entry) => {
37+
const model = typeof entry === "string" ? entry : entry.model;
38+
return buildMessage({
39+
type: typeof entry === "string" ? "assistant" : "error",
40+
parentNodeId: userMessage.nodeId,
41+
messageId: nextNodeId * 100,
42+
overridden_model: model,
43+
modelDisplayName: model,
44+
});
45+
});
46+
userMessage.childrenNodeIds = responses.map((r) => r.nodeId);
47+
const preferred = responses.find(
48+
(r) => r.overridden_model === options.preferredModel
49+
);
50+
userMessage.preferredResponseId = preferred?.messageId ?? null;
51+
userMessage.latestChildNodeId =
52+
preferred?.nodeId ?? responses.at(-1)?.nodeId ?? null;
53+
if (options.parent) {
54+
options.parent.childrenNodeIds = [
55+
...(options.parent.childrenNodeIds ?? []),
56+
userMessage.nodeId,
57+
];
58+
options.parent.latestChildNodeId = userMessage.nodeId;
59+
}
60+
tree.set(userMessage.nodeId, userMessage);
61+
responses.forEach((r) => tree.set(r.nodeId, r));
62+
return { userMessage, responses };
63+
}
64+
65+
// The production chain walk, so tests exercise the same traversal onSubmit
66+
// feeds the helpers.
67+
const chainOf = getLatestMessageChain;
68+
69+
beforeEach(() => {
70+
nextNodeId = 1;
71+
});
72+
73+
describe("getMultiModelChildren", () => {
74+
it("returns model-tagged assistant and error children in order", () => {
75+
const tree = new Map<number, Message>();
76+
const { userMessage, responses } = buildTurn(tree, [
77+
"gpt-5",
78+
{ model: "claude-opus-5", type: "error" },
79+
]);
80+
expect(getMultiModelChildren(userMessage, tree)).toEqual(responses);
81+
});
82+
83+
it("ignores turns without two model-tagged children", () => {
84+
const tree = new Map<number, Message>();
85+
const userMessage = buildMessage({ type: "user" });
86+
const regenerated = [
87+
buildMessage({ type: "assistant", parentNodeId: userMessage.nodeId }),
88+
buildMessage({ type: "assistant", parentNodeId: userMessage.nodeId }),
89+
];
90+
userMessage.childrenNodeIds = regenerated.map((r) => r.nodeId);
91+
tree.set(userMessage.nodeId, userMessage);
92+
regenerated.forEach((r) => tree.set(r.nodeId, r));
93+
expect(getMultiModelChildren(userMessage, tree)).toBeNull();
94+
});
95+
});
96+
97+
describe("getUnresolvedMultiModelTurn", () => {
98+
it("finds the last turn when no preferred response is set", () => {
99+
const tree = new Map<number, Message>();
100+
const { userMessage, responses } = buildTurn(tree, [
101+
"gpt-5",
102+
"claude-opus-5",
103+
]);
104+
const turn = getUnresolvedMultiModelTurn(chainOf(tree), tree);
105+
expect(turn?.userMessage).toBe(userMessage);
106+
expect(turn?.responses).toEqual(responses);
107+
});
108+
109+
it("returns null once a preferred response exists", () => {
110+
const tree = new Map<number, Message>();
111+
buildTurn(tree, ["gpt-5", "claude-opus-5"], {
112+
preferredModel: "gpt-5",
113+
});
114+
expect(getUnresolvedMultiModelTurn(chainOf(tree), tree)).toBeNull();
115+
});
116+
117+
it("returns null for single-model turns", () => {
118+
const tree = new Map<number, Message>();
119+
buildTurn(tree, ["gpt-5"]);
120+
expect(getUnresolvedMultiModelTurn(chainOf(tree), tree)).toBeNull();
121+
});
122+
});
123+
124+
describe("chooseImplicitPreferred", () => {
125+
it("keeps the model preferred in the previous turn", () => {
126+
const tree = new Map<number, Message>();
127+
const first = buildTurn(tree, ["gemini-3", "gpt-5"], {
128+
preferredModel: "gemini-3",
129+
});
130+
const second = buildTurn(tree, ["gemini-3", "gpt-5"], {
131+
parent: first.responses[0],
132+
});
133+
const turn = getUnresolvedMultiModelTurn(chainOf(tree), tree)!;
134+
expect(chooseImplicitPreferred(chainOf(tree), tree, turn)).toBe(
135+
second.responses[0]
136+
);
137+
});
138+
139+
it("falls back to the first model (last child) without a prior preference", () => {
140+
const tree = new Map<number, Message>();
141+
const { responses } = buildTurn(tree, ["gemini-3", "gpt-5"]);
142+
const turn = getUnresolvedMultiModelTurn(chainOf(tree), tree)!;
143+
expect(chooseImplicitPreferred(chainOf(tree), tree, turn)).toBe(
144+
responses[1]
145+
);
146+
});
147+
148+
it("falls back to the first model when the prior model did not answer this turn", () => {
149+
const tree = new Map<number, Message>();
150+
const first = buildTurn(tree, ["gemini-3", "gpt-5"], {
151+
preferredModel: "gemini-3",
152+
});
153+
const second = buildTurn(tree, ["claude-opus-5", "gpt-5"], {
154+
parent: first.responses[0],
155+
});
156+
const turn = getUnresolvedMultiModelTurn(chainOf(tree), tree)!;
157+
expect(chooseImplicitPreferred(chainOf(tree), tree, turn)).toBe(
158+
second.responses[1]
159+
);
160+
});
161+
162+
it("never assumes an errored response", () => {
163+
const tree = new Map<number, Message>();
164+
const { responses } = buildTurn(tree, [
165+
"gemini-3",
166+
{ model: "gpt-5", type: "error" },
167+
]);
168+
const turn = getUnresolvedMultiModelTurn(chainOf(tree), tree)!;
169+
expect(chooseImplicitPreferred(chainOf(tree), tree, turn)).toBe(
170+
responses[0]
171+
);
172+
});
173+
174+
it("returns null when every response errored", () => {
175+
const tree = new Map<number, Message>();
176+
buildTurn(tree, [
177+
{ model: "gemini-3", type: "error" },
178+
{ model: "gpt-5", type: "error" },
179+
]);
180+
const turn = getUnresolvedMultiModelTurn(chainOf(tree), tree)!;
181+
expect(chooseImplicitPreferred(chainOf(tree), tree, turn)).toBeNull();
182+
});
183+
});

0 commit comments

Comments
 (0)