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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user