From 8b9314b70afdbc84318bed6c90328b85e3e86b66 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 4 Aug 2026 12:04:51 +0200 Subject: [PATCH] feat(app): say where a workspace lives in the compact header Step the title and project name down one rung of the scale on compact, and put the host on the project line. On a phone this header is the only thing on screen that names the machine, because the sidebar carrying that badge is closed. It follows each host's own badge setting, so a local-only setup stays quiet, and it yields space before the project name does. A project name that only repeats the workspace name is still dropped where the two sit side by side, and kept on compact, where the line exists for the host anyway. That made shouldShowSubtitle a verdict the header source was not in a position to reach, so it now reports the fact: isSubtitleDistinct. Drop the 60% cap on the project name. It resolved against a box whose own width came from that same text, so it clipped while there was still room beside it; flexShrink already gives up space only when there is none. --- .../workspace/workspace-header-source.ts | 9 +- .../screens/workspace/workspace-screen.tsx | 105 +++++++++++++----- .../workspace-source-of-truth.test.ts | 10 +- 3 files changed, 92 insertions(+), 32 deletions(-) diff --git a/packages/app/src/screens/workspace/workspace-header-source.ts b/packages/app/src/screens/workspace/workspace-header-source.ts index 73389e835..86558b0b8 100644 --- a/packages/app/src/screens/workspace/workspace-header-source.ts +++ b/packages/app/src/screens/workspace/workspace-header-source.ts @@ -11,7 +11,12 @@ type WorkspaceHeaderRenderState = kind: "ready"; title: string; subtitle: string; - shouldShowSubtitle: boolean; + /** + * Whether the project name says anything the workspace name doesn't. It is the fact, not + * the verdict: a header that stacks the two lines shows the project either way, and one + * that sets them side by side drops the repeat. + */ + isSubtitleDistinct: boolean; isGitCheckout: boolean; currentBranchName: string | null; }; @@ -65,7 +70,7 @@ export function resolveWorkspaceHeaderRenderState(input: { kind: "ready", title: header.title, subtitle: header.subtitle, - shouldShowSubtitle: !areHeaderLabelsEquivalent(header.title, header.subtitle), + isSubtitleDistinct: !areHeaderLabelsEquivalent(header.title, header.subtitle), isGitCheckout: checkout?.isGit ?? false, currentBranchName, }; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 01abafadf..f92e5d967 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -45,6 +45,8 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenTitle } from "@/components/headers/screen-title"; +import { HostBadge } from "@/hosts/host-badge"; +import { useHostBadges } from "@/hosts/use-host-badges"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -1194,11 +1196,52 @@ function WorkspaceHeaderMenu({ ); } +/** + * Which project the workspace belongs to, and which machine it runs on. + * + * Compact gets both, on their own line under the workspace name: this header is the only thing on + * screen that says where the workspace lives, because the sidebar that normally carries the host + * badge is closed. It still follows the host's own badge setting, so a purely local setup stays + * quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit + * side by side, and kept on compact, where the line exists for the host anyway. + */ +function WorkspaceHeaderProjectRow({ + subtitle, + isSubtitleDistinct, + serverId, +}: { + subtitle: string; + isSubtitleDistinct: boolean; + serverId: string; +}) { + const isCompact = useIsCompactFormFactor(); + const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null; + const showProject = isSubtitleDistinct || isCompact; + if (!showProject && !hostBadge) { + return null; + } + return ( + + {showProject ? ( + + {subtitle} + + ) : null} + {showProject && hostBadge ? ยท : null} + {hostBadge ? : null} + + ); +} + interface WorkspaceHeaderTitleBarProps { isLoading: boolean; title: string; subtitle: string; - showSubtitle: boolean; + isSubtitleDistinct: boolean; currentBranchName: string | null; normalizedServerId: string; normalizedWorkspaceId: string; @@ -1233,7 +1276,7 @@ function WorkspaceHeaderTitleBar({ isLoading, title, subtitle, - showSubtitle, + isSubtitleDistinct, currentBranchName, normalizedServerId, normalizedWorkspaceId, @@ -1271,16 +1314,14 @@ function WorkspaceHeaderTitleBar({ ) : ( - {title} - {showSubtitle ? ( - - {subtitle} - - ) : null} + + {title} + + )} @@ -1410,7 +1451,7 @@ interface WorkspaceHeaderFields { isWorkspaceHeaderLoading: boolean; workspaceHeaderTitle: string; workspaceHeaderSubtitle: string; - shouldShowWorkspaceHeaderSubtitle: boolean; + isWorkspaceHeaderSubtitleDistinct: boolean; isGitCheckout: boolean; currentBranchName: string | null; } @@ -1445,7 +1486,7 @@ function deriveWorkspaceHeaderFields(input: { isWorkspaceHeaderLoading: true, workspaceHeaderTitle: "", workspaceHeaderSubtitle: "", - shouldShowWorkspaceHeaderSubtitle: false, + isWorkspaceHeaderSubtitleDistinct: false, isGitCheckout: false, currentBranchName: null, }; @@ -1454,7 +1495,7 @@ function deriveWorkspaceHeaderFields(input: { isWorkspaceHeaderLoading: false, workspaceHeaderTitle: renderState.title, workspaceHeaderSubtitle: renderState.subtitle, - shouldShowWorkspaceHeaderSubtitle: renderState.shouldShowSubtitle, + isWorkspaceHeaderSubtitleDistinct: renderState.isSubtitleDistinct, isGitCheckout: renderState.isGitCheckout, currentBranchName: renderState.currentBranchName, }; @@ -1929,7 +1970,7 @@ function WorkspaceScreenContent({ isWorkspaceHeaderLoading, workspaceHeaderTitle, workspaceHeaderSubtitle, - shouldShowWorkspaceHeaderSubtitle, + isWorkspaceHeaderSubtitleDistinct, isGitCheckout, currentBranchName, } = deriveWorkspaceHeaderFields({ @@ -3760,7 +3801,7 @@ function WorkspaceScreenContent({ isLoading={isWorkspaceHeaderLoading} title={workspaceHeaderTitle} subtitle={workspaceHeaderSubtitle} - showSubtitle={shouldShowWorkspaceHeaderSubtitle} + isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct} currentBranchName={currentBranchName} normalizedServerId={normalizedServerId} normalizedWorkspaceId={normalizedWorkspaceId} @@ -3925,14 +3966,14 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, + // Compact steps the whole header down one rung of the scale. The title and the project line + // stack there, so at the shared size they read as two headings rather than a subject and its + // caption, and they cost two full lines of a header that has none to spare. headerTitle: { - fontSize: theme.fontSize.base, - fontWeight: { - xs: "400", - md: "300", + fontSize: { + xs: theme.fontSize.sm, + md: theme.fontSize.base, }, - color: theme.colors.foreground, - flexShrink: 1, }, headerTitleContainer: { flex: 1, @@ -3968,15 +4009,29 @@ const styles = StyleSheet.create((theme) => ({ md: theme.spacing[2], }, }, + // No width cap. A percentage cap resolves against the title group, whose own width comes from + // this row's content, so it clips the project name while there is still room beside it. + // `flexShrink` on both this row and the title already gives up space only when there is none. + headerProjectRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + minWidth: 0, + flexShrink: 1, + }, headerProjectTitle: { color: theme.colors.foregroundMuted, fontSize: { - xs: theme.fontSize.sm, + xs: theme.fontSize.xs, md: theme.fontSize.base, }, flexShrink: 1, minWidth: 0, - maxWidth: "60%", + }, + headerProjectSeparator: { + color: theme.colors.foregroundExtraMuted, + fontSize: theme.fontSize.xs, + flexShrink: 0, }, headerTitleSkeleton: { width: 220, diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 552a92833..7e0b10e92 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -105,7 +105,7 @@ describe("workspace source of truth consumption", () => { kind: "ready", title: "feat/workspace-sot", subtitle: "getpaseo/paseo", - shouldShowSubtitle: true, + isSubtitleDistinct: true, isGitCheckout: false, currentBranchName: null, }); @@ -126,7 +126,7 @@ describe("workspace source of truth consumption", () => { kind: "ready", title: "notes", subtitle: "Local folders", - shouldShowSubtitle: true, + isSubtitleDistinct: true, isGitCheckout: false, currentBranchName: null, }); @@ -145,7 +145,7 @@ describe("workspace source of truth consumption", () => { kind: "ready", title: "feat/workspace-sot", subtitle: "getpaseo/paseo", - shouldShowSubtitle: true, + isSubtitleDistinct: true, isGitCheckout: true, currentBranchName: "feat/workspace-sot", }); @@ -169,7 +169,7 @@ describe("workspace source of truth consumption", () => { kind: "ready", title: "notes", subtitle: "notes", - shouldShowSubtitle: false, + isSubtitleDistinct: false, isGitCheckout: false, currentBranchName: null, }); @@ -185,7 +185,7 @@ describe("workspace source of truth consumption", () => { kind: "ready", title: "feat/workspace-sot", subtitle: "getpaseo/paseo", - shouldShowSubtitle: true, + isSubtitleDistinct: true, isGitCheckout: false, currentBranchName: null, });