Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions front/components/assistant/details/AgentDetailsButtonBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}`)
);
Expand Down
1 change: 0 additions & 1 deletion front/components/assistant/details/AgentDetailsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,6 @@ export function AgentDetailsSheet({
owner={owner}
agentConfiguration={agentConfiguration}
isAgentConfigurationValidating={isAgentConfigurationValidating}
onClose={onClose}
/>
)}

Expand Down
11 changes: 11 additions & 0 deletions front/components/editor/input_bar/useHandleMentions.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -49,6 +50,8 @@ const useHandleMentions = ({
}: UseHandleMentionsOptions) => {
const stickyMentionsTextContent = useRef<string | null>(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.
Expand All @@ -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.
Expand Down Expand Up @@ -116,6 +126,7 @@ const useHandleMentions = ({
}
}
}, [
agentSearchParam,
isAgentBuilder,
conversation,
stickyMentions,
Expand Down
49 changes: 49 additions & 0 deletions front/hooks/useAgentFromSearchParam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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");
Expand Down Expand Up @@ -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();
});
});
70 changes: 53 additions & 17 deletions front/hooks/useAgentFromSearchParam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,49 +15,85 @@ export function useAgentFromSearchParam(workspaceId: string) {
const agent = useSearchParam("agent");
const activeConversationId = useActiveConversationId();
const { selectedSingleAgent, setSelectedAgent } = useContext(InputBarContext);
const syncedParamRef = useRef<string | null>(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<string | null>(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;
}

Expand Down
Loading