From 7e14635f33081db55fa64c8ce8242d313ffe8611 Mon Sep 17 00:00:00 2001
From: sungl <81428141+Sun-GLiang@users.noreply.github.com>
Date: Sat, 5 Sep 2026 14:13:26 +0800
Subject: [PATCH] fix(desktop): keep titlebar actions clear of workbar
Hoist the right workbar width to the shared frame and reserve that space in the titlebar while the panel is open. Add a regression test for a long session title with the maximum-width usage panel and verify Share remains clickable.
Fixes #4826
Generated-by: Maka
---
apps/desktop/e2e-budget.json | 4 +-
apps/desktop/renderer-architecture.json | 2 +-
apps/desktop/src/renderer/app-shell.tsx | 51 ++++----
.../features/workbar/ui/workbar-host.tsx | 1 -
.../src/renderer/styles/shell-layout.css | 14 ++-
.../src/renderer/styles/workbar/artifacts.css | 4 +
apps/desktop/stories/app-shell.stories.tsx | 117 ++++++++++++++++--
7 files changed, 150 insertions(+), 43 deletions(-)
diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json
index 383cff4e3c..60817f69f2 100644
--- a/apps/desktop/e2e-budget.json
+++ b/apps/desktop/e2e-budget.json
@@ -30,8 +30,8 @@
"electron": "needs a second real Session (Host round trip) to switch to; the focus and draft-restore halves alone would not earn a window"
},
"session-workbar.spec.ts": {
- "tests": 5,
- "electron": "Git changes re-read on window focus, terminal PTY ownership across Sessions, Side Chat's fork lifecycle, and a first send that has to reach the Host; the composer-usage test is renderer-only and rides along on those windows until app-shell.tsx's composer-to-workbar wiring has a story host"
+ "tests": 6,
+ "electron": "workbar visibility persists across Session switches and reloads, Git changes re-read on window focus, terminal PTY ownership across Sessions, Side Chat's fork lifecycle, and a first send that has to reach the Host; the composer-usage test is renderer-only and rides along on those windows until app-shell.tsx's composer-to-workbar wiring has a story host"
},
"settings.spec.ts": {
"tests": 4,
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index f0212f15df..40a345cb03 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -899,7 +899,7 @@
"react": 1
},
"importSpecifiers": 124,
- "nonTriviaTokens": 15563
+ "nonTriviaTokens": 15562
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 53e23f1994..c205701ab3 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -1604,6 +1604,7 @@ function AppShellContent({
modelChoices: chatModelChoices,
reportError: reportWorkbarError,
});
+ const { commands, selectors, LiveContextUsageProbe } = workbar;
const exitWorkHub = useCallback(() => setWorkHubActive(false), []);
const selectSessionSurface = useCallback(
@@ -1648,7 +1649,7 @@ function AppShellContent({
sessions,
activeSessionId: activeId,
activeSession,
- hiddenSessionIds: workbar.selectors.hiddenSessionIds,
+ hiddenSessionIds: selectors.hiddenSessionIds,
});
const visibleSessions = sessionRail.sessions;
const sessionListCollapsed = railLayout.collapsed;
@@ -1727,9 +1728,9 @@ function AppShellContent({
setSearchModalOpen,
setSessionListCollapsed: sessionRailLayoutStore.setCollapsed,
workbar: {
- rightCollapsed: workbar.selectors.rightCollapsed,
- toggleRight: workbar.commands.toggleRight,
- openTool: workbar.commands.openTool,
+ rightCollapsed: selectors.rightCollapsed,
+ toggleRight: commands.toggleRight,
+ openTool: commands.openTool,
},
setThemePref,
setUiLocaleOverride,
@@ -1764,7 +1765,7 @@ function AppShellContent({
setInteractionBySession: sessionUiController.setInteractionBySession,
onInteractionChanged: markInteractionChanged,
onExecutionBoundaryChanged: reloadActiveExecutionBoundary,
- respondToUserForm: workbar.commands.respondToUserForm,
+ respondToUserForm: commands.respondToUserForm,
showModelSetupToast,
toastApi,
newChatModel: newChatModel ?? null,
@@ -1992,7 +1993,7 @@ function AppShellContent({
);
return false;
}
- workbar.commands.openTool('side-chat', 'right', {
+ commands.openTool('side-chat', 'right', {
...(slashCommand.command.prompt
? { initialPrompt: slashCommand.command.prompt }
: {}),
@@ -2594,7 +2595,7 @@ function AppShellContent({
},
openProjectFolder,
openSessionInChat,
- openSideConversation: () => workbar.commands.openTool('side-chat'),
+ openSideConversation: () => commands.openTool('side-chat'),
openSettings,
openSettingsSection,
openSkillsFolder,
@@ -2654,24 +2655,14 @@ function AppShellContent({
readers at all — and three writers of the same value is three chances
for them to disagree. */
data-sidebar-state={sessionListCollapsed ? 'collapsed' : 'expanded'}
- /* Published here for the same reason `data-sidebar-state` is: the frame is
- the only ancestor shared by the sidebar column and the titlebar strip,
- and both need this number. The column is this wide; the titlebar's
- session breadcrumb opens at that edge rather than straddling the seam
- between the columns.
-
- Only the EXPANDED width, and only as an inline style, because that is
- the half of the answer this component owns — the user's dragged width.
- The collapsed width is a constant, so shell-layout.css states it off
- `data-sidebar-state`. Writing both here would duplicate the constant;
- writing this one unconditionally would bury the other, since an inline
- custom property outranks any rule that redefines it. */
+ /* The frame is the shared owner for dimensions consumed by both shell
+ columns and titlebar chrome. CSS clears the titlebar reserve when the
+ responsive layout moves the workbar below the conversation. */
style={
- sessionListCollapsed
- ? undefined
- : ({
- '--maka-sidenav-width': `${sessionListWidth}px`,
- } as CSSProperties)
+ ({
+ '--maka-session-workbar-width': `${workbar.host.rightWidth}px`,
+ '--maka-sidenav-width': sessionListCollapsed ? 0 : `${sessionListWidth}px`,
+ } as CSSProperties)
}
>
)}
>
@@ -2893,7 +2884,7 @@ function AppShellContent({
newTaskSendPending={newTaskSendPending}
stopPendingBySession={stopPendingBySession}
respondToSandboxBoundary={respondToSandboxBoundary}
- respondToClientCapability={workbar.commands.respondToClientCapability}
+ respondToClientCapability={commands.respondToClientCapability}
respondToUserQuestion={respondToUserQuestion}
respondToUserForm={respondToUserForm}
stop={stop}
@@ -2959,8 +2950,8 @@ function AppShellContent({
activeModelLabel={activeModelLabel}
activeProviderType={activeConnection?.providerType}
latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)}
- onOpenContextUsage={() => workbar.commands.openTool('inspector')}
- LiveContextUsageProbe={workbar.LiveContextUsageProbe}
+ onOpenContextUsage={() => commands.openTool('inspector')}
+ LiveContextUsageProbe={LiveContextUsageProbe}
modelChoices={chatModelChoices}
modelSwitchHasHistory={modelSwitchHasHistory}
hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'}
@@ -3137,7 +3128,7 @@ function AppShellContent({
text: input.text,
sourceTurnId: input.turnId,
};
- workbar.commands.openSideChatWithQuote(quote);
+ commands.openSideChatWithQuote(quote);
}
: undefined
}
diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx
index 08af527931..2cce85524e 100644
--- a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx
+++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx
@@ -127,7 +127,6 @@ export function WorkbarHost({ model: props }: { model: WorkbarHostModel }) {
const toast = useToast();
const copy = getShellCopy(locale).app;
const style = {
- '--maka-session-workbar-width': `${props.rightWidth}px`,
'--maka-session-bottom-panel-height': `${props.bottomHeight}px`,
} as CSSProperties;
diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css
index ce6ea21dd8..3dfe4f8785 100644
--- a/apps/desktop/src/renderer/styles/shell-layout.css
+++ b/apps/desktop/src/renderer/styles/shell-layout.css
@@ -273,7 +273,10 @@
the OS reports (macOS: traffic lights on the left; Windows: caption buttons
on the right; Linux: neither, so the design floor stands). */
padding-left: var(--maka-titlebar-gutter-left);
- padding-right: calc(var(--space-6) + var(--maka-titlebar-overlay-right-width));
+ padding-right: calc(
+ var(--space-6) + var(--maka-titlebar-overlay-right-width) +
+ var(--maka-titlebar-workbar-reserve, 0px)
+ );
-webkit-app-region: drag;
/* Three columns, not a flex row, because the middle one has to line up with
@@ -321,6 +324,15 @@
change to the sidebar's motion, not to this strip. */
}
+:where(
+ .appFrame:has(.maka-session-workbar[data-placement='right']:not([data-collapsed]))
+)
+ .maka-window-titlebar {
+ --maka-titlebar-workbar-reserve: calc(
+ var(--maka-session-workbar-width) + var(--agents-content-area-gap)
+ );
+}
+
/* Drag regions are hit-tested from element rects, and the top layer is invisible
to that pass: a `showModal()` dialog paints over the titlebar, but its controls
inside the titlebar rect still reach the OS as window drags. */
diff --git a/apps/desktop/src/renderer/styles/workbar/artifacts.css b/apps/desktop/src/renderer/styles/workbar/artifacts.css
index 3b87bcffc8..a5df99bbb5 100644
--- a/apps/desktop/src/renderer/styles/workbar/artifacts.css
+++ b/apps/desktop/src/renderer/styles/workbar/artifacts.css
@@ -372,6 +372,10 @@
/* Narrow windows place the single workbar below the conversation. */
@media (max-width: 990px) {
+ .maka-window-titlebar {
+ --maka-titlebar-workbar-reserve: 0px;
+ }
+
.maka-detail-with-artifacts {
grid-template-areas:
"main"
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index 0fb8135c2e..0d35bac14e 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -18,7 +18,7 @@
*/
import type { Meta, StoryObj } from '@storybook/react-vite';
-import { expect, userEvent, waitFor, within } from 'storybook/test';
+import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import { useEffect, useReducer, useState, type CSSProperties, type ReactNode } from 'react';
import type { ComponentProps } from 'react';
import type { ProjectRecord } from '@maka/core/project';
@@ -42,6 +42,7 @@ import { WorkbarSurface } from '../src/renderer/features/workbar/stories';
import {
createFakeWorkbarServices,
createSessionWorkbarPanelsState,
+ isSessionWorkbarCollapsed,
reduceWorkbarLayout,
SESSION_BOTTOM_PANEL_DEFAULT_HEIGHT,
SESSION_WORKBAR_DEFAULT_WIDTH,
@@ -287,6 +288,7 @@ function ShellFrame(props: {
height?: number | string;
motionEnabled?: boolean;
sidebarCollapsed?: boolean;
+ workbarWidth?: number;
}) {
return (
@@ -356,6 +361,8 @@ function ComposedShell(props: {
updateReminder?: SessionListPanelProps['updateReminder'];
workbarCollapsed?: boolean;
onToggleWorkbar?: () => void;
+ workbarWidth?: number;
+ titlebarAction?: ComponentProps
['action'];
}) {
const [collapsed, setCollapsed] = useState(props.sidebarCollapsed ?? false);
const [viewMode, setViewMode] = useState(props.initialViewMode ?? 'conversation');
@@ -402,6 +409,7 @@ function ComposedShell(props: {
height={props.frameHeight}
motionEnabled={props.motionEnabled}
sidebarCollapsed={collapsed}
+ workbarWidth={props.workbarWidth}
>
{
const name = deriveTitlebarProjectName({
projectName: catalogProjects.find((item) => item.id === active.projectId)?.name,
@@ -1494,6 +1503,11 @@ export const PlanAndSwarmModeOn: Story = {
function PlusMenuRefreshHarness() {
const [planModeActive, setPlanModeActive] = useState(false);
const [skillsLoading, setSkillsLoading] = useState(false);
+ useEffect(() => {
+ const settleRefresh = () => setSkillsLoading(false);
+ window.addEventListener('maka-story-settle-skill-refresh', settleRefresh);
+ return () => window.removeEventListener('maka-story-settle-skill-refresh', settleRefresh);
+ }, []);
return (
setSkillsLoading(false), 150);
},
}}
/>
@@ -1542,6 +1555,9 @@ export const PlusMenuDuringSkillRefresh: Story = {
await expect(editor).toHaveTextContent('');
await expect(page.queryByRole('listbox', { name: /技能/ })).not.toBeInTheDocument();
+ const view = canvasElement.ownerDocument.defaultView;
+ if (!view) throw new Error('the Storybook window is missing');
+ view.dispatchEvent(new view.Event('maka-story-settle-skill-refresh'));
await waitFor(() => {
const settledRow = within(
page.getByRole('menu', { name: '添加上下文' }),
@@ -2971,7 +2987,8 @@ export const RailStaysOnTheVisiblePrompt: Story = {
const workbarLayoutWithOneFace: WorkbarLayoutState = reduceWorkbarLayout(
{
panels: createSessionWorkbarPanelsState(),
- rightCollapsed: true,
+ activeSessionId: 'session-active',
+ collapsedBySession: {},
bottomOpen: false,
rightWidth: SESSION_WORKBAR_DEFAULT_WIDTH,
bottomHeight: SESSION_BOTTOM_PANEL_DEFAULT_HEIGHT,
@@ -2979,21 +2996,30 @@ const workbarLayoutWithOneFace: WorkbarLayoutState = reduceWorkbarLayout(
{ type: 'open', placement: 'right', tab: { id: 'workbar:files', kind: 'files' } },
);
-function WorkbarInShell() {
+function WorkbarInShell(props: {
+ sessionName?: string;
+ titlebarAction?: ComponentProps['action'];
+ workbarWidth?: number;
+}) {
const [layout, dispatch] = useReducer(reduceWorkbarLayout, workbarLayoutWithOneFace);
const collapseRight = (collapsed: boolean) =>
dispatch({ type: 'collapse', placement: 'right', collapsed });
+ const workbarWidth = props.workbarWidth ?? layout.rightWidth;
+ const rightCollapsed = isSessionWorkbarCollapsed(layout);
return (
collapseRight(!layout.rightCollapsed)}
+ session={props.sessionName ? { name: props.sessionName } : undefined}
+ titlebarAction={props.titlebarAction}
+ workbarCollapsed={rightCollapsed}
+ onToggleWorkbar={() => collapseRight(!rightCollapsed)}
+ workbarWidth={workbarWidth}
detailChildren={
@@ -3002,7 +3028,7 @@ function WorkbarInShell() {
hidden={false}
onDismissPanel={() => collapseRight(true)}
panelsState={layout.panels}
- rightCollapsed={layout.rightCollapsed}
+ rightCollapsed={rightCollapsed}
bottomOpen={layout.bottomOpen}
onActivateTab={(placement, tabId) =>
dispatch({ type: 'activate', placement, tabId })
@@ -3117,3 +3143,78 @@ export const WorkbarCollapseKeepsOneToggleInPlace: Story = {
expect(Math.abs(restoredToggleBox.y - parked.y)).toBeLessThanOrEqual(1);
},
};
+
+const longWorkbarTitle =
+ 'Investigate why the completed plan session title overlaps the token usage dashboard';
+
+const wideWorkbarShare = fn();
+
+export const WorkbarKeepsTitlebarClear: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ wideWorkbarShare.mockClear();
+ const canvas = within(canvasElement);
+ const identity = canvasElement.querySelector
(
+ '[data-maka-contract="titlebar-identity"]',
+ );
+ const workbar = canvasElement.querySelector(
+ '.maka-session-workbar[data-placement="right"]:not([data-collapsed])',
+ );
+ if (!identity || !workbar) throw new Error('the titlebar or right workbar is missing');
+
+ await waitFor(() => expect(workbar.getBoundingClientRect().width).toBeCloseTo(600, 0));
+ expect(identity.getBoundingClientRect().right).toBeLessThanOrEqual(
+ workbar.getBoundingClientRect().left,
+ );
+
+ await userEvent.click(canvas.getByRole('button', { name: '分享此任务' }));
+ expect(wideWorkbarShare).toHaveBeenCalledOnce();
+ },
+};
+
+const narrowWorkbarShare = fn();
+
+export const NarrowWorkbarClearsTitlebarReserve: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ narrowWorkbarShare.mockClear();
+ const canvas = within(canvasElement);
+ const titlebar = canvasElement.querySelector('.maka-window-titlebar');
+ const detail = canvasElement.querySelector('.maka-detail-with-artifacts');
+ const workbar = canvasElement.querySelector(
+ '.maka-session-workbar[data-placement="right"]:not([data-collapsed])',
+ );
+ if (!titlebar || !detail || !workbar) {
+ throw new Error('the titlebar, detail area, or right workbar is missing');
+ }
+
+ const share = canvas.getByRole('button', { name: '分享此任务' });
+ await waitFor(() =>
+ expect(share.getBoundingClientRect().left).toBeGreaterThanOrEqual(
+ titlebar.getBoundingClientRect().left,
+ ),
+ );
+ expect(workbar.getBoundingClientRect().width).toBeCloseTo(
+ detail.getBoundingClientRect().width,
+ 0,
+ );
+ expect(share.getBoundingClientRect().right).toBeLessThanOrEqual(
+ titlebar.getBoundingClientRect().right,
+ );
+
+ await userEvent.click(share);
+ expect(narrowWorkbarShare).toHaveBeenCalledOnce();
+ },
+};