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,8 @@ import {
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
@@ -19,6 +21,8 @@ import {
type ReactElement,
type MutableRefObject,
type Ref,
type ComponentProps,
type PropsWithChildren,
} from "react";
import { useTranslation } from "react-i18next";
import { router, usePathname, type Href } from "expo-router";
@@ -68,7 +72,13 @@ import {
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
useContextMenu,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -84,7 +94,10 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import {
SidebarWorkspaceContextMenu,
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
@@ -93,6 +106,7 @@ import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
SidebarWorkspaceShortcutBadge,
resolveTrailingActionVisibility,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
@@ -126,7 +140,8 @@ import {
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -212,7 +227,6 @@ interface SidebarWorkspaceListProps {
pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByViewKey: Map<string, string>;
collapsedProjectKeys: ReadonlySet<string>;
onToggleProjectCollapsed: (projectViewKey: string) => void;
shortcutIndexByWorkspaceKey: Map<string, number>;
@@ -255,7 +269,9 @@ interface ProjectHeaderRowProps {
interface WorkspaceRowInnerProps {
workspace: SidebarWorkspaceEntry;
subtitle?: string | null;
hostBadge?: HostBadgeModel | null;
leadingProjectName?: string | null;
leadingProjectIconDataUri?: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -280,7 +296,7 @@ interface WorkspaceRowInnerProps {
reserveIdleStatusIndicatorSpace?: boolean;
}
export function PrBadge({ hint }: { hint: PrHint }) {
export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewStyle> }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -299,6 +315,18 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
// Callers that place the badge in a list of icon+text rows pass that row's layout in, so the
// icon and text land on the same rails as their neighbors instead of on the badge's tighter
// inline spacing.
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
prBadgeStyles.badge,
style,
pressed && prBadgeStyles.badgePressed,
],
[style],
);
const textStyle = isHovered
? [prBadgeStyles.text, prBadgeStyles.textHovered]
: prBadgeStyles.text;
@@ -317,7 +345,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={prBadgePressableStyle}
style={pressableStyle}
>
{isHovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
@@ -332,10 +360,6 @@ export function PrBadge({ hint }: { hint: PrHint }) {
);
}
function prBadgePressableStyle({ pressed }: PressableStateCallbackType) {
return [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed];
}
function projectKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
@@ -382,9 +406,11 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
}));
function ProjectRowTrailingActions({
project,
projectViewKey,
displayName,
worktreeTarget,
settingsTarget,
projectPath,
isHovered,
isMobileBreakpoint,
isProjectActive,
@@ -392,9 +418,11 @@ function ProjectRowTrailingActions({
onRemoveProject,
removeProjectStatus,
}: {
project: SidebarProjectEntry;
projectViewKey: string;
displayName: string;
worktreeTarget: SidebarProjectHostTarget | null;
settingsTarget: { serverId: string; projectId: string } | null;
projectPath: string;
isHovered: boolean;
isMobileBreakpoint: boolean;
isProjectActive: boolean;
@@ -403,9 +431,6 @@ function ProjectRowTrailingActions({
removeProjectStatus: "idle" | "pending" | "success";
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
const localDaemonServerId = useLocalDaemonServerId();
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
const settingsTarget = project.hosts[0] ?? null;
return (
<View style={styles.projectTrailingActions}>
{worktreeTarget ? (
@@ -414,7 +439,7 @@ function ProjectRowTrailingActions({
onPress={onBeginWorkspaceSetup}
visible={actionsVisible}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.viewKey}`}
testID={`sidebar-project-new-worktree-${projectViewKey}`}
/>
) : null}
{onRemoveProject ? (
@@ -423,9 +448,9 @@ function ProjectRowTrailingActions({
pointerEvents={actionsVisible ? "auto" : "none"}
>
<ProjectKebabMenu
projectViewKey={project.viewKey}
projectViewKey={projectViewKey}
settingsTarget={settingsTarget}
projectPath={localProjectPath}
projectPath={projectPath}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -464,26 +489,6 @@ function ProjectKebabMenu({
removeProjectStatus: "idle" | "pending" | "success";
}) {
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (!settingsTarget) return;
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
}, [settingsTarget]);
const canOpenProjectSettings = settingsTarget !== null;
// Desktop-only: open a second window that lands on this project via the same
// open-project flow as a CLI launch. The project stays visible here too — no
// ownership, no move.
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
const handleOpenInNewWindow = useCallback(() => {
const trimmedPath = projectPath.trim();
if (trimmedPath.length === 0) return;
void getDesktopHost()
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
?.catch((error) => {
console.warn("[sidebar] openNew failed", error);
toast.error(t("sidebar.project.actions.openNewWindowFailed"));
});
}, [projectPath, t, toast]);
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -496,42 +501,108 @@ function ProjectKebabMenu({
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{canOpenProjectSettings ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-settings-${projectViewKey}`}
leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings}
>
{t("sidebar.project.actions.openSettings")}
</DropdownMenuItem>
) : null}
{canOpenInNewWindow ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-new-window-${projectViewKey}`}
leading={openInNewWindowLeadingIcon}
onSelect={handleOpenInNewWindow}
>
{t("sidebar.project.actions.openNewWindow")}
</DropdownMenuItem>
) : null}
<OpenInFileManagerMenuItem
path={projectPath}
testID={`sidebar-project-menu-open-folder-${projectViewKey}`}
<ProjectMenuItems
surface="dropdown"
projectViewKey={projectViewKey}
settingsTarget={settingsTarget}
projectPath={projectPath}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectViewKey}`}
leading={trash2LeadingIcon}
status={removeProjectStatus}
pendingLabel={t("sidebar.project.actions.removing")}
onSelect={onRemoveProject}
>
{t("sidebar.project.actions.remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
type ProjectMenuSurface = "context" | "dropdown";
function ProjectMenuItem({
surface,
children,
...props
}: PropsWithChildren<
Omit<ComponentProps<typeof DropdownMenuItem>, "children"> & { surface: ProjectMenuSurface }
>) {
if (surface === "context") {
return <ContextMenuItem {...props}>{children}</ContextMenuItem>;
}
return <DropdownMenuItem {...props}>{children}</DropdownMenuItem>;
}
function ProjectMenuItems({
surface,
projectViewKey,
settingsTarget,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
surface: ProjectMenuSurface;
projectViewKey: string;
settingsTarget: { serverId: string; projectId: string } | null;
projectPath: string;
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
}) {
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (!settingsTarget) return;
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
}, [settingsTarget]);
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
const handleOpenInNewWindow = useCallback(() => {
const trimmedPath = projectPath.trim();
if (trimmedPath.length === 0) return;
void getDesktopHost()
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
?.catch((error) => {
console.warn("[sidebar] openNew failed", error);
toast.error(t("sidebar.project.actions.openNewWindowFailed"));
});
}, [projectPath, t, toast]);
return (
<>
{settingsTarget ? (
<ProjectMenuItem
surface={surface}
testID={`sidebar-project-menu-open-settings-${projectViewKey}`}
leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings}
>
{t("sidebar.project.actions.openSettings")}
</ProjectMenuItem>
) : null}
{canOpenInNewWindow ? (
<ProjectMenuItem
surface={surface}
testID={`sidebar-project-menu-open-new-window-${projectViewKey}`}
leading={openInNewWindowLeadingIcon}
onSelect={handleOpenInNewWindow}
>
{t("sidebar.project.actions.openNewWindow")}
</ProjectMenuItem>
) : null}
<OpenInFileManagerMenuItem
surface={surface}
path={projectPath}
testID={`sidebar-project-menu-open-folder-${projectViewKey}`}
/>
<ProjectMenuItem
surface={surface}
testID={`sidebar-project-menu-remove-${projectViewKey}`}
leading={trash2LeadingIcon}
status={removeProjectStatus}
pendingLabel={t("sidebar.project.actions.removing")}
onSelect={onRemoveProject}
>
{t("sidebar.project.actions.remove")}
</ProjectMenuItem>
</>
);
}
function WorkspaceRowRightGroup({
workspace,
isHovered,
@@ -572,8 +643,17 @@ function WorkspaceRowRightGroup({
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
const { t } = useTranslation();
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);
return (
@@ -583,9 +663,7 @@ function WorkspaceRowRightGroup({
) : null}
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
>
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
@@ -593,7 +671,7 @@ function WorkspaceRowRightGroup({
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
{onArchive ? (
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
@@ -784,7 +862,11 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const localDaemonServerId = useLocalDaemonServerId();
const projectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
const settingsTarget = project.hosts[0] ?? null;
const handleBeginWorkspaceSetup = useCallback(() => {
if (!worktreeTarget) {
return;
@@ -818,8 +900,14 @@ function ProjectHeaderRow({
onPress();
}, [interaction.didLongPressRef, onPress]);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerEnter = useCallback(() => {
if (!contextMenuOpen) setIsHovered(true);
}, [contextMenuOpen]);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const handleContextMenuOpenChange = useCallback((open: boolean) => {
setContextMenuOpen(open);
if (open) setIsHovered(false);
}, []);
const projectRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
@@ -852,9 +940,11 @@ function ProjectHeaderRow({
</View>
</View>
<ProjectRowTrailingActions
project={project}
projectViewKey={project.viewKey}
displayName={displayName}
worktreeTarget={worktreeTarget}
settingsTarget={settingsTarget}
projectPath={projectPath}
isHovered={isHovered}
isMobileBreakpoint={isMobileBreakpoint}
isProjectActive={isProjectActive}
@@ -870,8 +960,32 @@ function ProjectHeaderRow({
</>
);
if (menuController) {
if (!onRemoveProject) {
return (
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
accessibilityRole="button"
style={projectRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
{rowChildren}
</Pressable>
</View>
);
}
return (
<ContextMenu open={contextMenuOpen} onOpenChange={handleContextMenuOpenChange}>
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
@@ -892,35 +1006,29 @@ function ProjectHeaderRow({
{rowChildren}
</ContextMenuTrigger>
</View>
);
}
return (
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
accessibilityRole="button"
style={projectRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
<ContextMenuContent
align="start"
width={220}
testID={`sidebar-project-context-menu-${project.viewKey}`}
>
{rowChildren}
</Pressable>
</View>
<ProjectMenuItems
surface="context"
projectViewKey={project.viewKey}
settingsTarget={settingsTarget}
projectPath={projectPath}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
</ContextMenuContent>
</ContextMenu>
);
}
function WorkspaceRowInner({
workspace,
subtitle,
hostBadge,
leadingProjectName,
leadingProjectIconDataUri,
selected,
shortcutNumber,
showShortcutBadge,
@@ -968,7 +1076,7 @@ function WorkspaceRowInner({
return (
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, hoverHandlers }) => {
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
@@ -991,7 +1099,24 @@ function WorkspaceRowInner({
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<Pressable
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
leadingProjectName={leadingProjectName}
hostBadgeLabel={hostBadge?.label}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
@@ -1005,7 +1130,9 @@ function WorkspaceRowInner({
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={subtitle}
hostBadge={hostBadge}
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
@@ -1033,7 +1160,7 @@ function WorkspaceRowInner({
onTogglePin={onTogglePin}
/>
</SidebarWorkspaceRowContent>
</Pressable>
</SidebarWorkspaceContextMenu>
</View>
);
}}
@@ -1043,7 +1170,9 @@ function WorkspaceRowInner({
function WorkspaceRowWithMenu({
workspace,
subtitle,
hostBadge,
leadingProjectName,
leadingProjectIconDataUri,
selected,
shortcutNumber,
showShortcutBadge,
@@ -1058,7 +1187,9 @@ function WorkspaceRowWithMenu({
isCreating = false,
}: {
workspace: SidebarWorkspaceEntry;
subtitle?: string | null;
hostBadge?: HostBadgeModel | null;
leadingProjectName?: string | null;
leadingProjectIconDataUri?: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -1186,7 +1317,9 @@ function WorkspaceRowWithMenu({
<>
<WorkspaceRowInner
workspace={workspace}
subtitle={subtitle}
hostBadge={hostBadge}
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
@@ -1227,7 +1360,9 @@ function WorkspaceRowWithMenu({
interface WorkspaceRowItemProps {
workspace: SidebarWorkspacePlacement;
workspaceEntry: SidebarWorkspaceEntry | null;
subtitle?: string | null;
hostBadge?: HostBadgeModel | null;
leadingProjectName?: string | null;
leadingProjectIconDataUri?: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
canCopyBranchName: boolean;
@@ -1246,7 +1381,9 @@ interface WorkspaceRowItemProps {
function WorkspaceRowItem({
workspace,
workspaceEntry,
subtitle,
hostBadge,
leadingProjectName,
leadingProjectIconDataUri,
shortcutNumber,
showShortcutBadge,
canCopyBranchName,
@@ -1272,7 +1409,9 @@ function WorkspaceRowItem({
return (
<WorkspaceRow
workspaceEntry={workspaceEntry}
subtitle={subtitle}
hostBadge={hostBadge}
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
canCopyBranchName={canCopyBranchName}
@@ -1313,7 +1452,9 @@ function areWorkspaceRowItemPropsEqual(
return (
previous.workspace === next.workspace &&
previous.workspaceEntry === next.workspaceEntry &&
previous.subtitle === next.subtitle &&
previous.hostBadge === next.hostBadge &&
previous.leadingProjectName === next.leadingProjectName &&
previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri &&
previous.shortcutNumber === next.shortcutNumber &&
previous.showShortcutBadge === next.showShortcutBadge &&
previous.canCopyBranchName === next.canCopyBranchName &&
@@ -1333,7 +1474,9 @@ const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqua
function WorkspaceRow({
workspaceEntry,
subtitle,
hostBadge,
leadingProjectName,
leadingProjectIconDataUri,
shortcutNumber,
showShortcutBadge,
onPress,
@@ -1348,7 +1491,9 @@ function WorkspaceRow({
selected,
}: {
workspaceEntry: SidebarWorkspaceEntry | null;
subtitle?: string | null;
hostBadge?: HostBadgeModel | null;
leadingProjectName?: string | null;
leadingProjectIconDataUri?: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
@@ -1369,7 +1514,9 @@ function WorkspaceRow({
return (
<WorkspaceRowWithMenu
workspace={workspaceEntry}
subtitle={subtitle}
hostBadge={hostBadge}
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
@@ -1407,8 +1554,7 @@ function ProjectBlock({
dragGestureHostPresented,
creatingWorkspaceIds,
activeWorkspaceSelection,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
supportsMultiplicityByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
@@ -1433,8 +1579,7 @@ function ProjectBlock({
dragGestureHostPresented?: boolean;
creatingWorkspaceIds: ReadonlySet<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
@@ -1481,9 +1626,7 @@ function ProjectBlock({
<MemoWorkspaceRowItem
workspace={item}
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
subtitle={
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
}
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === "git"}
@@ -1503,10 +1646,9 @@ function ProjectBlock({
project.projectKind,
onToggleWorkspacePin,
supportsPinningByServerId,
showHostLabels,
activeWorkspaceSelection,
creatingWorkspaceIds,
hostLabelByServerId,
hostBadgeByServerId,
onWorkspacePress,
selectionEnabled,
shortcutIndexByWorkspaceKey,
@@ -1681,8 +1823,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.selectionEnabled === next.selectionEnabled &&
previous.showShortcutBadges === next.showShortcutBadges &&
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
previous.hostLabelByServerId === next.hostLabelByServerId &&
previous.showHostLabels === next.showHostLabels &&
previous.hostBadgeByServerId === next.hostBadgeByServerId &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
@@ -1734,7 +1875,6 @@ export function SidebarWorkspaceList({
pinnedGroups,
projects,
workspaceEntriesByKey,
projectNamesByViewKey,
collapsedProjectKeys,
onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey,
@@ -1750,18 +1890,22 @@ export function SidebarWorkspaceList({
}: SidebarWorkspaceListProps) {
const pathname = usePathname();
const hosts = useHosts();
const hostLabelByServerId = useMemo(() => {
const labels = new Map<string, string>();
for (const host of hosts) {
labels.set(host.serverId, host.label?.trim() || host.serverId);
}
return labels;
}, [hosts]);
const localServerId = useLocalDaemonServerId();
const localDaemon = useLocalDaemonServerIdState();
const hostBadgeByServerId = useMemo(
() =>
selectHostBadges({
hosts,
localServerId,
localHostResolutionPending: localDaemon.status !== "resolved",
enabled: shouldShowSidebarHostLabels(projects),
}),
[hosts, localDaemon.status, localServerId, projects],
);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
const onToggleWorkspacePin = useSidebarWorkspacePinController();
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
// Status mode drops the project grouping, so its rows carry their own project
// icon. Project mode fetches the same icons inside ProjectModeList for its
// project headers, so only the active mode requests them.
@@ -1779,12 +1923,10 @@ export function SidebarWorkspaceList({
statusGroups={statusGroups}
pinnedGroups={pinnedGroups}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByViewKey={projectNamesByViewKey}
projectIconByProjectViewKey={statusProjectIconByProjectViewKey}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
listHeaderComponent={listHeaderComponent}
@@ -1804,8 +1946,7 @@ export function SidebarWorkspaceList({
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
pathname={pathname}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
hostBadgeByServerId={hostBadgeByServerId}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
@@ -1819,12 +1960,10 @@ function SidebarStatusModeWrapper({
statusGroups,
pinnedGroups,
workspaceEntriesByKey,
projectNamesByViewKey,
projectIconByProjectViewKey,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
listHeaderComponent,
@@ -1832,12 +1971,10 @@ function SidebarStatusModeWrapper({
statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
listHeaderComponent?: ReactElement | null;
@@ -1851,13 +1988,11 @@ function SidebarStatusModeWrapper({
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
return entry ? [entry] : [];
})}
projectNamesByViewKey={projectNamesByViewKey}
projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
listHeaderComponent={listHeaderComponent}
@@ -1879,18 +2014,13 @@ function ProjectModeList({
parentGestureRef,
dragGestureHostPresented,
pathname,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
supportsMultiplicityByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
}: Omit<
SidebarWorkspaceListProps,
"statusGroups" | "projectNamesByViewKey" | "groupMode" | "isRefreshing" | "onRefresh"
> & {
}: Omit<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
@@ -2095,8 +2225,7 @@ function ProjectModeList({
dragGestureHostPresented={dragGestureHostPresented}
creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
hostBadgeByServerId={hostBadgeByServerId}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
@@ -2108,8 +2237,7 @@ function ProjectModeList({
activeWorkspaceSelection,
handleWorktreeCreated,
handleWorkspaceReorder,
hostLabelByServerId,
showHostLabels,
hostBadgeByServerId,
supportsMultiplicityByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
@@ -2134,17 +2262,16 @@ function ProjectModeList({
const renderPinnedChat = useCallback(
(workspace: SidebarWorkspacePlacement) => {
// A hoisted chat loses its project context, so surface the project name (plus
// host when the sidebar spans multiple hosts) as the subtitle.
const hostLabel = showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null;
return (
<MemoWorkspaceRowItem
key={workspace.workspaceKey}
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
subtitle={hostLabel ? `${workspace.projectName} · ${hostLabel}` : workspace.projectName}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
leadingProjectName={workspace.projectName}
leadingProjectIconDataUri={
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
}
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={workspace.projectKind === "git"}
@@ -2160,14 +2287,14 @@ function ProjectModeList({
[
activeWorkspaceSelection,
creatingWorkspaceIds,
hostLabelByServerId,
hostBadgeByServerId,
onWorkspacePress,
selectionEnabled,
shortcutIndexByWorkspaceKey,
showHostLabels,
showShortcutBadges,
supportsPinningByServerId,
onToggleWorkspacePin,
projectIconByProjectViewKey,
workspaceEntriesByKey,
],
);