From 476af6fa37f633ca85bb507682afa17ca737e611 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Wed, 20 May 2026 07:59:19 +0200 Subject: [PATCH] fix: workspaces ui component reuse strategy --- .../strategies/component-reuse.strategy.ts | 33 ++++++++++++++- .../lib/state/agents/agents.reducer.spec.ts | 4 +- .../src/lib/state/agents/agents.reducer.ts | 1 - .../lib/state/clients/clients.reducer.spec.ts | 4 +- .../src/lib/state/clients/clients.reducer.ts | 1 - .../src/lib/chat/chat.component.ts | 40 ++++++++++++++++--- 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/apps/frontend-agent-console/src/app/strategies/component-reuse.strategy.ts b/apps/frontend-agent-console/src/app/strategies/component-reuse.strategy.ts index 0326e7ff..9df3b5be 100644 --- a/apps/frontend-agent-console/src/app/strategies/component-reuse.strategy.ts +++ b/apps/frontend-agent-console/src/app/strategies/component-reuse.strategy.ts @@ -4,12 +4,35 @@ import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from * Custom RouteReuseStrategy that reuses component instances when navigating * between routes that use the same component. * - * Only applies when the route path ends with /editor. Otherwise defaults to + * Applies when the route path ends with /editor, or when navigating between the + * clients shell routes (workspaces → environments → chat). Editor/config/deployments + * panels use the editor or default rules. Otherwise defaults to * Angular's default strategy (no reuse, standard route config matching). */ export class ComponentReuseStrategy implements RouteReuseStrategy { private storedRoutes = new Map(); + /** + * Returns true when the route is a leaf under the `clients` parent with a component. + */ + private isUnderClientsRoute(route: ActivatedRouteSnapshot): boolean { + return route.pathFromRoot.some((segment) => segment.routeConfig?.path === 'clients'); + } + + /** + * Main clients shell routes (workspaces list → workspace → environment/chat). + * Excludes editor/config/deployments so those keep distinct route-config semantics. + */ + private isClientsShellRoute(route: ActivatedRouteSnapshot): boolean { + if (!route.component || !this.isUnderClientsRoute(route)) { + return false; + } + + const path = route.routeConfig?.path ?? ''; + + return path === '' || path === ':clientId' || path === ':clientId/agents/:agentId'; + } + /** * Returns true if the route path ends with /editor, meaning the custom reuse strategy applies. */ @@ -75,6 +98,14 @@ export class ComponentReuseStrategy implements RouteReuseStrategy { * For editor routes: reuses when components are identical AND route paths match. */ shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { + const futureIsShell = this.isClientsShellRoute(future); + const currIsShell = this.isClientsShellRoute(curr); + + // Workspaces / environments / chat: reuse shell across param-only navigation + if (futureIsShell && currIsShell && future.component && future.component === curr.component) { + return true; + } + const futureIsEditor = this.isEditorRoute(future); const currIsEditor = this.isEditorRoute(curr); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.spec.ts index ede4c81f..b6521ea1 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.spec.ts @@ -66,7 +66,7 @@ describe('agentsReducer', () => { }); describe('loadClientAgents', () => { - it('should set loading to true for the client, clear existing agents, and clear error', () => { + it('should set loading to true for the client, preserve existing agents, and clear error', () => { const state: AgentsState = { ...initialAgentsState, entities: { [clientId]: [mockAgent] }, @@ -75,7 +75,7 @@ describe('agentsReducer', () => { const newState = agentsReducer(state, loadClientAgents({ clientId, params: {} })); expect(newState.loading[clientId]).toBe(true); - expect(newState.entities[clientId]).toEqual([]); + expect(newState.entities[clientId]).toEqual([mockAgent]); expect(newState.errors[clientId]).toBeNull(); }); }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.ts index bed10b00..9f6c89d5 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.ts @@ -183,7 +183,6 @@ export const agentsReducer = createReducer( on(loadClientAgents, (state, { clientId }) => updateClientState(state, clientId, (clientState) => ({ ...clientState, - agents: [], // Clear existing agents when starting new load loading: true, error: null, })), diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts index aac39190..36f495b3 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts @@ -71,7 +71,7 @@ describe('clientsReducer', () => { }); describe('loadClients', () => { - it('should set loading to true, clear existing clients, and clear error', () => { + it('should set loading to true, preserve existing clients, and clear error', () => { const state: ClientsState = { ...initialClientsState, entities: [mockClient], @@ -80,7 +80,7 @@ describe('clientsReducer', () => { const newState = clientsReducer(state, loadClients({})); expect(newState.loading).toBe(true); - expect(newState.entities).toEqual([]); + expect(newState.entities).toEqual([mockClient]); expect(newState.error).toBeNull(); }); }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.ts index 7feb04e1..b134fb89 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.ts @@ -109,7 +109,6 @@ export const clientsReducer = createReducer( // Load Clients on(loadClients, (state) => ({ ...state, - entities: [], // Clear existing clients when starting new load loading: true, error: null, })), diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts index 23244d76..e517e9b1 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts @@ -268,7 +268,10 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe ); readonly activeClientId$: Observable = this.clientsFacade.activeClientId$; readonly activeClient$: Observable = this.clientsFacade.activeClient$; - readonly clientsLoading$: Observable = this.clientsFacade.loading$; + readonly clientsLoading$: Observable = combineLatest([ + this.clientsFacade.loading$, + this.clientsFacade.clients$, + ]).pipe(map(([loading, clients]) => loading && clients.length === 0)); readonly clientsError$: Observable = this.clientsFacade.error$; readonly clientsDeleting$: Observable = this.clientsFacade.deleting$; readonly clientsCreating$: Observable = this.clientsFacade.creating$; @@ -300,7 +303,10 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe return of(false); } - return this.agentsFacade.getClientAgentsLoading$(clientId); + return combineLatest([ + this.agentsFacade.getClientAgentsLoading$(clientId), + this.agentsFacade.getClientAgents$(clientId), + ]).pipe(map(([loading, agents]) => loading && agents.length === 0)); }), ); readonly agentsDeleting$: Observable = this.activeClientId$.pipe( @@ -1229,8 +1235,19 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } }); - // Load clients on init - this.clientsFacade.loadClients(); + // Load clients on init only when not already cached (avoids spinner on route reuse) + this.clientsFacade.hasClients$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe((hasClients) => { + if (!hasClients) { + this.clientsFacade.loadClients(); + } + }); + + // Sync local active client from store when component is recreated + this.activeClientId$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe((clientId) => { + if (clientId) { + this.activeClientId = clientId; + } + }); // Load provider model catalog when client + selected agent are set (includes deep-link and reducer-driven selection). combineLatest([this.activeClientId$, this.selectedAgent$]) @@ -1599,7 +1616,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe this.selectedAgentId.set(null); // Update active client this.activeClientId = clientId; - this.agentsFacade.loadClientAgents(clientId); + this.loadClientAgentsIfNeeded(clientId); // Clear search agent query if (this.searchAgentQuery()) { @@ -1974,6 +1991,17 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } } + private loadClientAgentsIfNeeded(clientId: string): void { + this.agentsFacade + .hasClientAgents$(clientId) + .pipe(take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe((hasAgents) => { + if (!hasAgents) { + this.agentsFacade.loadClientAgents(clientId); + } + }); + } + onClientSelect(clientId: string, navigate = true): void { // Use local state for immediate check to avoid race conditions const currentActiveClientId = this.activeClientId; @@ -2034,7 +2062,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe // This ensures agents are loaded even if the subscription doesn't fire due to timing if (this.activeClientId !== clientId) { this.activeClientId = clientId; - this.agentsFacade.loadClientAgents(clientId); + this.loadClientAgentsIfNeeded(clientId); // Ensure socket is connected before setting client this.ensureSocketConnectedAndSetClient(clientId); }