feat(app): host picker trigger and Usage host placement

HostFilter renders the shared ComboboxTrigger, as in Settings, and
truncates long host names. Sessions, Schedules, plugin surfaces, the
labels manager and Usage pick this up together.

The Usage modal puts the host picker at the top of its body on every
form factor. The header is title, Refresh all, Settings, X, so the
title stays whole on a phone.
This commit is contained in:
Mohamed Boudra
2026-11-15 05:01:10 +01:00
parent 89e2b84a96
commit 52f72fcbb0
5 changed files with 120 additions and 79 deletions
@@ -5,6 +5,8 @@ import {
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
export const HOST_STATUS_DOT_SIZE = 8;
export function HostStatusDot({ serverId }: { serverId: string }) {
const status = useHostRuntimeConnectionStatus(serverId);
@@ -19,8 +21,8 @@ function statusStyle(status: HostRuntimeConnectionStatus) {
const styles = StyleSheet.create((theme) => ({
dot: {
width: 8,
height: 8,
width: HOST_STATUS_DOT_SIZE,
height: HOST_STATUS_DOT_SIZE,
borderRadius: theme.borderRadius.full,
},
dotOnline: {
@@ -1,8 +1,12 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, Server } from "lucide-react-native";
import { Text, View } from "react-native";
import { Server } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { HOST_STATUS_DOT_SIZE } from "@/components/host-status-dot";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { mapLayout } from "@/components/ui/control-sizing";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
@@ -12,7 +16,8 @@ import {
} from "@/components/hosts/host-picker";
const ThemedServer = withUnistyles(Server);
const ThemedChevronDown = withUnistyles(ChevronDown);
// The height a hung trigger keeps: one line of its base-size label.
const LABEL_LINE = 20;
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface HostFilterProps {
@@ -24,20 +29,28 @@ export interface HostFilterProps {
* manager edits a single host's catalog, so "all" is not an answer it could carry out.
*/
includeAllHost?: boolean;
/**
* Hang the trigger into the surrounding padding, for a trigger that leads a column such as a
* modal body: its leading glyph's ink sits on the content rail and it adds only a label line to
* the column, so the column's own gap sets the rhythm. The frame still grows outward on hover.
*/
hang?: boolean;
triggerTestID?: string;
hostOptionTestID?: (serverId: string) => string;
}
/**
* The "All hosts / <host>" host pill shared by History, Schedules, Usage, the label manager and
* The "All hosts / <host>" host picker shared by History, Schedules, Usage, the label manager and
* plugin screens: an anchored HostPicker, hidden by the caller when only one host exists. Without
* `includeAllHost` it picks the one host a surface shows.
* `includeAllHost` it picks the one host a surface shows. Its trigger is the Settings host
* trigger; a long host name truncates instead of pushing the row.
*/
export function HostFilter({
hosts,
selectedHost,
onSelectHost,
includeAllHost = true,
hang = false,
triggerTestID,
hostOptionTestID,
}: HostFilterProps): ReactElement {
@@ -50,12 +63,21 @@ export function HostFilter({
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const showsAllHosts = selectedHost === ALL_HOSTS_OPTION_ID;
const slotStyle = useMemo(
() => [
styles.triggerSlot,
hang && styles.triggerSlotHang,
hang && (showsAllHosts ? styles.glyphHang : styles.dotHang),
],
[hang, showsAllHosts],
);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
const triggerStyle = useCallback(
({ pressed, hovered }: { pressed: boolean; hovered: boolean }) => [
styles.trigger,
hovered && styles.triggerHovered,
pressed && styles.triggerPressed,
],
[],
);
@@ -74,54 +96,73 @@ export function HostFilter({
desktopPlacement="bottom-start"
hostOptionTestID={hostOptionTestID}
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
<View style={slotStyle}>
<ComboboxTrigger
ref={filterAnchorRef}
onPress={handleFilterOpen}
style={filterTriggerStyle}
style={triggerStyle}
testID={triggerTestID}
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<ThemedServer size={14} uniProps={mutedColorMapping} />
{showsAllHosts ? (
<ThemedServer size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
<Text style={styles.label} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</ComboboxTrigger>
</View>
</HostPicker>
);
}
const styles = StyleSheet.create((theme) => ({
filterTriggerWrap: {
alignSelf: "flex-start",
// A row around the trigger keeps it as wide as its label in a column, and centred like any other
// item in a row.
triggerSlot: {
flexDirection: "row",
flexShrink: 1,
minWidth: 0,
maxWidth: "100%",
},
filterTrigger: {
// Hangs the frame's height down to a label line.
triggerSlotHang: {
marginVertical: mapLayout(density.tightRowHeight, (height) => (LABEL_LINE - height) / 2),
},
// Hangs the padding, so the server glyph's box sits on the rail.
glyphHang: {
marginLeft: -theme.spacing[2],
},
// Hangs the padding and the dot's inset in its icon slot, so the dot's ink sits on the rail.
dotHang: {
marginLeft: -(theme.spacing[2] + (ICON_SIZE.sm - HOST_STATUS_DOT_SIZE) / 2),
},
// The Settings host trigger: a borderless row that fills on hover and press.
trigger: {
flexShrink: 1,
minWidth: 0,
maxWidth: "100%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
minHeight: density.tightRowHeight,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
filterTriggerHovered: {
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
triggerPressed: {
backgroundColor: theme.colors.surface2,
},
filterTriggerText: {
label: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
},
}));
+32 -37
View File
@@ -16,47 +16,41 @@ export interface UsageHostSelection {
}
/**
* The controls on the right of every usage title row: the host filter when there is more than one
* host, Refresh all, and the Settings cog. A host that cannot report usage keeps only the host filter.
* The controls on the right of every usage title row: Refresh all and the Settings cog. A host
* that cannot report usage has none.
*/
export function UsageControls({
view,
serverId,
onRefresh,
hostSelection,
}: {
view: UsageView;
serverId: string;
onRefresh: () => void;
hostSelection?: UsageHostSelection;
}) {
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
if (view.kind === "unavailable") return null;
return (
<View style={styles.controls}>
{hasHostChoice(hostSelection) ? (
<HostFilter
hosts={hostSelection.hosts}
selectedHost={hostSelection.serverId}
onSelectHost={hostSelection.onSelect}
includeAllHost={false}
triggerTestID="usage-host-filter-trigger"
hostOptionTestID={usageHostOptionTestID}
/>
) : null}
{view.kind === "unavailable" ? null : (
<>
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
<UsageOptions serverId={serverId} />
</>
)}
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
<UsageOptions serverId={serverId} />
</View>
);
}
function hasHostChoice(
hostSelection: UsageHostSelection | undefined,
): hostSelection is UsageHostSelection {
return hostSelection !== undefined && hostSelection.hosts.length > 1;
/** The host whose usage shows, when there is more than one to choose from. */
function UsageHostFilter({ hostSelection }: { hostSelection: UsageHostSelection }) {
return (
<HostFilter
hosts={hostSelection.hosts}
selectedHost={hostSelection.serverId}
onSelectHost={hostSelection.onSelect}
includeAllHost={false}
hang
triggerTestID="usage-host-filter-trigger"
hostOptionTestID={usageHostOptionTestID}
/>
);
}
function usageHostOptionTestID(serverId: string): string {
@@ -73,27 +67,28 @@ const styles = StyleSheet.create((theme) => ({
}));
/**
* One host’s usage and title-row controls for the Usage modal.
* Returns null when there is nothing to control, so headers leave no empty island.
* One host’s usage for the Usage modal: its title-row controls, null when there is nothing to
* control so the header leaves no empty island, and the host filter for the top of the body, null
* with a single host.
*/
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
view: UsageView;
refresh: () => void;
controls: ReactElement | null;
hostFilter: ReactElement | null;
} {
const { view, refresh } = useHostUsage(hostSelection.serverId);
const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable";
const controls = useMemo(
() =>
hasControls ? (
<UsageControls
serverId={hostSelection.serverId}
view={view}
onRefresh={refresh}
hostSelection={hostSelection}
/>
) : null,
[hasControls, hostSelection, refresh, view],
view.kind === "unavailable" ? null : (
<UsageControls serverId={hostSelection.serverId} view={view} onRefresh={refresh} />
),
[hostSelection.serverId, refresh, view],
);
return { view, refresh, controls };
const hostFilter = useMemo(
() =>
hostSelection.hosts.length > 1 ? <UsageHostFilter hostSelection={hostSelection} /> : null,
[hostSelection],
);
return { view, refresh, controls, hostFilter };
}
+7 -5
View File
@@ -17,8 +17,8 @@ interface UsageModalProps {
/**
* Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It
* shows the usage host's reports with pins, and the host filter, Refresh all, and Settings in its
* title row.
* shows the usage host's reports with pins, Refresh all and Settings in its title row, and the
* host filter at the top of its body.
*/
export function UsageModal(props: UsageModalProps) {
const { t } = useTranslation();
@@ -56,10 +56,10 @@ function HostUsageModal({
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
const { view, refresh, controls, hostFilter } = useHostUsageWithControls(hostSelection);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
return (
<UsageModalFrame {...props} actions={controls}>
<UsageModalFrame {...props} actions={controls} hostFilter={hostFilter}>
<View testID={`usage-host-${serverId}`}>
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
@@ -72,8 +72,9 @@ function UsageModalFrame({
onClose,
onDismiss,
actions,
hostFilter,
children,
}: UsageModalProps & { actions?: ReactNode; children: ReactNode }) {
}: UsageModalProps & { actions?: ReactNode; hostFilter?: ReactNode; children: ReactNode }) {
const { t } = useTranslation();
const title = t(builtinSidebarNavLabelKey("usage"));
const header = useMemo(() => ({ title, actions }), [actions, title]);
@@ -87,6 +88,7 @@ function UsageModalFrame({
contextBridge={null}
testID="usage-modal"
>
{hostFilter}
<View testID="usage-modal-body">{children}</View>
</AdaptiveModalSheet>
);
@@ -191,6 +191,7 @@ export function WorkspaceLabelManagerModal({
selectedHost={state.serverId}
onSelectHost={selectHost}
includeAllHost={false}
hang
triggerTestID="workspace-label-manager-host"
hostOptionTestID={managerHostOptionTestID}
/>