From 0bca90cbfc12f7f6d76d88af83436ab0285cb6ab Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 11 Sep 2026 22:25:42 +0000 Subject: [PATCH 1/4] feat(web): show which environment a project lives in inside project pickers Two projects with the same name on two machines were indistinguishable in the sidebar project scope dropdown and in the new-thread headline picker. Once the catalog spans more than one environment, each row now carries a muted environment label ("Local", the saved environment label, or both for a project grouped across machines) with the machine icon used elsewhere. The sidebar search also matches those labels and the scoped trigger repeats the label. Co-Authored-By: Claude Fable 5.1 --- .../components/ProjectEnvironmentLabel.tsx | 49 ++++++++++++++++ apps/web/src/components/Sidebar.tsx | 56 +++++++++++++++++-- .../src/components/chat/DraftHeroHeadline.tsx | 42 +++++++++++++- .../settings/scopedSettings.test.ts | 1 + .../components/settings/settingsScope.test.ts | 1 + apps/web/src/environmentGrouping.test.ts | 38 +++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 28 ++++++++++ 7 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/ProjectEnvironmentLabel.tsx diff --git a/apps/web/src/components/ProjectEnvironmentLabel.tsx b/apps/web/src/components/ProjectEnvironmentLabel.tsx new file mode 100644 index 000000000000..b7d6e55529af --- /dev/null +++ b/apps/web/src/components/ProjectEnvironmentLabel.tsx @@ -0,0 +1,49 @@ +import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts"; + +import { cn } from "~/lib/utils"; +import type { SidebarProjectSnapshot } from "~/sidebarProjectGrouping"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; + +/** + * Machine icon for a project group's row: the first non-primary member's + * machine, or none when the group only lives on this device. Matches the + * thread rows and command palette, which draw an icon for remote work only. + */ +export function resolveProjectGroupMachine(input: { + readonly group: Pick; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly machineByEnvironmentId: ReadonlyMap; +}): EnvironmentMachineKind | null { + const remoteMember = input.group.memberProjects.find( + (member) => member.environmentId !== input.primaryEnvironmentId, + ); + return remoteMember + ? (input.machineByEnvironmentId.get(remoteMember.environmentId) ?? null) + : null; +} + +/** + * Muted "where this project lives" suffix for a project picker row. Callers + * render it only while the catalog spans environments (see + * projectGroupsSpanEnvironments), so single-machine users see no change. + */ +export function ProjectEnvironmentLabel(props: { + readonly labels: readonly string[]; + readonly machine: EnvironmentMachineKind | null; + readonly className?: string; +}) { + if (props.labels.length === 0) return null; + return ( + + {props.machine ? ( + + ) : null} + {props.labels.join(" · ")} + + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c269b73ee761..e84bbbdbbd61 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 { ProjectEnvironmentLabel, resolveProjectGroupMachine } from "./ProjectEnvironmentLabel"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, @@ -2333,14 +2335,22 @@ export default function Sidebar() { // while the popup search filters the same collection. const projectScopeItems = useMemo( () => [ - { value: "all", label: "All projects" }, + { value: "all", label: "All projects", environmentLabels: [] as readonly string[] }, ...projectGroups.map((project) => ({ value: project.projectKey, label: project.displayName, + environmentLabels: project.environmentLabels, })), ], [projectGroups], ); + // Same-named projects on two machines are only told apart by where they + // live, so rows carry their environment once the catalog spans more than + // one; 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], @@ -2370,9 +2380,19 @@ export default function Sidebar() { activeScopeKey: projectScopeKey, query: projectScopeMenuState.query, matches: (item, query) => - projectScopeFilter.contains(item, query, (candidate) => candidate.label), + projectScopeFilter.contains(item, query, (candidate) => candidate.label) || + (showProjectEnvironments && + item.environmentLabels.some((label) => + projectScopeFilter.contains(label, query, (candidate) => candidate), + )), }), - [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query], + [ + projectScopeFilter, + projectScopeItems, + projectScopeKey, + projectScopeMenuState.query, + showProjectEnvironments, + ], ); const scopedProjectGroup = useMemo( () => @@ -4449,11 +4469,29 @@ export default function Sidebar() { {scopedProjectGroup?.displayName ?? "All projects"} + {scopedProjectGroup && showProjectEnvironments ? ( + + ) : null} )} {item.label} + {project && showProjectEnvironments ? ( + + ) : null} {project ? (