diff --git a/apps/web/src/components/chat/DraftHeroHeadline.logic.test.ts b/apps/web/src/components/chat/DraftHeroHeadline.logic.test.ts new file mode 100644 index 000000000000..c450034f73c5 --- /dev/null +++ b/apps/web/src/components/chat/DraftHeroHeadline.logic.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { SidebarProjectPickerEntry } from "~/sidebarProjectGrouping"; +import { resolveProjectPickerEnvironmentHints } from "./DraftHeroHeadline.logic"; + +function entry(input: { + projectKey: string; + displayName: string; + environmentId: string; + environmentLabel: string | null; + workspaceRoot: string; +}): SidebarProjectPickerEntry { + return { + group: { projectKey: input.projectKey, displayName: input.displayName }, + targetProject: { + environmentId: input.environmentId, + environmentLabel: input.environmentLabel, + workspaceRoot: input.workspaceRoot, + }, + isPreferred: false, + } as unknown as SidebarProjectPickerEntry; +} + +describe("resolveProjectPickerEnvironmentHints", () => { + it("hides environment labels when every entry lives in one environment", () => { + const hints = resolveProjectPickerEnvironmentHints([ + entry({ + projectKey: "a", + displayName: "quasar", + environmentId: "local", + environmentLabel: "laptop", + workspaceRoot: "/a/quasar", + }), + entry({ + projectKey: "b", + displayName: "pulsar", + environmentId: "local", + environmentLabel: "laptop", + workspaceRoot: "/a/pulsar", + }), + ]); + expect(hints.get("a")).toEqual({ environmentLabel: null, text: null }); + expect(hints.get("b")).toEqual({ environmentLabel: null, text: null }); + }); + + it("shows the environment label once entries span two environments", () => { + const hints = resolveProjectPickerEnvironmentHints([ + entry({ + projectKey: "a", + displayName: "quasar", + environmentId: "local", + environmentLabel: "laptop", + workspaceRoot: "/a/quasar", + }), + entry({ + projectKey: "b", + displayName: "quasar", + environmentId: "remote", + environmentLabel: "build-box", + workspaceRoot: "/srv/quasar", + }), + ]); + expect(hints.get("a")).toEqual({ environmentLabel: "laptop", text: "laptop" }); + expect(hints.get("b")).toEqual({ environmentLabel: "build-box", text: "build-box" }); + }); + + it("falls back to the workspace path when name and label both collide", () => { + const hints = resolveProjectPickerEnvironmentHints([ + entry({ + projectKey: "a", + displayName: "quasar", + environmentId: "local", + environmentLabel: "laptop", + workspaceRoot: "/a/quasar", + }), + entry({ + projectKey: "b", + displayName: "quasar", + environmentId: "local", + environmentLabel: "laptop", + workspaceRoot: "/b/quasar", + }), + entry({ + projectKey: "c", + displayName: "quasar", + environmentId: "remote", + environmentLabel: "build-box", + workspaceRoot: "/srv/quasar", + }), + ]); + expect(hints.get("a")).toEqual({ + environmentLabel: "laptop", + text: "laptop · /a/quasar", + }); + expect(hints.get("b")).toEqual({ + environmentLabel: "laptop", + text: "laptop · /b/quasar", + }); + expect(hints.get("c")).toEqual({ environmentLabel: "build-box", text: "build-box" }); + }); + + it("uses the bare workspace path for same-name checkouts in a single environment", () => { + const hints = resolveProjectPickerEnvironmentHints([ + entry({ + projectKey: "a", + displayName: "quasar", + environmentId: "local", + environmentLabel: null, + workspaceRoot: "/a/quasar", + }), + entry({ + projectKey: "b", + displayName: "quasar", + environmentId: "local", + environmentLabel: null, + workspaceRoot: "/b/quasar", + }), + ]); + expect(hints.get("a")).toEqual({ environmentLabel: null, text: "/a/quasar" }); + expect(hints.get("b")).toEqual({ environmentLabel: null, text: "/b/quasar" }); + }); +}); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.logic.ts b/apps/web/src/components/chat/DraftHeroHeadline.logic.ts new file mode 100644 index 000000000000..d50d67bdd2dd --- /dev/null +++ b/apps/web/src/components/chat/DraftHeroHeadline.logic.ts @@ -0,0 +1,42 @@ +import type { SidebarProjectPickerEntry } from "~/sidebarProjectGrouping"; + +export interface ProjectPickerEnvironmentHint { + /** Environment label for the row, or null when the row has no environment to show. */ + readonly environmentLabel: string | null; + /** + * Secondary text rendered next to the project name. Falls back to the + * workspace path when another row would otherwise read identically. + */ + readonly text: string | null; +} + +/** + * The same project name can exist in several environments, so the environment + * label is only surfaced when the picker spans two or more environments. + * Labels are not unique either: two checkouts of one project on the same + * machine collide on both name and label, so those rows fall back to the + * workspace path (mirroring ProjectSettingsPanel's checkout labels). + */ +export function resolveProjectPickerEnvironmentHints( + entries: ReadonlyArray, +): ReadonlyMap { + const environmentIds = new Set(entries.map((entry) => entry.targetProject.environmentId)); + const showEnvironmentLabels = environmentIds.size >= 2; + const hints = new Map(); + for (const entry of entries) { + const environmentLabel = showEnvironmentLabels ? entry.targetProject.environmentLabel : null; + const collides = entries.some( + (other) => + other.group.projectKey !== entry.group.projectKey && + other.group.displayName === entry.group.displayName && + (showEnvironmentLabels ? other.targetProject.environmentLabel : null) === environmentLabel, + ); + const text = collides + ? environmentLabel === null + ? entry.targetProject.workspaceRoot + : `${environmentLabel} · ${entry.targetProject.workspaceRoot}` + : environmentLabel; + hints.set(entry.group.projectKey, { environmentLabel, text }); + } + return hints; +} diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 4a9421f2011f..c794df015060 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,6 +1,7 @@ import type { DraftId } from "~/composerDraftStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import type { ScopedProjectRef } from "@t3tools/contracts"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { FolderPlusIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; @@ -15,6 +16,7 @@ import { } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { ProjectFavicon } from "../ProjectFavicon"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { @@ -27,6 +29,7 @@ import { MenuTrigger, } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { resolveProjectPickerEnvironmentHints } from "./DraftHeroHeadline.logic"; interface DraftHeroHeadlineProps { readonly draftId: DraftId | null; @@ -60,6 +63,19 @@ export function DraftHeroHeadline({ ), [environments], ); + const environmentMachineById = useMemo( + () => + new Map( + environments.map( + (environment) => + [ + environment.environmentId, + resolveEnvironmentMachineKind(environment.serverConfig), + ] as const, + ), + ), + [environments], + ); const projectGroups = useMemo( () => sortLogicalProjectsForSidebar( @@ -94,6 +110,10 @@ export function DraftHeroHeadline({ () => new Map(projectPickerEntries.map((entry) => [entry.group.projectKey, entry] as const)), [projectPickerEntries], ); + const environmentHintByKey = useMemo( + () => resolveProjectPickerEnvironmentHints(projectPickerEntries), + [projectPickerEntries], + ); const activeProjectGroup = activeProjectRef === null ? null @@ -163,7 +183,11 @@ export function DraftHeroHeadline({ } }} > - {projectPickerEntries.map(({ group }) => { + {projectPickerEntries.map(({ group, targetProject }) => { + const hint = environmentHintByKey.get(group.projectKey) ?? { + environmentLabel: null, + text: null, + }; return ( - {group.displayName} + {hint.text === null ? group.displayName : `${group.displayName} (${hint.text})`} + {hint.text === null ? null : ( + + {hint.environmentLabel === null ? null : ( + + )} + {hint.text} + + )} ); })}