Improve sidebar workspace readability and host identity (#2790)

* fix(app): align turn footer actions

Keep running-turn actions in the same position as completed-turn actions and use the shared small icon size for both copy and fork controls.

* feat(app): improve sidebar workspace identity

Make dense sidebar workspace rows easier to scan and let each device control how connected hosts are identified.

* fix(app): keep host badges readable

Use a compact display menu and keep identity color on the host icon so translated controls fit and badge text retains standard contrast.

* fix(app): announce selected sidebar badge mode

* fix(app): retain icon-only host names for screen readers

* fix(app): name hoisted workspace rows

* fix(app): retain host identity in workspace labels

* fix(app): retain workspace status in row labels

* fix(app): defer host appearance until identity resolves

* fix(app): serialize host appearance writes

* fix(app): serialize host registry writes

* fix(app): bound sidebar host badge labels

* test(app): match workspace status labels

* fix(app): consolidate sidebar identity behavior

* test(app): assert status grouping behavior

* fix(app): keep sidebar row actions accessible

* fix(app): stabilize optimistic message layout
This commit is contained in:
Mohamed Boudra
2026-08-02 16:58:20 +02:00
committed by GitHub
parent 553ac53643
commit ccb668b3b4
57 changed files with 3251 additions and 809 deletions
@@ -0,0 +1,357 @@
import { useCallback, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, Pencil } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { HostBadge } from "@/components/sidebar/host-badge";
import { useToast } from "@/contexts/toast-context";
import { SettingsSection } from "@/screens/settings/settings-section";
import {
HOST_BADGE_DISPLAYS,
HOST_COLORS,
resolveHostBadgeDisplay,
type HostBadgeDisplay,
type HostColor,
} from "@/hosts/appearance";
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { useHostMutations } from "@/runtime/host-runtime";
import { identityColor } from "@/styles/identity-colors";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedPencil = withUnistyles(Pencil);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
return pressed ? [styles.trigger, styles.triggerPressed] : styles.trigger;
}
function HostRenameButton({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { renameHost } = useHostMutations();
const [isEditing, setIsEditing] = useState(false);
const handleSubmit = useCallback(
async (value: string) => {
const nextLabel = value.trim();
if (nextLabel === host.label.trim()) return;
await renameHost(host.serverId, nextLabel);
},
[host.label, host.serverId, renameHost],
);
const openEditor = useCallback(() => setIsEditing(true), []);
const closeEditor = useCallback(() => setIsEditing(false), []);
return (
<>
<Pressable
onPress={openEditor}
hitSlop={8}
style={styles.renameButton}
accessibilityRole="button"
accessibilityLabel={t("settings.host.daemon.rename.editLabel")}
testID="host-page-label-edit-button"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</Pressable>
<AdaptiveRenameModal
visible={isEditing}
title={t("settings.host.daemon.rename.title")}
initialValue={host.label}
placeholder={t("settings.host.daemon.rename.placeholder")}
submitLabel={t("settings.host.daemon.rename.submit")}
onClose={closeEditor}
onSubmit={handleSubmit}
testID="host-page-rename-modal"
/>
</>
);
}
function colorLabel(t: TFunction, color: HostColor): string {
return t(`settings.host.appearance.color.options.${color}`);
}
function badgeDisplayLabel(t: TFunction, display: HostBadgeDisplay): string {
return t(`settings.host.appearance.badge.options.${display}`);
}
function ColorSwatch({ color }: { color: HostColor }) {
const swatchStyle = useMemo(
() => [styles.swatch, color === "none" ? null : { backgroundColor: identityColor(color) }],
[color],
);
return <View style={swatchStyle} />;
}
function ColorMenuItem({
color,
selected,
onChange,
}: {
color: HostColor;
selected: boolean;
onChange: (color: HostColor) => void;
}) {
const { t } = useTranslation();
const handleSelect = useCallback(() => onChange(color), [color, onChange]);
const leading = useMemo(() => <ColorSwatch color={color} />, [color]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
{colorLabel(t, color)}
</DropdownMenuItem>
);
}
function ColorRow({ color, onChange }: { color: HostColor; onChange: (color: HostColor) => void }) {
const { t } = useTranslation();
const selectedLabel = colorLabel(t, color);
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.color.label")}</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.host.appearance.color.accessibilityLabel", {
value: selectedLabel,
})}
>
<ColorSwatch color={color} />
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{HOST_COLORS.map((option) => (
<ColorMenuItem
key={option}
color={option}
selected={option === color}
onChange={onChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
function BadgeDisplayRow({
badgeDisplay,
onChange,
}: {
badgeDisplay: HostBadgeDisplay;
onChange: (badgeDisplay: HostBadgeDisplay) => void;
}) {
const { t } = useTranslation();
const selectedLabel = badgeDisplayLabel(t, badgeDisplay);
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.badge.label")}</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
testID="host-appearance-badge-display"
style={dropdownTriggerStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.host.appearance.badge.accessibilityLabel", {
value: selectedLabel,
})}
>
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{HOST_BADGE_DISPLAYS.map((option) => (
<BadgeDisplayMenuItem
key={option}
display={option}
selected={option === badgeDisplay}
onChange={onChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
function BadgeDisplayMenuItem({
display,
selected,
onChange,
}: {
display: HostBadgeDisplay;
selected: boolean;
onChange: (display: HostBadgeDisplay) => void;
}) {
const { t } = useTranslation();
const handleSelect = useCallback(() => onChange(display), [display, onChange]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{badgeDisplayLabel(t, display)}
</DropdownMenuItem>
);
}
/**
* Shows the badge exactly as the sidebar will draw it, next to a sample workspace title on
* the sidebar surface. The real component, never a restyled copy — a preview that can drift
* from the thing it previews is worse than no preview.
*/
function BadgePreview({
host,
badgeDisplay,
}: {
host: HostProfile;
badgeDisplay: HostBadgeDisplay;
}) {
const { t } = useTranslation();
return (
<View style={styles.preview} testID="host-appearance-preview">
<Text style={styles.previewTitle} numberOfLines={1}>
{t("settings.host.appearance.preview.workspaceName")}
</Text>
{badgeDisplay === "hidden" ? null : (
<HostBadge
serverId={host.serverId}
label={host.label}
color={host.appearance.color}
showLabel={badgeDisplay === "name"}
/>
)}
</View>
);
}
export function HostAppearanceSection({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const toast = useToast();
const { setHostColor, setHostBadgeDisplay } = useHostMutations();
const localDaemon = useLocalDaemonServerIdState();
const isLocalHost = localDaemon.status === "resolved" && localDaemon.serverId === host.serverId;
const badgeDisplay = resolveHostBadgeDisplay({
appearance: host.appearance,
isLocalHost,
localHostResolutionPending: localDaemon.status !== "resolved",
});
const handleColorChange = useCallback(
async (color: HostColor) => {
try {
await setHostColor(host.serverId, color);
} catch {
toast.error(t("errors.unableToSave"));
}
},
[host.serverId, setHostColor, t, toast],
);
const handleBadgeDisplayChange = useCallback(
async (next: HostBadgeDisplay) => {
try {
await setHostBadgeDisplay(host.serverId, next);
} catch {
toast.error(t("errors.unableToSave"));
}
},
[host.serverId, setHostBadgeDisplay, t, toast],
);
return (
<SettingsSection title={t("settings.host.appearance.title")}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.name.label")}</Text>
</View>
<View style={styles.nameValue}>
<Text style={styles.nameText} numberOfLines={1}>
{host.label}
</Text>
<HostRenameButton host={host} />
</View>
</View>
<ColorRow color={host.appearance.color} onChange={handleColorChange} />
{badgeDisplay === null ? null : (
<>
<BadgeDisplayRow badgeDisplay={badgeDisplay} onChange={handleBadgeDisplayChange} />
<BadgePreview host={host} badgeDisplay={badgeDisplay} />
</>
)}
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
triggerPressed: {
opacity: 0.85,
},
triggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
swatch: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
borderRadius: ICON_SIZE.md / 2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
renameButton: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
nameValue: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 1,
},
nameText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
preview: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
previewTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
}));
@@ -22,7 +22,6 @@ import {
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Alert as InlineAlert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -56,6 +55,7 @@ import {
import { ProvidersSection } from "@/screens/settings/providers-section";
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { HostAppearanceSection } from "@/screens/settings/host-appearance-section";
import { SettingsSection } from "@/screens/settings/settings-section";
import { useSessionStore } from "@/stores/session-store";
import { settingsStyles } from "@/styles/settings";
@@ -368,11 +368,12 @@ export function HostSettingsPage({
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
{host.label}
</Text>
<HostRenameButton host={host} />
</View>
<HostStatusBadges serverId={serverId} />
<HostAppearanceSection host={host} />
{isLocalDaemon ? <LocalDaemonSection /> : null}
{!isLocalDaemon ? <UpdateDaemonCard key={host.serverId} host={host} /> : null}
@@ -382,51 +383,6 @@ export function HostSettingsPage({
);
}
export function HostRenameButton({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { renameHost } = useHostMutations();
const [isEditing, setIsEditing] = useState(false);
const handleSubmit = useCallback(
async (value: string) => {
const nextLabel = value.trim();
if (nextLabel === host.label.trim()) return;
await renameHost(host.serverId, nextLabel);
},
[host.label, host.serverId, renameHost],
);
const openEditor = useCallback(() => setIsEditing(true), []);
const closeEditor = useCallback(() => setIsEditing(false), []);
return (
<>
<Pressable
onPress={openEditor}
hitSlop={8}
style={styles.identityEditButton}
accessibilityRole="button"
accessibilityLabel={t("settings.host.daemon.rename.editLabel")}
testID="host-page-label-edit-button"
>
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<AdaptiveRenameModal
visible={isEditing}
title={t("settings.host.daemon.rename.title")}
initialValue={host.label}
placeholder={t("settings.host.daemon.rename.placeholder")}
submitLabel={t("settings.host.daemon.rename.submit")}
onClose={closeEditor}
onSubmit={handleSubmit}
testID="host-page-rename-modal"
/>
</>
);
}
function ConnectionsSection({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { removeConnection } = useHostMutations();
@@ -1856,10 +1812,6 @@ const styles = StyleSheet.create((theme) => ({
marginHorizontal: theme.spacing[4],
marginBottom: theme.spacing[4],
},
identityEditButton: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
daemonHeader: {
flexDirection: "row",
alignItems: "center",