From 441c8a34ac49bb295cd743cf767d16d12c76437c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:24:23 +0100 Subject: [PATCH] fix(web): align status bar with sidebar (#2096) --- apps/web/app/layout.tsx | 10 +- .../app-status-surface-provider.tsx | 2 +- .../e2e/tests/layout/app-status-bar.spec.ts | 74 +++++++++++-- apps/web/src/app-shell.tsx | 10 +- docs/plans/app-status-bar/plan.md | 11 +- .../task-14-sidebar-aligned-status-bar.md | 100 ++++++++++++++++++ docs/specs/INDEX.md | 2 +- docs/specs/ui/app-status-bar.md | 10 +- 8 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 docs/plans/app-status-bar/task-14-sidebar-aligned-status-bar.md diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index e5fe607498..869da8512c 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -87,12 +87,12 @@ export default async function RootLayout({ - -
- +
+ +
{children}
-
- + +
diff --git a/apps/web/components/app-status-bar/app-status-surface-provider.tsx b/apps/web/components/app-status-bar/app-status-surface-provider.tsx index c6c783d6c6..be4a7a215c 100644 --- a/apps/web/components/app-status-bar/app-status-surface-provider.tsx +++ b/apps/web/components/app-status-bar/app-status-surface-provider.tsx @@ -135,7 +135,7 @@ export function AppStatusSurfaceProvider({ children }: { children: ReactNode }) return ( -
+
{children} {(useDrawerSurface ? drawerEnabled : appStatusBarEnabled) && (useDrawerSurface ? ( diff --git a/apps/web/e2e/tests/layout/app-status-bar.spec.ts b/apps/web/e2e/tests/layout/app-status-bar.spec.ts index 2627c204d6..a0157881de 100644 --- a/apps/web/e2e/tests/layout/app-status-bar.spec.ts +++ b/apps/web/e2e/tests/layout/app-status-bar.spec.ts @@ -1,25 +1,46 @@ import { expect, test } from "../../fixtures/test-base"; import type { Page } from "@playwright/test"; +const PIXEL_TOLERANCE = 1; + +async function expectStatusBarAfterSidebar(page: Page) { + const bar = page.getByTestId("app-status-bar"); + const sidebar = page.getByTestId("app-sidebar"); + const [barBox, sidebarBox, viewport] = await Promise.all([ + bar.boundingBox(), + sidebar.boundingBox(), + page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), + ]); + if (!barBox || !sidebarBox) throw new Error("app shell geometry unavailable"); + + expect(Math.abs(sidebarBox.x)).toBeLessThanOrEqual(PIXEL_TOLERANCE); + expect(Math.abs(barBox.x - (sidebarBox.x + sidebarBox.width))).toBeLessThanOrEqual( + PIXEL_TOLERANCE, + ); + expect(Math.abs(barBox.x + barBox.width - viewport.width)).toBeLessThanOrEqual(PIXEL_TOLERANCE); + expect(Math.abs(sidebarBox.y + sidebarBox.height - viewport.height)).toBeLessThanOrEqual( + PIXEL_TOLERANCE, + ); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(viewport.width); + + return { barBox, viewport }; +} + test.describe("App status bar", () => { - test("uses one 24px in-flow footer across sidebar and route content", async ({ testPage }) => { + test("starts after sidebar and tracks its layout width", async ({ testPage }) => { + await testPage.setViewportSize({ width: 1600, height: 900 }); await testPage.goto("/"); await setConnectionIssueSeverity(testPage, "unstable"); const bar = testPage.getByTestId("app-status-bar"); + const sidebar = testPage.getByTestId("app-sidebar"); await expect(bar).toBeVisible(); - await expect(testPage.getByTestId("app-sidebar")).toBeVisible(); + await expect(sidebar).toBeVisible(); - const [barBox, viewport] = await Promise.all([ - bar.boundingBox(), - testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), - ]); - if (!barBox) throw new Error("app status bar has no bounding box"); + const { barBox, viewport } = await expectStatusBarAfterSidebar(testPage); expect(barBox.height).toBe(24); expect(Math.abs(barBox.y + barBox.height - viewport.height)).toBeLessThanOrEqual(1); - expect(Math.abs(barBox.x)).toBeLessThanOrEqual(1); - expect(Math.abs(barBox.width - viewport.width)).toBeLessThanOrEqual(1); await expect .poll(() => bar.evaluate((element) => getComputedStyle(element).fontFamily)) .toMatch(/^"?Geist"?/); @@ -38,6 +59,41 @@ test.describe("App status bar", () => { })), ) .toEqual({ separatorHeight: "1px", contentHeight: "24px" }); + + const resizeHandle = sidebar.getByRole("button", { name: "Resize sidebar" }); + const handleBox = await resizeHandle.boundingBox(); + if (!handleBox) throw new Error("sidebar resize handle has no bounding box"); + const handleCenter = handleBox.x + handleBox.width / 2; + + await testPage.mouse.move(handleCenter, handleBox.y + handleBox.height / 2); + await testPage.mouse.down(); + await testPage.mouse.move(handleCenter + 80, handleBox.y + handleBox.height / 2); + await testPage.mouse.up(); + await expect.poll(async () => (await sidebar.boundingBox())?.width ?? 0).toBeGreaterThan(350); + await expectStatusBarAfterSidebar(testPage); + + await testPage.getByRole("button", { name: "Collapse sidebar" }).click(); + await expect(sidebar).toHaveAttribute("data-collapsed", "true"); + await expect.poll(async () => Math.round((await sidebar.boundingBox())?.width ?? 0)).toBe(56); + await expectStatusBarAfterSidebar(testPage); + }); + + test("fills available width when responsive layout hides sidebar", async ({ testPage }) => { + await testPage.setViewportSize({ width: 700, height: 900 }); + await testPage.goto("/"); + + const bar = testPage.getByTestId("app-status-bar"); + await expect(bar).toBeVisible(); + await expect(testPage.getByTestId("app-sidebar")).toBeHidden(); + + const [barBox, viewport] = await Promise.all([ + bar.boundingBox(), + testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), + ]); + if (!barBox) throw new Error("app status bar has no bounding box"); + + expect(Math.abs(barBox.x)).toBeLessThanOrEqual(PIXEL_TOLERANCE); + expect(Math.abs(barBox.width - viewport.width)).toBeLessThanOrEqual(PIXEL_TOLERANCE); }); test("persists a modifier-mouse move across the spacer", async ({ testPage }) => { diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 4e504503ce..3cab54d9db 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -43,12 +43,12 @@ export function AppShell({ children }: AppShellProps) { - -
- +
+ +
{children}
-
- + +
diff --git a/docs/plans/app-status-bar/plan.md b/docs/plans/app-status-bar/plan.md index 7941e8bbcb..a8fcc804e8 100644 --- a/docs/plans/app-status-bar/plan.md +++ b/docs/plans/app-status-bar/plan.md @@ -7,14 +7,14 @@ spec: docs/specs/ui/app-status-bar.md ## Outcome -Ship one app-owned status surface: 24 px in-flow bar on desktop/tablet, native Status drawer on phone. It shows only connection plus existing opted-in host/active-executor metrics, exposes live plugin regions without changing chat-local status UI, and lets users preserve a full-bar item order. +Ship one app-owned status surface: 24 px in-flow bar in the non-phone route-content column, native Status drawer on phone. It shows only connection plus existing opted-in host/active-executor metrics, exposes live plugin regions without changing chat-local status UI, and lets users preserve a full-bar item order. ## Fixed decisions - Slots: `app-status-bar-left`, `app-status-bar-right`; exact props live in [spec](../../specs/ui/app-status-bar.md#plugin-slots) and [plugin API](../plugins/PLUGIN-API.md#app-status-bar-slots). - One responsive presentation mounts at once. Desktop=`full`, tablet=`compact`, drawer=`full`. - Metrics subscribe only when legacy `showInTopbar` preference permits: desktop/tablet while bar mounted, phone only while drawer open. -- Shell uses an in-flow footer. `--app-status-bar-height` offsets audited desktop fixed overlays only. +- Shell places the full-height sidebar beside a route/status column whose in-flow footer starts at the sidebar layout edge. `--app-status-bar-height` offsets audited desktop fixed overlays only. - Orca is conceptual reference only. Ship pinned MIT attribution; do not transplant its component or add data providers. - `app-status-bar-left` and `app-status-bar-right` choose default sides only. Cmd/Ctrl plus mouse drag may move an opaque host item across the full bar; no keyboard-arrow or touch reorder interaction is added. - Portable order lives only in backend-owned user settings as `app_status_bar_order.left_item_ids/right_item_ids`. Phone renders left then right vertically. @@ -32,6 +32,7 @@ Ship one app-owned status surface: 24 px in-flow bar on desktop/tablet, native S | 6 | [10](task-10-portable-item-order.md), [11](task-11-stable-status-item-identities.md) | User-settings round trip and deterministic opaque item identities. | | 7 | [12](task-12-reorder-interaction-and-alignment.md) | Full-bar modifier drag, aligned content, and mirrored phone order. | | 8 | [13](task-13-reorder-docs-e2e-verification.md) | Public contract, desktop/mobile persistence E2E, review, and full verification. | +| 9 | [14](task-14-sidebar-aligned-status-bar.md) | Sidebar and status-bar geometry agree in expanded, collapsed, resized, and sidebar-hidden layouts. | ## Dependency graph @@ -45,13 +46,15 @@ Ship one app-owned status surface: 24 px in-flow bar on desktop/tablet, native S 10 portable order ─┐ ├─> 12 reorder + alignment ─> 13 docs/E2E/verify 11 stable items ───┘ + +13 shipped surface ──> 14 sidebar-aligned status bar ``` ## File / test map - **Plugin contract:** typed status props, stable owned registrations, owner-aware error boundaries, typed left/right wrapper, unit tests. - **First-party surface:** connection item; status metrics presentation; app bar/drawer/provider; remove `TopbarMetrics` mounts and update settings copy. -- **Shell and geometry:** app/legacy shells, shell-owned route heights, audited fixed controls, explicit z-index and CSS variable offsets. +- **Shell and geometry:** full-height sidebar beside the route/status column, shell-owned route heights, audited fixed controls, explicit z-index and CSS variable offsets. - **Mobile:** native Status actions in Home, task, Settings, PageTopbar, Office; no second footer. - **Proof:** fixture plugin hot enable/disable desktop test; `mobile-status-drawer.spec.ts`; layout/overlay assertions; licenses-page test. - **Records:** public plugin/operations/features docs, resource-metrics decision amendment, MIT notice generation. @@ -89,6 +92,7 @@ cd apps/web && pnpm e2e:run tests/layout/app-status-bar.spec.ts tests/plugins/mo - Slot registration IDs created from a process counter do not survive re-enable/restart: derive a separate deterministic ordering identity from plugin/slot/ordinal and keep error-boundary identity behavior covered. - Pointer dragging can steal plugin clicks: wait for horizontal movement beyond a threshold before starting reorder, and suppress only the click produced by a completed drag. - Disabled plugins and hidden metrics can disappear from active rendering: retain their opaque IDs in the saved arrays and reconcile active items without rewriting missing entries. +- Sidebar collapse deliberately snaps its layout reservation while the visual panel animates over content. Align the bar to the shared layout edge without changing that established animation contract or duplicating sidebar-width calculations. ## Tasks @@ -107,3 +111,4 @@ cd apps/web && pnpm e2e:run tests/layout/app-status-bar.spec.ts tests/plugins/mo | 11 | done | 6 | [Stable status item identities](task-11-stable-status-item-identities.md) | | 12 | done | 7 | [Reorder interaction and alignment](task-12-reorder-interaction-and-alignment.md) | | 13 | done | 8 | [Reorder docs, E2E, and verification](task-13-reorder-docs-e2e-verification.md) | +| 14 | done | 9 | [Sidebar-aligned status bar](task-14-sidebar-aligned-status-bar.md) | diff --git a/docs/plans/app-status-bar/task-14-sidebar-aligned-status-bar.md b/docs/plans/app-status-bar/task-14-sidebar-aligned-status-bar.md new file mode 100644 index 0000000000..17470074f6 --- /dev/null +++ b/docs/plans/app-status-bar/task-14-sidebar-aligned-status-bar.md @@ -0,0 +1,100 @@ +--- +id: app-status-bar-14 +title: Sidebar-aligned status bar +status: done +wave: 9 +depends_on: [app-status-bar-13] +plan: docs/plans/app-status-bar/plan.md +spec: docs/specs/ui/app-status-bar.md +--- + +# Sidebar-aligned status bar + +## Inputs + +[Spec: responsive and layout contract](../../specs/ui/app-status-bar.md#responsive-and-layout-contract), +the root `AppSidebar` layout reservation, the existing `AppStatusSurfaceProvider`, +and the shipped desktop/mobile status-surface E2E coverage. + +## Implementation + +- Make the root shell a full-viewport row whose first child is `AppSidebar` and + whose second child is the `min-w-0 flex-1` status-surface column. +- Keep route content as that column's `min-h-0 flex-1` region and keep the 24 px + desktop/tablet bar as its in-flow footer. This reuses the sidebar's flex + reservation for alignment instead of calculating its width again. +- Preserve current sidebar snap-and-overlay animation, status item rendering, + drawer state, metrics subscriptions, and fixed-overlay clearance. + +## Mobile design contract + +- **Desktop outcome:** the bar begins at the visible sidebar layout edge and the + sidebar reaches the viewport bottom. +- **Mobile entry and exemplar:** retain the shipped `AppStatusDrawer` and native + triggers; `mobile-status-drawer.spec.ts` remains the closest mobile proof. +- **Hierarchy and presentation:** phone still has no persistent bar and opens the + same inset bottom drawer. No action, ordering, or information hierarchy changes. +- **Scroll, viewport, safe area:** the shell remains `h-dvh` with one route scroll + owner; drawer internal scrolling and safe-area clearance remain unchanged. +- **Shared state:** `AppStatusSurfaceProvider` continues to own responsive + selection, drawer state, active context, and status data for both presentations. + +## Files likely touched + +- `apps/web/app/layout.tsx` +- `apps/web/components/app-status-bar/app-status-surface-provider.tsx` +- `apps/web/components/app-status-bar/app-status-surface-provider.test.tsx` +- `apps/web/e2e/tests/layout/app-status-bar.spec.ts` + +## Acceptance + +1. On sidebar-visible viewports, the settled status-bar left edge equals the + sidebar right edge while the bar right edge equals the viewport right edge; + this holds expanded, collapsed, and after direct sidebar resize. +2. The sidebar reaches the viewport bottom, route content retains the remaining + height above the 24 px bar, and no document-level overflow is introduced. +3. Sidebar-hidden tablet layouts keep a full-width bar. Phone keeps no persistent + bar and its existing Status drawer path remains usable and viewport-contained. + +## Verification + +```sh +cd apps && pnpm --filter @kandev/web test -- components/app-status-bar/app-status-surface-provider.test.tsx +cd apps/web && pnpm run typecheck +cd apps/web && pnpm e2e:run tests/layout/app-status-bar.spec.ts +cd apps/web && pnpm e2e:run tests/plugins/mobile-status-drawer.spec.ts -- --project=mobile-chrome +``` + +The desktop E2E must compare sidebar/status-bar bounding boxes before and after +collapse and resize. Existing mobile E2E supplies parity coverage because phone +composition and interaction stay unchanged. + +## Verification results + +- `cd apps && pnpm --filter @kandev/web test -- components/app-status-bar/app-status-surface-provider.test.tsx` — 4 passed. +- `cd apps/web && pnpm run typecheck` — passed. +- `cd apps/web && pnpm e2e:run tests/layout/app-status-bar.spec.ts` — 3 passed; + the new assertion first failed against the old full-width bar, then passed + after the shell change. +- `cd apps/web && pnpm e2e:run tests/plugins/mobile-status-drawer.spec.ts -- --project=mobile-chrome` — 2 passed. + +## Dependencies + +Task 13. Sequential: it amends the shipped shell geometry. + +## Risks + +- A `w-full` flex child beside the sidebar can overflow the viewport; the + status-surface column must use `min-w-0 flex-1`. +- Moving the shell boundary can accidentally make a route own `h-dvh` again or + change drawer/provider lifetime. Preserve current parent-height and provider + ownership contracts. +- Sidebar visual width animates after its layout reservation snaps. Tests should + assert settled alignment and live resize without changing that intentional + animation behavior. + +## Output contract + +Report desktop expanded/collapsed/resized geometry, sidebar-hidden and phone +behavior, files changed, exact tests run, and blockers. Mark this task done and +return the plan/spec to shipped only after focused verification passes. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 52ddc4178b..49aae3cd85 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -134,7 +134,7 @@ Subscription quota tracking and per-agent cheap-model profile routing. | [mermaid-rendering](ui/mermaid-rendering.md) | shipped | | [settings-manual-save](ui/settings-manual-save.md) | shipped | | [transcript-navigation-settings](ui/transcript-navigation-settings.md) | shipped | -| [app-status-bar](ui/app-status-bar.md) | approved | +| [app-status-bar](ui/app-status-bar.md) | shipped | | [mobile-task-navigation](ui/mobile-task-navigation.md) | shipped | | [adaptive-kanban](ui/adaptive-kanban.md) | shipped | | [task-layout-profiles](ui/task-layout-profiles.md) | draft | diff --git a/docs/specs/ui/app-status-bar.md b/docs/specs/ui/app-status-bar.md index 9f8d392c32..27f7d5f7a7 100644 --- a/docs/specs/ui/app-status-bar.md +++ b/docs/specs/ui/app-status-bar.md @@ -12,7 +12,7 @@ Kandev has useful app-wide state, but it is scattered through route headers. A s ## What -- Desktop and tablet render one persistent **24 px**, in-flow bottom status bar across the app shell, including sidebar and route content. Desktop uses `full` density; tablet uses `compact` density. +- Non-phone viewports render one persistent **24 px**, in-flow bottom status bar in the route-content column. Where the global sidebar is visible, the bar begins at the sidebar's right edge and tracks its expanded, collapsed, and resized width; the sidebar itself continues to the bottom of the viewport. Where the sidebar is hidden, the bar fills the available app width. Desktop uses `full` density; tablet uses `compact` density. - Phone renders no persistent second bottom bar. Native route controls open one global **Status** inset bottom drawer, so it does not collide with task bottom navigation. The drawer mirrors the saved bar order vertically (saved left sequence, then saved right sequence), has a fixed header, one internal scroll body, safe-area clearance, 44 px action rows, and returns focus to its trigger. - The production-default-off `features.appStatusBar` runtime toggle controls both general-purpose presentations. When disabled, neither the desktop/tablet bar nor ordinary phone Status entry points render. An active [WebSocket connectivity warning](ws-connectivity-warning.md) is the sole exception: it uses problem-only fallback chrome and a connection-only phone drawer without mounting the bar, metrics, or plugin contributions. Enabling or disabling the toggle requires a Kandev restart. It changes visibility only; it does not stop connections, metrics collection requested by other clients, or plugin execution. - Built-ins are limited to Kandev-owned state: @@ -29,7 +29,8 @@ Kandev has useful app-wide state, but it is scattered through route headers. A s ## Responsive and layout contract -- `AppShell` owns viewport geometry: an `h-dvh` column with a `min-h-0 flex-1` sidebar/route row, followed by the status surface. Shell-owned route roots use parent height and explicit local overflow rather than adding a second viewport height. +- `AppShell` owns viewport geometry: an `h-dvh` row with the sidebar beside a `min-w-0 flex-1` route/status column. That column contains a `min-h-0 flex-1` route region followed by the status surface. Shell-owned route roots use parent height and explicit local overflow rather than adding a second viewport height. +- The status bar's left edge equals the sidebar layout edge after expand/collapse settles and throughout direct resize updates. Its top separator begins at that same edge; no status-bar strip or empty footer is rendered beneath the sidebar. When responsive CSS hides the sidebar, the route/status column occupies the full viewport width. - `--app-status-bar-height` is `1.5rem` on tablet/desktop and `0` on phone. It offsets only audited desktop `position: fixed` overlays; it is not global content padding. Phone bottom navigation and phone drop targets retain `bottom: 0`. - Exactly one general-purpose presentation mounts at once: bar on tablet/desktop, drawer contents only while the phone drawer is open. This prevents duplicate plugin effects and metrics subscriptions at breakpoints. Disabling `features.appStatusBar` mounts neither general-purpose presentation; a connection-only warning drawer may mount under the exception above. - Standard mobile headers, Home utility menu, task bottom navigation, Settings mobile menu, and Office topbar expose Status. A full-bleed plugin route (`topbar: false`) owns its chrome and must mount `AppStatusDrawerTrigger` if it wants status access. @@ -104,7 +105,9 @@ Visual interaction is a clean Kandev adaptation of Orca's public status-bar idea ## Scenarios -- **GIVEN** the feature is enabled on a desktop or tablet route, **WHEN** it opens, **THEN** one 24 px app status bar remains at its bottom and route/sidebar content use the remaining height without a new page scrollbar. +- **GIVEN** the feature is enabled where the global sidebar is visible, **WHEN** a route opens or the sidebar finishes expanding or collapsing, **THEN** one 24 px app status bar begins at the sidebar's right edge, ends at the viewport's right edge, and the sidebar continues to the viewport bottom without a new page scrollbar. +- **GIVEN** an expanded sidebar and visible status bar, **WHEN** the user resizes the sidebar, **THEN** the bar's left edge follows the sidebar layout edge while its right edge remains at the viewport edge. +- **GIVEN** a non-phone viewport where responsive layout hides the global sidebar, **WHEN** the route opens, **THEN** the 24 px status bar spans the available viewport width. - **GIVEN** metrics preference enabled, **WHEN** a desktop/tablet status bar mounts, **THEN** existing Kandev-host metrics appear there, task/session/executor metrics do not, and no route header still renders metrics. - **GIVEN** metrics preference enabled and no metrics snapshot has arrived yet, **WHEN** a desktop/tablet status bar mounts or a phone user opens Status, **THEN** the metrics item remains hidden without showing an unavailable message until the first snapshot arrives, while the normal metrics subscription remains active. - **GIVEN** the detailed metrics style or no stored style preference, **WHEN** host metrics render in a status surface, **THEN** the host marker and percentage meter bars remain visible. @@ -128,6 +131,7 @@ Visual interaction is a clean Kandev adaptation of Orca's public status-bar idea - A phone persistent bar, plugin slot priority system, plugin manifest/protocol change, plugin JavaScript sandbox, keyboard-arrow reordering, or touch reordering. - Per-metric label controls, per-breakpoint metrics styles, or changes to which metrics are collected. - Broad global fixed-position padding; only audited desktop overlays receive the height offset. +- Changing the sidebar's existing snap-and-overlay animation behavior or adding a second sidebar-width state source. ## Implementation plan