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:
co-authored by
Mohamed Boudra
parent
d9b72e1c76
commit
87ef631ac4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user