-
Notifications
You must be signed in to change notification settings - Fork 312
fix(desktop): plan titlebar chrome from the front surface #2628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import test from 'node:test'; | ||
| import { planTitlebarChrome } from '../../renderer/app-shell-titlebar-chrome.js'; | ||
|
|
||
| test('settings owns the front surface: no shell session or workbar chrome', () => { | ||
| assert.deepEqual( | ||
| planTitlebarChrome({ | ||
| settingsOpen: true, | ||
| agentsView: 'chat', | ||
| onSessionsSurface: true, | ||
| hasSessionIdentity: true, | ||
| hasWorkbarSession: true, | ||
| }), | ||
| { | ||
| showShellRail: false, | ||
| showSessionIdentity: false, | ||
| showWorkbarActions: false, | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| test('active session on the sessions surface shows shell + workbar chrome', () => { | ||
| assert.deepEqual( | ||
| planTitlebarChrome({ | ||
| settingsOpen: false, | ||
| agentsView: 'chat', | ||
| onSessionsSurface: true, | ||
| hasSessionIdentity: true, | ||
| hasWorkbarSession: true, | ||
| }), | ||
| { | ||
| showShellRail: true, | ||
| showSessionIdentity: true, | ||
| showWorkbarActions: true, | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| test('sessions surface without an active session keeps search/sidebar only', () => { | ||
| assert.deepEqual( | ||
| planTitlebarChrome({ | ||
| settingsOpen: false, | ||
| agentsView: 'chat', | ||
| onSessionsSurface: true, | ||
| hasSessionIdentity: false, | ||
| hasWorkbarSession: false, | ||
| }), | ||
| { | ||
| showShellRail: true, | ||
| showSessionIdentity: false, | ||
| showWorkbarActions: false, | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| test('session identity can appear before workbar while a placeholder view loads', () => { | ||
| assert.deepEqual( | ||
| planTitlebarChrome({ | ||
| settingsOpen: false, | ||
| agentsView: 'chat', | ||
| onSessionsSurface: true, | ||
| hasSessionIdentity: true, | ||
| hasWorkbarSession: false, | ||
| }), | ||
| { | ||
| showShellRail: true, | ||
| showSessionIdentity: true, | ||
| showWorkbarActions: false, | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| test('module hubs that own the column drop workbar actions', () => { | ||
| for (const agentsView of ['skills', 'cron', 'daily-review'] as const) { | ||
| assert.deepEqual( | ||
| planTitlebarChrome({ | ||
| settingsOpen: false, | ||
| agentsView, | ||
| onSessionsSurface: false, | ||
| hasSessionIdentity: false, | ||
| hasWorkbarSession: false, | ||
| }), | ||
| { | ||
| showShellRail: true, | ||
| showSessionIdentity: false, | ||
| showWorkbarActions: false, | ||
| }, | ||
| ); | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| /** | ||
| * Frame-level titlebar chrome planning. | ||
| * | ||
| * The titlebar strip is always painted: content columns bleed under it, and | ||
| * the OS drag region has to stay alive. Interactive controls, however, belong | ||
| * only to the surface currently in front of the user. | ||
| * | ||
| * Settings is a full-window surface (stacked under the titlebar on purpose so | ||
| * window drag still works). It owns its own navigation column. Shell | ||
| * session/workspace controls that still reflect the session *under* Settings | ||
| * are wrong affordances — hide them rather than special-casing each button. | ||
| */ | ||
|
|
||
| /** Module surfaces that own the whole column and have no session workbar. */ | ||
| export const TITLEBAR_MODULES_WITHOUT_WORKBAR = new Set([ | ||
| 'skills', | ||
| 'cron', | ||
| 'daily-review', | ||
| ]); | ||
|
|
||
| export type TitlebarChromePlan = { | ||
| /** Conversation search + session sidebar toggle (left rail). */ | ||
| readonly showShellRail: boolean; | ||
| /** Active session name / project breadcrumb (center). */ | ||
| readonly showSessionIdentity: boolean; | ||
| /** Workbar launcher + workbar column toggle (right). */ | ||
| readonly showWorkbarActions: boolean; | ||
| }; | ||
|
|
||
| export type TitlebarChromeInput = { | ||
| readonly settingsOpen: boolean; | ||
| /** Current module key used for hub surfaces (skills, cron, daily-review, …). */ | ||
| readonly agentsView: string; | ||
| readonly onSessionsSurface: boolean; | ||
| /** | ||
| * Session identity can appear on a short-lived placeholder record while the | ||
| * real summary loads (`activeSessionForView`). | ||
| */ | ||
| readonly hasSessionIdentity: boolean; | ||
| /** Workbar needs a real active session id, not only a view placeholder. */ | ||
| readonly hasWorkbarSession: boolean; | ||
| }; | ||
|
|
||
| export function planTitlebarChrome(input: TitlebarChromeInput): TitlebarChromePlan { | ||
| // Settings is the primary surface: keep drag chrome, drop shell tools. | ||
| if (input.settingsOpen) { | ||
| return { | ||
| showShellRail: false, | ||
| showSessionIdentity: false, | ||
| showWorkbarActions: false, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| showShellRail: true, | ||
| showSessionIdentity: input.onSessionsSurface && input.hasSessionIdentity, | ||
| showWorkbarActions: | ||
| input.onSessionsSurface && | ||
| input.hasWorkbarSession && | ||
| !TITLEBAR_MODULES_WITHOUT_WORKBAR.has(input.agentsView), | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -135,6 +135,7 @@ import { | |
| import { modelSetupToastCopy } from './model-connection-errors'; | ||
| import type { AppShellCommandListOptions } from './app-shell-command-actions'; | ||
| import { AppShellTopbarActions, AppShellWorkspaceTopActions } from './app-shell-chrome-actions'; | ||
| import { planTitlebarChrome } from './app-shell-titlebar-chrome'; | ||
| import { updateReminderFromStatus } from './app-shell-app-update'; | ||
| import { AppShellDetailPanel } from './app-shell-detail-panel'; | ||
| import { AppShellOverlays } from './app-shell-overlays'; | ||
|
|
@@ -222,14 +223,6 @@ type ComposerImportOwner = { | |
| * assistant stream slot when the primary post-commit signal is missed. | ||
| */ | ||
| const SETTLE_FALLBACK_GRACE_MS = 1000; | ||
| /** | ||
| * Module surfaces that own their whole column and render no workspace toolbar. | ||
| * This used to be a `display: none` rule keyed on the detail panel's | ||
| * `data-agents-view`; the toolbar now lives in the window titlebar, which is not | ||
| * a descendant of the detail panel, so the condition belongs here. | ||
| */ | ||
| const VIEWS_WITHOUT_WORKSPACE_ACTIONS = new Set(['skills', 'cron', 'daily-review']); | ||
|
|
||
| type AppShellProps = { | ||
| /** Pre-mount snapshot prefetched by main.tsx — see prefetchOnboardingSnapshot. */ | ||
| initialOnboardingSnapshot?: OnboardingSnapshot | null; | ||
|
|
@@ -2479,6 +2472,16 @@ function AppShellContent({ | |
| ? navSelection.module | ||
| : 'im_hub'; | ||
|
|
||
| // Titlebar paint is frame-level; interactive chrome follows the front surface. | ||
| // See planTitlebarChrome — Settings must not leak session workbar tools. | ||
| const titlebarChrome = planTitlebarChrome({ | ||
| settingsOpen, | ||
| agentsView, | ||
| onSessionsSurface: navSelection.section === 'sessions', | ||
| hasSessionIdentity: Boolean(activeSessionForView), | ||
| hasWorkbarSession: Boolean(activeId), | ||
| }); | ||
|
|
||
| return ( | ||
| <div | ||
| className="appFrame agents-layout-root" | ||
|
|
@@ -2523,55 +2526,51 @@ function AppShellContent({ | |
| aria-hidden={shellObscured ? 'true' : undefined} | ||
| inert={hasModalOpen ? true : undefined} | ||
| > | ||
| {/* Settings owns the full window chrome. Keep this empty header mounted | ||
| as the frameless window's drag authority, but remove every control | ||
| and identity belonging to the obscured session shell. */} | ||
| {!settingsOpen && ( | ||
| <> | ||
| <AppShellTopbarActions | ||
| sidebarCollapsed={sessionListCollapsed} | ||
| onToggleSidebar={() => sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} | ||
| onOpenSearchModal={() => setSearchModalOpen(true)} | ||
| /> | ||
| {/* Only a session has an identity to state. The other views name | ||
| themselves in the nav column they are selected from, and the | ||
| new-task surface still shows its project in the composer's | ||
| WorkspacePicker — which stops rendering at the exact moment this | ||
| takes over, when the first message creates the session. */} | ||
| {/* `activeSessionForView`, not `activeSession`: opening or creating a | ||
| session runs a few hundred ms on a placeholder record while the real | ||
| summary loads, and the name this replaced (the context layer's) was | ||
| showing through that window. Hung on the real record alone, 新任务 | ||
| was named nowhere for the length of it. */} | ||
| {navSelection.section === 'sessions' && activeSessionForView && ( | ||
| <TitlebarSessionIdentity | ||
| /* Keyed by session: the open rename is local state and the field is | ||
| uncontrolled, so a switch that left the instance mounted would | ||
| carry one session's half-typed name — and its commit — onto the | ||
| next one. A remount ties the edit to the session it belongs to. */ | ||
| key={activeSessionForView.id} | ||
| sessionName={activeSessionForView.name} | ||
| onRenameSession={(name) => { | ||
| void sessionRowActionHandlers.renameSession(activeSessionForView.id, name); | ||
| }} | ||
| project={ | ||
| titlebarProjectName | ||
| ? { name: titlebarProjectName, onOpenFolder: () => void openProjectFolder() } | ||
| : undefined | ||
| } | ||
| parentSession={titlebarParentSession} | ||
| /> | ||
| )} | ||
| {!VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && ( | ||
| <AppShellWorkspaceTopActions | ||
| workbarAvailable={navSelection.section === 'sessions' && Boolean(activeId)} | ||
| workbarCollapsed={workbarCollapsed} | ||
| onOpenWorkbarLauncher={revealWorkbarLauncher} | ||
| onToggleWorkbar={toggleWorkbar} | ||
| /> | ||
| )} | ||
| </> | ||
| {/* Titlebar strip stays mounted as the frameless window's drag authority | ||
| (Settings stacks under it). Interactive chrome follows | ||
| planTitlebarChrome — only the front surface's tools. */} | ||
| {titlebarChrome.showShellRail && ( | ||
| <AppShellTopbarActions | ||
| sidebarCollapsed={sessionListCollapsed} | ||
| onToggleSidebar={() => sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} | ||
| onOpenSearchModal={() => setSearchModalOpen(true)} | ||
| /> | ||
| )} | ||
| {/* Only a session has an identity to state. The other views name | ||
| themselves in the nav column they are selected from, and the | ||
| new-task surface still shows its project in the composer's | ||
| WorkspacePicker — which stops rendering at the exact moment this | ||
| takes over, when the first message creates the session. */} | ||
| {/* `activeSessionForView`, not `activeSession`: opening or creating a | ||
| session runs a few hundred ms on a placeholder record while the real | ||
| summary loads, and the name this replaced (the context layer's) was | ||
| showing through that window. Hung on the real record alone, 新任务 | ||
| was named nowhere for the length of it. */} | ||
| {titlebarChrome.showSessionIdentity && activeSessionForView && ( | ||
| <TitlebarSessionIdentity | ||
| /* Keyed by session: the open rename is local state and the field is | ||
| uncontrolled, so a switch that left the instance mounted would | ||
| carry one session's half-typed name — and its commit — onto the | ||
| next one. A remount ties the edit to the session it belongs to. */ | ||
| key={activeSessionForView.id} | ||
| sessionName={activeSessionForView.name} | ||
| onRenameSession={(name) => { | ||
| void sessionRowActionHandlers.renameSession(activeSessionForView.id, name); | ||
| }} | ||
| project={ | ||
| titlebarProjectName | ||
| ? { name: titlebarProjectName, onOpenFolder: () => void openProjectFolder() } | ||
| : undefined | ||
| } | ||
| parentSession={titlebarParentSession} | ||
| /> | ||
| )} | ||
| <AppShellWorkspaceTopActions | ||
| workbarAvailable={titlebarChrome.showWorkbarActions} | ||
| workbarCollapsed={workbarCollapsed} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 — Rebase without restoring the superseded global workbar launcher. Current |
||
| onOpenWorkbarLauncher={revealWorkbarLauncher} | ||
| onToggleWorkbar={toggleWorkbar} | ||
| /> | ||
| </header> | ||
| <AstryxAppShell | ||
| className="app maka-shell-astryx agents-layout-body" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Rebase this planner onto the current titlebar/workbar authority before merging. This one-commit branch is 605 commits behind current
main; a real rebase conflicts inapp-shell-chrome-actions.tsxandapp-shell.tsx, and GitHub reports the current head as conflicting. Currentmainno longer mounts this PR's globalAppShellWorkspaceTopActionsfrom ahasWorkbarSessionboolean: it delegates to panel-localWorkbarTitlebarActions, suppresses identity while WorkHub owns the front surface, and capability-gates the project-folder action. Resolving those two conflicts by carrying this old return shape forward would restore superseded chrome behavior. Please preserve the current-main authorities in the planner/call site and rerun the titlebar plus session-workbar coverage on the rebased exact head.