authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-11 20:19:39-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 20:19:39-03:00
log4a8ab1b720c41b035dc4b7c58030ed62fbe6bd7f
tree98ee300804215aec819f84e64fcc3f2b3a511f31
parent095d57552c4579ce33a6560a76b7c643b92f00f9
signature Signed by PGP key B5690EEEBB952194

feat(web): mark projects on another machine in project pickers (#11323)

Co-authored-by: maria-rcks <mwria.rocks@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

5 files changed, 155 insertions(+), 1 deletions(-)

apps/web/src/components/ProjectEnvironmentBadge.tsx created+54
......@@ -0,0 +1,54 @@
1import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts";
2
3import type { SidebarProjectSnapshot } from "~/sidebarProjectGrouping";
4import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
5import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
6
7/**
8 * Machine icon for a project picker row whose group has a member on another
9 * environment, with the environment names in a tooltip. Projects that only
10 * live on this device render nothing, the rule thread rows use for their
11 * machine icon. Callers
12 * render it only while the catalog spans environments (see
13 * projectGroupsSpanEnvironments), so single-machine users see no change.
14 */
15export function ProjectEnvironmentBadge(props: {
16 readonly group: Pick<SidebarProjectSnapshot, "memberProjects">;
17 readonly primaryEnvironmentId: EnvironmentId | null;
18 readonly machineByEnvironmentId: ReadonlyMap<EnvironmentId, EnvironmentMachineKind>;
19}) {
20 // Member order follows registration order and can differ between sessions,
21 // so sort by label to keep the icon and tooltip stable.
22 const remoteMembers = props.group.memberProjects
23 .filter((member) => member.environmentId !== props.primaryEnvironmentId)
24 .map((member) => ({ ...member, environmentLabel: member.environmentLabel ?? "Remote" }))
25 .sort((a, b) => a.environmentLabel.localeCompare(b.environmentLabel));
26 const first = remoteMembers[0];
27 if (!first) return null;
28 const labels = remoteMembers
29 .map((member) => member.environmentLabel)
30 .filter((label, index, all) => all.indexOf(label) === index)
31 .join(", ");
32 const alsoHere = remoteMembers.length < props.group.memberProjects.length;
33 const description = `${alsoHere ? "Also on" : "On"} ${labels}`;
34 return (
35 <Tooltip>
36 <TooltipTrigger
37 render={
38 <span
39 role="img"
40 aria-label={description}
41 className="ml-auto inline-flex shrink-0 items-center text-muted-foreground"
42 />
43 }
44 >
45 <EnvironmentMachineIcon
46 aria-hidden
47 kind={props.machineByEnvironmentId.get(first.environmentId) ?? "server"}
48 className="size-3.5"
49 />
50 </TooltipTrigger>
51 <TooltipPopup side="top">{description}</TooltipPopup>
52 </Tooltip>
53 );
54}
apps/web/src/components/Sidebar.tsx+23
......@@ -104,6 +104,7 @@ import {
104104import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
105105import {
106106 buildSidebarProjectSnapshots,
107 projectGroupsSpanEnvironments,
107108 type SidebarProjectSnapshot,
108109} from "../sidebarProjectGrouping";
109110import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
......@@ -141,6 +142,7 @@ import type { SidebarThreadSummary } from "../types";
141142import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
142143import { cn } from "~/lib/utils";
143144import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
145import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
144146import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
145147import {
146148 animateSidebarLayoutChanges,
......@@ -2341,6 +2343,13 @@ export default function Sidebar() {
23412343 ],
23422344 [projectGroups],
23432345 );
2346 // Same-named projects on two machines are only told apart by where they
2347 // live, so rows on another machine carry its icon once the catalog spans
2348 // more than one environment; a single-machine catalog stays as it was.
2349 const showProjectEnvironments = useMemo(
2350 () => projectGroupsSpanEnvironments(projectGroups),
2351 [projectGroups],
2352 );
23442353 const projectGroupByScopeKey = useMemo(
23452354 () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
23462355 [projectGroups],
......@@ -4449,6 +4458,13 @@ export default function Sidebar() {
44494458 <span className="min-w-0 flex-1 truncate">
44504459 {scopedProjectGroup?.displayName ?? "All projects"}
44514460 </span>
4461 {scopedProjectGroup && showProjectEnvironments ? (
4462 <ProjectEnvironmentBadge
4463 group={scopedProjectGroup}
4464 primaryEnvironmentId={primaryEnvironmentId}
4465 machineByEnvironmentId={environmentMachineById}
4466 />
4467 ) : null}
44524468 <ChevronDownIcon className="-mr-px size-4 shrink-0" />
44534469 </ComboboxTrigger>
44544470 <ComboboxPopup
......@@ -4504,6 +4520,13 @@ export default function Sidebar() {
45044520 <FolderIcon className="size-4 shrink-0" />
45054521 )}
45064522 <span className="min-w-0 flex-1 truncate text-sm">{item.label}</span>
4523 {project && showProjectEnvironments ? (
4524 <ProjectEnvironmentBadge
4525 group={project}
4526 primaryEnvironmentId={primaryEnvironmentId}
4527 machineByEnvironmentId={environmentMachineById}
4528 />
4529 ) : null}
45074530 {project ? (
45084531 <Button
45094532 size="icon-xs"
apps/web/src/components/chat/DraftHeroHeadline.tsx+30-1
......@@ -1,6 +1,6 @@
11import type { DraftId } from "~/composerDraftStore";
22import { useComposerDraftStore } from "~/composerDraftStore";
3import type { ScopedProjectRef } from "@t3tools/contracts";
3import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
44import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
55import { FolderPlusIcon } from "lucide-react";
66import { useCallback, useMemo } from "react";
......@@ -12,9 +12,11 @@ import { selectProjectGroupingSettings } from "~/logicalProject";
1212import {
1313 buildSidebarProjectPickerEntries,
1414 buildSidebarProjectSnapshots,
15 projectGroupsSpanEnvironments,
1516} from "~/sidebarProjectGrouping";
1617import { useProjects, useThreadShells } from "~/state/entities";
1718import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
19import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
1820import { ProjectFavicon } from "../ProjectFavicon";
1921import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
2022import {
......@@ -83,6 +85,26 @@ export function DraftHeroHeadline({
8385 threads,
8486 ],
8587 );
88 // Same-named projects on two machines are only told apart by where they
89 // live, so rows on another machine carry its icon once the catalog spans
90 // more than one environment; a single-machine catalog stays as it was.
91 const showProjectEnvironments = useMemo(
92 () => projectGroupsSpanEnvironments(projectGroups),
93 [projectGroups],
94 );
95 const environmentMachineById = useMemo(
96 () =>
97 new Map(
98 environments.map(
99 (environment) =>
100 [
101 environment.environmentId,
102 resolveEnvironmentMachineKind(environment.serverConfig),
103 ] as const,
104 ),
105 ),
106 [environments],
107 );
86108 const projectPickerEntries = useMemo(
87109 () =>
88110 buildSidebarProjectPickerEntries({
......@@ -183,6 +205,13 @@ export function DraftHeroHeadline({
183205 {group.displayName}
184206 </TooltipPopup>
185207 </Tooltip>
208 {showProjectEnvironments ? (
209 <ProjectEnvironmentBadge
210 group={group}
211 primaryEnvironmentId={primaryEnvironmentId}
212 machineByEnvironmentId={environmentMachineById}
213 />
214 ) : null}
186215 </MenuRadioItem>
187216 );
188217 })}
apps/web/src/environmentGrouping.test.ts+35
......@@ -12,6 +12,7 @@ import {
1212 buildPhysicalToLogicalProjectKeyMap,
1313 buildSidebarProjectPickerEntries,
1414 buildSidebarProjectSnapshots,
15 projectGroupsSpanEnvironments,
1516} from "./sidebarProjectGrouping";
1617import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
1718import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
......@@ -81,6 +82,40 @@ describe("environment grouping", () => {
8182 expect(projectGroupCount).toBe(1);
8283 });
8384
85 it("reports whether the project groups span more than one environment", () => {
86 const grouped = makeProject({ repositoryIdentity });
87 const groupedRemote = makeProject({
88 id: ProjectId.make("project-remote"),
89 environmentId: remoteEnvironmentId,
90 repositoryIdentity,
91 });
92 const separateLocal = makeProject({
93 id: ProjectId.make("workbench-local"),
94 title: "workbench",
95 workspaceRoot: "/tmp/workbench",
96 });
97 const separateRemote = makeProject({
98 id: ProjectId.make("workbench-remote"),
99 environmentId: remoteEnvironmentId,
100 title: "workbench",
101 workspaceRoot: "/tmp/workbench",
102 });
103 const build = (projects: Project[]) =>
104 buildSidebarProjectSnapshots({
105 projects,
106 settings: defaultGroupingSettings,
107 primaryEnvironmentId,
108 resolveEnvironmentLabel: (environmentId) =>
109 environmentId === remoteEnvironmentId ? "Mac mini" : "Primary",
110 });
111
112 const groups = build([groupedRemote, grouped, separateLocal, separateRemote]);
113 expect(groups).toHaveLength(3);
114 expect(projectGroupsSpanEnvironments(groups)).toBe(true);
115 expect(projectGroupsSpanEnvironments(build([grouped, separateLocal]))).toBe(false);
116 expect(projectGroupsSpanEnvironments(build([separateRemote]))).toBe(false);
117 });
118
84119 it("keeps projects without repository identity physically scoped", () => {
85120 const primary = makeProject();
86121 const remote = makeProject({
apps/web/src/sidebarProjectGrouping.ts+13
......@@ -26,6 +26,19 @@ export interface SidebarProjectSnapshot extends Project {
2626 remoteEnvironmentLabels: readonly string[];
2727}
2828
29export function projectGroupsSpanEnvironments(
30 groups: ReadonlyArray<Pick<SidebarProjectSnapshot, "memberProjects">>,
31): boolean {
32 const environmentIds = new Set<EnvironmentId>();
33 for (const group of groups) {
34 for (const member of group.memberProjects) {
35 environmentIds.add(member.environmentId);
36 if (environmentIds.size > 1) return true;
37 }
38 }
39 return false;
40}
41
2942export interface SidebarProjectPickerEntry {
3043 group: SidebarProjectSnapshot;
3144 targetProject: SidebarProjectGroupMember;