Compare commits

..

2 Commits

Author SHA1 Message Date
Nik
7baad34266 fix(chat): model selection + multi-model follow-up correctness (ENG-4007)
Three related frontend/backend-state sync bugs in the multi-model chat
path, all manifesting inside an existing chat session:

1. Manual model override was silently ignored inside an existing session
   (ENG-4007). `useLlmManager.currentLlm` resolved session
   `current_alternate_model` before `userHasManuallyOverriddenLLM`, so
   the selector pill never updated when the user picked a new model
   mid-conversation. Reordered the precedence so manual override wins,
   and added a session-switch reset effect that clears the override when
   the user navigates to a different existing session (but preserves it
   when transitioning to the new-chat screen, matching existing intent).

2. "The new message sent is not on the latest mainline of messages"
   validation error on every multi-model follow-up after selecting a
   preferred response. The backend's `set_preferred_response`
   (backend/onyx/db/chat.py:722) updates
   `user_msg.latest_child_message_id = preferred_assistant_message_id`,
   but the frontend's `handleSelectPreferred` only set
   `preferredResponseId` locally — it left `latestChildNodeId` at the
   original last-sibling from the initial multi-model upsert. The
   frontend's chain walk (`getLastSuccessfulMessageId`) then returned a
   different sibling's id than the backend's `chat_history` walk, and
   the backend rejected the follow-up. Fix: keep the two in sync in the
   local tree update.

3. Model-selector popover flashed in the upper-left corner of the
   viewport after swapping the model. Pre-existing latent bug exposed
   once the ENG-4007 fix made swaps actually re-render the pill: the
   single-model `OpenButton` was keyed on `modelKey(provider, modelName)`,
   which changed with the model, unmounting the old pill and leaving
   `anchorRef.current` pointing at a detached DOM node. Radix Popover's
   close animation then positioned against the detached anchor and fell
   back to (0,0). Fix: stable key (`"single-model-pill"`) so React
   reuses the same `OpenButton` DOM element across model changes.
