Show project icons in status-grouped sidebar rows (#2416)

* feat: show project icons in sidebar status view

* test(sidebar): keep status icon regression active

The ten-second mock can finish during app startup, removing the Working bucket before the icon assertion. Keep the seeded workspace active for the full scenario.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nikuscs
2026-07-31 14:44:31 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent d9b72e1c76
commit 87ef631ac4
6 changed files with 207 additions and 22 deletions
+9
View File
@@ -122,6 +122,7 @@ test.describe("Model B sidebar shape", () => {
repoPrefix: "model-b-status-active-",
title: "Working workspace",
initialPrompt: "stay busy",
model: "one-minute-stream",
});
try {
@@ -152,6 +153,14 @@ test.describe("Model B sidebar shape", () => {
// the status view.
await expect(sidebar.locator('[data-testid^="workspace-tab-"]')).toHaveCount(0);
// Status mode drops the project grouping, so each row leads its subtitle
// with the project's icon to keep projects distinguishable.
for (const workspaceId of [idleProject.workspaceId, activeMock.workspaceId]) {
await expect(
page.getByTestId(`sidebar-row-project-icon-${getServerId()}:${workspaceId}`).first(),
).toBeVisible({ timeout: 60_000 });
}
// The busy workspace is grouped under Working, the idle one under Done:
// changing one workspace's status moved only that row.
const workingRows = page.getByTestId("sidebar-status-group-rows-running");
@@ -114,7 +114,7 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio
import type { PrHint } from "@/git/use-pr-status-query";
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectIconTargets,
resolveSidebarProjectLocalPath,
type SidebarProjectHostTarget,
} from "@/utils/sidebar-project-row-model";
@@ -1952,6 +1952,16 @@ export function SidebarWorkspaceList({
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.
const statusProjectIconTargets = useMemo(
() => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []),
[groupMode, projects],
);
const statusProjectIconByProjectKey = useProjectIconDataByProjectKey({
projects: statusProjectIconTargets,
});
const content =
groupMode === "status" ? (
@@ -1960,6 +1970,7 @@ export function SidebarWorkspaceList({
pinnedGroups={pinnedGroups}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
projectIconByProjectKey={statusProjectIconByProjectKey}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
@@ -1998,6 +2009,7 @@ function SidebarStatusModeWrapper({
pinnedGroups,
workspaceEntriesByKey,
projectNamesByKey,
projectIconByProjectKey,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
onWorkspacePress,
hostLabelByServerId,
@@ -2010,6 +2022,7 @@ function SidebarStatusModeWrapper({
pinnedGroups: PinnedSidebarGroups;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
@@ -2028,6 +2041,7 @@ function SidebarStatusModeWrapper({
return entry ? [entry] : [];
})}
projectNamesByKey={projectNamesByKey}
projectIconByProjectKey={projectIconByProjectKey}
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -2099,14 +2113,7 @@ function ProjectModeList({
canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup(pinnedChats);
const projectIconTargets = useMemo(
() =>
projects.flatMap((project) => {
const target = resolveSidebarProjectIconTarget(project);
return target ? [{ projectKey: project.projectKey, ...target }] : [];
}),
[projects],
);
const projectIconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]);
const nativeScrollGestureProps = useMemo(
() =>
parentGestureRef
@@ -65,6 +65,7 @@ interface StatusWorkspaceListProps {
groups: StatusGroup[];
pinnedWorkspaces: SidebarWorkspaceEntry[];
projectNamesByKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -79,6 +80,7 @@ export function SidebarStatusWorkspaceList({
groups,
pinnedWorkspaces,
projectNamesByKey,
projectIconByProjectKey,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
onWorkspacePress,
@@ -114,11 +116,12 @@ export function SidebarStatusWorkspaceList({
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
subtitle={buildStatusRowSubtitle({
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
hostLabel: showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null,
{...buildStatusRowProjectPresentation({
workspace,
projectNamesByKey,
projectIconByProjectKey,
hostLabelByServerId,
showHostLabels,
})}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
@@ -143,6 +146,7 @@ export function SidebarStatusWorkspaceList({
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
projectIconByProjectKey={projectIconByProjectKey}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -183,6 +187,7 @@ function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByKey,
projectIconByProjectKey,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -194,6 +199,7 @@ function StatusGroupList({
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -210,6 +216,7 @@ function StatusGroupList({
group={group}
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
projectNamesByKey={projectNamesByKey}
projectIconByProjectKey={projectIconByProjectKey}
shortcutIndex={shortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -227,6 +234,7 @@ function StatusGroupRows({
group,
collapsed,
projectNamesByKey,
projectIconByProjectKey,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -238,6 +246,7 @@ function StatusGroupRows({
group: StatusGroup;
collapsed: boolean;
projectNamesByKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -265,11 +274,12 @@ function StatusGroupRows({
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
subtitle={buildStatusRowSubtitle({
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
hostLabel: showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null,
{...buildStatusRowProjectPresentation({
workspace,
projectNamesByKey,
projectIconByProjectKey,
hostLabelByServerId,
showHostLabels,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
@@ -306,6 +316,36 @@ function buildStatusRowSubtitle({
return projectName ? `${projectName} · ${hostLabel}` : hostLabel;
}
interface StatusRowProjectPresentation {
subtitle: string;
projectName: string;
projectIconDataUri: string | null;
}
function buildStatusRowProjectPresentation({
workspace,
projectNamesByKey,
projectIconByProjectKey,
hostLabelByServerId,
showHostLabels,
}: {
workspace: SidebarWorkspaceEntry;
projectNamesByKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
}): StatusRowProjectPresentation {
const projectName = projectNamesByKey.get(workspace.projectKey) ?? "";
const hostLabel = showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null;
return {
subtitle: buildStatusRowSubtitle({ projectName, hostLabel }),
projectName,
projectIconDataUri: projectIconByProjectKey.get(workspace.projectKey) ?? null,
};
}
function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) {
const [isHovered, setIsHovered] = useState(false);
const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore(
@@ -391,6 +431,8 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
workspace,
subtitle,
projectName,
projectIconDataUri,
shortcutNumber,
showShortcutBadge,
canPin,
@@ -400,6 +442,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
}: {
workspace: SidebarWorkspaceEntry;
subtitle: string;
projectName: string;
projectIconDataUri: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
canPin: boolean;
@@ -422,6 +466,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
<StatusWorkspaceRowWithMenu
workspace={workspace}
subtitle={subtitle}
projectName={projectName}
projectIconDataUri={projectIconDataUri}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
@@ -436,6 +482,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
function StatusWorkspaceRowWithMenu({
workspace,
subtitle,
projectName,
projectIconDataUri,
selected,
shortcutNumber,
showShortcutBadge,
@@ -446,6 +494,8 @@ function StatusWorkspaceRowWithMenu({
}: {
workspace: SidebarWorkspaceEntry;
subtitle: string;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -554,6 +604,8 @@ function StatusWorkspaceRowWithMenu({
<StatusWorkspaceRowInner
workspace={workspace}
subtitle={subtitle}
projectName={projectName}
projectIconDataUri={projectIconDataUri}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
@@ -589,6 +641,8 @@ function StatusWorkspaceRowWithMenu({
function StatusWorkspaceRowInner({
workspace,
subtitle,
projectName,
projectIconDataUri,
selected,
shortcutNumber,
showShortcutBadge,
@@ -609,6 +663,8 @@ function StatusWorkspaceRowInner({
}: {
workspace: SidebarWorkspaceEntry;
subtitle: string;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -662,6 +718,8 @@ function StatusWorkspaceRowInner({
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={subtitle}
subtitleProjectName={projectName}
subtitleProjectIconDataUri={projectIconDataUri}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}
@@ -0,0 +1,74 @@
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ProjectIconView } from "@/components/project-icon-view";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
/**
* Decorative project marker for a sidebar row's subtitle line. Status mode breaks
* the project grouping, so the icon is what makes two same-named branches from
* different projects tell apart at a glance — the project name next to it carries
* the meaning, so the icon itself stays out of the accessibility tree.
*/
export function SidebarSubtitleProjectIcon({
projectKey,
projectName,
iconDataUri,
testID,
}: {
projectKey: string;
projectName: string;
iconDataUri: string | null;
testID?: string;
}) {
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(projectName)
.charAt(0)
.toUpperCase();
if (!iconDataUri && !placeholderInitial) {
return null;
}
return (
<View
style={styles.slot}
testID={testID}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
imageStyle={styles.icon}
fallbackStyle={styles.fallback}
textStyle={styles.fallbackText}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
slot: {
width: 12,
// Match the 14px subtitle line so the icon does not change row density.
height: 14,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
icon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.sm,
},
fallback: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
fallbackText: {
fontSize: 8,
lineHeight: 12,
},
}));
@@ -13,6 +13,7 @@ import {
Monitor,
SquareTerminal,
} from "lucide-react-native";
import { SidebarSubtitleProjectIcon } from "@/components/sidebar/sidebar-subtitle-project-icon";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { SyncedLoader } from "@/components/synced-loader";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
@@ -93,6 +94,8 @@ export function SidebarWorkspaceRowFrame({
export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({
workspace,
subtitle,
subtitleProjectName = null,
subtitleProjectIconDataUri = null,
scriptIconKind = null,
isHovered,
isLoading,
@@ -104,6 +107,9 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
}: {
workspace: SidebarWorkspaceEntry;
subtitle?: string | null;
/** Project named by the subtitle. Set it to lead the subtitle line with that project's icon. */
subtitleProjectName?: string | null;
subtitleProjectIconDataUri?: string | null;
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
isHovered: boolean;
isLoading: boolean;
@@ -148,9 +154,19 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
{subtitle}
</Text>
<View style={styles.workspaceSubtitleRow}>
{subtitleProjectName ? (
<SidebarSubtitleProjectIcon
projectKey={workspace.projectKey}
projectName={subtitleProjectName}
iconDataUri={subtitleProjectIconDataUri}
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
/>
) : null}
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
{subtitle}
</Text>
</View>
) : null}
{workspace.prHint ? (
<View style={styles.workspacePrBadgeRow}>
@@ -567,10 +583,18 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchTextHovered: {
opacity: 1,
},
workspaceSubtitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
minWidth: 0,
},
workspaceSubtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 14,
flexShrink: 1,
minWidth: 0,
},
workspacePrBadgeRow: {
flexDirection: "row",
@@ -43,6 +43,19 @@ export function resolveSidebarProjectIconTarget(
return null;
}
export interface SidebarProjectIconTarget extends SidebarProjectHostTarget {
projectKey: string;
}
export function resolveSidebarProjectIconTargets(
projects: readonly SidebarProjectEntry[],
): SidebarProjectIconTarget[] {
return projects.flatMap((project) => {
const target = resolveSidebarProjectIconTarget(project);
return target ? [{ projectKey: project.projectKey, ...target }] : [];
});
}
export function resolveSidebarProjectLocalPath(
project: SidebarProjectEntry,
localServerId: string | null,