diff --git a/front/components/assistant/details/AgentDetailsButtonBar.tsx b/front/components/assistant/details/AgentDetailsButtonBar.tsx index 6766fdf2b1bc..8cd78667c5d6 100644 --- a/front/components/assistant/details/AgentDetailsButtonBar.tsx +++ b/front/components/assistant/details/AgentDetailsButtonBar.tsx @@ -38,14 +38,12 @@ interface AgentDetailsButtonBarProps { agentConfiguration: LightAgentConfigurationType; owner: WorkspaceType; isAgentConfigurationValidating: boolean; - onClose: () => void; } export function AgentDetailsButtonBar({ agentConfiguration, isAgentConfigurationValidating, owner, - onClose, }: AgentDetailsButtonBarProps) { const { user, providersHealth } = useAuth(); const router = useAppRouter(); @@ -76,7 +74,8 @@ export function AgentDetailsButtonBar({ const agentIsFavorite = agentConfiguration.userFavorite || isFavoriteDisabled; const handleNewConversation = async () => { - onClose(); + // Navigate only — closing the sheet first does a separate router.push that + // races this navigation when opening a new conversation with ?agent=. await router.push( getConversationRoute(owner.sId, "new", `agent=${agentConfiguration.sId}`) ); diff --git a/front/components/assistant/details/AgentDetailsSheet.tsx b/front/components/assistant/details/AgentDetailsSheet.tsx index 92b66aa10a4b..51581b6b03ef 100644 --- a/front/components/assistant/details/AgentDetailsSheet.tsx +++ b/front/components/assistant/details/AgentDetailsSheet.tsx @@ -267,7 +267,6 @@ export function AgentDetailsSheet({ owner={owner} agentConfiguration={agentConfiguration} isAgentConfigurationValidating={isAgentConfigurationValidating} - onClose={onClose} /> )} diff --git a/front/components/editor/input_bar/useHandleMentions.tsx b/front/components/editor/input_bar/useHandleMentions.tsx index 5dd5265734e1..06f37970a272 100644 --- a/front/components/editor/input_bar/useHandleMentions.tsx +++ b/front/components/editor/input_bar/useHandleMentions.tsx @@ -1,6 +1,7 @@ import type { PendingInputText } from "@app/components/assistant/conversation/input_bar/InputBarContext"; import { InputBarContext } from "@app/components/assistant/conversation/input_bar/InputBarContext"; import type { EditorService } from "@app/components/editor/input_bar/useCustomEditor"; +import { useSearchParam } from "@app/lib/platform"; import type { LightAgentConfigurationType } from "@app/types/assistant/agent"; import { GLOBAL_AGENTS_SID } from "@app/types/assistant/assistant"; import type { ConversationWithoutContentType } from "@app/types/assistant/conversation"; @@ -49,6 +50,8 @@ const useHandleMentions = ({ }: UseHandleMentionsOptions) => { const stickyMentionsTextContent = useRef(null); const { setSelectedSingleAgent } = useContext(InputBarContext); + // When present, useAgentFromSearchParam owns the selection on the new-conversation page. + const agentSearchParam = useSearchParam("agent"); // Priority: draft > sticky mentions > @dust fallback. // Also resets when the conversation changes so stale state doesn't leak. @@ -72,6 +75,13 @@ const useHandleMentions = ({ return; } + // New conversation with ?agent= in the URL: leave selection to + // useAgentFromSearchParam / the selectedAgent effect. Applying draft, sticky, + // or @dust here races that path and can overwrite the custom agent in the URL. + if (agentSearchParam && !conversation && !isAgentBuilder) { + return; + } + // Agent builder: wait for the draft agent to arrive via stickyMentions. // Clear any stale selectedSingleAgent from the previous page while waiting, // so the old agent doesn't flash in the input bar. @@ -116,6 +126,7 @@ const useHandleMentions = ({ } } }, [ + agentSearchParam, isAgentBuilder, conversation, stickyMentions, diff --git a/front/hooks/useAgentFromSearchParam.test.ts b/front/hooks/useAgentFromSearchParam.test.ts index 95934c4e1217..d70bf34fa008 100644 --- a/front/hooks/useAgentFromSearchParam.test.ts +++ b/front/hooks/useAgentFromSearchParam.test.ts @@ -143,8 +143,10 @@ describe("useAgentFromSearchParam", () => { selectedSingleAgentHolder.current = makeMention("agent_1"); const { rerender } = renderHook(() => useAgentFromSearchParam("w_1")); + await waitFor(() => expect(setSelectedAgent).toHaveBeenCalledTimes(1)); expect(replaceMock).not.toHaveBeenCalled(); + setSelectedAgent.mockClear(); selectedSingleAgentHolder.current = makeMention("agent_2"); rerender(); @@ -161,7 +163,9 @@ describe("useAgentFromSearchParam", () => { selectedSingleAgentHolder.current = makeMention("agent_1"); const { rerender } = renderHook(() => useAgentFromSearchParam("w_1")); + await waitFor(() => expect(setSelectedAgent).toHaveBeenCalledTimes(1)); + setSelectedAgent.mockClear(); setUrl("?agent=agent_2"); searchParamHolder.current = "agent_2"; agentConfigurationHolder.current = makeAgentConfiguration("agent_2"); @@ -221,4 +225,49 @@ describe("useAgentFromSearchParam", () => { expect(replaceMock).not.toHaveBeenCalled(); }); + + it("re-pushes a matching URL agent when entering a new conversation", async () => { + activeConversationIdHolder.current = "conv_1"; + setUrl("?agent=agent_1"); + searchParamHolder.current = "agent_1"; + selectedSingleAgentHolder.current = makeMention("agent_1"); + + const { rerender } = renderHook(() => useAgentFromSearchParam("w_1")); + await waitFor(() => expect(setSelectedAgent).toHaveBeenCalledTimes(1)); + setSelectedAgent.mockClear(); + + // Navigate to /conversation/new with the same custom agent still selected. + activeConversationIdHolder.current = null; + rerender(); + + await waitFor(() => expect(setSelectedAgent).toHaveBeenCalledTimes(1)); + expect(setSelectedAgent).toHaveBeenCalledWith( + expect.objectContaining({ id: "agent_1" }) + ); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("does not mirror a default overwrite while the URL agent is pending after entering new", async () => { + activeConversationIdHolder.current = "conv_1"; + setUrl("?agent=agent_1"); + searchParamHolder.current = "agent_1"; + selectedSingleAgentHolder.current = makeMention("agent_1"); + + const { rerender } = renderHook(() => useAgentFromSearchParam("w_1")); + await waitFor(() => expect(setSelectedAgent).toHaveBeenCalledTimes(1)); + setSelectedAgent.mockClear(); + + // Enter new conversation, then simulate the homepage default winning the race + // before the URL agent is re-applied. + activeConversationIdHolder.current = null; + selectedSingleAgentHolder.current = makeMention("dust"); + agentConfigurationHolder.current = makeAgentConfiguration("agent_1"); + rerender(); + + await waitFor(() => expect(setSelectedAgent).toHaveBeenCalled()); + expect(setSelectedAgent).toHaveBeenCalledWith( + expect.objectContaining({ id: "agent_1" }) + ); + expect(replaceMock).not.toHaveBeenCalled(); + }); }); diff --git a/front/hooks/useAgentFromSearchParam.ts b/front/hooks/useAgentFromSearchParam.ts index 985f843ac1bc..c20719fe397e 100644 --- a/front/hooks/useAgentFromSearchParam.ts +++ b/front/hooks/useAgentFromSearchParam.ts @@ -15,49 +15,85 @@ export function useAgentFromSearchParam(workspaceId: string) { const agent = useSearchParam("agent"); const activeConversationId = useActiveConversationId(); const { selectedSingleAgent, setSelectedAgent } = useContext(InputBarContext); - const syncedParamRef = useRef(null); + // Last ?agent= value whose selection we have observed in the composer (isSynced). + // Intentionally not set when we merely call setSelectedAgent — that would let a + // still-stale selection be mirrored into the URL as a fake picker change. + const appliedParamRef = useRef(null); + const prevConversationIdRef = useRef(activeConversationId); + // Bumped when entering /conversation/new so the URL→composer effect re-runs after + // appliedParamRef is cleared (refs alone do not invalidate effects). + const newConversationVisitRef = useRef(0); + + // Entering /conversation/new from an existing conversation remounts the homepage + // InputBar. Clear the applied marker so we re-push ?agent= through setSelectedAgent; + // otherwise an already-matching selection looks "synced" without that push and can + // lose to the @dust default, which then gets mirrored back into the URL. + if (prevConversationIdRef.current !== activeConversationId) { + const enteredNewConversation = + activeConversationId === null && prevConversationIdRef.current !== null; + prevConversationIdRef.current = activeConversationId; + if (enteredNewConversation) { + appliedParamRef.current = null; + newConversationVisitRef.current += 1; + } + } + const newConversationVisit = newConversationVisitRef.current; const isSynced = !!agent && selectedSingleAgent?.id === agent; + const isUrlAgentPending = !!agent && appliedParamRef.current !== agent; const { agentConfiguration, isAgentConfigurationError } = useAgentConfiguration({ workspaceId, agentConfigurationId: agent, - disabled: !agent || isSynced, + disabled: !isUrlAgentPending, }); - // URL to composer. When url param "agent" names an agent other than the selected one, fetch it - // and select it. syncedParamRef tracks the last param the selection has matched, so a - // param that is still being applied is not mistaken for a picker change by the effect - // below. + // URL to composer. When url param "agent" is not yet reflected in the composer, + // push it via setSelectedAgent. Re-push even when selection already matches so a + // remounted InputBar can mark it as an external/URL selection. useEffect(() => { - if (!agent || !agentConfiguration || syncedParamRef.current === agent) { + // Read the visit counter so entering /conversation/new re-runs this effect after + // appliedParamRef is cleared. + void newConversationVisit; + + if (!agent || appliedParamRef.current === agent) { + return; + } + + if (selectedSingleAgent?.id === agent) { + setSelectedAgent(selectedSingleAgent); + return; + } + + if (!agentConfiguration) { return; } setSelectedAgent(toRichAgentMentionType(agentConfiguration)); - }, [agent, agentConfiguration, setSelectedAgent]); + }, [ + agent, + agentConfiguration, + newConversationVisit, + selectedSingleAgent, + setSelectedAgent, + ]); // Composer to URL. On a new conversation, once the URL agent has been applied, a picker // change is mirrored into the url param "agent" so the address bar always reflects the selected agent. useEffect(() => { if (isSynced) { - syncedParamRef.current = agent; + appliedParamRef.current = agent; } const isNewConversation = activeConversationId === null; const isUrlAgentNotFound = isAgentConfigurationError?.error?.type === "agent_configuration_not_found"; - const isUrlAgentPending = - !!agent && syncedParamRef.current !== agent && !isUrlAgentNotFound; + const isPending = + !!agent && appliedParamRef.current !== agent && !isUrlAgentNotFound; - if ( - isSynced || - !isNewConversation || - !selectedSingleAgent || - isUrlAgentPending - ) { + if (isSynced || !isNewConversation || !selectedSingleAgent || isPending) { return; }