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 17:31:08 +07:00
parent 1ce00dca1f
commit 07680cdd69
6 changed files with 84 additions and 42 deletions

View File

@@ -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 ({
page,
}) => {

View File

@@ -132,6 +132,25 @@ test.describe("Sidebar workspace list", () => {
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", () => {

View File

@@ -100,7 +100,6 @@ export function HostPickerOption({
trailingSlot={trailingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>
@@ -134,7 +133,6 @@ function SystemHostPickerOption({
leadingSlot={leadingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>

View File

@@ -230,7 +230,6 @@ export interface ComboboxItemProps {
disabled?: boolean;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean;
interactiveFeedback?: boolean;
onPress: () => void;
testID?: string;
}
@@ -245,7 +244,6 @@ export function ComboboxItem({
active,
disabled,
elevated,
interactiveFeedback = true,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -271,16 +269,12 @@ export function ComboboxItem({
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.comboboxItem,
interactiveFeedback &&
hovered &&
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
interactiveFeedback &&
pressed &&
(elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
interactiveFeedback && active && styles.comboboxItemActive,
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
],
[elevated, active, disabled, interactiveFeedback],
[elevated, active, disabled],
);
const itemContentStyle = useMemo(

View File

@@ -21,6 +21,7 @@ import {
ExternalLink,
Folder,
GitBranch,
Server,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme";
@@ -39,8 +40,7 @@ import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import { useHosts } from "@/runtime/host-runtime";
interface Rect {
x: number;
@@ -330,20 +330,16 @@ function WorkspaceHoverCardContent({
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedFolder = withUnistyles(Folder);
const ThemedServer = withUnistyles(Server);
type CardInfoIcon = React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
function HostRow({ serverId }: { serverId: string }): ReactElement | null {
const hosts = useHosts();
const host = hosts.find((h) => h.serverId === serverId);
const snapshot = useHostRuntimeSnapshot(serverId);
const label = host?.label?.trim() || serverId;
const status = formatConnectionStatus(snapshot?.connectionStatus ?? "idle");
return (
<View style={styles.hostRow}>
<Text style={styles.hostRowLabel}>{label}</Text>
<Text style={styles.hostRowStatus}>{status}</Text>
</View>
);
return <InfoRow icon={ThemedServer} value={label} testID="hover-card-workspace-host" />;
}
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 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({
icon: Icon,
value,
@@ -367,7 +382,7 @@ function CopyableInfoRow({
copyLabel,
testID,
}: {
icon: React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
icon: CardInfoIcon;
value: string;
copyValue: string;
copyLabel: string;
@@ -567,22 +582,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
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: {
flexDirection: "row",
alignItems: "center",

View File

@@ -1196,6 +1196,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
const selectedHostLabel =
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
const showHostControl = host.allHosts.length > 1;
const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation);
const badgePressableStyle = useCallback(
@@ -1242,7 +1243,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
</View>
);
const hostControl = (
const hostControl = showHostControl ? (
<View>
<HostPicker
hosts={host.allHosts}
@@ -1271,7 +1272,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
</Pressable>
</HostPicker>
</View>
);
) : null;
const isolationControl = isolation.canCreateWorktree ? (
<View>
@@ -1334,7 +1335,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
return isCompact ? (
<View testID="new-workspace-ref-picker-row" style={styles.formStack}>
<FormRow>{projectControl}</FormRow>
<FormRow>{hostControl}</FormRow>
{hostControl ? <FormRow>{hostControl}</FormRow> : null}
{/* Keep fixed row height when git-only controls are hidden. */}
{isolationControl ? (
<FormRow>{isolationControl}</FormRow>