fix(ui): key the new-session draft on the requested agent (#114415)

The route loader reports an empty agent id whenever it cannot verify one
through the Gateway, so an offline catalog target keyed as ["", catalog]
and became [agent, catalog] on reconnect. That looked like navigation and
cleared whatever the user had typed. Key the draft identity on the agent
the URL requested, which only a navigation can change, and re-derive agent
defaults when the resolved id finally arrives.

Fixes #114398
This commit is contained in:
Peter Steinberger
2026-07-27 04:53:36 -04:00
committed by GitHub
parent afb1b014e2
commit 65cf9c1d65
5 changed files with 45 additions and 4 deletions
@@ -11,7 +11,13 @@ describe("new-session catalog target", () => {
const agents = [{ id: "main" }, { id: "research" }];
it("keeps the draft identity stable while target metadata resolves", () => {
const pending = { agentId: "main", catalogId: "claude", model: "", catalogLabel: "" };
const pending = {
agentId: "main",
requestedAgentId: "main",
catalogId: "claude",
model: "",
catalogLabel: "",
};
const ready = {
...pending,
model: "anthropic/claude-opus-4-8",
@@ -23,6 +29,22 @@ describe("new-session catalog target", () => {
expect(allowsSelectedAgent(ready, { id: "main" })).toBe(true);
});
it("keeps the draft identity stable while the target agent resolves", () => {
const requested = {
requestedAgentId: "research",
catalogId: "claude",
model: "",
catalogLabel: "",
};
const unresolved = { ...requested, agentId: "" };
const resolved = { ...requested, agentId: "research" };
expect(routeKey(unresolved)).toBe(routeKey(resolved));
// Only a navigation changes the requested agent or the target.
expect(routeKey({ ...resolved, requestedAgentId: "main" })).not.toBe(routeKey(resolved));
expect(routeKey({ ...resolved, catalogId: "codex" })).not.toBe(routeKey(resolved));
});
it("fails closed when the requested creation capability is unavailable", async () => {
const request = vi.fn(async () => ({
catalogs: [
+7 -1
View File
@@ -6,8 +6,14 @@ import { t } from "../../i18n/index.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
import type { NewSessionRouteData } from "./location.ts";
/**
* Which draft a new-session route has open. This keys on the requested agent,
* not the resolved one: a catalog route resolves its agent through the Gateway
* and reports it empty until the roster arrives, so keying on the resolved id
* would make that fill-in look like a navigation and discard the draft.
*/
export function routeKey(data?: NewSessionRouteData): string {
return JSON.stringify([data?.agentId ?? "", data?.catalogId ?? ""]);
return JSON.stringify([data?.requestedAgentId ?? "", data?.catalogId ?? ""]);
}
export function isTarget(data?: NewSessionRouteData): boolean {
+3
View File
@@ -1,5 +1,8 @@
export type NewSessionRouteData = {
/** The agent the loader resolved; empty until the Gateway can name one. */
agentId: string;
/** The agent the URL asked for, which only a navigation can change. */
requestedAgentId: string;
catalogId: string;
model: string;
catalogLabel: string;
@@ -97,6 +97,7 @@ class NewSessionPage extends OpenClawLightDomElement {
@state() private restoredFolderValidation: "none" | "checking" | "failed" = "none";
private openedFor: string | null = null;
private openedAgentId = "";
private agentsHydrated = false;
private nodesHydrated = false;
// Discovery retry provenance separates user choices from Gateway-derived defaults.
@@ -383,12 +384,20 @@ class NewSessionPage extends OpenClawLightDomElement {
this.agents().length > 0,
);
const openKey = catalog.routeKey(this.data);
const resolvedAgentId = this.data?.agentId ?? "";
if (this.openedFor !== openKey) {
this.openedFor = openKey;
this.openedAgentId = resolvedAgentId;
this.agentsHydrated = agentsReady;
this.resetDraft();
return;
}
if (this.openedAgentId !== resolvedAgentId) {
// The route named the target agent after the draft opened, so the page is
// still holding the fallback agent it adopted while the id was unknown.
this.openedAgentId = resolvedAgentId;
this.agentsHydrated = false;
}
// A hard reload can land here before agents.list resolves. Once the list
// arrives, adopt only agent-derived defaults; a full reset would discard
// anything the user already typed while the list was loading.
+3 -2
View File
@@ -11,13 +11,15 @@ async function loadNewSessionData(
search: string,
): Promise<NewSessionRouteData> {
const requestedLocation = newSessionLocationFromSearch(search);
const requestedAgentId = requestedLocation.agentId.trim();
if (!requestedLocation.catalogId) {
return { ...requestedLocation, model: "", catalogLabel: "" };
return { ...requestedLocation, requestedAgentId, model: "", catalogLabel: "" };
}
const { resolveAgentId, resolveCreateTarget } = await import("./catalog-target.ts");
const unresolved = (agentId = ""): NewSessionRouteData => ({
...requestedLocation,
agentId,
requestedAgentId,
model: "",
catalogLabel: "",
});
@@ -50,7 +52,6 @@ async function loadNewSessionData(
const availableAgents = listSelectableAgents(agentsList?.agents ?? []);
const gatewayDefaultId =
gateway.phase === "connected" && gateway.hello ? gateway.assistantAgentId : null;
const requestedAgentId = requestedLocation.agentId.trim();
if (
!agentsList &&
requestedAgentId &&