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
@@ -6,6 +6,7 @@ import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
@@ -35,12 +36,16 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
resolveTrailingActionVisibility,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import {
SidebarWorkspaceContextMenu,
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
@@ -50,7 +55,6 @@ import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pi
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
@@ -64,13 +68,11 @@ const ThemedCircleX = withUnistyles(CircleX);
interface StatusWorkspaceListProps {
groups: StatusGroup[];
pinnedWorkspaces: SidebarWorkspaceEntry[];
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
listHeaderComponent?: ReactNode;
@@ -79,13 +81,11 @@ interface StatusWorkspaceListProps {
export function SidebarStatusWorkspaceList({
groups,
pinnedWorkspaces,
projectNamesByViewKey,
projectIconByProjectViewKey,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
listHeaderComponent,
@@ -118,10 +118,8 @@ export function SidebarStatusWorkspaceList({
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectNamesByViewKey,
projectIconByProjectViewKey,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
})}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
@@ -145,13 +143,11 @@ export function SidebarStatusWorkspaceList({
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByViewKey={projectNamesByViewKey}
projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
@@ -186,25 +182,21 @@ export function SidebarStatusWorkspaceList({
function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByViewKey,
projectIconByProjectViewKey,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
}: {
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
@@ -215,13 +207,11 @@ function StatusGroupList({
key={group.bucket}
group={group}
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
projectNamesByViewKey={projectNamesByViewKey}
projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndex={shortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
@@ -233,25 +223,21 @@ function StatusGroupList({
function StatusGroupRows({
group,
collapsed,
projectNamesByViewKey,
projectIconByProjectViewKey,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
}: {
group: StatusGroup;
collapsed: boolean;
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
@@ -276,10 +262,8 @@ function StatusGroupRows({
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectNamesByViewKey,
projectIconByProjectViewKey,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
@@ -301,47 +285,24 @@ function StatusGroupRows({
);
}
// Status mode breaks the project grouping, so the row needs the project name to stay
// legible; the host is appended after a middle dot once labels are active.
function buildStatusRowSubtitle({
projectName,
hostLabel,
}: {
projectName: string;
hostLabel: string | null;
}): string {
if (!hostLabel) {
return projectName;
}
return projectName ? `${projectName} · ${hostLabel}` : hostLabel;
}
interface StatusRowProjectPresentation {
subtitle: string;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
}
function buildStatusRowProjectPresentation({
workspace,
projectNamesByViewKey,
projectIconByProjectViewKey,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
}: {
workspace: SidebarWorkspaceEntry;
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
}): StatusRowProjectPresentation {
const projectName = projectNamesByViewKey.get(workspace.projectViewKey) ?? "";
const hostLabel = showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null;
return {
subtitle: buildStatusRowSubtitle({ projectName, hostLabel }),
projectName,
hostBadge: hostBadgeByServerId.get(workspace.serverId) ?? null,
projectName: workspace.projectName,
projectIconDataUri: projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null,
};
}
@@ -422,7 +383,7 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
case "attention":
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
case "running":
return <ThemedCircleDot size={14} uniProps={blueColorMapping} />;
return <ThemedCircleDot size={14} uniProps={amberColorMapping} />;
case "done":
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
}
@@ -430,7 +391,7 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
workspace,
subtitle,
hostBadge,
projectName,
projectIconDataUri,
shortcutNumber,
@@ -441,7 +402,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
onWorkspacePress,
}: {
workspace: SidebarWorkspaceEntry;
subtitle: string;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
shortcutNumber: number | null;
@@ -465,7 +426,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
return (
<StatusWorkspaceRowWithMenu
workspace={workspace}
subtitle={subtitle}
hostBadge={hostBadge}
projectName={projectName}
projectIconDataUri={projectIconDataUri}
selected={selected}
@@ -481,7 +442,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
function StatusWorkspaceRowWithMenu({
workspace,
subtitle,
hostBadge,
projectName,
projectIconDataUri,
selected,
@@ -493,7 +454,7 @@ function StatusWorkspaceRowWithMenu({
onPress,
}: {
workspace: SidebarWorkspaceEntry;
subtitle: string;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
@@ -603,7 +564,7 @@ function StatusWorkspaceRowWithMenu({
<>
<StatusWorkspaceRowInner
workspace={workspace}
subtitle={subtitle}
hostBadge={hostBadge}
projectName={projectName}
projectIconDataUri={projectIconDataUri}
selected={selected}
@@ -640,7 +601,7 @@ function StatusWorkspaceRowWithMenu({
function StatusWorkspaceRowInner({
workspace,
subtitle,
hostBadge,
projectName,
projectIconDataUri,
selected,
@@ -662,7 +623,7 @@ function StatusWorkspaceRowInner({
reserveIdleStatusIndicatorSpace = true,
}: {
workspace: SidebarWorkspaceEntry;
subtitle: string;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
@@ -699,15 +660,43 @@ function StatusWorkspaceRowInner({
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const {
showDiffStat,
showKebab: showKebabInSlot,
showScrim,
} = resolveTrailingActionVisibility({
hasDiffStat: Boolean(workspace.diffStat),
hasArchiveAction: Boolean(onArchive),
isHovered,
isTouchPlatform,
showShortcut,
});
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<Pressable
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
leadingProjectName={projectName}
hostBadgeLabel={hostBadge?.label}
scriptIconKind={scriptIconKind}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
@@ -717,9 +706,9 @@ function StatusWorkspaceRowInner({
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={subtitle}
subtitleProjectName={projectName}
subtitleProjectIconDataUri={projectIconDataUri}
hostBadge={hostBadge}
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}
@@ -730,8 +719,9 @@ function StatusWorkspaceRowInner({
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showBase={showDiffStat}
showKebab={showKebabInSlot}
showScrim={showScrim}
isPinned={isPinned}
onTogglePin={onTogglePin}
onCopyPath={onCopyPath}
@@ -746,7 +736,7 @@ function StatusWorkspaceRowInner({
/>
) : null}
</SidebarWorkspaceRowContent>
</Pressable>
</SidebarWorkspaceContextMenu>
</View>
);
}}
@@ -758,6 +748,7 @@ function StatusWorkspaceActionSlot({
workspace,
showBase,
showKebab,
showScrim,
isPinned,
onTogglePin,
onCopyPath,
@@ -773,6 +764,7 @@ function StatusWorkspaceActionSlot({
workspace: SidebarWorkspaceEntry;
showBase: boolean;
showKebab: boolean;
showScrim: boolean;
isPinned?: boolean;
onTogglePin?: () => void;
onCopyPath?: () => void;
@@ -795,7 +787,7 @@ function StatusWorkspaceActionSlot({
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebab}>
<SidebarWorkspaceTrailingActionOverlay visible={showKebab} scrim={showScrim}>
{showKebab && onArchive ? (
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}