Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/web/src/components/ProjectEnvironmentBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts";

import type { SidebarProjectSnapshot } from "~/sidebarProjectGrouping";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

/**
* Machine icon for a project picker row whose group has a member on another
* environment, with the environment names in a tooltip. Projects that only
* live on this device render nothing, the rule thread rows use for their
* machine icon. Callers
* render it only while the catalog spans environments (see
* projectGroupsSpanEnvironments), so single-machine users see no change.
*/
export function ProjectEnvironmentBadge(props: {
readonly group: Pick<SidebarProjectSnapshot, "memberProjects">;
readonly primaryEnvironmentId: EnvironmentId | null;
readonly machineByEnvironmentId: ReadonlyMap<EnvironmentId, EnvironmentMachineKind>;
}) {
// Member order follows registration order and can differ between sessions,
// so sort by label to keep the icon and tooltip stable.
const remoteMembers = props.group.memberProjects
.filter((member) => member.environmentId !== props.primaryEnvironmentId)
.map((member) => ({ ...member, environmentLabel: member.environmentLabel ?? "Remote" }))
.sort((a, b) => a.environmentLabel.localeCompare(b.environmentLabel));
const first = remoteMembers[0];
if (!first) return null;
const labels = remoteMembers
.map((member) => member.environmentLabel)
.filter((label, index, all) => all.indexOf(label) === index)
.join(", ");
const alsoHere = remoteMembers.length < props.group.memberProjects.length;
const description = `${alsoHere ? "Also on" : "On"} ${labels}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return (
<Tooltip>
<TooltipTrigger
render={
<span
role="img"
aria-label={description}
className="ml-auto inline-flex shrink-0 items-center text-muted-foreground"
/>
}
>
<EnvironmentMachineIcon
aria-hidden
kind={props.machineByEnvironmentId.get(first.environmentId) ?? "server"}
className="size-3.5"
/>
</TooltipTrigger>
<TooltipPopup side="top">{description}</TooltipPopup>
</Tooltip>
);
}
23 changes: 23 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import {
import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
import {
buildSidebarProjectSnapshots,
projectGroupsSpanEnvironments,
type SidebarProjectSnapshot,
} from "../sidebarProjectGrouping";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
Expand Down Expand Up @@ -141,6 +142,7 @@ import type { SidebarThreadSummary } from "../types";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { cn } from "~/lib/utils";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import {
animateSidebarLayoutChanges,
Expand Down Expand Up @@ -2341,6 +2343,13 @@ export default function Sidebar() {
],
[projectGroups],
);
// Same-named projects on two machines are only told apart by where they
// live, so rows on another machine carry its icon once the catalog spans
// more than one environment; a single-machine catalog stays as it was.
const showProjectEnvironments = useMemo(
() => projectGroupsSpanEnvironments(projectGroups),
[projectGroups],
);
const projectGroupByScopeKey = useMemo(
() => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
[projectGroups],
Expand Down Expand Up @@ -4449,6 +4458,13 @@ export default function Sidebar() {
<span className="min-w-0 flex-1 truncate">
{scopedProjectGroup?.displayName ?? "All projects"}
</span>
{scopedProjectGroup && showProjectEnvironments ? (
<ProjectEnvironmentBadge
group={scopedProjectGroup}
primaryEnvironmentId={primaryEnvironmentId}
machineByEnvironmentId={environmentMachineById}
/>
) : null}
<ChevronDownIcon className="-mr-px size-4 shrink-0" />
</ComboboxTrigger>
<ComboboxPopup
Expand Down Expand Up @@ -4504,6 +4520,13 @@ export default function Sidebar() {
<FolderIcon className="size-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-sm">{item.label}</span>
{project && showProjectEnvironments ? (
<ProjectEnvironmentBadge
group={project}
primaryEnvironmentId={primaryEnvironmentId}
machineByEnvironmentId={environmentMachineById}
/>
) : null}
{project ? (
<Button
size="icon-xs"
Expand Down
31 changes: 30 additions & 1 deletion apps/web/src/components/chat/DraftHeroHeadline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DraftId } from "~/composerDraftStore";
import { useComposerDraftStore } from "~/composerDraftStore";
import type { ScopedProjectRef } from "@t3tools/contracts";
import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
import { FolderPlusIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
Expand All @@ -12,9 +12,11 @@ import { selectProjectGroupingSettings } from "~/logicalProject";
import {
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
projectGroupsSpanEnvironments,
} from "~/sidebarProjectGrouping";
import { useProjects, useThreadShells } from "~/state/entities";
import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
import { ProjectFavicon } from "../ProjectFavicon";
import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
import {
Expand Down Expand Up @@ -83,6 +85,26 @@ export function DraftHeroHeadline({
threads,
],
);
// Same-named projects on two machines are only told apart by where they
// live, so rows on another machine carry its icon once the catalog spans
// more than one environment; a single-machine catalog stays as it was.
const showProjectEnvironments = useMemo(
() => projectGroupsSpanEnvironments(projectGroups),
[projectGroups],
);
const environmentMachineById = useMemo(
() =>
new Map(
environments.map(
(environment) =>
[
environment.environmentId,
resolveEnvironmentMachineKind(environment.serverConfig),
] as const,
),
),
[environments],
);
const projectPickerEntries = useMemo(
() =>
buildSidebarProjectPickerEntries({
Expand Down Expand Up @@ -183,6 +205,13 @@ export function DraftHeroHeadline({
{group.displayName}
</TooltipPopup>
</Tooltip>
{showProjectEnvironments ? (
<ProjectEnvironmentBadge
group={group}
primaryEnvironmentId={primaryEnvironmentId}
machineByEnvironmentId={environmentMachineById}
/>
) : null}
</MenuRadioItem>
);
})}
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/environmentGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildPhysicalToLogicalProjectKeyMap,
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
projectGroupsSpanEnvironments,
} from "./sidebarProjectGrouping";
import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
Expand Down Expand Up @@ -81,6 +82,40 @@ describe("environment grouping", () => {
expect(projectGroupCount).toBe(1);
});

it("reports whether the project groups span more than one environment", () => {
const grouped = makeProject({ repositoryIdentity });
const groupedRemote = makeProject({
id: ProjectId.make("project-remote"),
environmentId: remoteEnvironmentId,
repositoryIdentity,
});
const separateLocal = makeProject({
id: ProjectId.make("workbench-local"),
title: "workbench",
workspaceRoot: "/tmp/workbench",
});
const separateRemote = makeProject({
id: ProjectId.make("workbench-remote"),
environmentId: remoteEnvironmentId,
title: "workbench",
workspaceRoot: "/tmp/workbench",
});
const build = (projects: Project[]) =>
buildSidebarProjectSnapshots({
projects,
settings: defaultGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: (environmentId) =>
environmentId === remoteEnvironmentId ? "Mac mini" : "Primary",
});

const groups = build([groupedRemote, grouped, separateLocal, separateRemote]);
expect(groups).toHaveLength(3);
expect(projectGroupsSpanEnvironments(groups)).toBe(true);
expect(projectGroupsSpanEnvironments(build([grouped, separateLocal]))).toBe(false);
expect(projectGroupsSpanEnvironments(build([separateRemote]))).toBe(false);
});

it("keeps projects without repository identity physically scoped", () => {
const primary = makeProject();
const remote = makeProject({
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/sidebarProjectGrouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ export interface SidebarProjectSnapshot extends Project {
remoteEnvironmentLabels: readonly string[];
}

export function projectGroupsSpanEnvironments(
groups: ReadonlyArray<Pick<SidebarProjectSnapshot, "memberProjects">>,
): boolean {
const environmentIds = new Set<EnvironmentId>();
for (const group of groups) {
for (const member of group.memberProjects) {
environmentIds.add(member.environmentId);
if (environmentIds.size > 1) return true;
}
}
return false;
}

export interface SidebarProjectPickerEntry {
group: SidebarProjectSnapshot;
targetProject: SidebarProjectGroupMember;
Expand Down
Loading