From 4a8ab1b720c41b035dc4b7c58030ed62fbe6bd7f Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 11 Sep 2026 20:19:39 -0300 Subject: [PATCH] feat(web): mark projects on another machine in project pickers (#11323) Co-authored-by: maria-rcks Co-authored-by: Claude Fable 5.1 --- .../components/ProjectEnvironmentBadge.tsx | 54 +++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 23 ++++++++ .../src/components/chat/DraftHeroHeadline.tsx | 31 ++++++++++- apps/web/src/environmentGrouping.test.ts | 35 ++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 13 +++++ 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/ProjectEnvironmentBadge.tsx diff --git a/apps/web/src/components/ProjectEnvironmentBadge.tsx b/apps/web/src/components/ProjectEnvironmentBadge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22be8298a4f8baa1ea815588cc529c08c5bb879c --- /dev/null +++ b/apps/web/src/components/ProjectEnvironmentBadge.tsx @@ -0,0 +1,54 @@ +import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts"; + +import type { SidebarProjectSnapshot } from "~/sidebarProjectGrouping"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * Machine icon for a project picker row whose group has a member on another + * environment, with the environment names in a tooltip. Projects that only + * live on this device render nothing, the rule thread rows use for their + * machine icon. Callers + * render it only while the catalog spans environments (see + * projectGroupsSpanEnvironments), so single-machine users see no change. + */ +export function ProjectEnvironmentBadge(props: { + readonly group: Pick; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly machineByEnvironmentId: ReadonlyMap; +}) { + // Member order follows registration order and can differ between sessions, + // so sort by label to keep the icon and tooltip stable. + const remoteMembers = props.group.memberProjects + .filter((member) => member.environmentId !== props.primaryEnvironmentId) + .map((member) => ({ ...member, environmentLabel: member.environmentLabel ?? "Remote" })) + .sort((a, b) => a.environmentLabel.localeCompare(b.environmentLabel)); + const first = remoteMembers[0]; + if (!first) return null; + const labels = remoteMembers + .map((member) => member.environmentLabel) + .filter((label, index, all) => all.indexOf(label) === index) + .join(", "); + const alsoHere = remoteMembers.length < props.group.memberProjects.length; + const description = `${alsoHere ? "Also on" : "On"} ${labels}`; + return ( + + + } + > + + + {description} + + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c269b73ee76150bad8ca1add34de65837d92e06d..2bc0c6bab019c4ab9da898a12c48a1aa1c809ae8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -104,6 +104,7 @@ import { import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, + projectGroupsSpanEnvironments, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -141,6 +142,7 @@ import type { SidebarThreadSummary } from "../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, @@ -2341,6 +2343,13 @@ export default function Sidebar() { ], [projectGroups], ); + // Same-named projects on two machines are only told apart by where they + // live, so rows on another machine carry its icon once the catalog spans + // more than one environment; a single-machine catalog stays as it was. + const showProjectEnvironments = useMemo( + () => projectGroupsSpanEnvironments(projectGroups), + [projectGroups], + ); const projectGroupByScopeKey = useMemo( () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), [projectGroups], @@ -4449,6 +4458,13 @@ export default function Sidebar() { {scopedProjectGroup?.displayName ?? "All projects"} + {scopedProjectGroup && showProjectEnvironments ? ( + + ) : null} )} {item.label} + {project && showProjectEnvironments ? ( + + ) : null} {project ? (