From 14f0a65516ea0ad5e32a03fd1c62f24adf148d93 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 | 9 +- .../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 | 85 ++++----- .../use-session-navigation-reads.ts | 30 ++- .../src/renderer/features/workbar/README.md | 30 ++- .../controller/use-workbar-controller.ts | 6 +- .../controller/workbar-shell-bridge.ts | 94 ++++++++++ .../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 | 4 +- apps/desktop/stories/module-hubs.stories.tsx | 4 +- docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + scripts/check-app-shell-hooks.mjs | 1 - 22 files changed, 573 insertions(+), 88 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 d23898ba63..47d8c5c69d 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -485,7 +485,7 @@ "react": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 672 + "nonTriviaTokens": 671 }, "src/renderer/app-shell-effects.ts": { "importDeclarations": 23, @@ -871,8 +871,7 @@ "useTaskEntryController": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, - "useTurnActionRegistry": 1, - "useWorkbarController": 1 + "useTurnActionRegistry": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -979,8 +978,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 184, - "nonTriviaTokens": 15687 + "importSpecifiers": 182, + "nonTriviaTokens": 15659 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, 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 bde0503b47..c358f0acc2 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -121,7 +121,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 b37fc542e1..66bd6bcb56 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, @@ -123,10 +123,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 0a685bbab4..59b2049632 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -86,11 +86,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 * as Goals from './features/goals'; import { ModuleHubHost, useModuleHubController } from './features/module-hub'; import { @@ -289,13 +285,18 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { - + + {(workbarBridge) => ( + + )} + @@ -320,12 +321,14 @@ function AppShellContent({ uiLocaleOverride, setUiLocaleOverride, setUiLocalePreference, + workbarBridge, }: { initialOnboardingSnapshot?: OnboardingSnapshot | null; uiLocale: UiLocale; uiLocaleOverride: UiLocale | null; setUiLocaleOverride: Dispatch>; setUiLocalePreference: Dispatch>; + workbarBridge: Workbar.WorkbarShellBridge; }) { const toastApi = useToast(); const [appUpdateStatus, setAppUpdateStatus] = useState(null); @@ -1636,24 +1639,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' }), @@ -1697,7 +1682,7 @@ function AppShellContent({ sessions, activeSessionId: activeId, activeSession, - hiddenSessionIds: workbar.selectors.hiddenSessionIds, + hiddenSessionIdsStore: workbarBridge.hiddenSessionIds, }); const visibleSessions = sessionRail.sessions; const sessionListCollapsed = railLayout.collapsed; @@ -1776,9 +1761,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, @@ -2041,7 +2026,7 @@ function AppShellContent({ ); return false; } - workbar.commands.openTool('side-chat', 'right', { + workbarBridge.commands.openTool('side-chat', 'right', { ...(slashCommand.command.prompt ? { initialPrompt: slashCommand.command.prompt } : {}), @@ -2645,7 +2630,7 @@ function AppShellContent({ }, openProjectFolder, openSessionInChat, - openSideConversation: () => workbar.commands.openTool('side-chat'), + openSideConversation: () => workbarBridge.commands.openTool('side-chat'), openSettings, openSettingsSection, openSkillsFolder, @@ -2670,8 +2655,19 @@ function AppShellContent({ : 'im_hub'; return ( - // Goal state lives below the shell and wakes only its three readers. Composer - // mentions still wrap the frame so one projection serves every composer. + // Workbar and Goal state live below the shell and wake only their readers. + // Composer mentions still wrap the frame so one projection serves every composer. + )} {!sharedSessionActive && !VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && ( - + )} )} @@ -2927,7 +2919,7 @@ function AppShellContent({ newTaskSendPending={newTaskSendPending} stopPendingBySession={stopPendingBySession} respondToSandboxBoundary={respondToSandboxBoundary} - respondToClientCapability={workbar.commands.respondToClientCapability} + respondToClientCapability={workbarBridge.commands.respondToClientCapability} respondToUserQuestion={respondToUserQuestion} stop={stop} directoryComposerProps={directoryComposerProps} @@ -3176,7 +3168,7 @@ function AppShellContent({ text: input.text, sourceTurnId: input.turnId, }; - workbar.commands.openSideChatWithQuote(quote); + workbarBridge.commands.openSideChatWithQuote(quote); } : undefined } @@ -3246,7 +3238,7 @@ function AppShellContent({ {/* Collapse hides the Workbar surface without unmounting its tools; dynamic resources therefore keep their existing lifecycle. */} - + @@ -3318,5 +3310,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 e8c6754ca2..05f505710a 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 77a7fbad4f..5ca511acaf 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 @@ -93,7 +93,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 { @@ -195,9 +195,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, ); } }, @@ -364,13 +364,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..79a554f24c --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/controller/workbar-shell-bridge.ts @@ -0,0 +1,94 @@ +/* + * 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'; + +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(), + toggleRight: () => publication?.commands.toggleRight(), + }; + + return { + commands, + 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 6604a4f050..e55ef71c4d 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -52,6 +52,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 d0cf65016e..3256756017 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) => ({ @@ -134,7 +135,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 9ca749119f..86209544ca 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. */ export function WorkbarToggle(props: { @@ -57,7 +58,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 d82ab9389b..fc5e9d3489 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -33,7 +33,7 @@ import { import type { ChatModelChoice, SessionViewMode, TurnViewModel } from '@maka/ui'; import { SessionRail, type SessionRailStoryProps } from '../../../packages/ui/stories/session-rail-harness.js'; import { AppShellTopbarActions } from '../src/renderer/app-shell-chrome-actions'; -import { WorkbarTitlebarActions } from '../src/renderer/features/workbar'; +import { WorkbarTitlebarActionsView } from '../src/renderer/features/workbar/stories'; import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel'; import { deriveAppShellTurnPresentation } from '../src/renderer/app-shell-turn-view-model'; import { @@ -404,7 +404,7 @@ function ComposedShell(props: { })()} /> )} - -