From d2e59fc2dddedf32c8403d6ae6c486bdc05f7751 Mon Sep 17 00:00:00 2001 From: Satvik Shrivas <44926681+theSatvik@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:06:50 +0530 Subject: [PATCH] refactor(desktop): move Workbar ownership below AppShell Own the Workbar controller in a feature provider, expose only stable shell commands plus the hidden-session projection, and let host/titlebar consumers read scoped contexts. Generated-by: Codex --- apps/desktop/renderer-architecture.json | 7 +- .../session-navigation-controller.test.ts | 6 +- .../main/__tests__/workbar-boundary.test.ts | 47 ++++- .../main/__tests__/workbar-controller.test.ts | 3 +- .../__tests__/workbar-provider-scope.test.ts | 174 ++++++++++++++++++ .../src/renderer/app-shell-e2e-fixture.ts | 7 +- apps/desktop/src/renderer/app-shell.tsx | 103 +++++------ .../use-session-navigation-reads.ts | 30 ++- .../src/renderer/features/workbar/README.md | 30 ++- .../controller/use-workbar-controller.ts | 6 +- .../controller/workbar-shell-bridge.ts | 98 ++++++++++ .../src/renderer/features/workbar/index.ts | 6 +- .../src/renderer/features/workbar/stories.ts | 1 + .../src/renderer/features/workbar/testing.ts | 8 + .../features/workbar/ui/workbar-host.tsx | 8 +- .../features/workbar/ui/workbar-provider.tsx | 126 +++++++++++++ .../features/workbar/ui/workbar-toggle.tsx | 8 +- apps/desktop/stories/app-shell.stories.tsx | 8 +- apps/desktop/stories/module-hubs.stories.tsx | 4 +- .../stories/session-workbar.stories.tsx | 9 +- docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + scripts/check-app-shell-hooks.mjs | 1 - 23 files changed, 594 insertions(+), 100 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/workbar-provider-scope.test.ts create mode 100644 apps/desktop/src/renderer/features/workbar/controller/workbar-shell-bridge.ts create mode 100644 apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 85906cc9e0..804f853598 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -453,7 +453,7 @@ "./theme": 1 }, "importSpecifiers": 1, - "nonTriviaTokens": 642 + "nonTriviaTokens": 641 }, "src/renderer/app-shell-effects.ts": { "importDeclarations": 11, @@ -793,8 +793,7 @@ "useSystemUiLocale": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, - "useTurnActionRegistry": 1, - "useWorkbarController": 1 + "useTurnActionRegistry": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -893,7 +892,7 @@ "react": 1 }, "importSpecifiers": 121, - "nonTriviaTokens": 14996 + "nonTriviaTokens": 14968 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index 77ae054556..2cbf4273f4 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -69,6 +69,10 @@ const project: ProjectRecord = { }; const hiddenSessionIds = new Set(['hidden']); +const hiddenSessionIdsStore = { + getState: () => hiddenSessionIds, + subscribe: () => () => undefined, +}; const fakeServices = createFakeSessionNavigationServices(); @@ -217,7 +221,7 @@ describe('useSessionNavigationReads', () => { sessions: linkedCatalog, activeSessionId: 'child', activeSession: linkedCatalog[1], - hiddenSessionIds, + hiddenSessionIdsStore, }), }), ), diff --git a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts index 3865055dd7..19cb456770 100644 --- a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts @@ -97,24 +97,59 @@ describe('Workbar feature boundary', () => { assert.equal(productionEntry.includes("from './testing"), false); }); - it('keeps Workbar topology and resource lifecycle out of AppShell', () => { + it('owns the Workbar controller below AppShell', () => { const appShell = readFileSync( join(desktopRoot, 'src', 'renderer', 'app-shell.tsx'), 'utf8', ); + const productionEntry = readFileSync(join(featureRoot, 'index.ts'), 'utf8'); + const provider = readFileSync( + join(featureRoot, 'ui', 'workbar-provider.tsx'), + 'utf8', + ); + const host = readFileSync( + join(featureRoot, 'ui', 'workbar-host.tsx'), + 'utf8', + ); for (const forbidden of [ 'useWorkbarLayoutState', 'terminalSessionWorkbarTabId', 'pendingSideChatClose', 'sideConversations', 'window.maka.shellRuns.start', + 'useWorkbarController', + 'workbar.selectors', + 'workbar.commands', + ''), - true, + assert.equal(appShell.includes(''), true); + assert.equal(provider.includes('useWorkbarController({'), true); + assert.equal(provider.includes('function WorkbarShellBridgeOwner'), true); + assert.equal(provider.includes('createWorkbarShellBridge()'), true); + assert.equal(host.includes('useWorkbarHostModel()'), true); + assert.equal(productionEntry.includes('useWorkbarController'), false); + }); + + it('publishes the rail visibility as an equality-selected reader projection', () => { + const reads = readFileSync( + join( + desktopRoot, + 'src', + 'renderer', + 'features', + 'session-navigation', + 'controller', + 'use-session-navigation-reads.ts', + ), + 'utf8', ); + + assert.equal(reads.includes('hiddenSessionIdsStore'), true); + assert.equal(reads.includes('useExternalStoreSelector('), true); + assert.equal(reads.includes('readonlyStringSetEqual'), true); }); it('projects Work Board project identity through the controller-owned host model', () => { @@ -131,9 +166,9 @@ describe('Workbar feature boundary', () => { 'utf8', ); - assert.equal(appShell.includes('projectId: currentProjectId'), true); + assert.equal(appShell.includes('projectId={currentProjectId}'), true); assert.equal( - appShell.includes('projectAliases: currentProject?.aliases ?? []'), + appShell.includes('projectAliases={currentProject?.aliases ?? []}'), true, ); assert.equal(controller.includes('projectId: input.projectId'), true); diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index c44f0a7ccc..4a2823253c 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -127,7 +127,8 @@ function input( authoritativeSessionIds: new Set(activeSession ? [activeSession.id] : []), shellObscured: false, modelChoices: [], - reportError: (title, description) => errors.push(`${title}: ${description}`), + reportError: (_sessionId, title, description) => + errors.push(`${title}: ${description}`), }; } diff --git a/apps/desktop/src/main/__tests__/workbar-provider-scope.test.ts b/apps/desktop/src/main/__tests__/workbar-provider-scope.test.ts new file mode 100644 index 0000000000..a3ca1c49e3 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workbar-provider-scope.test.ts @@ -0,0 +1,174 @@ +/* + * 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 { SessionSummary } from '@maka/core/session'; +import { LocaleProvider } from '@maka/ui'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + createFakeWorkbarServices, + createWorkbarShellBridge, + useWorkbarHostModel, + useWorkbarTitlebarModel, + WorkbarProvider, + WorkbarServicesProvider, + type UseWorkbarControllerInput, + type WorkbarHostModel, + type WorkbarServices, + type WorkbarShellBridge, + type WorkbarTitlebarModel, +} from '../../renderer/features/workbar/testing.js'; + +let shellRenders = 0; +let hostRenders = 0; +let titlebarRenders = 0; +let latestHost: WorkbarHostModel | undefined; +let latestTitlebar: WorkbarTitlebarModel | undefined; + +function HostProbe() { + latestHost = useWorkbarHostModel(); + hostRenders += 1; + return null; +} + +function TitlebarProbe() { + latestTitlebar = useWorkbarTitlebarModel(); + titlebarRenders += 1; + return null; +} + +function ShellProbe() { + shellRenders += 1; + return createElement( + Fragment, + null, + createElement(HostProbe), + createElement(TitlebarProbe), + ); +} + +function session(id: string): SessionSummary { + return { + id, + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'test', + connectionLocked: false, + model: 'test-model', + permissionMode: 'ask', + }; +} + +function input(activeSession: SessionSummary): UseWorkbarControllerInput { + return { + available: true, + activeSession, + projectId: activeSession.projectId, + projectAliases: [], + authoritativeSessionIds: new Set([activeSession.id, 'fork']), + shellObscured: false, + modelChoices: [], + reportError: () => undefined, + }; +} + +function renderProvider( + root: ReturnType['root'], + services: WorkbarServices, + bridge: WorkbarShellBridge, +) { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement( + WorkbarServicesProvider, + { services }, + createElement( + WorkbarProvider, + { ...input(session('a')), bridge }, + createElement(ShellProbe), + ), + ), + }), + ); +} + +afterEach(() => { + shellRenders = 0; + hostRenders = 0; + titlebarRenders = 0; + latestHost = undefined; + latestTitlebar = undefined; + cleanupFakeDom(); + delete (globalThis as { window?: unknown }).window; +}); + +describe('WorkbarProvider render scope', () => { + it('keeps controller updates below the shell and publishes its narrow bridge', async () => { + const { root } = installReactRenderer(); + const bridge = createWorkbarShellBridge(); + let visibilityNotifications = 0; + const unsubscribe = bridge.hiddenSessionIds.subscribe(() => { + visibilityNotifications += 1; + }); + + await act(async () => + renderProvider(root, createFakeWorkbarServices(), bridge), + ); + assert.equal(shellRenders, 1); + assert.equal(latestTitlebar?.available, true); + + const initiallyCollapsed = bridge.getRightCollapsed(); + const hostBeforeToggle = hostRenders; + const titlebarBeforeToggle = titlebarRenders; + await act(async () => latestTitlebar?.onToggle()); + assert.equal(bridge.getRightCollapsed(), !initiallyCollapsed); + assert.equal(shellRenders, 1); + assert.equal(hostRenders, hostBeforeToggle + 1); + assert.equal(titlebarRenders, titlebarBeforeToggle + 1); + + await act(async () => bridge.commands.openTool('review')); + assert.equal( + latestHost?.panelsState.right.tabs.some((tab) => tab.kind === 'review'), + true, + ); + assert.equal(shellRenders, 1); + + await act(async () => + latestHost?.onForkVisibilityChange?.({ + type: 'fork-created', + sessionId: 'fork', + }), + ); + assert.equal(bridge.hiddenSessionIds.getState().has('fork'), true); + assert.equal(visibilityNotifications, 1); + assert.equal(shellRenders, 1); + + unsubscribe(); + await act(async () => root.unmount()); + assert.equal(bridge.hiddenSessionIds.getState().size, 0); + }); +}); diff --git a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts index af7eabc496..149f7f522c 100644 --- a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts +++ b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts @@ -36,7 +36,7 @@ export function createAppShellE2eFixtureActions(options: { setSearchModalOpen: Dispatch>; setSessionListCollapsed(collapsed: boolean): void; workbar: { - rightCollapsed: boolean; + getRightCollapsed(): boolean; toggleRight(): void; openTool( kind: SessionWorkbarTabKind, @@ -117,10 +117,7 @@ export function createAppShellE2eFixtureActions(options: { if (state.sidebarCollapsed !== undefined) { setSessionListCollapsed(state.sidebarCollapsed); } - if ( - state.workbarCollapsed !== undefined && - state.workbarCollapsed !== workbar.rightCollapsed - ) { + if (state.workbarCollapsed === !workbar.getRightCollapsed()) { workbar.toggleRight(); } if ( diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ec4596a269..bd76a5fc6a 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -85,11 +85,7 @@ import { LiveTurnReconciler } from './live-turn-reconciler'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; import { AgentGraphPanel } from './agent-graph-panel'; import { ChatComposerRegion, selectLatestRequestUsage } from './chat-composer-region'; -import { - WorkbarHost, - WorkbarTitlebarActions, - useWorkbarController, -} from './features/workbar'; +import * as Workbar from './features/workbar'; import { AppUpdateProvider } from './features/app-update/index.js'; import * as Goals from './features/goals'; import * as ModuleHub from './features/module-hub'; @@ -284,20 +280,25 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { - - - {(taskEntry) => ( - - )} - - + + {(workbarBridge) => ( + + + {(taskEntry) => ( + + )} + + + )} + @@ -323,6 +324,7 @@ function AppShellContent({ uiLocaleOverride, setUiLocaleOverride, setUiLocalePreference, + workbarBridge, }: { initialOnboardingSnapshot?: OnboardingSnapshot | null; taskEntry: TaskEntryShellProjection; @@ -330,6 +332,7 @@ function AppShellContent({ uiLocaleOverride: UiLocale | null; setUiLocaleOverride: Dispatch>; setUiLocalePreference: Dispatch>; + workbarBridge: Workbar.WorkbarShellBridge; }) { const toastApi = useToast(); const sharedSessionDialog = useSessionCollaborationDialog(); @@ -1486,24 +1489,6 @@ function AppShellContent({ }), [toastApi], ); - const reportWorkbarError = useCallback( - (title: string, description: string, sessionId: string) => - toastApi.error(title, description, undefined, { sessionId }), - [toastApi], - ); - const workbarAvailable = - navSelection.section === 'sessions' && !workHubActive && Boolean(activeId); - const workbar = useWorkbarController({ - available: workbarAvailable, - activeSession: activeSessionForView, - projectId: currentProjectId, - projectAliases: currentProject?.aliases ?? [], - authoritativeSessionIds: authoritativeSessionIds ?? undefined, - shellObscured, - modelChoices: chatModelChoices, - reportError: reportWorkbarError, - }); - const exitWorkHub = useCallback(() => setWorkHubActive(false), []); const selectSessionSurface = useCallback( () => setNavSelection({ section: 'sessions' }), @@ -1547,7 +1532,7 @@ function AppShellContent({ sessions, activeSessionId: activeId, activeSession, - hiddenSessionIds: workbar.selectors.hiddenSessionIds, + hiddenSessionIdsStore: workbarBridge.hiddenSessionIds, }); const visibleSessions = sessionRail.sessions; const sessionListCollapsed = railLayout.collapsed; @@ -1626,9 +1611,9 @@ function AppShellContent({ setSearchModalOpen, setSessionListCollapsed: sessionRailLayoutStore.setCollapsed, workbar: { - rightCollapsed: workbar.selectors.rightCollapsed, - toggleRight: workbar.commands.toggleRight, - openTool: workbar.commands.openTool, + getRightCollapsed: workbarBridge.getRightCollapsed, + toggleRight: workbarBridge.commands.toggleRight, + openTool: workbarBridge.commands.openTool, }, setThemePref, setUiLocaleOverride, @@ -1663,7 +1648,7 @@ function AppShellContent({ setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, - respondToUserForm: workbar.commands.respondToUserForm, + respondToUserForm: workbarBridge.commands.respondToUserForm, showModelSetupToast, toastApi, newChatModel: newChatModel ?? null, @@ -1891,7 +1876,7 @@ function AppShellContent({ ); return false; } - workbar.commands.openTool('side-chat', 'right', { + workbarBridge.commands.openTool('side-chat', 'right', { ...(slashCommand.command.prompt ? { initialPrompt: slashCommand.command.prompt } : {}), @@ -2494,7 +2479,7 @@ function AppShellContent({ }, openProjectFolder, openSessionInChat, - openSideConversation: () => workbar.commands.openTool('side-chat'), + openSideConversation: () => workbarBridge.commands.openTool('side-chat'), openSettings, openSettingsSection, openSkillsFolder, @@ -2521,10 +2506,21 @@ function AppShellContent({ return ( // Feature controllers live below the shell. Task Entry publishes a stable // shell projection plus reader-local Host/Workspace Picker projections; - // Goal state and Module Hub ownership likewise wake only their narrow + // Workbar, Goal, and Module Hub ownership likewise wake only their narrow // readers. Composer mentions still wrap the frame so one projection serves // every composer, including side-chat panels, without rebuilding the frame // on catalog moves. + )} {!sharedSessionActive && !VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && ( - + )} )} @@ -2800,7 +2792,7 @@ function AppShellContent({ newTaskSendPending={newTaskSendPending} stopPendingBySession={stopPendingBySession} respondToSandboxBoundary={respondToSandboxBoundary} - respondToClientCapability={workbar.commands.respondToClientCapability} + respondToClientCapability={workbarBridge.commands.respondToClientCapability} respondToUserQuestion={respondToUserQuestion} respondToUserForm={respondToUserForm} stop={stop} @@ -2866,8 +2858,8 @@ function AppShellContent({ activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)} - onOpenContextUsage={() => workbar.commands.openTool('inspector')} - LiveContextUsageProbe={workbar.LiveContextUsageProbe} + onOpenContextUsage={() => workbarBridge.commands.openTool('inspector')} + LiveContextUsageProbe={workbarBridge.LiveContextUsageProbe} modelChoices={chatModelChoices} modelSwitchHasHistory={modelSwitchHasHistory} hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'} @@ -3041,7 +3033,7 @@ function AppShellContent({ text: input.text, sourceTurnId: input.turnId, }; - workbar.commands.openSideChatWithQuote(quote); + workbarBridge.commands.openSideChatWithQuote(quote); } : undefined } @@ -3111,7 +3103,7 @@ function AppShellContent({ {/* Collapse hides the Workbar surface without unmounting its tools; dynamic resources therefore keep their existing lifecycle. */} - + @@ -3184,5 +3176,6 @@ function AppShellContent({ + ); } diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts index a84bc26d96..d6f50d919c 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts @@ -18,7 +18,10 @@ */ import { useMemo } from 'react'; -import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; +import { + useExternalStoreSelector, + type ExternalStore, +} from '../../../use-external-store-selector.js'; import { deriveBranchBanner, type BranchBanner } from '../model/branch-banner.js'; import { sessionMatchesRail } from '../model/session-nav-filter.js'; import { deriveSessionRail, type SessionRailProjection } from '../model/session-rail.js'; @@ -33,6 +36,21 @@ import { } from '../model/session-revisions.js'; import type { SessionNavigationSession } from '../ports.js'; +const selectHiddenSessionIds = ( + state: ReadonlySet, +): ReadonlySet => state; + +function readonlyStringSetEqual( + left: ReadonlySet, + right: ReadonlySet, +): boolean { + if (left.size !== right.size) return false; + for (const value of left) { + if (!right.has(value)) return false; + } + return true; +} + export interface SessionNavigationReads { /** The rail's membership, derived once and shared with the command palette. */ rail: SessionRailProjection; @@ -56,9 +74,15 @@ export function useSessionNavigationReads(input: { sessions: readonly SessionNavigationSession[]; activeSessionId: string | undefined; activeSession: SessionNavigationSession | undefined; - hiddenSessionIds: ReadonlySet; + hiddenSessionIdsStore: ExternalStore>; }): SessionNavigationReads { - const { activeSession, activeSessionId, hiddenSessionIds, sessions } = input; + const { activeSession, activeSessionId, sessions } = input; + const hiddenSessionIds = useExternalStoreSelector( + input.hiddenSessionIdsStore, + selectHiddenSessionIds, + undefined, + readonlyStringSetEqual, + ); const rail = useMemo( () => deriveSessionRail(sessions, activeSessionId, (session) => diff --git a/apps/desktop/src/renderer/features/workbar/README.md b/apps/desktop/src/renderer/features/workbar/README.md index 85c141f985..f0afdcc46c 100644 --- a/apps/desktop/src/renderer/features/workbar/README.md +++ b/apps/desktop/src/renderer/features/workbar/README.md @@ -37,18 +37,30 @@ remounted when the active session changes. - Workbar must not import shell composition, Desktop bridge, or main-process implementation. - Desktop I/O enters through `WorkbarServices`; tool code does not read the Desktop global bridge directly. -- `useWorkbarController` is the application boundary for topology, shortcuts, - dynamic resources and Side Chat visibility. `AppShell` supplies only the - active Session, workspace availability, authoritative Session ids, shell visibility and composer +- `WorkbarProvider` is the application boundary for topology, shortcuts, + dynamic resources and Side Chat visibility. It is the only production caller + of `useWorkbarController`; that hook is intentionally absent from the + production barrel. `AppShell` supplies only the active Session, workspace + availability, authoritative Session ids, shell visibility and composer mention/model context. -## Public surface +## Public surface and render ownership -- `host` is passed intact to ``. -- `commands.openTool`, `commands.openSideChatWithQuote` and - `commands.toggleRight` are the only shell actions. -- `selectors.rightCollapsed` drives the titlebar restore affordance and - `selectors.hiddenSessionIds` filters ephemeral companion forks from the rail. +- `` reads its controller-owned host model directly from + `WorkbarProvider`; `AppShell` cannot accept or pass that model. +- The titlebar restore affordance reads only `available`, `collapsed`, and + `onToggle` from its own context. Host-only changes do not repaint it. +- Cross-feature intents use the stable imperative commands on the per-shell + `WorkbarShellBridge`. Replacing the controller publication does not re-render + the shell. +- Ephemeral companion fork ids are the one reactive value another feature + needs. Session Navigation equality-selects that external-store projection at + its reader boundary before deriving the rail. + +The bridge is created per `AppShell`; it is neither global state nor a service +locator. Controller-only updates re-render the provider and whichever narrow +context consumes the changed projection, while the provider retains the shell +element built by its parent. ## Lifecycle invariants diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 1ec9769211..8b8dac3ed4 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -96,7 +96,7 @@ export interface UseWorkbarControllerInput { authoritativeSessionIds: ReadonlySet | undefined; shellObscured: boolean; modelChoices: readonly ChatModelChoice[]; - reportError(title: string, description: string, sessionId: string): void; + reportError(sessionId: string, title: string, description?: string): void; } export interface WorkbarController { @@ -206,9 +206,9 @@ export function useWorkbarController( if (activeSessionIdRef.current !== sessionId) return; const copy = getShellCopy(locale).chatActions; input.reportError( + sessionId, copy.responseFailedTitle, localizedShellErrorMessage(error, copy.responseFailedFallback, locale), - sessionId, ); } }, @@ -375,13 +375,13 @@ export function useWorkbarController( return; } input.reportError( + ownerSessionId, terminalCopy.startFailed, localizedShellErrorMessage( error, terminalCopy.startFailed, locale, ), - ownerSessionId, ); }); return; diff --git a/apps/desktop/src/renderer/features/workbar/controller/workbar-shell-bridge.ts b/apps/desktop/src/renderer/features/workbar/controller/workbar-shell-bridge.ts new file mode 100644 index 0000000000..17577f71fe --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/controller/workbar-shell-bridge.ts @@ -0,0 +1,98 @@ +/* + * 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 type { + WorkbarControllerCommands, + WorkbarControllerSelectors, +} from './use-workbar-controller.js'; +import { LiveContextUsageProbe } from '../tools/inspector/live-context-usage-probe.js'; + +const EMPTY_HIDDEN_SESSION_IDS: ReadonlySet = new Set(); + +function readonlyStringSetEqual( + left: ReadonlySet, + right: ReadonlySet, +): boolean { + if (left.size !== right.size) return false; + for (const value of left) { + if (!right.has(value)) return false; + } + return true; +} + +export interface WorkbarShellBridgePublication { + readonly commands: WorkbarControllerCommands; + readonly selectors: WorkbarControllerSelectors; +} + +/** + * The deliberately small seam between the Workbar owner and AppShell. + * + * Commands are stable imperative delegates: publishing a new controller does + * not re-render the shell. Hidden companion Sessions are the one reactive + * value another feature genuinely reads, so they use an external-store + * projection that Session Navigation can equality-select at its own boundary. + */ +export function createWorkbarShellBridge() { + let hiddenSessionIds: ReadonlySet = EMPTY_HIDDEN_SESSION_IDS; + const hiddenSessionIdListeners = new Set<() => void>(); + let publication: WorkbarShellBridgePublication | null = null; + + const replaceHiddenSessionIds = (next: ReadonlySet): void => { + if (readonlyStringSetEqual(hiddenSessionIds, next)) return; + hiddenSessionIds = next; + for (const listener of [...hiddenSessionIdListeners]) listener(); + }; + + const commands: WorkbarControllerCommands = { + openTool: (...args) => publication?.commands.openTool(...args), + openSideChatWithQuote: (...args) => + publication?.commands.openSideChatWithQuote(...args), + respondToClientCapability: (...args) => + publication?.commands.respondToClientCapability(...args) ?? + Promise.resolve(), + respondToUserForm: (...args) => + publication?.commands.respondToUserForm(...args) ?? Promise.resolve(), + toggleRight: () => publication?.commands.toggleRight(), + }; + + return { + commands, + LiveContextUsageProbe, + hiddenSessionIds: { + getState: () => hiddenSessionIds, + subscribe(listener: () => void): () => void { + hiddenSessionIdListeners.add(listener); + return () => hiddenSessionIdListeners.delete(listener); + }, + }, + getRightCollapsed: (): boolean => + publication?.selectors.rightCollapsed ?? true, + publish(next: WorkbarShellBridgePublication): void { + publication = next; + replaceHiddenSessionIds(next.selectors.hiddenSessionIds); + }, + disconnect(): void { + publication = null; + replaceHiddenSessionIds(EMPTY_HIDDEN_SESSION_IDS); + }, + }; +} + +export type WorkbarShellBridge = ReturnType; diff --git a/apps/desktop/src/renderer/features/workbar/index.ts b/apps/desktop/src/renderer/features/workbar/index.ts index 2b2decd585..cb86766b4b 100644 --- a/apps/desktop/src/renderer/features/workbar/index.ts +++ b/apps/desktop/src/renderer/features/workbar/index.ts @@ -24,7 +24,11 @@ // which nothing shipped imports. export { WorkbarHost } from './ui/workbar-host'; export { WorkbarTitlebarActions } from './ui/workbar-toggle'; +export { + WorkbarProvider, + WorkbarShellBridgeOwner, +} from './ui/workbar-provider'; +export type { WorkbarShellBridge } from './controller/workbar-shell-bridge'; export { WorkbarServicesProvider } from './services-context'; -export { useWorkbarController } from './controller/use-workbar-controller'; export type { SessionWorkbarTabKind } from './model/workbar-tabs'; export type { WorkbarServices } from './ports'; diff --git a/apps/desktop/src/renderer/features/workbar/stories.ts b/apps/desktop/src/renderer/features/workbar/stories.ts index 724bd0e9d0..a1152ed1fd 100644 --- a/apps/desktop/src/renderer/features/workbar/stories.ts +++ b/apps/desktop/src/renderer/features/workbar/stories.ts @@ -31,3 +31,4 @@ */ export { WorkbarSurface } from './ui/workbar-surface.js'; +export { WorkbarTitlebarActionsView } from './ui/workbar-toggle.js'; diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 11c1e45a56..fb96f9b073 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -54,6 +54,14 @@ export * from './tools/terminal/session-terminal-query.js'; export * from './tools/terminal/session-terminal-frame.js'; export * from './tools/inspector/use-session-trace.js'; export * from './controller/use-workbar-controller.js'; +export * from './controller/workbar-shell-bridge.js'; +export { + WorkbarProvider, + useWorkbarHostModel, + useWorkbarTitlebarModel, +} from './ui/workbar-provider.js'; +export type { WorkbarTitlebarModel } from './ui/workbar-provider.js'; +export type { WorkbarHostModel } from './ui/workbar-host.js'; export { SideChatCloseConfirmation } from './ui/side-chat-close-confirmation.js'; const noopSubscription = (): (() => void) => () => undefined; diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx index 08af527931..b5346ffbd7 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx @@ -38,6 +38,7 @@ import type { } from '../tools/side-chat/quote-companion-panel-state'; import type { CompanionForkVisibilityEvent } from '../tools/side-chat/quote-companion-visibility'; import { SideChatCloseConfirmation } from './side-chat-close-confirmation.js'; +import { useWorkbarHostModel } from './workbar-provider.js'; const WorkbarSurface = lazy(() => import('./workbar-surface').then((module) => ({ @@ -122,7 +123,12 @@ export interface WorkbarHostModel { }; } -export function WorkbarHost({ model: props }: { model: WorkbarHostModel }) { +export function WorkbarHost() { + return ; +} + +/** Environment-free view seam for focused tests and Storybook. */ +export function WorkbarHostView({ model: props }: { model: WorkbarHostModel }) { const locale = useUiLocale(); const toast = useToast(); const copy = getShellCopy(locale).app; diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx new file mode 100644 index 0000000000..61d23853ec --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx @@ -0,0 +1,126 @@ +/* + * 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 { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + type ReactNode, +} from 'react'; +import { + useWorkbarController, + type UseWorkbarControllerInput, +} from '../controller/use-workbar-controller.js'; +import { + createWorkbarShellBridge, + type WorkbarShellBridge, +} from '../controller/workbar-shell-bridge.js'; +import type { WorkbarHostModel } from './workbar-host.js'; + +export interface WorkbarTitlebarModel { + readonly available: boolean; + readonly collapsed: boolean; + readonly onToggle: () => void; +} + +const WorkbarHostContext = createContext(null); +const WorkbarTitlebarContext = createContext(null); + +export interface WorkbarProviderProps extends UseWorkbarControllerInput { + readonly bridge: WorkbarShellBridge; + readonly children?: ReactNode; +} + +/** Owns one imperative bridge for the lifetime of its shell. */ +export function WorkbarShellBridgeOwner(props: { + readonly children: (bridge: WorkbarShellBridge) => ReactNode; +}) { + const bridgeRef = useRef(null); + bridgeRef.current ??= createWorkbarShellBridge(); + return props.children(bridgeRef.current); +} + +/** + * Owns the Workbar controller below AppShell and publishes only the models + * read by the host, titlebar, and Session rail. + * + * Controller updates re-render this provider and the matching context reader. + * The shell's cross-feature intents use stable imperative delegates on + * `bridge`; only hidden companion Session ids create a reactive subscription. + */ +export function WorkbarProvider({ + bridge, + reportError: reportErrorInput, + children, + ...input +}: WorkbarProviderProps) { + const reportErrorRef = useRef(reportErrorInput); + useLayoutEffect(() => { + reportErrorRef.current = reportErrorInput; + }, [reportErrorInput]); + const reportError = useCallback( + (...args) => reportErrorRef.current(...args), + [], + ); + const controller = useWorkbarController({ ...input, reportError }); + + useLayoutEffect(() => { + bridge.publish({ + commands: controller.commands, + selectors: controller.selectors, + }); + }); + useLayoutEffect(() => () => bridge.disconnect(), [bridge]); + + const titlebar = useMemo( + () => ({ + available: input.available, + collapsed: controller.selectors.rightCollapsed, + onToggle: controller.commands.toggleRight, + }), + [ + controller.commands.toggleRight, + controller.selectors.rightCollapsed, + input.available, + ], + ); + + return ( + + + {children} + + + ); +} + +export function useWorkbarHostModel(): WorkbarHostModel { + const model = useContext(WorkbarHostContext); + if (!model) throw new Error('WorkbarProvider is missing'); + return model; +} + +export function useWorkbarTitlebarModel(): WorkbarTitlebarModel { + const model = useContext(WorkbarTitlebarContext); + if (!model) throw new Error('WorkbarProvider is missing'); + return model; +} diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx index 55e858bf9a..e4d2aa341e 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx @@ -22,6 +22,7 @@ import { Tooltip } from '@astryxdesign/core/Tooltip'; import { IconButton, useUiLocale } from '@maka/ui'; import { PanelRightClose, PanelRightOpen } from '@maka/ui/icons'; import { getShellCopy } from '../../../locales/shell-copy'; +import { useWorkbarTitlebarModel } from './workbar-provider.js'; /** * Shared titlebar/panel toggle for the Workbar column. @@ -65,7 +66,12 @@ export function WorkbarToggle(props: { } /** Titlebar restore affordance shown only while the Workbar is collapsed. */ -export function WorkbarTitlebarActions(props: { +export function WorkbarTitlebarActions() { + return ; +} + +/** Environment-free view seam for focused tests and Storybook. */ +export function WorkbarTitlebarActionsView(props: { available: boolean; collapsed: boolean; onToggle(): void; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 35d1836957..abc0652a2d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -36,9 +36,11 @@ import { SessionRail, type SessionRailStoryProps } from '../../../packages/ui/st import { AppShellTopbarActions } from '../src/renderer/app-shell-chrome-actions'; import { WorkbarServicesProvider, - WorkbarTitlebarActions, } from '../src/renderer/features/workbar'; -import { WorkbarSurface } from '../src/renderer/features/workbar/stories'; +import { + WorkbarSurface, + WorkbarTitlebarActionsView, +} from '../src/renderer/features/workbar/stories'; import { createFakeWorkbarServices, createSessionWorkbarPanelsState, @@ -427,7 +429,7 @@ function ComposedShell(props: { })()} /> )} - -
{props.collapsible && ( - setCollapsed(false)} diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 0d9f86e5af..4b7c2294a8 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 252 files — blocker 0, reimplementation 0, polish 1, aligned 251. +**Totals:** 253 files — blocker 0, reimplementation 0, polish 1, aligned 252. ## Exclusions (explicit) @@ -93,6 +93,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx` | shell-chrome-or-panel | Banner, EmptyState | aligned — uses Astryx (Banner, EmptyState) | aligned | | `apps/desktop/src/renderer/features/workbar/ui/side-chat-close-confirmation.tsx` | shell-chrome-or-panel | Button, CheckboxInput, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text, VStack | aligned — uses Astryx (Button, CheckboxInput, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter) | aligned | | `apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx` | shell-chrome-or-panel | Card, ResizeHandle, Spinner | aligned — uses Astryx (Card, ResizeHandle, Spinner) | aligned | +| `apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx` | shell-chrome-or-panel | Badge, Card, DropdownMenu, DropdownMenuItem, Heading, Icon, Kbd, List, ListItem, Section, Spinner, Tab, TabList | aligned — uses Astryx (Badge, Card, DropdownMenu, DropdownMenuItem, Heading, Icon, Kbd, List) | aligned | | `apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx` | shell-chrome-or-panel | Icon, IconButton, Tooltip | aligned — uses Astryx (Icon, IconButton, Tooltip) | aligned | | `apps/desktop/src/renderer/keyboard-help.tsx` | dialog-overlay | Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent | aligned — uses Astryx (Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 9dcb9521de..018fe569ae 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -64,6 +64,7 @@ apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx apps/desktop/src/renderer/features/workbar/ui/side-chat-close-confirmation.tsx apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx +apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx apps/desktop/src/renderer/keyboard-help.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 00e9afad00..265868d721 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -156,7 +156,6 @@ export const ALLOWED = { // one stays because the shell body reads `keys` to build the turn footer's // disabled mask. useTurnActionRegistry: 1, - useWorkbarController: 1, }, };