From 65cf9c1d65753cd1b82d52c186dc1a748e1b48cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 04:53:36 -0400 Subject: [PATCH] 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 --- .../pages/new-session/catalog-target.test.ts | 24 ++++++++++++++++++- ui/src/pages/new-session/catalog-target.ts | 8 ++++++- ui/src/pages/new-session/location.ts | 3 +++ ui/src/pages/new-session/new-session-page.ts | 9 +++++++ ui/src/pages/new-session/route.ts | 5 ++-- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/new-session/catalog-target.test.ts b/ui/src/pages/new-session/catalog-target.test.ts index b2fc1465bcd9..09f1e90cc678 100644 --- a/ui/src/pages/new-session/catalog-target.test.ts +++ b/ui/src/pages/new-session/catalog-target.test.ts @@ -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: [ diff --git a/ui/src/pages/new-session/catalog-target.ts b/ui/src/pages/new-session/catalog-target.ts index 234b0d4be3cb..52e58d8521b4 100644 --- a/ui/src/pages/new-session/catalog-target.ts +++ b/ui/src/pages/new-session/catalog-target.ts @@ -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 { diff --git a/ui/src/pages/new-session/location.ts b/ui/src/pages/new-session/location.ts index 0f125e6fd02d..92c8f40fcfc2 100644 --- a/ui/src/pages/new-session/location.ts +++ b/ui/src/pages/new-session/location.ts @@ -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; diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 34f05fc6db90..1b0057ed2135 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -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. diff --git a/ui/src/pages/new-session/route.ts b/ui/src/pages/new-session/route.ts index 4ad495ad2179..2674725945aa 100644 --- a/ui/src/pages/new-session/route.ts +++ b/ui/src/pages/new-session/route.ts @@ -11,13 +11,15 @@ async function loadNewSessionData( search: string, ): Promise { 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 &&