2026-04-10 19:26:23 -07:00
Raunak Bhagat
9af9148ca7 fix: italicize proper nouns in modal titles (#10073) 2026-04-10 22:36:29 +00:00
20 changed files with 100 additions and 37 deletions

View File

@@ -1,6 +1,7 @@
import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { CloudEmbeddingModel } from "../../../../components/embedding/interfaces";
import { markdown } from "@opal/utils";
import { SvgCheck } from "@opal/icons";
export interface AlreadyPickedModalProps {
@@ -17,7 +18,7 @@ export default function AlreadyPickedModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgCheck}
title={`${model.model_name} already chosen`}
title={markdown(`*${model.model_name}* already chosen`)}
description="You can select a different one if you want!"
onClose={onClose}
/>

View File

@@ -12,6 +12,7 @@ import {
getFormattedProviderName,
} from "@/components/embedding/interfaces";
import { EMBEDDING_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { markdown } from "@opal/utils";
import { mutate } from "swr";
import { SWR_KEYS } from "@/lib/swr-keys";
import { testEmbedding } from "@/app/admin/embeddings/pages/utils";
@@ -172,9 +173,11 @@ export default function ChangeCredentialsModal({
<Modal.Content>
<Modal.Header
icon={SvgSettings}
title={`Modify your ${getFormattedProviderName(
provider.provider_type
)} ${isProxy ? "Configuration" : "key"}`}
title={markdown(
`Modify your *${getFormattedProviderName(
provider.provider_type
)}* ${isProxy ? "configuration" : "key"}`
)}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -7,6 +7,7 @@ import {
getFormattedProviderName,
} from "../../../../components/embedding/interfaces";
import { SvgTrash } from "@opal/icons";
import { markdown } from "@opal/utils";
export interface DeleteCredentialsModalProps {
modelProvider: CloudEmbeddingProvider;
@@ -24,9 +25,11 @@ export default function DeleteCredentialsModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgTrash}
title={`Delete ${getFormattedProviderName(
modelProvider.provider_type
)} Credentials?`}
title={markdown(
`Delete *${getFormattedProviderName(
modelProvider.provider_type
)}* credentials?`
)}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -12,6 +12,7 @@ import {
} from "@/components/embedding/interfaces";
import { EMBEDDING_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import Modal from "@/refresh-components/Modal";
import { markdown } from "@opal/utils";
import { SvgSettings } from "@opal/icons";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
export interface ProviderCreationModalProps {
@@ -185,9 +186,11 @@ export default function ProviderCreationModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgSettings}
title={`Configure ${getFormattedProviderName(
selectedProvider.provider_type
)}`}
title={markdown(
`Configure *${getFormattedProviderName(
selectedProvider.provider_type
)}*`
)}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -2,6 +2,7 @@ import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { CloudEmbeddingModel } from "@/components/embedding/interfaces";
import { markdown } from "@opal/utils";
import { SvgServer } from "@opal/icons";
export interface SelectModelModalProps {
@@ -20,7 +21,7 @@ export default function SelectModelModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgServer}
title={`Select ${model.model_name}`}
title={markdown(`Select *${model.model_name}*`)}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -1,6 +1,7 @@
"use client";
import { toast } from "@/hooks/useToast";
import { markdown } from "@opal/utils";
import EmbeddingModelSelection from "../EmbeddingModelSelectionForm";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
@@ -538,7 +539,9 @@ export default function EmbeddingForm() {
<Modal.Content>
<Modal.Header
icon={SvgAlertTriangle}
title={`Are you sure you want to select ${selectedProvider.model_name}?`}
title={markdown(
`Are you sure you want to select *${selectedProvider.model_name}*?`
)}
onClose={() => setShowPoorModel(false)}
/>
<Modal.Body>

View File

@@ -210,8 +210,10 @@ export default function MultiModelResponseView({
const response = responses.find((r) => r.modelIndex === modelIndex);
if (!response) return;
// Persist preferred response to backend + update local tree so the
// input bar unblocks (awaitingPreferredSelection clears).
// Persist preferred response + sync `latestChildNodeId`. Backend's
// `set_preferred_response` updates `latest_child_message_id`; if the
// frontend chain walk disagrees, the next follow-up fails with
// "not on the latest mainline".
if (parentMessage?.messageId && response.messageId && currentSessionId) {
setPreferredResponse(parentMessage.messageId, response.messageId).catch(
(err) => console.error("Failed to persist preferred response:", err)
@@ -227,6 +229,7 @@ export default function MultiModelResponseView({
updated.set(parentMessage.nodeId, {
...userMsg,
preferredResponseId: response.messageId,
latestChildNodeId: response.nodeId,
});
updateSessionMessageTree(currentSessionId, updated);
}

View File

@@ -137,7 +137,7 @@ function DeleteConfirmModal({ hook, onDelete }: DeleteConfirmModalProps) {
<Modal.Header
// TODO(@raunakab): replace the colour of this SVG with red.
icon={SvgTrash}
title={`Delete ${hook.name}`}
title={markdown(`Delete *${hook.name}*`)}
onClose={onClose}
/>
<Modal.Body>

View File

@@ -694,6 +694,25 @@ export function useLlmManager(
prevAgentIdRef.current = liveAgent?.id;
}, [liveAgent?.id]);
// Clear manual override when arriving at a *different* existing session
// from any previously-seen defined session. Tracks only the last
// *defined* session id so a round-trip through new-chat (A → undefined
// → B) still resets, while A → undefined (new-chat) preserves it.
const prevDefinedSessionIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
const nextId = currentChatSession?.id;
if (
nextId !== undefined &&
prevDefinedSessionIdRef.current !== undefined &&
nextId !== prevDefinedSessionIdRef.current
) {
setUserHasManuallyOverriddenLLM(false);
}
if (nextId !== undefined) {
prevDefinedSessionIdRef.current = nextId;
}
}, [currentChatSession?.id]);
function getValidLlmDescriptor(
modelName: string | null | undefined
): LlmDescriptor {
@@ -715,8 +734,9 @@ export function useLlmManager(
if (llmProviders === undefined || llmProviders === null) {
resolved = manualLlm;
} else if (userHasManuallyOverriddenLLM && !currentChatSession) {
// User has overridden in this session and switched to a new session
} else if (userHasManuallyOverriddenLLM) {
// Manual override wins over session's `current_alternate_model`.
// Cleared on cross-session navigation by the effect above.
resolved = manualLlm;
} else if (currentChatSession?.current_alternate_model) {
resolved = getValidLlmDescriptorForProviders(
@@ -728,8 +748,6 @@ export function useLlmManager(
liveAgent.llm_model_version_override,
llmProviders
);
} else if (userHasManuallyOverriddenLLM) {
resolved = manualLlm;
} else if (user?.preferences?.default_model) {
resolved = getValidLlmDescriptorForProviders(
user.preferences.default_model,

View File

@@ -159,9 +159,12 @@ export default function ModelSelector({
);
if (!isMultiModel) {
// Stable key — keying on model would unmount the pill
// on change and leave Radix's anchorRef detached,
// flashing the closing popover at (0,0).
return (
<OpenButton
key={modelKey(model.provider, model.modelName)}
key="single-model-pill"
icon={ProviderIcon}
onClick={(e: React.MouseEvent) =>
handlePillClick(index, e.currentTarget as HTMLElement)

View File

@@ -425,17 +425,32 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [multiModel.isMultiModelActive]);
// Sync single-model selection to llmManager so the submission path
// uses the correct provider/version (replaces the old LLMPopover sync).
// Sync single-model selection to llmManager so the submission path uses
// the correct provider/version. Guard against echoing derived state back
// — only call updateCurrentLlm when the selection actually differs from
// currentLlm, otherwise the initial [] → [currentLlmModel] sync would
// pin `userHasManuallyOverriddenLLM=true` with whatever was resolved
// first (often the default model before the session's alt_model loads).
useEffect(() => {
if (multiModel.selectedModels.length === 1) {
const model = multiModel.selectedModels[0]!;
llmManager.updateCurrentLlm({
name: model.name,
provider: model.provider,
modelName: model.modelName,
});
const current = llmManager.currentLlm;
if (
model.provider !== current.provider ||
model.modelName !== current.modelName ||
model.name !== current.name
) {
llmManager.updateCurrentLlm({
name: model.name,
provider: model.provider,
modelName: model.modelName,
});
}
}
// llmManager intentionally omitted from deps — including it would
// re-run this effect whenever currentLlm changes, creating the
// feedback loop the guard above is designed to break.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [multiModel.selectedModels]);
const {

View File

@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
import * as InputLayouts from "@/layouts/input-layouts";
import { Section, AttachmentItemLayout } from "@/layouts/general-layouts";
import { Content, ContentAction } from "@opal/layouts";
import { markdown } from "@opal/utils";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import {
@@ -1556,7 +1557,7 @@ function FederatedConnectorCard({
{showDisconnectConfirmation && (
<ConfirmationModalLayout
icon={SvgUnplug}
title={`Disconnect ${sourceMetadata.displayName}`}
title={markdown(`Disconnect *${sourceMetadata.displayName}*`)}
onClose={() => setShowDisconnectConfirmation(false)}
submit={
<Button

View File

@@ -4,7 +4,7 @@ import { useCallback, useState } from "react";
import { Button } from "@opal/components";
// TODO(@raunakab): migrate to Opal LineItemButton once it supports danger variant
import LineItem from "@/refresh-components/buttons/LineItem";
import { cn } from "@opal/utils";
import { cn, markdown } from "@opal/utils";
import {
SvgMoreHorizontal,
SvgEdit,
@@ -341,7 +341,7 @@ export default function AgentRowActions({
{unlistOpen && (
<ConfirmationModalLayout
icon={SvgEyeOff}
title={`Unlist ${agent.name}`}
title={markdown(`Unlist *${agent.name}*`)}
onClose={isSubmitting ? undefined : () => setUnlistOpen(false)}
submit={
<Button

View File

@@ -347,7 +347,7 @@ export default function ImageGenerationContent() {
{disconnectProvider && (
<ConfirmationModalLayout
icon={SvgUnplug}
title={`Disconnect ${disconnectProvider.title}`}
title={markdown(`Disconnect *${disconnectProvider.title}*`)}
description="This will remove the stored credentials for this provider."
onClose={() => {
setDisconnectProvider(null);

View File

@@ -201,7 +201,7 @@ function VoiceDisconnectModal({
return (
<ConfirmationModalLayout
icon={SvgUnplug}
title={`Disconnect ${disconnectTarget.providerLabel}`}
title={markdown(`Disconnect *${disconnectTarget.providerLabel}*`)}
description="Voice models"
onClose={onClose}
submit={

View File

@@ -9,6 +9,7 @@ import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { SvgArrowExchange } from "@opal/icons";
import { markdown } from "@opal/utils";
import { SvgOnyxLogo } from "@opal/logos";
import type { IconProps } from "@opal/types";
@@ -81,7 +82,7 @@ export const WebProviderSetupModal = memo(
<Modal.Content width="sm" preventAccidentalClose>
<Modal.Header
icon={LogoArrangement}
title={`Set up ${providerLabel}`}
title={markdown(`Set up *${providerLabel}*`)}
description={description}
onClose={onClose}
/>

View File

@@ -7,6 +7,7 @@ import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { Content, Card } from "@opal/layouts";
import { markdown } from "@opal/utils";
import useSWR from "swr";
import { errorHandlingFetcher, FetchError } from "@/lib/fetcher";
import { SWR_KEYS } from "@/lib/swr-keys";
@@ -146,7 +147,7 @@ function WebSearchDisconnectModal({
return (
<ConfirmationModalLayout
icon={SvgUnplug}
title={`Disconnect ${disconnectTarget.label}`}
title={markdown(`Disconnect *${disconnectTarget.label}*`)}
description="This will remove the stored credentials for this provider."
onClose={onClose}
submit={

View File

@@ -5,6 +5,7 @@ import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { markdown } from "@opal/utils";
import { SvgUnplug } from "@opal/icons";
interface DisconnectEntityModalProps {
isOpen: boolean;
@@ -51,7 +52,7 @@ export default function DisconnectEntityModal({
icon={({ className }) => (
<SvgUnplug className={cn(className, "stroke-action-danger-05")} />
)}
title={`Disconnect ${name}`}
title={markdown(`Disconnect *${name}*`)}
onClose={onClose}
/>

View File

@@ -10,6 +10,7 @@ import InputSelect from "@/refresh-components/inputs/InputSelect";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn";
import { Button } from "@opal/components";
import { markdown } from "@opal/utils";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import Text from "@/refresh-components/texts/Text";
import { Formik, Form } from "formik";
@@ -317,7 +318,11 @@ export default function MCPAuthenticationModal({
<Modal.Content width="sm" height="lg" skipOverlay={skipOverlay}>
<Modal.Header
icon={SvgArrowExchange}
title={`Authenticate ${mcpServer?.name || "MCP Server"}`}
title={
mcpServer
? markdown(`Authenticate *${mcpServer.name}*`)
: "Authenticate MCP Server"
}
description="Authenticate your connection to start using the MCP server."
/>

View File

@@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
import { Formik, Form, useFormikContext } from "formik";
import type { FormikConfig } from "formik";
import { cn } from "@/lib/utils";
import { markdown } from "@opal/utils";
import { Interactive } from "@opal/core";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useAgents } from "@/hooks/useAgents";
@@ -720,7 +721,7 @@ function ModalWrapperInner({
} = getProvider(providerName);
const title = llmProvider
? `Configure "${llmProvider.name}"`
? markdown(`Configure *${llmProvider.name}*`)
: `Set up ${providerProductName}`;
const description =
descriptionOverride ??