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(); + }, +};