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