From 95df886b0c558ad42cbc51e622c01f28a71ca3d4 Mon Sep 17 00:00:00 2001 From: vedantlavale Date: Thu, 15 Oct 2026 17:34:48 +0530 Subject: [PATCH] fix(ui): keep agent context while switching chats Signed-off-by: vedantlavale --- .../agent/AgentRail.loading.test.tsx | 46 +++++++++++++++++++ ui/src/components/agent/AgentRail.tsx | 30 ++++++------ ui/src/components/chat/ChatTranscript.tsx | 5 +- ui/src/pages/AgentChatPage.tsx | 1 + 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/ui/src/components/agent/AgentRail.loading.test.tsx b/ui/src/components/agent/AgentRail.loading.test.tsx index 61c1f1fa0d..f18c9084f0 100644 --- a/ui/src/components/agent/AgentRail.loading.test.tsx +++ b/ui/src/components/agent/AgentRail.loading.test.tsx @@ -28,6 +28,29 @@ const loadingList: ApiResource = { refresh: async () => {}, }; +const listedConversation = (id: string, agent: string): AgentInstance => ({ + id, + name: "", + creator: "reader", + agent, + state: "ready", + operation: "unspecified", + createdAt: "", + updatedAt: "", +}); + +const loadedList: ApiResource = { + ...loadingList, + data: [ + listedConversation("abc123", "default/alpha"), + listedConversation("sibling456", "default/alpha"), + listedConversation("other789", "default/beta"), + ], + isLoading: false, + isValidating: false, + isEmpty: false, +}; + /** The rail as a chat page mounts it before the record lands: an id, and nothing else. */ function renderLoadingRail() { return render( @@ -77,4 +100,27 @@ describe("the agent rail while the conversation is being read", () => { expect(secondary.textContent).toBe(""); expect(getComputedStyle(secondary).minHeight).toBe("16px"); }); + + it("uses the listed conversation to keep the agent and sibling chats stable", () => { + render( + + + + + , + ); + + expect(screen.getByTestId("agent-rail-identity")).toHaveTextContent("alpha"); + expect(screen.getByTestId("agent-nav-agent-conversations")).toHaveAttribute( + "href", + "/agents/default/alpha", + ); + expect(screen.getByTestId("chat-new-session")).toHaveAttribute( + "href", + "/agents/default/alpha/new", + ); + expect(screen.getByTestId("chat-session-abc123")).toBeInTheDocument(); + expect(screen.getByTestId("chat-session-sibling456")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-session-other789")).toBeNull(); + }); }); diff --git a/ui/src/components/agent/AgentRail.tsx b/ui/src/components/agent/AgentRail.tsx index 61c5a51d7c..427be84ea8 100644 --- a/ui/src/components/agent/AgentRail.tsx +++ b/ui/src/components/agent/AgentRail.tsx @@ -205,6 +205,10 @@ export function AgentRail({ }; const conversations = instances; + // A conversation route has only its instance ID. Resolve its Agent from the + // already-loaded list while the per-instance read catches up during a switch. + const currentAgentName = + conversations.data?.find((item) => item.id === ref.id)?.agent ?? instance?.agent; /** * Which agent the switcher is open *for*, rather than whether it is open. @@ -256,10 +260,10 @@ export function AgentRail({ */ const agentPageHref = agentHrefFromCaller ?? - (instance?.agent + (currentAgentName ? agentPageUrl({ - namespace: instance.agent.split("/")[0], - name: bareName(instance.agent), + namespace: currentAgentName.split("/")[0], + name: bareName(currentAgentName), }) : undefined); @@ -269,9 +273,9 @@ export function AgentRail({ * instance — the pages with no conversation open have only the first. */ const agent = agentRef ?? { - namespace: instance?.agent?.split("/")[0] ?? "", - name: instance?.agent - ? bareName(instance.agent) + namespace: currentAgentName?.split("/")[0] ?? "", + name: currentAgentName + ? bareName(currentAgentName) : agentTitle?.primary, }; @@ -339,10 +343,10 @@ export function AgentRail({ * what left the rail empty on the agent and new-conversation pages: an agent's own * navigation showing none of its conversations. */ - const siblings = instance + const siblings = currentAgentName ? (conversations.data ?? []).filter( (candidate) => - candidate.agent === instance.agent, + candidate.agent === currentAgentName, ) : (conversations.data ?? []); const needle = query.trim().toLowerCase(); @@ -357,7 +361,7 @@ export function AgentRail({ candidate.name.toLowerCase().includes(needle), ); return [...found].sort(byNewestFirst); - }, [conversations.data, instance, query]); + }, [conversations.data, currentAgentName, query]); /* * Collapsed, and remembered. @@ -804,7 +808,7 @@ export function AgentRail({ being switched. It took them from the instance id, which meant the badge changed every time a reader opened a different conversation with the same agent — while the menu behind it listed agents that never changed. */} - {(instance?.agent ? bareName(instance.agent) : (agentTitle?.primary ?? ref.id ?? "")) + {(currentAgentName ? bareName(currentAgentName) : (agentTitle?.primary ?? ref.id ?? "")) .slice(0, 2) .toUpperCase()} @@ -817,8 +821,8 @@ export function AgentRail({ recognises the agent by; the id distinguishes this conversation from the others with it. Until the instance loads there is only the id. */} - {instance?.agent - ? bareName(instance.agent) + {currentAgentName + ? bareName(currentAgentName) : (agentTitle?.primary ?? shortInstanceId(ref.id ?? ""))} {/* Which conversation, under which agent. Named the way the reader named @@ -840,7 +844,7 @@ export function AgentRail({ named Agent definition. The conversation is named in the list below, where it is one row among its siblings; naming it here made the card describe a conversation while the menu it opens describes agents. */} - { (agentTitle?.secondary ?? agentRef?.namespace ?? instance?.agent?.split("/")[0] ?? "")} + { (agentTitle?.secondary ?? agentRef?.namespace ?? currentAgentName?.split("/")[0] ?? "")} diff --git a/ui/src/components/chat/ChatTranscript.tsx b/ui/src/components/chat/ChatTranscript.tsx index f729122d70..0ac76403b5 100644 --- a/ui/src/components/chat/ChatTranscript.tsx +++ b/ui/src/components/chat/ChatTranscript.tsx @@ -54,6 +54,7 @@ const PHASE_LABEL: Partial> = { export function ChatTranscript({ chat, readOnly = false, + isLoading = false, sessionId, onAnswered, onOpenCheckpoint, @@ -67,6 +68,8 @@ export function ChatTranscript({ chat: ChatController; /** Suppresses replies, approvals, cancellation, and retry controls. */ readOnly?: boolean; + /** Keeps the history skeleton visible while the conversation record is resolving. */ + isLoading?: boolean; /** * Opens a saved boundary's record, where its name and the rename live. Absent when * read-only, as the two below are, which leaves the line as a mark and nothing more. @@ -272,7 +275,7 @@ export function ChatTranscript({ }, [chat.messages, chat.turnPhase]); // Both fill the panel like the transcript does, or the composer jumps up while they show. - if (chat.isLoadingHistory) { + if (isLoading || chat.isLoadingHistory) { return (
diff --git a/ui/src/pages/AgentChatPage.tsx b/ui/src/pages/AgentChatPage.tsx index 080edb46eb..baa9044f30 100644 --- a/ui/src/pages/AgentChatPage.tsx +++ b/ui/src/pages/AgentChatPage.tsx @@ -701,6 +701,7 @@ export function AgentChatPage() { do it in and nothing else. */}