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 {...@@ -104,6 +104,7 @@ import {
104import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";104import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
105import {105import {
106 buildSidebarProjectSnapshots,106 buildSidebarProjectSnapshots,
107 projectGroupsSpanEnvironments,
107 type SidebarProjectSnapshot,108 type SidebarProjectSnapshot,
108} from "../sidebarProjectGrouping";109} from "../sidebarProjectGrouping";
109import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";110import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
...@@ -141,6 +142,7 @@ import type { SidebarThreadSummary } from "../types";...@@ -141,6 +142,7 @@ import type { SidebarThreadSummary } from "../types";
141import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";142import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
142import { cn } from "~/lib/utils";143import { cn } from "~/lib/utils";
143import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";144import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
145import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
144import { buildThreadActionMenuItems } from "./threadActionMenu.logic";146import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
145import {147import {
146 animateSidebarLayoutChanges,148 animateSidebarLayoutChanges,
...@@ -2341,6 +2343,13 @@ export default function Sidebar() {...@@ -2341,6 +2343,13 @@ export default function Sidebar() {
2341 ],2343 ],
2342 [projectGroups],2344 [projectGroups],
2343 );2345 );
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 );
2344 const projectGroupByScopeKey = useMemo(2353 const projectGroupByScopeKey = useMemo(
2345 () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),2354 () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
2346 [projectGroups],2355 [projectGroups],
...@@ -4449,6 +4458,13 @@ export default function Sidebar() {...@@ -4449,6 +4458,13 @@ export default function Sidebar() {
4449 <span className="min-w-0 flex-1 truncate">4458 <span className="min-w-0 flex-1 truncate">
4450 {scopedProjectGroup?.displayName ?? "All projects"}4459 {scopedProjectGroup?.displayName ?? "All projects"}
4451 </span>4460 </span>
4461 {scopedProjectGroup && showProjectEnvironments ? (
4462 <ProjectEnvironmentBadge
4463 group={scopedProjectGroup}
4464 primaryEnvironmentId={primaryEnvironmentId}
4465 machineByEnvironmentId={environmentMachineById}
4466 />
4467 ) : null}
4452 <ChevronDownIcon className="-mr-px size-4 shrink-0" />4468 <ChevronDownIcon className="-mr-px size-4 shrink-0" />
4453 </ComboboxTrigger>4469 </ComboboxTrigger>
4454 <ComboboxPopup4470 <ComboboxPopup
...@@ -4504,6 +4520,13 @@ export default function Sidebar() {...@@ -4504,6 +4520,13 @@ export default function Sidebar() {
4504 <FolderIcon className="size-4 shrink-0" />4520 <FolderIcon className="size-4 shrink-0" />
4505 )}4521 )}
4506 <span className="min-w-0 flex-1 truncate text-sm">{item.label}</span>4522 <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}
4507 {project ? (4530 {project ? (
4508 <Button4531 <Button
4509 size="icon-xs"4532 size="icon-xs"
apps/web/src/components/chat/DraftHeroHeadline.tsx+30-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { DraftId } from "~/composerDraftStore";1import type { DraftId } from "~/composerDraftStore";
2import { useComposerDraftStore } from "~/composerDraftStore";2import { useComposerDraftStore } from "~/composerDraftStore";
3import type { ScopedProjectRef } from "@t3tools/contracts";3import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
5import { FolderPlusIcon } from "lucide-react";5import { FolderPlusIcon } from "lucide-react";
6import { useCallback, useMemo } from "react";6import { useCallback, useMemo } from "react";
...@@ -12,9 +12,11 @@ import { selectProjectGroupingSettings } from "~/logicalProject";...@@ -12,9 +12,11 @@ import { selectProjectGroupingSettings } from "~/logicalProject";
12import {12import {
13 buildSidebarProjectPickerEntries,13 buildSidebarProjectPickerEntries,
14 buildSidebarProjectSnapshots,14 buildSidebarProjectSnapshots,
15 projectGroupsSpanEnvironments,
15} from "~/sidebarProjectGrouping";16} from "~/sidebarProjectGrouping";
16import { useProjects, useThreadShells } from "~/state/entities";17import { useProjects, useThreadShells } from "~/state/entities";
17import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";18import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
19import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
18import { ProjectFavicon } from "../ProjectFavicon";20import { ProjectFavicon } from "../ProjectFavicon";
19import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";21import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
20import {22import {
...@@ -83,6 +85,26 @@ export function DraftHeroHeadline({...@@ -83,6 +85,26 @@ export function DraftHeroHeadline({
83 threads,85 threads,
84 ],86 ],
85 );87 );
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 );
86 const projectPickerEntries = useMemo(108 const projectPickerEntries = useMemo(
87 () =>109 () =>
88 buildSidebarProjectPickerEntries({110 buildSidebarProjectPickerEntries({
...@@ -183,6 +205,13 @@ export function DraftHeroHeadline({...@@ -183,6 +205,13 @@ export function DraftHeroHeadline({
183 {group.displayName}205 {group.displayName}
184 </TooltipPopup>206 </TooltipPopup>
185 </Tooltip>207 </Tooltip>
208 {showProjectEnvironments ? (
209 <ProjectEnvironmentBadge
210 group={group}
211 primaryEnvironmentId={primaryEnvironmentId}
212 machineByEnvironmentId={environmentMachineById}
213 />
214 ) : null}
186 </MenuRadioItem>215 </MenuRadioItem>
187 );216 );
188 })}217 })}
apps/web/src/environmentGrouping.test.ts+35
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 buildPhysicalToLogicalProjectKeyMap,12 buildPhysicalToLogicalProjectKeyMap,
13 buildSidebarProjectPickerEntries,13 buildSidebarProjectPickerEntries,
14 buildSidebarProjectSnapshots,14 buildSidebarProjectSnapshots,
15 projectGroupsSpanEnvironments,
15} from "./sidebarProjectGrouping";16} from "./sidebarProjectGrouping";
16import { orderItemsByPreferredIds } from "./components/Sidebar.logic";17import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
17import { legacyProjectCwdPreferenceKey } from "./uiStateStore";18import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
...@@ -81,6 +82,40 @@ describe("environment grouping", () => {...@@ -81,6 +82,40 @@ describe("environment grouping", () => {
81 expect(projectGroupCount).toBe(1);82 expect(projectGroupCount).toBe(1);
82 });83 });
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
84 it("keeps projects without repository identity physically scoped", () => {119 it("keeps projects without repository identity physically scoped", () => {
85 const primary = makeProject();120 const primary = makeProject();
86 const remote = makeProject({121 const remote = makeProject({
apps/web/src/sidebarProjectGrouping.ts+13
...@@ -26,6 +26,19 @@ export interface SidebarProjectSnapshot extends Project {...@@ -26,6 +26,19 @@ export interface SidebarProjectSnapshot extends Project {
26 remoteEnvironmentLabels: readonly string[];26 remoteEnvironmentLabels: readonly string[];
27}27}
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
29export interface SidebarProjectPickerEntry {42export interface SidebarProjectPickerEntry {
30 group: SidebarProjectSnapshot;43 group: SidebarProjectSnapshot;
31 targetProject: SidebarProjectGroupMember;44 targetProject: SidebarProjectGroupMember;