diff --git a/frontend/e2e/specs/cross/responsive.spec.ts b/frontend/e2e/specs/cross/responsive.spec.ts index 6aed8333..6693e5fb 100644 --- a/frontend/e2e/specs/cross/responsive.spec.ts +++ b/frontend/e2e/specs/cross/responsive.spec.ts @@ -24,8 +24,17 @@ test.describe('responsive', { tag: '@cross' }, () => { expect(await page.evaluate(hasHorizontalOverflow)).toBe(false); await page.getByRole('row', { name: /E2E Alpha/ }).click(); + // The title, not the actions trigger: below md the star and the pager live inside + // the menu, and the trigger itself renders before the flow arrives. + await expect(page.locator('header').getByText('E2E Alpha')).toBeVisible(); await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible(); expect(await page.evaluate(hasHorizontalOverflow)).toBe(false); + + await page.getByRole('button', { name: 'Flow actions' }).click(); + await expect(page.getByRole('menuitem', { name: /Flows/ })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /favorites/ })).toBeVisible(); + await page.keyboard.press('Escape'); + expectCleanPage(pageErrorLog); }); }); diff --git a/frontend/e2e/specs/flows/create.spec.ts b/frontend/e2e/specs/flows/create.spec.ts index 2bf08cb3..770483b1 100644 --- a/frontend/e2e/specs/flows/create.spec.ts +++ b/frontend/e2e/specs/flows/create.spec.ts @@ -43,7 +43,7 @@ test.describe('flow create', { tag: ['@flows', '@smoke'] }, () => { await page.getByRole('button', { name: 'Submit' }).click(); await expect(page).toHaveURL(/\/flows\/7/); - await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible(); + await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled(); await expect(page.locator('header').getByText('Say Hello Flow')).toBeVisible(); expectCleanPage(pageErrorLog); }); diff --git a/frontend/e2e/specs/flows/lifecycle.spec.ts b/frontend/e2e/specs/flows/lifecycle.spec.ts index 59ea3284..01ed2b98 100644 --- a/frontend/e2e/specs/flows/lifecycle.spec.ts +++ b/frontend/e2e/specs/flows/lifecycle.spec.ts @@ -17,7 +17,7 @@ import { FLOW_A, flowsCassette, makeFlow } from '../../mocks/cassettes/flows.ts' const openFlowA = async (page: import('@playwright/test').Page) => { await page.goto('/flows'); await page.getByRole('row', { name: /E2E Alpha/ }).click(); - await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible(); + await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled(); }; test.describe('flow lifecycle', { tag: '@flows' }, () => { diff --git a/frontend/e2e/specs/flows/tabs.spec.ts b/frontend/e2e/specs/flows/tabs.spec.ts index 7145229f..216d75b3 100644 --- a/frontend/e2e/specs/flows/tabs.spec.ts +++ b/frontend/e2e/specs/flows/tabs.spec.ts @@ -21,7 +21,7 @@ test.describe('flow detail tabs', { tag: '@flows' }, () => { test('each tab renders its populated content', async ({ page, pageErrorLog }) => { await page.goto('/flows/5'); - await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible(); + await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled(); for (const { marker, name } of TABS) { await page.getByRole('tab', { name }).click(); @@ -33,7 +33,7 @@ test.describe('flow detail tabs', { tag: '@flows' }, () => { test('the screenshot image decodes from its REST endpoint', async ({ page, pageErrorLog }) => { await page.goto('/flows/5'); - await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible(); + await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled(); await page.getByRole('tab', { name: 'Screenshots' }).click(); const image = page.getByRole('img', { name: TABS_SCREENSHOT_NAME }); diff --git a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png index 2570ff10..c357674f 100644 Binary files a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png and b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png differ diff --git a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png index dedda0f9..724793c1 100644 Binary files a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png and b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png differ diff --git a/frontend/src/components/layouts/app/app-header.tsx b/frontend/src/components/layouts/app/app-header.tsx index 3a4c896a..0a5f135d 100644 --- a/frontend/src/components/layouts/app/app-header.tsx +++ b/frontend/src/components/layouts/app/app-header.tsx @@ -55,23 +55,10 @@ export function AppHeaderAction({ ); } -export function AppHeaderActions({ - children, - className, - pager, -}: { - children?: ReactNode; - className?: string; - pager?: ReactNode; -}) { - // Pass a detail prev/next control as `pager`, not as a child: as the trailing child it stays edge-pinned, - // so a conditional neighbour (e.g. a Report button that loads late) can't shift it under the cursor mid-click. - return ( -
- {children} - {pager} -
- ); +// The cluster is right-pinned, so it grows leftward: controls that come and go belong at the +// start of the children, and everything after them keeps its position when they appear. +export function AppHeaderActions({ children, className }: { children?: ReactNode; className?: string }) { + return
{children}
; } export function AppHeaderContent({ children, className }: { children: ReactNode; className?: string }) { diff --git a/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx index d60451f4..692811e0 100644 --- a/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx @@ -36,6 +36,9 @@ export function DetailNavigationButtons({ const isSm = size === 'sm'; const sideButtonSize = isSm ? 'size-7' : 'size-8'; const middleHeight = isSm ? 'h-7' : 'h-8'; + // Reserve the widest label the set can produce ("2409/2409"), so stepping across a digit + // boundary cannot widen the counter and slide Previous out from under the cursor. + const reservedLabelWidth = `${String(controller.total).length * 2 + 1}ch`; return (
@@ -65,6 +68,7 @@ export function DetailNavigationButtons({ )} disabled={!controller.hasEntries} onClick={controller.openSheet} + style={{ minWidth: reservedLabelWidth }} type="button" variant="outline" > diff --git a/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.test.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.test.tsx index ebccb3c4..987fd4b1 100644 --- a/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.test.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.test.tsx @@ -80,10 +80,11 @@ const renderToolbar = (props: HarnessProps = {}) => { }; describe('DetailNavigationToolbar', () => { - it('renders nothing when raw items is empty', () => { + it('renders a disabled cluster when raw items is empty', () => { renderToolbar({ items: [] }); - expect(screen.queryByRole('button', { name: /Previous/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Next/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Previous/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Next/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /–\/0/ })).toBeDisabled(); }); it('composes Buttons + Sheet: position button opens the listbox', async () => { diff --git a/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.tsx index 1935bd4e..5b180d29 100644 --- a/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.tsx @@ -25,9 +25,6 @@ export interface DetailNavigationToolbarProps { * Most desktop call sites use this directly; pages with non-standard chrome * (e.g. mobile prev/position/next inside a ``) can compose * the leaves themselves and read from the same controller. - * - * Renders `null` when the controller reports `itemsEmpty` — saves the user - * from a momentary "–/0" flash while the parent provider's data is in flight. */ export function DetailNavigationToolbar({ controller, @@ -37,10 +34,6 @@ export function DetailNavigationToolbar({ sheetIcon, sheetTitle, }: DetailNavigationToolbarProps) { - if (controller.itemsEmpty) { - return null; - } - return ( <> 0; + const hasEntityRows = !isNew; + const hasNavRow = isMobile && !isNew; const { handleDropdownCloseAutoFocus, @@ -161,7 +165,7 @@ export function KnowledgeHeader({ - {isEditingTitle && canShowActions ? ( + {isEditingTitle && hasKnowledge ? ( - ) : canShowActions ? ( + ) : hasKnowledge ? ( - - controller={knowledgeNav} - renderItem={renderKnowledgeItem} - sheetIcon={} - sheetTitle="Knowledges" - /> - ) - } - > + {canAnonymize && !isMobile && ( )} - {saveButton} - {(canShowActions || (isMobile && canAnonymize) || !!onModeChange) && ( - - - - - } + label={isNew ? 'Create' : 'Save'} + type="button" + /> + )} + {!isNew && !isMobile && ( + + controller={knowledgeNav} + renderItem={renderKnowledgeItem} + sheetIcon={} + sheetTitle="Knowledges" + /> + )} + + + + + + {hasAnonymizeRow && ( + + {isAnonymizing ? ( + <> + + Anonymizing... + + ) : ( + <> + + Anonymize + + )} + + )} + {hasNavRow && ( + <> + {hasAnonymizeRow && } event.preventDefault()} > - {isAnonymizing ? ( + + Knowledges +
+ + controller={knowledgeNav} + sheetTitle="Knowledges" + size="sm" + /> +
+
+ + )} + {hasEntityRows && ( + <> + {(hasAnonymizeRow || hasNavRow) && } + + + Rename + + + + )} + {onModeChange ? ( + <> + {!hasEntityRows && (hasAnonymizeRow || hasNavRow) && } + event.preventDefault()} + > + View + + + + ) : null} + {hasEntityRows && ( + <> + + setIsDeleteDialogOpen(true)} + > + {isDeleting ? ( <> - Anonymizing... + Deleting... ) : ( <> - - Anonymize + + Delete )} - )} - {hasNavRow && ( - <> - {hasAnonymizeRow && } - event.preventDefault()} - > - - Knowledges -
- - controller={knowledgeNav} - sheetTitle="Knowledges" - size="sm" - /> -
-
- - )} - {canShowActions && ( - <> - {(hasAnonymizeRow || hasNavRow) && } - - - Rename - - - - )} - {onModeChange ? ( - <> - {!canShowActions && (hasAnonymizeRow || hasNavRow) && } - event.preventDefault()} - > - View - - - - ) : null} - {canShowActions && ( - <> - - setIsDeleteDialogOpen(true)} - > - {isDeleting ? ( - <> - - Deleting... - - ) : ( - <> - - Delete - - )} - - - )} -
-
- )} + + )} +
+
- {isMobile && canShowActions && ( + {isMobile && !isNew && ( controller={knowledgeNav} renderItem={renderKnowledgeItem} diff --git a/frontend/src/features/knowledges/knowledge-layout.tsx b/frontend/src/features/knowledges/knowledge-layout.tsx index 1902c41f..4ed26e64 100644 --- a/frontend/src/features/knowledges/knowledge-layout.tsx +++ b/frontend/src/features/knowledges/knowledge-layout.tsx @@ -9,6 +9,7 @@ import { KnowledgeHeader } from './knowledge-header'; interface KnowledgeLayoutProps { children: ReactNode; className?: string; + isLoading?: boolean; isNew: boolean; knowledge?: KnowledgeDocumentFragmentFragment | null; saveButton?: ReactNode; @@ -20,10 +21,18 @@ interface KnowledgeLayoutProps { * renders the header inline because the form must be the parent of every * input. */ -export function KnowledgeLayout({ children, className, isNew, knowledge, saveButton }: KnowledgeLayoutProps) { +export function KnowledgeLayout({ + children, + className, + isLoading, + isNew, + knowledge, + saveButton, +}: KnowledgeLayoutProps) { return (
diff --git a/frontend/src/pages/templates/template.tsx b/frontend/src/pages/templates/template.tsx index a2ed3f66..1abb993c 100644 --- a/frontend/src/pages/templates/template.tsx +++ b/frontend/src/pages/templates/template.tsx @@ -241,9 +241,6 @@ function Template() { const { isDesktop, isMobile } = useBreakpoint(); const isNew = templateId === 'new'; - // Pass `null` while creating a new template — there is no "current item" - // to highlight, and the toolbar shouldn't render at all anyway (gated - // below by `canShowActions`). const templateNav = useTemplateDetailNavigation(isNew ? null : templateId); const [expandedPresetIndex, setExpandedPresetIndex] = useState(null); @@ -415,7 +412,8 @@ function Template() { } }, [pendingPreset, setValue]); - const canShowActions = !isNew && !!templateData?.flowTemplate; + const hasTemplate = !!templateData?.flowTemplate; + const isTemplatePending = !isNew && (isLoadingTemplate || !hasTemplate); const pageHeader = ( <> @@ -424,7 +422,7 @@ function Template() { - {isEditingTitle && canShowActions ? ( + {isEditingTitle && hasTemplate ? ( - ) : canShowActions ? ( + ) : hasTemplate ? ( - - controller={templateNav} - renderItem={renderTemplateItem} - sheetIcon={} - sheetTitle="Templates" - /> - ) - } - > - {(isNew || !!templateData?.flowTemplate) && ( - } - label={isNew ? 'Create' : 'Save'} - loading={isSaving} - type="submit" + + } + label={isNew ? 'Create' : 'Save'} + loading={isSaving} + type="submit" + /> + {!isNew && !isMobile && ( + + controller={templateNav} + renderItem={renderTemplateItem} + sheetIcon={} + sheetTitle="Templates" /> )} @@ -493,9 +485,9 @@ function Template() { className="min-w-24" onCloseAutoFocus={handleDropdownCloseAutoFocus} > - {canShowActions && ( + {!isNew && ( <> - {isMobile && templateNav.total > 0 && ( + {isMobile && ( <> )} - + Rename @@ -533,11 +528,11 @@ function Template() { rawTooltip="Edit the raw template" /> - {canShowActions && ( + {!isNew && ( <> setIsDeleteDialogOpen(true)} > {isDeleting ? ( @@ -558,7 +553,7 @@ function Template() { - {isMobile && canShowActions && ( + {isMobile && !isNew && ( controller={templateNav} renderItem={renderTemplateItem}