diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 6ae669540aa..3303a62b23b 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -277,6 +277,13 @@ "ownerSymbol": "TaskEntryRoot", "count": 1 }, + { + "implementation": "src/renderer/features/workbar/controller/use-workbar-controller.ts", + "symbol": "useWorkbarController", + "owner": "src/renderer/features/workbar/ui/workbar-provider.tsx", + "ownerSymbol": "WorkbarProvider", + "count": 1 + }, { "implementation": "src/renderer/features/workhub/controller/use-workhub-controller.ts", "symbol": "useWorkHubController", @@ -702,8 +709,7 @@ "useSystemUiLocale": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, - "useTurnActionRegistry": 1, - "useWorkbarController": 1 + "useTurnActionRegistry": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, diff --git a/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts b/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts index b6db8bbb5fc..ef50a498400 100644 --- a/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts @@ -25,15 +25,12 @@ test('every published width stays a , collapsed included', () => { const collapsed = appShellFrameStyle({ sessionListCollapsed: true, sessionListWidth: 260, - workbarRightWidth: 480, }) as Record; const expanded = appShellFrameStyle({ sessionListCollapsed: false, sessionListWidth: 291, - workbarRightWidth: 480, }) as Record; assert.equal(collapsed['--maka-sidenav-width'], '0px'); assert.equal(expanded['--maka-sidenav-width'], '291px'); - assert.equal(collapsed['--maka-session-workbar-width'], '480px'); }); diff --git a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts index 9caf2c54740..8cda999cd14 100644 --- a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts @@ -111,10 +111,26 @@ describe('Workbar feature boundary', () => { ]) { assert.equal(appShell.includes(forbidden), false, forbidden); } - assert.match( - appShell, - /]*\bmodel=\{workbar\.host\}/, + assert.match(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', + ); + for (const forbidden of ['useWorkbarController', 'workbar.host', 'model={workbar']) { + assert.equal(appShell.includes(forbidden), false, forbidden); + } + assert.equal(appShell.includes(''), true); + assert.equal(appShell.includes(' { diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index ca1f2aad467..252eeda62f8 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -688,6 +688,14 @@ describe('useWorkbarController', () => { const firstStop = deferred(); const retryStop = deferred(); const stops: Array<{ sessionId: string; ref: string }> = []; + const stopErrors: Array<{ title: string; description?: string; sessionId?: string }> = []; + const toastApi: ToastApi = { + ...createFakeToastApi(), + error: (title, description, _action, options) => { + stopErrors.push({ title, description, sessionId: options?.sessionId }); + return ''; + }, + }; const defaults = createFakeWorkbarServices(); const services = createFakeWorkbarServices({ terminal: { @@ -700,7 +708,7 @@ describe('useWorkbarController', () => { }, }); - await act(async () => renderController(root, services, input(session('a')))); + await act(async () => renderController(root, services, input(session('a'), toastApi))); await act(async () => controller().commands.openTool('terminal')); const tab = controller().host.panelsState.right.tabs.find( (candidate) => candidate.kind === 'terminal', @@ -715,12 +723,20 @@ describe('useWorkbarController', () => { firstStop.reject(new Error('Host disconnected')); await Promise.resolve(); }); - await act(async () => renderController(root, services, input(session('b')))); + // The failure is reported against the Terminal's owner Session, and the + // Session id never lands in the visible title or description. + assert.ok(stopErrors.length > 0); + for (const error of stopErrors) { + assert.equal(error.sessionId, 'a'); + assert.notEqual(error.title, 'a'); + assert.notEqual(error.description, 'a'); + } + await act(async () => renderController(root, services, input(session('b'), toastApi))); assert.equal(stops.length, 1); - await act(async () => renderController(root, services, input(session('a')))); + await act(async () => renderController(root, services, input(session('a'), toastApi))); assert.ok(controller().host.panelsState.right.tabs.includes(tab)); await act(async () => controller().host.onCloseTab('right', tab)); - await act(async () => renderController(root, services, input(session('b')))); + await act(async () => renderController(root, services, input(session('b'), toastApi))); await act(async () => controller().commands.toggleRight()); assert.equal(controller().host.rightCollapsed, false); await act(async () => retryStop.resolve()); 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 00000000000..3183a1f1595 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workbar-provider-scope.test.ts @@ -0,0 +1,189 @@ +/* + * 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 } from 'react'; +import type { SessionSummary } from '@maka/core/session'; +import { LocaleProvider, type ToastApi } from '@maka/ui'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + createFakeWorkbarServices, + createWorkbarShellBridge, + useWorkbarHostModel, + WorkbarProvider, + WorkbarServicesProvider, + WorkbarShellRoot, + type UseWorkbarControllerInput, +} from '../../renderer/features/workbar/testing.js'; + +type ShellIntent = Parameters[0]['children']>[0]; +type HostModel = ReturnType; + +let shellRenders = 0; +let hostRenders = 0; +let latestShell: ShellIntent | undefined; +let latestHost: HostModel | undefined; + +function HostProbe() { + latestHost = useWorkbarHostModel(); + hostRenders += 1; + return null; +} + +/** AppShellContent's shape: the projection from the root, the owner below. */ +function ShellProbe(props: { workbar: ShellIntent; input: UseWorkbarControllerInput }) { + latestShell = props.workbar; + shellRenders += 1; + return createElement( + WorkbarProvider, + { bridge: props.workbar.bridge, input: props.input }, + createElement(HostProbe), + ); +} + +const toastApi: ToastApi = { + toast: () => '', + success: () => '', + error: () => '', + info: () => '', + warning: () => '', + confirm: async () => false, + dismiss: () => {}, +}; + +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', + }; +} + +const input: UseWorkbarControllerInput = { + available: true, + layoutSessionId: 'a', + activeSession: session('a'), + projectId: undefined, + projectAliases: [], + authoritativeSessionIds: new Set(['a', 'fork']), + shellObscured: false, + modelChoices: [], + toastApi, +}; + +function shell(): ShellIntent { + assert.ok(latestShell); + return latestShell; +} + +function host(): HostModel { + assert.ok(latestHost); + return latestHost; +} + +afterEach(() => { + shellRenders = 0; + hostRenders = 0; + latestShell = undefined; + latestHost = undefined; + cleanupFakeDom(); + delete (globalThis as { window?: unknown }).window; +}); + +describe('WorkbarProvider render scope', () => { + it('keeps Workbar updates the shell does not read below the shell', async () => { + const { root } = installReactRenderer(); + await act(async () => + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement( + WorkbarServicesProvider, + { services: createFakeWorkbarServices() }, + createElement(WorkbarShellRoot, { + children: (workbar: ShellIntent) => + createElement(ShellProbe, { workbar, input }), + }), + ), + }), + ), + ); + assert.equal(shell().selectors.ready, true); + + // The shell's own commands reach the controller through the bridge. + await act(async () => shell().commands.openTool('review')); + assert.equal( + host().panelsState.right.tabs.some((tab) => tab.kind === 'review'), + true, + ); + assert.equal(shell().selectors.rightCollapsed, false); + + // Switching tools changes only the host's topology: the shell stays put. + const shellBeforeSwitch = shellRenders; + const hostBeforeSwitch = hostRenders; + await act(async () => shell().commands.openTool('inspector')); + assert.equal( + host().panelsState.right.tabs.some((tab) => tab.kind === 'inspector'), + true, + ); + assert.equal(hostRenders > hostBeforeSwitch, true); + assert.equal(shellRenders, shellBeforeSwitch); + + // A hidden companion fork is shell state: the rail and palette filter it. + const shellBeforeFork = shellRenders; + await act(async () => + host().onForkVisibilityChange?.({ type: 'fork-created', sessionId: 'fork' }), + ); + assert.equal(shell().selectors.hiddenSessionIds.has('fork'), true); + assert.equal(shellRenders, shellBeforeFork + 1); + + // So is collapse, which WorkHub's dock reads. + const shellBeforeCollapse = shellRenders; + await act(async () => host().onToggleRightPanel()); + assert.equal(shell().selectors.rightCollapsed, true); + assert.equal(shellRenders, shellBeforeCollapse + 1); + + const { bridge } = shell(); + await act(async () => root.unmount()); + assert.equal(bridge.getState().hiddenSessionIds.size, 0); + assert.equal(bridge.getState().ready, false); + }); + + it('keeps shell commands safe before the provider publishes', async () => { + const bridge = createWorkbarShellBridge(); + let notified = 0; + bridge.subscribe(() => { notified += 1; }); + bridge.commands.openTool('review'); + bridge.commands.toggleRight(); + await bridge.commands.respondToUserForm('a', { requestId: 'form', action: 'cancel' }); + assert.equal(typeof bridge.commands.bindNewTaskSessionResolver(1), 'function'); + bridge.disconnect(); + assert.equal(notified, 0); + }); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 4a773008da8..1f8c83614e1 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -73,7 +73,7 @@ import * as Conversation from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { AgentGraphPanel } from './agent-graph-panel'; import { ChatComposerRegion, selectLatestRequestUsage } from './chat-composer-region'; -import { WorkbarHost, useWorkbarController } from './features/workbar'; +import { WorkbarHost, WorkbarProvider, WorkbarShellRoot, type WorkbarShellProjection } from './features/workbar'; import { AppUpdateProvider } from './features/app-update/index.js'; import * as Goals from './features/goals'; import * as ModuleHub from './features/module-hub'; @@ -248,9 +248,13 @@ export function AppShell() { {(overlays) => ( {(sharedSessionDialog) => ( - + + {(workbar) => ( + + )} + )} )} @@ -280,6 +284,7 @@ function AppShellContent({ taskEntry, overlays, sharedSessionDialog, + workbar: { bridge, commands, selectors, LiveContextUsageProbe }, uiLocale, uiLocaleOverride, setUiLocaleOverride, @@ -288,6 +293,7 @@ function AppShellContent({ taskEntry: TaskEntryShellProjection; overlays: OverlaysShellProjection; sharedSessionDialog: SessionCollaborationDialogProjection; + workbar: WorkbarShellProjection; uiLocale: UiLocale; uiLocaleOverride: UiLocale | null; setUiLocaleOverride: Dispatch>; @@ -1169,24 +1175,6 @@ function AppShellContent({ }), [toastApi], ); - const workbar = useWorkbarController({ - workHub: { enabled: workHubEnabled, active: workHubActive }, - available: sessionsSelected && (workHubActive || Boolean(activeHostSession)), - layoutSessionId: activeId, - activeSession: activeHostSession, - projectId: currentProjectId, - projectAliases: currentProject?.aliases ?? [], - authoritativeSessionIds, - shellObscured, - modelChoices: chatModelChoices, - toastApi, - composerRef, - openNewTaskSurface, - openSessionInChat, - resolveWorkBoardTarget, - prepareWorkBoardDraft, - }); - const { commands, selectors, LiveContextUsageProbe } = workbar; const exitWorkHub = useCallback(() => setWorkHubActive(false), []); const selectSessionSurface = useCallback( @@ -1261,10 +1249,8 @@ function AppShellContent({ setNavSelection, openSearchModal: openSearch, setSessionListCollapsed: sessionRailLayoutStore.setCollapsed, - workbar: { - setWorkbarCollapsed: commands.setWorkbarCollapsed, - openTool: commands.openTool, - }, + // Stable bridge delegates: the fixture keeps them across Workbar updates. + workbar: commands, setThemePref, setUiLocaleOverride, }); @@ -1604,7 +1590,7 @@ function AppShellContent({ const ok = await send(text, pending, { waitForHostAdmission: revisionSend, targetSessionId: expectedRevisionDraft?.draftSessionId, - onSessionResolved: workbar.commands.bindNewTaskSessionResolver(readSelectionRevision()), + onSessionResolved: commands.bindNewTaskSessionResolver(readSelectionRevision()), ...directoryOptions, ...(quotes ? { quotes } : {}), ...(workspaceFileReferences.length @@ -2047,10 +2033,10 @@ 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 - // readers. Composer mentions still wrap the frame so one projection serves - // every composer, including side-chat panels, without rebuilding the frame - // on catalog moves. + // Workbar, Goal state 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. +
sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} onOpenSearchModal={openSearch} - workbar={{ model: workbar.host, togglePosition: workbarTogglePosition }} + workbar={{ togglePosition: workbarTogglePosition }} > {/* Only a session has an identity to state. The other views name themselves in the nav column they are selected from, and the @@ -2278,7 +2283,7 @@ function AppShellContent({ inert={switchingSession || undefined} aria-busy={switchingSession || undefined}> - commands.toggleTool('inspector')} onToggleWorkbar={commands.toggleRight} onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} /> @@ -2575,7 +2580,7 @@ function AppShellContent({
{/* Collapse hides the Workbar surface without unmounting its tools. */} - + @@ -2625,6 +2630,7 @@ function AppShellContent({ onSelectedRuntimeHostProfileIdChange={setSettingsProfileId} /> +
diff --git a/apps/desktop/src/renderer/features/workbar/README.md b/apps/desktop/src/renderer/features/workbar/README.md index 214870b953b..505640bc6dd 100644 --- a/apps/desktop/src/renderer/features/workbar/README.md +++ b/apps/desktop/src/renderer/features/workbar/README.md @@ -38,17 +38,35 @@ remounted when the active session changes. - 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 - mention/model context. + dynamic resources and Side Chat visibility. `WorkbarProvider` is its sole + production owner; `AppShell` supplies only the active Session, workspace + availability, authoritative Session ids, shell visibility and composer + mention/model context as the provider's `input`. -## Public surface +## Shell boundary -- `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. +`WorkbarShellRoot` sits above `AppShellContent` beside the other feature +roots and hands it the Workbar projection, so the shell body calls no Workbar +hook. The root owns a per-shell bridge and reads its equality-selected shell +state, without calling the controller. `WorkbarProvider` publishes each +committed controller into that bridge, so tab switches, panel topology and +resize drags re-render the provider and the host model's readers, not the +shell. + +- `commands` are stable delegates to the latest committed controller: + `openTool`, `openSideChatWithQuote`, `toggleRight`, `toggleTool`, + `setWorkbarCollapsed`, the interaction responders and + `bindNewTaskSessionResolver`. Before the provider publishes they are no-ops. +- `selectors.hiddenSessionIds` filters ephemeral companion forks from the rail + and the command palette; `selectors.rightCollapsed` and `selectors.ready` + drive WorkHub's dock and navigation. The shell re-renders only when one of + these values changes. +- `WorkbarHost` and `WorkbarTitlebarActions` read the host model from the + provider's context. The provider also publishes + `--maka-session-workbar-width` on an inherited custom property, so the frame + and titlebar size from it without a shell render. Storybook renders the + environment-free `WorkbarHostView` and `WorkbarTitlebarActionsView` with its + own model. ## Review comparison preference 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 00000000000..e2238bf3edc --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/controller/workbar-shell-bridge.ts @@ -0,0 +1,114 @@ +/* + * 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 { + WorkbarController, + WorkbarControllerCommands, +} from './use-workbar-controller.js'; +import { LiveContextUsageProbe } from '../tools/inspector/live-context-usage-probe.js'; + +/** The Workbar state the shell body still renders from. */ +export interface WorkbarShellState { + /** Companion Sessions the rail and palette hide (mounted side-chat forks). */ + readonly hiddenSessionIds: ReadonlySet; + readonly rightCollapsed: boolean; + /** Whether the Workbar is available with a Session to show; WorkHub's + * navigation waits for it before opening a tool. */ + readonly ready: boolean; +} + +const EMPTY_STATE: WorkbarShellState = { + hiddenSessionIds: new Set(), + rightCollapsed: true, + ready: false, +}; + +function readonlyStringSetEqual( + left: ReadonlySet, + right: ReadonlySet, +): boolean { + if (left.size === right.size) { + for (const value of left) if (!right.has(value)) return false; + return true; + } + return false; +} + +function shellStateEqual(left: WorkbarShellState, right: WorkbarShellState): boolean { + return left.rightCollapsed === right.rightCollapsed && + left.ready === right.ready && + readonlyStringSetEqual(left.hiddenSessionIds, right.hiddenSessionIds); +} + +const ignoreResolvedSession = (): void => undefined; + +/** + * Per-shell command port and read store; the provider alone owns the + * controller. Commands are stable delegates, so publishing a new controller + * never re-renders the shell. The shell's read is the small state above, and + * it is replaced only when one of its values actually changes: tab switches, + * resize drags and panel topology stay inside the provider. + */ +export function createWorkbarShellBridge() { + let controller: WorkbarController | undefined; + let state = EMPTY_STATE; + const listeners = new Set<() => void>(); + const publishState = (next: WorkbarShellState) => { + if (shellStateEqual(state, next)) return; + state = next; + for (const listener of [...listeners]) listener(); + }; + const commands: WorkbarControllerCommands = { + openTool: (...args) => controller?.commands.openTool(...args), + openSideChatWithQuote: (quote) => controller?.commands.openSideChatWithQuote(quote), + respondToClientCapability: (response) => + controller?.commands.respondToClientCapability(response) ?? Promise.resolve(), + respondToUserForm: (sessionId, response) => + controller?.commands.respondToUserForm(sessionId, response) ?? Promise.resolve(), + toggleRight: () => controller?.commands.toggleRight(), + toggleTool: (kind) => controller?.commands.toggleTool(kind), + setWorkbarCollapsed: (collapsed) => controller?.commands.setWorkbarCollapsed(collapsed), + bindNewTaskSessionResolver: (surfaceOwnerToken) => + controller?.commands.bindNewTaskSessionResolver(surfaceOwnerToken) ?? ignoreResolvedSession, + }; + return { + commands, + /** Forwarded so the shell gains no import edge to the inspector (#4717). */ + LiveContextUsageProbe, + getState: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + publish(next: WorkbarController) { + controller = next; + publishState({ + hiddenSessionIds: next.selectors.hiddenSessionIds, + rightCollapsed: next.selectors.rightCollapsed, + ready: Boolean(next.host.activeId), + }); + }, + disconnect() { + controller = undefined; + publishState(EMPTY_STATE); + }, + }; +} + +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 a538600d2d4..54dcf2151e3 100644 --- a/apps/desktop/src/renderer/features/workbar/index.ts +++ b/apps/desktop/src/renderer/features/workbar/index.ts @@ -24,7 +24,8 @@ // which nothing shipped imports. export { WorkbarTitlebarActions } from './ui/workbar-toggle'; export { WorkbarHost } from './ui/workbar-host'; +export { WorkbarProvider } from './ui/workbar-provider'; +export { WorkbarShellRoot, type WorkbarShellProjection } from './ui/workbar-shell-root'; 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 3945830ccc6..dd9a73ea7b4 100644 --- a/apps/desktop/src/renderer/features/workbar/stories.ts +++ b/apps/desktop/src/renderer/features/workbar/stories.ts @@ -31,5 +31,7 @@ */ export { WorkbarSurface } from './ui/workbar-surface.js'; +export { WorkbarHostView } from './ui/workbar-host.js'; +export { WorkbarTitlebarActionsView } from './ui/workbar-toggle.js'; export type { WorkbarHostModel } from './ui/workbar-host.js'; export { useWorkbarLayoutState } from './controller/use-workbar-layout-state.js'; diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 114fd9513b3..a3e21a418bb 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -56,7 +56,14 @@ export * from './tools/terminal/session-terminal-hydration.js'; export * from './tools/terminal/session-terminal-query.js'; export * from './tools/terminal/session-terminal-frame.js'; export * from '../../application/contracts/session-inspector/use-session-trace.js'; -export * from './controller/use-workbar-controller.js'; +export { useWorkbarController } from './controller/use-workbar-controller.js'; +export type { + UseWorkbarControllerInput, + WorkbarController, +} from './controller/use-workbar-controller.js'; +export { createWorkbarShellBridge } from './controller/workbar-shell-bridge.js'; +export { WorkbarShellRoot } from './ui/workbar-shell-root.js'; +export { WorkbarProvider, useWorkbarHostModel } from './ui/workbar-provider.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 f5cf9e89fcd..40f0d87586e 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx @@ -47,6 +47,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, togglePosition = 'edge' }: { model: WorkbarHostModel; togglePosition?: WorkbarTogglePosition }) { +export function WorkbarHost({ togglePosition }: { togglePosition?: WorkbarTogglePosition }) { + return ; +} + +/** Environment-free view seam for Storybook, which supplies its own model. */ +export function WorkbarHostView({ model: props, togglePosition = 'edge' }: { model: WorkbarHostModel; togglePosition?: WorkbarTogglePosition }) { 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 00000000000..21ce0f7c571 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx @@ -0,0 +1,68 @@ +/* + * 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, + useContext, + useLayoutEffect, + type CSSProperties, + type ReactNode, +} from 'react'; +import { + useWorkbarController, + type UseWorkbarControllerInput, +} from '../controller/use-workbar-controller.js'; +import type { WorkbarShellBridge } from '../controller/workbar-shell-bridge.js'; +import type { WorkbarHostModel } from './workbar-host.js'; + +const WorkbarHostModelContext = createContext(null); + +/** + * Sole Workbar controller owner. Its updates reuse children built by the + * shell, so only the host model's readers (the host and its titlebar restore + * affordance) re-render; the shell reads the bridge's narrow state instead. + */ +export function WorkbarProvider(props: { + readonly bridge: WorkbarShellBridge; + readonly input: UseWorkbarControllerInput; + readonly children?: ReactNode; +}) { + const controller = useWorkbarController(props.input); + useLayoutEffect(() => props.bridge.publish(controller), [props.bridge, controller]); + useLayoutEffect(() => () => props.bridge.disconnect(), [props.bridge]); + // Shell columns and the titlebar reserve both size from the Workbar width. + // An inherited custom property carries it to them without re-rendering the + // shell on every resize step. + const style = { + '--maka-session-workbar-width': `${controller.host.rightWidth}px`, + } as CSSProperties; + return ( + +
+ {props.children} +
+
+ ); +} + +export function useWorkbarHostModel(): WorkbarHostModel { + const model = useContext(WorkbarHostModelContext); + if (!model) throw new Error('WorkbarProvider is missing'); + return model; +} diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-shell-root.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-shell-root.tsx new file mode 100644 index 00000000000..128321bffd3 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-shell-root.tsx @@ -0,0 +1,47 @@ +/* + * 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 { useMemo, useSyncExternalStore, type ReactNode } from 'react'; +import { createWorkbarShellBridge } from '../controller/workbar-shell-bridge.js'; + +/** What the shell receives: stable commands plus the state it renders from. */ +export type WorkbarShellProjection = ReturnType; + +function useWorkbarShellProjection() { + const bridge = useMemo(createWorkbarShellBridge, []); + const selectors = useSyncExternalStore(bridge.subscribe, bridge.getState, bridge.getState); + return useMemo(() => ({ + bridge, + commands: bridge.commands, + selectors, + LiveContextUsageProbe: bridge.LiveContextUsageProbe, + }), [bridge, selectors]); +} + +/** + * Owns the per-shell bridge above the shell and hands it the projection, the + * way `TaskEntryRoot` hands over `taskEntry`. The controller lives in + * `WorkbarProvider`, which publishes into the bridge; this root re-renders + * the shell only when the equality-selected shell state changes. + */ +export function WorkbarShellRoot(props: { + readonly children: (workbar: WorkbarShellProjection) => ReactNode; +}) { + return props.children(useWorkbarShellProjection()); +} 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 5e717d021dd..4c45d547c85 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx @@ -18,12 +18,13 @@ */ import type { WorkbarTogglePosition } from '@maka/core/settings'; -import type { WorkbarHostModel } from './workbar-host'; import { Icon } from '@astryxdesign/core/Icon'; 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 type { WorkbarHostModel } from './workbar-host'; +import { useWorkbarHostModel } from './workbar-provider.js'; /** The same control in the titlebar when collapsed and the panel when open. */ export function WorkbarToggle(props: { @@ -54,12 +55,17 @@ export function WorkbarToggle(props: { } /** Titlebar restore affordance shown only while the Workbar is collapsed. */ -export function WorkbarTitlebarActions(props: { +export function WorkbarTitlebarActions(props: { togglePosition: WorkbarTogglePosition }) { + return ; +} + +/** Environment-free view seam for Storybook, which supplies its own model. */ +export function WorkbarTitlebarActionsView({ model, ...props }: { model: Pick; togglePosition: WorkbarTogglePosition; }) { const copy = getShellCopy(useUiLocale()).chrome; - if (props.togglePosition !== 'titlebar' || props.model.hidden || !props.model.activeId || !props.model.rightCollapsed) return null; + if (props.togglePosition !== 'titlebar' || model.hidden || !model.activeId || !model.rightCollapsed) return null; return (
- +
); } diff --git a/apps/desktop/src/renderer/shell/frame-style.ts b/apps/desktop/src/renderer/shell/frame-style.ts index 65aabb9d6f4..1f20151b215 100644 --- a/apps/desktop/src/renderer/shell/frame-style.ts +++ b/apps/desktop/src/renderer/shell/frame-style.ts @@ -28,10 +28,8 @@ import type { CSSProperties } from 'react'; export function appShellFrameStyle(input: { sessionListCollapsed: boolean; sessionListWidth: number; - workbarRightWidth: number; }): CSSProperties { return { - '--maka-session-workbar-width': `${input.workbarRightWidth}px`, '--maka-sidenav-width': `${input.sessionListCollapsed ? 0 : input.sessionListWidth}px`, } as CSSProperties; } diff --git a/apps/desktop/src/renderer/styles/workbar/shell.css b/apps/desktop/src/renderer/styles/workbar/shell.css index bf939d18e32..e3498932cd6 100644 --- a/apps/desktop/src/renderer/styles/workbar/shell.css +++ b/apps/desktop/src/renderer/styles/workbar/shell.css @@ -341,6 +341,7 @@ display: none; } +.maka-workbar-shell-vars, .maka-workbar-layout-vars, .maka-workbar-workspace-contents { display: contents; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 8a6077a74f5..f40a065ecdb 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -45,7 +45,7 @@ import { SettingsOverlay } from '../src/renderer/app-shell-overlays'; import { WorkbarServicesProvider, } 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, @@ -321,8 +321,9 @@ function ShellFrame(props: { ...appShellFrameStyle({ sessionListCollapsed: props.sidebarCollapsed ?? false, sessionListWidth: SESSION_LIST_EXPANDED_DEFAULT_WIDTH, - workbarRightWidth: props.workbarWidth ?? SESSION_WORKBAR_DEFAULT_WIDTH, }), + // Production publishes this from WorkbarProvider, above the frame. + '--maka-session-workbar-width': `${props.workbarWidth ?? SESSION_WORKBAR_DEFAULT_WIDTH}px`, } as CSSProperties } > @@ -425,7 +426,6 @@ function ComposedShell(props: { sidebarCollapsed={collapsed} onToggleSidebar={() => setCollapsed((current) => !current)} onOpenSearchModal={noop} - workbar={props.workbarToggle ? { togglePosition: 'titlebar', model: { activeId: active?.id, hidden: !active, rightCollapsed: props.workbarToggle.collapsed, onToggleRightPanel: props.workbarToggle.onToggle } } : undefined} > {/* Derived from the same session and project catalog the sidebar reads, not hand-passed: a story cannot show a project the session does not @@ -445,6 +445,14 @@ function ComposedShell(props: { })()} /> )} + {/* Production renders this through the titlebar's `workbar` prop, after + the children; the story supplies its own model to the view. */} + {props.workbarToggle && ( + + )} }>
Conversation remains mounted while reading.
} - + ; } diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 94251525a7a..c22f11df447 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 302 files — blocker 0, reimplementation 0, polish 4, aligned 298. +**Totals:** 304 files — blocker 0, reimplementation 0, polish 4, aligned 300. ## Exclusions (explicit) @@ -127,6 +127,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/workbar/ui/recent-turn-overlay.tsx` | shell-chrome-or-panel | Button | aligned — uses Astryx (Button) | 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, IconButton, ResizeHandle, Spinner | aligned — uses Astryx (Card, IconButton, 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-shell-root.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, Button, Card, Heading, Icon, Kbd, List, ListItem, Section, Spinner, Tab, TabList | aligned — uses Astryx (Badge, Button, Card, Heading, Icon, Kbd, List, ListItem) | 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/features/workhub/ui/control-overlay.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index dac7e7087d8..a0c39e3391f 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -97,6 +97,8 @@ apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel apps/desktop/src/renderer/features/workbar/ui/recent-turn-overlay.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-shell-root.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/features/workhub/ui/control-overlay.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index b35cadef1a8..3dede6502dc 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -152,7 +152,6 @@ export const ALLOWED = { // one stays because the shell body reads `keys` to build the turn footer's // disabled mask. useTurnActionRegistry: 1, - useWorkbarController: 1, }, };