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: {
})()}
/>
)}
-
-