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