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
46 changes: 46 additions & 0 deletions ui/src/components/agent/AgentRail.loading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ const loadingList: ApiResource<AgentInstance[]> = {
refresh: async () => {},
};

const listedConversation = (id: string, agent: string): AgentInstance => ({
id,
name: "",
creator: "reader",
agent,
state: "ready",
operation: "unspecified",
createdAt: "",
updatedAt: "",
});

const loadedList: ApiResource<AgentInstance[]> = {
...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(
Expand Down Expand Up @@ -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(
<ThemeProvider theme={themeFor("dark")}>
<MemoryRouter initialEntries={["/agents/abc123/chat"]}>
<AgentRail instanceRef={{ id: "abc123" }} instances={loadedList} />
</MemoryRouter>
</ThemeProvider>,
);

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();
});
});
30 changes: 17 additions & 13 deletions ui/src/components/agent/AgentRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand All @@ -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,
};

Expand Down Expand Up @@ -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();
Expand All @@ -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.
Expand Down Expand Up @@ -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()}
</span>
Expand All @@ -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. */}
<Text ellipsis css={{ fontSize: 14, color: theme.color.text }}>
{instance?.agent
? bareName(instance.agent)
{currentAgentName
? bareName(currentAgentName)
: (agentTitle?.primary ?? shortInstanceId(ref.id ?? ""))}
</Text>
{/* Which conversation, under which agent. Named the way the reader named
Expand All @@ -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] ?? "")}
</Text>
</span>
<ChevronsUpDown size={14} color={theme.color.textMuted} aria-hidden />
Expand Down
5 changes: 4 additions & 1 deletion ui/src/components/chat/ChatTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const PHASE_LABEL: Partial<Record<ChatTurnPhase, string>> = {
export function ChatTranscript({
chat,
readOnly = false,
isLoading = false,
sessionId,
onAnswered,
onOpenCheckpoint,
Expand All @@ -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.
Expand Down Expand Up @@ -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 (
<div data-testid="chat-loading" css={{ flex: 1, minHeight: 0 }}>
<Skeleton active paragraph={{ rows: 4 }} />
Expand Down
1 change: 1 addition & 0 deletions ui/src/pages/AgentChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,7 @@ export function AgentChatPage() {
do it in and nothing else. */}
<ChatTranscript
chat={chat}
isLoading={instance.isLoading}
sessionId={id}
onOpenCheckpoint={openSnapshot}
onRenameCheckpoint={renameSnapshot}
Expand Down
Loading