From 3b47b2c3519adba1b5bcc501f1b7fa8860069a30 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Mon, 31 Aug 2026 14:03:30 +0800 Subject: [PATCH] refactor(desktop): move Goal controller ownership below AppShell Move production Goal controller ownership into GoalProvider and expose reader-local projections for the composer, indicator, and dialog host. Keep AppShell free of Goal controller and model ownership, route composer and indicator state through separate UI-owned contexts consumed only by the authorized Desktop leaves, bind them at the authoritative Composer and ChatView props, and lock the boundary with architecture and render-scope coverage. The Set Goal entry now waits for an owner-backed active session instead of briefly exposing an unusable action before catalog hydration. Generated-by: Codex --- apps/desktop/e2e/goal-dialog-budget.spec.ts | 16 ++ apps/desktop/renderer-architecture.json | 5 +- .../check-renderer-architecture.test.mjs | 46 ++++ .../__tests__/goal-provider-scope.test.ts | 216 ++++++++++++++++++ .../src/main/__tests__/goals-boundary.test.ts | 171 +++++++++++++- apps/desktop/src/renderer/app-shell.tsx | 26 +-- .../src/renderer/chat-composer-region.tsx | 56 +++-- .../src/renderer/chat-message-surface.tsx | 50 ++-- .../src/renderer/features/goals/README.md | 36 ++- .../src/renderer/features/goals/index.ts | 8 +- .../src/renderer/features/goals/testing.ts | 1 + .../renderer/features/goals/ui/goal-host.tsx | 8 +- .../features/goals/ui/goal-provider.tsx | 121 ++++++++++ docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + packages/ui/src/chat-view.tsx | 23 +- packages/ui/src/components.tsx | 12 + packages/ui/src/composer.tsx | 29 +-- packages/ui/src/goal-projection-context.ts | 63 +++++ scripts/check-app-shell-hooks.mjs | 1 - 20 files changed, 787 insertions(+), 105 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/goal-provider-scope.test.ts create mode 100644 apps/desktop/src/renderer/features/goals/ui/goal-provider.tsx create mode 100644 packages/ui/src/goal-projection-context.ts diff --git a/apps/desktop/e2e/goal-dialog-budget.spec.ts b/apps/desktop/e2e/goal-dialog-budget.spec.ts index 28f7d1efc1..14b56668e0 100644 --- a/apps/desktop/e2e/goal-dialog-budget.spec.ts +++ b/apps/desktop/e2e/goal-dialog-budget.spec.ts @@ -82,4 +82,20 @@ test('an unsendable budget blocks Start instead of arming a different one', asyn }; }) .toEqual({ condition: '所有测试通过', maxIterations: 25, tokenBudget: 5000 }); + + // The Host read above proves persistence; this proves the same broadcast now + // reaches the provider-owned chat projection without AppShell reading it. + const goalContext = page + .getByRole('region', { name: '任务上下文' }) + .filter({ visible: true }); + await expect( + goalContext + .getByText(/目标 0 \/ 25 · .* · 0 \/ 5k/) + .filter({ visible: true }), + ).toBeVisible(); + await expect( + goalContext + .getByRole('button', { name: '暂停自主执行目标(已进行 0/25 轮)' }) + .filter({ visible: true }), + ).toBeVisible(); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index b186177f2f..cf1cd513a0 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -921,7 +921,6 @@ "useCommandPalette": 1, "useComposerAttachments": 1, "useEffect": 14, - "useGoalController": 1, "useKeyboardHelp": 1, "useLayoutEffect": 2, "useModuleHubController": 1, @@ -1060,8 +1059,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 187, - "nonTriviaTokens": 15855 + "importSpecifiers": 186, + "nonTriviaTokens": 15825 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index 07b50469b8..27caed53af 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -1054,6 +1054,52 @@ describe('renderer architecture checker fixtures', () => { ); }); + it('rejects a feature controller Hook returning to AppShell after provider migration', async () => { + const providerOwnedAppShell = ` + import { GoalProvider } from './features/goals/index.js'; + export const AppShell = GoalProvider; + `; + await withDesktopFixture( + { + [TRANSITIVE_APP_SHELL_PATH]: providerOwnedAppShell, + 'src/renderer/features/goals/index.ts': ` + export const GoalProvider = true; + export function useGoalController() { return true; } + `, + }, + async (desktopRoot) => { + const seedConfig = transitiveAppShellSeedConfig(); + const providerOwnedConfig = generateArchitectureConfig(desktopRoot, seedConfig); + + await writeFile( + join(desktopRoot, TRANSITIVE_APP_SHELL_PATH), + ` + import { GoalProvider, useGoalController } from './features/goals/index.js'; + export const AppShell = [GoalProvider, useGoalController()]; + `, + 'utf8', + ); + const regressedConfig = generateArchitectureConfig( + desktopRoot, + providerOwnedConfig, + ); + const violations = violationsFor( + desktopRoot, + regressedConfig, + providerOwnedConfig, + ); + + assertHasViolation( + violations, + /^src\/renderer\/app-shell\.ts: hookCalls debt increased from 0 to 1$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/app-shell\.ts: new or increased hookCalls debt useGoalController$/u, + ); + }, + ); + }); it('rejects bridge and environment capability growth inside a transitive legacy AppShell helper', async () => { await withDesktopFixture( transitiveAppShellFiles(` diff --git a/apps/desktop/src/main/__tests__/goal-provider-scope.test.ts b/apps/desktop/src/main/__tests__/goal-provider-scope.test.ts new file mode 100644 index 0000000000..abd87308ba --- /dev/null +++ b/apps/desktop/src/main/__tests__/goal-provider-scope.test.ts @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { act, createElement, Fragment } from 'react'; +import type { GoalState } from '@maka/core/goal'; +import { + LocaleProvider, + useChatViewGoalProjection, + useComposerGoalProjection, +} from '@maka/ui'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + createFakeGoalServices, + GoalProvider, + GoalServicesProvider, + type GoalServices, +} from '../../renderer/features/goals/testing.js'; + +type ComposerProbeProps = ReturnType; +type IndicatorProbeProps = ReturnType; + +let shellRenders = 0; +let composerRenders = 0; +let indicatorRenders = 0; +let latestComposer: ComposerProbeProps | undefined; +let latestIndicator: IndicatorProbeProps | undefined; + +function ComposerProbe() { + const props = useComposerGoalProjection(); + composerRenders += 1; + latestComposer = props; + return null; +} + +function IndicatorProbe() { + const props = useChatViewGoalProjection(); + indicatorRenders += 1; + latestIndicator = props; + return null; +} + +function ShellProbe() { + shellRenders += 1; + return createElement( + Fragment, + null, + createElement(ComposerProbe), + createElement(IndicatorProbe), + ); +} + +function goal(tokensNow: number): GoalState { + return { + id: 'goal-a', + revision: tokensNow, + sessionId: 'a', + condition: 'Finish a', + status: 'active', + setAt: 100, + iterations: 2, + maxIterations: 9, + consecutiveNoProgress: 0, + blockCap: 3, + tokenBudget: 500, + tokensAtStart: 10, + tokensNow, + tokensBaselinePending: false, + }; +} + +function renderProvider( + root: ReturnType['root'], + services: GoalServices, + reportError: (sessionId: string, title: string, description?: string) => void, + enabled = true, +) { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement( + GoalServicesProvider, + { services }, + createElement( + GoalProvider, + { activeSessionId: 'a', canOpenDialog: enabled, reportError }, + createElement(ShellProbe), + ), + ), + }), + ); +} + +afterEach(() => { + shellRenders = 0; + composerRenders = 0; + indicatorRenders = 0; + latestComposer = undefined; + latestIndicator = undefined; + cleanupFakeDom(); +}); + +describe('GoalProvider render scope', () => { + it('updates only the projection whose reader changed', async () => { + const { root } = installReactRenderer(); + let current = goal(60); + let emit: ((sessionId: string | undefined) => void) | undefined; + const defaults = createFakeGoalServices(); + const services = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: async () => current, + subscribeChanges: (handler) => { + emit = handler; + return () => undefined; + }, + }, + }); + + await act(async () => renderProvider(root, services, () => undefined)); + assert.equal(latestComposer?.goalActive, true); + assert.equal(latestIndicator?.goalIndicator?.tokensSpent, 60); + assert.equal(shellRenders, 1); + + const composerBeforeRefresh = composerRenders; + const indicatorBeforeRefresh = indicatorRenders; + current = goal(75); + await act(async () => emit?.('a')); + + assert.equal(shellRenders, 1); + assert.equal(composerRenders, composerBeforeRefresh); + assert.equal(indicatorRenders, indicatorBeforeRefresh + 1); + assert.equal(latestIndicator?.goalIndicator?.tokensSpent, 75); + + const composerBeforeDialog = composerRenders; + const indicatorBeforeDialog = indicatorRenders; + await act(async () => latestComposer?.onSetGoal?.()); + assert.equal(shellRenders, 1); + assert.equal(composerRenders, composerBeforeDialog); + assert.equal(indicatorRenders, indicatorBeforeDialog); + + await act(async () => root.unmount()); + }); + + it('withholds the command when disabled and reports failures to the latest owner', async () => { + const { root } = installReactRenderer(); + const firstErrors: string[] = []; + const latestErrors: string[] = []; + const defaults = createFakeGoalServices(); + const services = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: async () => goal(60), + pause: async () => { + throw new Error('offline'); + }, + }, + }); + + await act(async () => + renderProvider( + root, + services, + (_sessionId, _title, description) => firstErrors.push(description ?? ''), + false, + ), + ); + assert.equal(latestComposer?.goalActive, true); + assert.equal(latestComposer?.onSetGoal, undefined); + + await act(async () => + renderProvider( + root, + services, + (_sessionId, _title, description) => latestErrors.push(description ?? ''), + ), + ); + assert.equal(typeof latestComposer?.onSetGoal, 'function'); + await act(async () => { + latestIndicator?.goalIndicator?.onPause?.(); + await Promise.resolve(); + }); + + assert.deepEqual(firstErrors, []); + assert.equal(latestErrors.length, 1); + assert.match(latestErrors[0] ?? '', /still be continuing/); + + await act(async () => root.unmount()); + }); + + it('defaults standalone UI readers to an inactive Goal projection', async () => { + const { root } = installReactRenderer(); + await act(async () => root.render(createElement(ShellProbe))); + assert.equal(latestComposer?.goalActive, false); + assert.equal(latestComposer?.onSetGoal, undefined); + assert.equal(latestIndicator?.goalIndicator, undefined); + await act(async () => root.unmount()); + }); +}); diff --git a/apps/desktop/src/main/__tests__/goals-boundary.test.ts b/apps/desktop/src/main/__tests__/goals-boundary.test.ts index 7078ea94cd..e52b4a280c 100644 --- a/apps/desktop/src/main/__tests__/goals-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/goals-boundary.test.ts @@ -24,6 +24,7 @@ import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; const desktopRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url))); +const repoRoot = resolve(desktopRoot, '..', '..'); const featureRoot = join(desktopRoot, 'src', 'renderer', 'features', 'goals'); function sourceFiles(root: string): string[] { @@ -56,7 +57,8 @@ describe('Goals feature boundary', () => { }); it('is consumed outside the feature only through public entries', () => { - const allowed = /\/features\/goals\/(?:index|testing)(?:\.js)?$/; + const productionEntry = /\/features\/goals\/index(?:\.js)?$/; + const testingEntry = /\/features\/goals\/testing(?:\.js)?$/; const violations: string[] = []; for (const root of [join(desktopRoot, 'src'), join(desktopRoot, 'stories')]) { for (const path of sourceFiles(root)) { @@ -68,7 +70,13 @@ describe('Goals feature boundary', () => { const explicitEntry = normalized.endsWith('/features/goals') ? `${normalized}/index` : normalized; - if (!allowed.test(explicitEntry)) { + const consumer = relative(desktopRoot, path).replace(/\\/g, '/'); + const testConsumer = + consumer.includes('/__tests__/') || consumer.startsWith('stories/'); + if ( + !productionEntry.test(explicitEntry) && + !(testConsumer && testingEntry.test(explicitEntry)) + ) { violations.push(`${relative(desktopRoot, path)}: ${imported}`); } } @@ -81,9 +89,144 @@ describe('Goals feature boundary', () => { const productionEntry = readFileSync(join(featureRoot, 'index.ts'), 'utf8'); assert.equal(productionEntry.includes('createFakeGoalServices'), false); assert.equal(productionEntry.includes("from './testing"), false); + assert.equal(productionEntry.includes('useGoalController'), false); }); - it('keeps Goal state, controls, and dialog ownership out of AppShell', () => { + it('keeps the controller owned by GoalProvider and out of renderer roots', () => { + const controllerOwner = join(featureRoot, 'ui', 'goal-provider.tsx'); + const consumers: string[] = []; + for (const path of sourceFiles(join(desktopRoot, 'src', 'renderer'))) { + if (!/\.tsx?$/.test(path) || path.endsWith('use-goal-controller.ts')) continue; + const source = readFileSync(path, 'utf8'); + if (/\buseGoalController\s*\(/.test(source)) { + consumers.push(relative(desktopRoot, path)); + } + } + assert.deepEqual(consumers, [relative(desktopRoot, controllerOwner)]); + }); + + it('keeps the controller module behind GoalProvider and the testing entry', () => { + const importers: string[] = []; + for (const path of sourceFiles(featureRoot)) { + if (!/\.tsx?$/.test(path)) continue; + const source = readFileSync(path, 'utf8'); + for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) { + if (match[1]?.includes('controller/use-goal-controller')) { + importers.push(relative(desktopRoot, path)); + } + } + } + assert.deepEqual(importers.sort(), [ + 'src/renderer/features/goals/testing.ts', + 'src/renderer/features/goals/ui/goal-provider.tsx', + ]); + }); + + it('binds separate projection contexts at the authoritative UI readers', () => { + const provider = readFileSync( + join(featureRoot, 'ui', 'goal-provider.tsx'), + 'utf8', + ); + for (const required of [ + 'ChatViewGoalProjectionProvider,', + 'ComposerGoalProjectionProvider,', + '', + '', + ]) { + assert.equal(provider.includes(required), true, required); + } + assert.equal(provider.includes('cloneElement'), false); + + const composer = readFileSync( + join(desktopRoot, 'src', 'renderer', 'chat-composer-region.tsx'), + 'utf8', + ); + const messageSurface = readFileSync( + join(desktopRoot, 'src', 'renderer', 'chat-message-surface.tsx'), + 'utf8', + ); + const uiComposer = readFileSync( + join(repoRoot, 'packages', 'ui', 'src', 'composer.tsx'), + 'utf8', + ); + const uiChatView = readFileSync( + join(repoRoot, 'packages', 'ui', 'src', 'chat-view.tsx'), + 'utf8', + ); + const uiGoalProjection = readFileSync( + join(repoRoot, 'packages', 'ui', 'src', 'goal-projection-context.ts'), + 'utf8', + ); + for (const [source, required] of [ + [composer, 'ComposerGoalProjectionConsumer,'], + [composer, ''], + [composer, 'goalActive={goalProjection.goalActive}'], + [composer, 'onSetGoal={goalProjection.onSetGoal}'], + [messageSurface, 'ChatViewGoalProjectionConsumer,'], + [messageSurface, ''], + [messageSurface, 'goalIndicator={goalProjection.goalIndicator}'], + [uiComposer, 'export interface ComposerGoalProps {'], + [uiComposer, '} & ComposerGoalProps'], + [uiChatView, 'export interface ChatViewGoalIndicatorProps {'], + [uiChatView, '} & ChatViewGoalIndicatorProps)'], + [uiGoalProjection, 'export interface ComposerGoalProjection {'], + [uiGoalProjection, "ComposerGoalProps['goalActive']"], + [uiGoalProjection, "ComposerGoalProps['onSetGoal']"], + [uiGoalProjection, 'export interface ChatViewGoalProjection {'], + [uiGoalProjection, "ChatViewGoalIndicatorProps['goalIndicator']"], + [uiGoalProjection, 'export const ComposerGoalProjectionConsumer ='], + [uiGoalProjection, 'export const ChatViewGoalProjectionConsumer ='], + ] as const) { + assert.equal(source.includes(required), true, required); + } + assert.equal(composer.includes("from './features/goals"), false); + assert.equal(messageSurface.includes("from './features/goals"), false); + assert.equal(composer.includes('useComposerGoalProjection'), false); + assert.equal(messageSurface.includes('useChatViewGoalProjection'), false); + assert.equal(uiComposer.includes('goal-projection-context'), false); + assert.equal(uiChatView.includes('goal-projection-context'), false); + }); + + it('keeps Goal projection consumers exclusive to the authorized renderer leaves', () => { + const rendererRoot = join(desktopRoot, 'src', 'renderer'); + const productionSources = sourceFiles(rendererRoot).filter((path) => { + const name = relative(desktopRoot, path).replace(/\\/g, '/'); + return /\.tsx?$/.test(path) && !name.includes('/__tests__/'); + }); + const importersOf = (name: string) => + productionSources + .filter((path) => readFileSync(path, 'utf8').includes(name)) + .map((path) => relative(desktopRoot, path).replace(/\\/g, '/')) + .sort(); + + assert.deepEqual(importersOf('ComposerGoalProjectionConsumer'), [ + 'src/renderer/chat-composer-region.tsx', + ]); + assert.deepEqual(importersOf('ChatViewGoalProjectionConsumer'), [ + 'src/renderer/chat-message-surface.tsx', + ]); + assert.deepEqual(importersOf('useComposerGoalProjection'), []); + assert.deepEqual(importersOf('useChatViewGoalProjection'), []); + + const composer = readFileSync( + join(rendererRoot, 'chat-composer-region.tsx'), + 'utf8', + ); + const messageSurface = readFileSync( + join(rendererRoot, 'chat-message-surface.tsx'), + 'utf8', + ); + for (const omitted of ["| 'goalActive'", "| 'onSetGoal'"]) { + assert.equal(composer.includes(omitted), true, omitted); + } + assert.equal( + messageSurface.includes("| 'goalIndicator'"), + true, + 'goalIndicator', + ); + }); + + it('keeps Goal state, controls, and dialog reads below AppShell', () => { const appShell = readFileSync( join(desktopRoot, 'src', 'renderer', 'app-shell.tsx'), 'utf8', @@ -94,10 +237,28 @@ describe('Goals feature boundary', () => { 'pendingGoalControlSessionIdsRef', 'goalDialogSessionId', 'setGoalDialogSessionId', + 'useGoalController', + 'goals.commands', + 'goals.selectors', + 'goals.host', + ''), true); + for (const required of [ + '', + ]) { + assert.equal(appShell.includes(required), true, required); + } }); }); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index fa27552207..c882b04f64 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -91,7 +91,7 @@ import { WorkbarTitlebarActions, useWorkbarController, } from './features/workbar'; -import { GoalHost, useGoalController } from './features/goals'; +import * as Goals from './features/goals'; import { ModuleHubHost, useModuleHubController } from './features/module-hub'; import { SessionNavigationProvider, @@ -813,10 +813,6 @@ function AppShellContent({ resumeInterruptedSession, } = useShellResume({ activeId: ownerActiveId, toastApi, shellCopy, uiLocale }); const rendererMountedRef = useRef(true); - const goals = useGoalController({ - activeSessionId: ownerActiveId, - reportError: showSessionError, - }); // Set of session ids whose backend / connection is no longer usable — // drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat // header banner). Derivation is pure (see `stale-sessions.ts`) so the @@ -2728,9 +2724,13 @@ function AppShellContent({ : 'im_hub'; return ( - // Wraps the whole frame rather than each composer, so one projection serves - // the main composer and every side-chat panel. Left un-indented on purpose: - // re-indenting 500 lines of JSX would bury the change that matters. + // Goal state lives below the shell and wakes only its three readers. Composer + // mentions still wrap the frame so one projection serves every composer. +
{ void setOrchestrationMode(mode); }} - onSetGoal={ - activeId && activeBoundarySurface.localInteractionAvailable - ? goals.commands.openDialog - : undefined - } - goalActive={goals.selectors.active} goalDisabledReason={ activeStreamingLive || (activeId && turnActive) ? shellCopy.goalTurnActive @@ -3143,7 +3137,6 @@ function AppShellContent({ userLabel={userLabel} memoryActive={memoryActive} onOpenMemorySettings={sharedSessionActive ? undefined : () => openSettingsSection('memory')} - goalIndicator={goals.selectors.indicator} messageLoadError={activeId ? messageLoadErrorBySession[activeId] : undefined} messageLoadRetryPending={activeId ? messageRetryPendingBySession[activeId] === true : false} onRetryMessages={activeId ? () => void retryMessages(activeId) : undefined} @@ -3277,7 +3270,7 @@ function AppShellContent({ contextKey={activeId} /> )} - + {sharedSessionDialog.target ? ( @@ -3345,5 +3338,6 @@ function AppShellContent({ />
+
); } diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index ab6a6e39c0..2c25797fd7 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -23,10 +23,11 @@ import { Button, ClientCapabilityPrompt, Composer, + ComposerGoalProjectionConsumer, SandboxBoundaryPrompt, UserQuestionPrompt, } from '@maka/ui'; -import type { ComposerHandle, ComposerInteraction } from '@maka/ui'; +import type { ComposerHandle } from '@maka/ui'; import { useComposerMentionsContext } from './composer-mentions.js'; import { readNewTaskReloadDraft, @@ -60,6 +61,11 @@ interface BoundaryUnreadableNotice { onRetry(): void; } +type ComposerInteraction = + | ComponentProps['request'] + | ComponentProps['request'] + | ComponentProps['request']; + /** * The composer region of the chat surface (issue #1043): the composer * interaction slot (permission / user-question prompts) plus the always-mounted @@ -81,6 +87,8 @@ interface ChatComposerRegionProps | 'hidden' | 'draftKey' | 'stopPending' + | 'goalActive' + | 'onSetGoal' | 'allowAttachmentImportWhileStreaming' // Read from ComposerMentionsProvider, so a catalog reload repaints the // popups without re-rendering the shell that would otherwise pass them. @@ -241,26 +249,32 @@ export function ChatComposerRegion({ /> )} -