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, });