From 7b61fddfebde3bb77b479ad2e0f23bc900e0b16a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 01:16:27 +0000 Subject: [PATCH 1/3] feat(app-shell): a phone record page draws one bar, its own top bar Below 768 px the console mounts no app bar on a record route; the record page's back bar is the only bar. AppShell gains an additive showHeader prop so the shell can draw no header element. The record page's early states (loading, not found, refused, missing, access ended, forbidden, failed) draw the same back bar on a phone, so a deep link always has a way off the page. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUBErc6JfKjjeojWHS --- .../app-shell/src/layout/ConsoleLayout.tsx | 19 +++ .../app-shell/src/views/RecordDetailView.tsx | 161 ++++++++++-------- ui/packages/layout/src/AppShell.tsx | 18 +- 3 files changed, 121 insertions(+), 77 deletions(-) diff --git a/ui/packages/app-shell/src/layout/ConsoleLayout.tsx b/ui/packages/app-shell/src/layout/ConsoleLayout.tsx index 6f3a2ba8964..af15253f4d7 100644 --- a/ui/packages/app-shell/src/layout/ConsoleLayout.tsx +++ b/ui/packages/app-shell/src/layout/ConsoleLayout.tsx @@ -30,6 +30,7 @@ import { ConsoleNotificationBanners } from './ConsoleNotificationBanners.js'; import { UnifiedSidebar } from './UnifiedSidebar.js'; import { AppHeader } from './AppHeader.js'; import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar.js'; +import { usePhoneViewport } from '../hooks/usePhoneViewport.js'; import { primeConsoleSidebarOpen } from './consoleSidebarRail.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; import { useNavigationContext } from '../context/NavigationContext.js'; @@ -118,6 +119,15 @@ function SkipToContentLink({ targetRef }: { targetRef: React.RefObject」 and the stepper), so the app + // bar is not mounted there. The app menu, search, notifications and account + // stay on every other route, one 「‹」 away. From 768 px up every route keeps + // the app bar. The width is read synchronously, at the breakpoint the record + // page draws its phone face at, so the two never disagree for a frame. + const phone = usePhoneViewport(); + const recordPageOwnsTopBar = phone && isRecordPagePath(location.pathname); // "/ai = the dock maximized": record where we maximized FROM at click time, // so the page's collapse-to-dock returns exactly here (history-back could // land on a prior /ai URL after in-page conversation switches). @@ -221,6 +239,7 @@ export function ConsoleLayout({ railOverlay /> } + showHeader={!recordPageOwnsTopBar} navbar={ (null); useRecordPageScrollMemory(phonePage ? pageScrollerEl : null, pageRecordStatus === 'loaded'); + // The trail and the way back the page's top bar is built from (objectstack#27524, + // Target 7; objectstack#27058, Target 3). They are read before the guard + // states below because on a phone those states draw the same 「‹」 bar. + const pageTrail = embedded ? [] : decodeRecordTrail(new URLSearchParams(location.search).get(RECORD_TRAIL_PARAM)); + const objectListPath = appName && objectName ? `/apps/${appName}/${objectName}` : undefined; + const originPathOnly = originFromState?.pathname?.split('?')[0]; + // An origin that is a list of this object's records (a view of it) is named + // by the object; any other origin (an inbox, a dashboard) by its own label. + const originIsObjectList = !!objectListPath && !!originPathOnly + && (originPathOnly === objectListPath || originPathOnly.startsWith(`${objectListPath}/`)); + const withoutPaneParam = (href: string) => { + const [path, query = ''] = href.split('?'); + const sp = new URLSearchParams(query); + sp.delete(RECORD_DRAWER_PARAM); + const qs = sp.toString(); + return qs ? `${path}?${qs}` : path; + }; + const crumbClass = + 'min-w-0 truncate rounded px-1 hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'; + const shortRecordId = (id: string) => `#${id.length > 12 ? `${id.slice(0, 8)}…` : id}`; + + // objectstack#27058, Target 3 — the top bar on a phone: one link that + // returns where the page came from. A top-level page reads + // 「‹ 」, the breadcrumb's object half (the record's name is + // the header title directly below), and returns to the list it was opened + // from, or to the object's list. A page one level down (Target 8) is named + // by where it returns, its parent record (「‹ Acme Corp」): pushed from a + // related row or a lookup it goes back in the history, so the parent comes + // back where it was scrolled; reached by a `?from=` link it follows the + // trail's way back. + // Right, the counter and ↓ ↑ when the page knows its siblings. No ⊟ (the + // stack has no right column), no ⤡ (there is no drawer to collapse to). + const phonePushed = phonePage && isRecordPagePushed(location.state); + const phoneBackClass = + 'inline-flex min-w-0 items-center gap-0.5 rounded-md py-1 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'; + const phoneBack = phonePage ? (() => { + const plural = objectDef ? objectPluralLabel(objectDef) : objectName; + const parent = pageTrail.length > 0 ? pageTrail[pageTrail.length - 1] : undefined; + const label = parent + ? parent.t || shortRecordId(parent.i) + : phonePushed || (originFromState?.pathname && !originIsObjectList) + ? originFromState?.label ?? plural + : plural; + const face = ( + <> +