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.
This commit is contained in:
Mohamed Boudra
2026-08-04 12:04:51 +02:00
parent 22cb7f9996
commit 8b9314b70a
3 changed files with 92 additions and 32 deletions
@@ -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,
};
@@ -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 (
<View style={styles.headerProjectRow}>
{showProject ? (
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{subtitle}
</Text>
) : null}
{showProject && hostBadge ? <Text style={styles.headerProjectSeparator}>·</Text> : null}
{hostBadge ? <HostBadge badge={hostBadge} /> : null}
</View>
);
}
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({
</View>
) : (
<View style={styles.headerTitleTextGroup}>
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
{showSubtitle ? (
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{subtitle}
</Text>
) : null}
<ScreenTitle testID="workspace-header-title" style={styles.headerTitle}>
{title}
</ScreenTitle>
<WorkspaceHeaderProjectRow
subtitle={subtitle}
isSubtitleDistinct={isSubtitleDistinct}
serverId={normalizedServerId}
/>
</View>
)}
<View style={styles.compactHeaderMenuCluster}>
@@ -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,
@@ -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,
});