diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts index fe57e5248..9eaeeab60 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -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"); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 59bc75afd..cc9ebfb87 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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; projectNamesByKey: Map; + projectIconByProjectKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; hostLabelByServerId: ReadonlyMap; @@ -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 diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 2b12a5f56..35444cc2a 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -65,6 +65,7 @@ interface StatusWorkspaceListProps { groups: StatusGroup[]; pinnedWorkspaces: SidebarWorkspaceEntry[]; projectNamesByKey: Map; + projectIconByProjectKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; 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({ ; projectNamesByKey: Map; + projectIconByProjectKey: ReadonlyMap; shortcutIndex: Map; 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; + projectIconByProjectKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -265,11 +274,12 @@ function StatusGroupRows({ ; + projectIconByProjectKey: ReadonlyMap; + hostLabelByServerId: ReadonlyMap; + 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({ + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index c1784589e..19794900d 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -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 {children} {subtitle ? ( - - {subtitle} - + + {subtitleProjectName ? ( + + ) : null} + + {subtitle} + + ) : null} {workspace.prHint ? ( @@ -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", diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index d9566198f..fac2384e1 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -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,