fix(app): clean up host selection affordances

Single-host users should not see host-selection chrome, while multi-host surfaces keep shared combobox feedback and consistent hover-card metadata rows.
This commit is contained in:
Mohamed Boudra
2026-06-26 20:28:55 +07:00
parent 1ce00dca1f
commit 07680cdd69
6 changed files with 84 additions and 42 deletions
@@ -74,6 +74,37 @@ test.describe("New workspace entry points", () => {
} }
}); });
test("the New Workspace screen hides the host selector when there is only one host", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
try {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
).toBeVisible({ timeout: 30_000 });
await openGlobalNewWorkspaceComposer(page);
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
} finally {
await seeded.cleanup();
}
});
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({ test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
page, page,
}) => { }) => {
@@ -132,6 +132,25 @@ test.describe("Sidebar workspace list", () => {
await workspace.cleanup(); await workspace.cleanup();
} }
}); });
test("workspace hover card shows host as metadata", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
await row.hover();
const hoverCard = page.getByTestId("workspace-hover-card");
await expect(hoverCard).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
} finally {
await workspace.cleanup();
}
});
}); });
test.describe("Mobile sidebar panelState transition", () => { test.describe("Mobile sidebar panelState transition", () => {
@@ -100,7 +100,6 @@ export function HostPickerOption({
trailingSlot={trailingSlot} trailingSlot={trailingSlot}
selected={selected} selected={selected}
active={active} active={active}
interactiveFeedback={false}
onPress={onPress} onPress={onPress}
testID={testID} testID={testID}
/> />
@@ -134,7 +133,6 @@ function SystemHostPickerOption({
leadingSlot={leadingSlot} leadingSlot={leadingSlot}
selected={selected} selected={selected}
active={active} active={active}
interactiveFeedback={false}
onPress={onPress} onPress={onPress}
testID={testID} testID={testID}
/> />
+4 -10
View File
@@ -230,7 +230,6 @@ export interface ComboboxItemProps {
disabled?: boolean; disabled?: boolean;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */ /** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean; elevated?: boolean;
interactiveFeedback?: boolean;
onPress: () => void; onPress: () => void;
testID?: string; testID?: string;
} }
@@ -245,7 +244,6 @@ export function ComboboxItem({
active, active,
disabled, disabled,
elevated, elevated,
interactiveFeedback = true,
onPress, onPress,
testID, testID,
}: ComboboxItemProps): ReactElement { }: ComboboxItemProps): ReactElement {
@@ -271,16 +269,12 @@ export function ComboboxItem({
const itemPressableStyle = useCallback( const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.comboboxItem, styles.comboboxItem,
interactiveFeedback && hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
hovered && pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered), active && styles.comboboxItemActive,
interactiveFeedback &&
pressed &&
(elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
interactiveFeedback && active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled, disabled && styles.comboboxItemDisabled,
], ],
[elevated, active, disabled, interactiveFeedback], [elevated, active, disabled],
); );
const itemContentStyle = useMemo( const itemContentStyle = useMemo(
@@ -21,6 +21,7 @@ import {
ExternalLink, ExternalLink,
Folder, Folder,
GitBranch, GitBranch,
Server,
} from "lucide-react-native"; } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -39,8 +40,7 @@ import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating"; import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
interface Rect { interface Rect {
x: number; x: number;
@@ -330,20 +330,16 @@ function WorkspaceHoverCardContent({
const ThemedGitBranch = withUnistyles(GitBranch); const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedFolder = withUnistyles(Folder); const ThemedFolder = withUnistyles(Folder);
const ThemedServer = withUnistyles(Server);
type CardInfoIcon = React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
function HostRow({ serverId }: { serverId: string }): ReactElement | null { function HostRow({ serverId }: { serverId: string }): ReactElement | null {
const hosts = useHosts(); const hosts = useHosts();
const host = hosts.find((h) => h.serverId === serverId); const host = hosts.find((h) => h.serverId === serverId);
const snapshot = useHostRuntimeSnapshot(serverId);
const label = host?.label?.trim() || serverId; const label = host?.label?.trim() || serverId;
const status = formatConnectionStatus(snapshot?.connectionStatus ?? "idle");
return ( return <InfoRow icon={ThemedServer} value={label} testID="hover-card-workspace-host" />;
<View style={styles.hostRow}>
<Text style={styles.hostRowLabel}>{label}</Text>
<Text style={styles.hostRowStatus}>{status}</Text>
</View>
);
} }
const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedExternalLink = withUnistyles(ExternalLink);
@@ -360,6 +356,25 @@ const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSucce
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning }); const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
function InfoRow({
icon: Icon,
value,
testID,
}: {
icon: CardInfoIcon;
value: string;
testID: string;
}) {
return (
<View style={styles.cardInfoRow}>
<Icon size={12} uniProps={foregroundMutedColorMapping} />
<Text style={styles.cardInfoText} numberOfLines={1} testID={testID}>
{value}
</Text>
</View>
);
}
function CopyableInfoRow({ function CopyableInfoRow({
icon: Icon, icon: Icon,
value, value,
@@ -367,7 +382,7 @@ function CopyableInfoRow({
copyLabel, copyLabel,
testID, testID,
}: { }: {
icon: React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>; icon: CardInfoIcon;
value: string; value: string;
copyValue: string; copyValue: string;
copyLabel: string; copyLabel: string;
@@ -567,22 +582,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
hostRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
hostRowLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hostRowStatus: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
cardMetaRow: { cardMetaRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -1196,6 +1196,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
const selectedHostLabel = const selectedHostLabel =
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
const showHostControl = host.allHosts.length > 1;
const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation); const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation);
const badgePressableStyle = useCallback( const badgePressableStyle = useCallback(
@@ -1242,7 +1243,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
</View> </View>
); );
const hostControl = ( const hostControl = showHostControl ? (
<View> <View>
<HostPicker <HostPicker
hosts={host.allHosts} hosts={host.allHosts}
@@ -1271,7 +1272,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
</Pressable> </Pressable>
</HostPicker> </HostPicker>
</View> </View>
); ) : null;
const isolationControl = isolation.canCreateWorktree ? ( const isolationControl = isolation.canCreateWorktree ? (
<View> <View>
@@ -1334,7 +1335,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
return isCompact ? ( return isCompact ? (
<View testID="new-workspace-ref-picker-row" style={styles.formStack}> <View testID="new-workspace-ref-picker-row" style={styles.formStack}>
<FormRow>{projectControl}</FormRow> <FormRow>{projectControl}</FormRow>
<FormRow>{hostControl}</FormRow> {hostControl ? <FormRow>{hostControl}</FormRow> : null}
{/* Keep fixed row height when git-only controls are hidden. */} {/* Keep fixed row height when git-only controls are hidden. */}
{isolationControl ? ( {isolationControl ? (
<FormRow>{isolationControl}</FormRow> <FormRow>{isolationControl}</FormRow>