| 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,6 +104,7 @@ import { |
| 104 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; | 104 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; |
| 105 | import { | 105 | import { |
| 106 | buildSidebarProjectSnapshots, | 106 | buildSidebarProjectSnapshots, |
| 107 | projectGroupsSpanEnvironments, | ||
| 107 | type SidebarProjectSnapshot, | 108 | type SidebarProjectSnapshot, |
| 108 | } from "../sidebarProjectGrouping"; | 109 | } from "../sidebarProjectGrouping"; |
| 109 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | 110 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| ... | @@ -141,6 +142,7 @@ import type { SidebarThreadSummary } from "../types"; | ... | @@ -141,6 +142,7 @@ import type { SidebarThreadSummary } from "../types"; |
| 141 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 142 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 142 | import { cn } from "~/lib/utils"; | 143 | import { cn } from "~/lib/utils"; |
| 143 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 144 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 145 | import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; | ||
| 144 | import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; | 146 | import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; |
| 145 | import { | 147 | import { |
| 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 | <ComboboxPopup | 4470 | <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 | <Button | 4531 | <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 @@ |
| 1 | import type { DraftId } from "~/composerDraftStore"; | 1 | import type { DraftId } from "~/composerDraftStore"; |
| 2 | import { useComposerDraftStore } from "~/composerDraftStore"; | 2 | import { useComposerDraftStore } from "~/composerDraftStore"; |
| 3 | import type { ScopedProjectRef } from "@t3tools/contracts"; | 3 | import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; |
| 4 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; | 4 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 5 | import { FolderPlusIcon } from "lucide-react"; | 5 | import { FolderPlusIcon } from "lucide-react"; |
| 6 | import { useCallback, useMemo } from "react"; | 6 | import { useCallback, useMemo } from "react"; |
| ... | @@ -12,9 +12,11 @@ import { selectProjectGroupingSettings } from "~/logicalProject"; | ... | @@ -12,9 +12,11 @@ import { selectProjectGroupingSettings } from "~/logicalProject"; |
| 12 | import { | 12 | import { |
| 13 | buildSidebarProjectPickerEntries, | 13 | buildSidebarProjectPickerEntries, |
| 14 | buildSidebarProjectSnapshots, | 14 | buildSidebarProjectSnapshots, |
| 15 | projectGroupsSpanEnvironments, | ||
| 15 | } from "~/sidebarProjectGrouping"; | 16 | } from "~/sidebarProjectGrouping"; |
| 16 | import { useProjects, useThreadShells } from "~/state/entities"; | 17 | import { useProjects, useThreadShells } from "~/state/entities"; |
| 17 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | 18 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 19 | import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; | ||
| 18 | import { ProjectFavicon } from "../ProjectFavicon"; | 20 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 19 | import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; | 21 | import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; |
| 20 | import { | 22 | import { |
| ... | @@ -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"; |
| 16 | import { orderItemsByPreferredIds } from "./components/Sidebar.logic"; | 17 | import { orderItemsByPreferredIds } from "./components/Sidebar.logic"; |
| 17 | import { legacyProjectCwdPreferenceKey } from "./uiStateStore"; | 18 | import { 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 | }); |
| 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 | 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 | } |
| 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 | export interface SidebarProjectPickerEntry { | 42 | export interface SidebarProjectPickerEntry { |
| 30 | group: SidebarProjectSnapshot; | 43 | group: SidebarProjectSnapshot; |
| 31 | targetProject: SidebarProjectGroupMember; | 44 | targetProject: SidebarProjectGroupMember; |