Skip to content
Draft
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
13 changes: 13 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,19 @@ describe("buildProjectActionItems", () => {
);
expect(iconTitles).toEqual(["fleet"]);
});

it("marks unreachable projects disabled", () => {
const project = makeProject({ title: "fleet" });
const [item] = buildProjectActionItems({
projects: [{ ...project, displayName: "fleet" }],
valuePrefix: "new-thread-in",
icon: () => null,
runProject: async () => undefined,
isDisabled: () => true,
});

expect(item?.disabled).toBe(true);
});
});

describe("buildThreadActionItems", () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export function buildProjectActionItems(input: {
runProject: (project: CommandPaletteProject) => Promise<void>;
searchTerms?: (project: CommandPaletteProject) => ReadonlyArray<string>;
renderDescription?: (project: CommandPaletteProject) => ReactNode;
isDisabled?: (project: CommandPaletteProject) => boolean;
shortcutCommand?: KeybindingCommand;
}): CommandPaletteActionItem[] {
return input.projects.map((project) => ({
Expand All @@ -228,6 +229,7 @@ export function buildProjectActionItems(input: {
title: project.displayName,
description: input.renderDescription?.(project) ?? project.workspaceRoot,
icon: input.icon(project),
...(input.isDisabled?.(project) === true ? { disabled: true } : {}),
...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),
run: async () => {
await input.runProject(project);
Expand Down
74 changes: 54 additions & 20 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -925,13 +925,32 @@ function OpenCommandPaletteDialog(props: {
}),
[activeDraftThread, activeThread, defaultProjectRef, handleNewThread],
);
const isEnvironmentReachable = useCallback(
(environmentId: EnvironmentId) =>
canCreateProjectInEnvironment(
environments.find((environment) => environment.environmentId === environmentId)?.connection
.phase,
),
[environments],
);
const projectPickerEntries = useMemo(
() =>
buildSidebarProjectPickerEntries({
groups: projectGroups,
preferredProjectRef: contextualProjectRef,
isEnvironmentReachable,
}),
[contextualProjectRef, projectGroups],
[contextualProjectRef, isEnvironmentReachable, projectGroups],
);
const newThreadPickerEntries = useMemo(
() =>
buildSidebarProjectPickerEntries({
groups: projectGroups,
preferredProjectRef: contextualProjectRef,
expandMembers: true,
isEnvironmentReachable,
}),
[contextualProjectRef, isEnvironmentReachable, projectGroups],
);
const pickerProjects = useMemo(
() =>
Expand All @@ -951,6 +970,24 @@ function OpenCommandPaletteDialog(props: {
),
[projectPickerEntries],
);
const newThreadProjects = useMemo(
() =>
newThreadPickerEntries.map(({ group, targetProject }) => ({
...targetProject,
displayName: group.displayName,
})),
[newThreadPickerEntries],
);
const newThreadGroupByTargetKey = useMemo(
() =>
new Map(
newThreadPickerEntries.map(({ group, targetProject }) => [
`${targetProject.environmentId}:${targetProject.id}`,
group,
]),
),
[newThreadPickerEntries],
);

const addProjectEnvironmentOptions = useMemo(() => {
const options = environments.map((environment): AddProjectEnvironmentOption => {
Expand Down Expand Up @@ -1260,10 +1297,10 @@ function OpenCommandPaletteDialog(props: {
() =>
enumerateCommandPaletteItems(
buildProjectActionItems({
projects: pickerProjects,
projects: newThreadProjects,
valuePrefix: "new-thread-in",
searchTerms: (project) => {
const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
const group = newThreadGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
const location = projectEnvironmentLocationById.get(project.environmentId);
return [
...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ??
Expand All @@ -1277,6 +1314,13 @@ function OpenCommandPaletteDialog(props: {
label: "Remote",
machine: "server" as const,
};
const environment = environments.find(
(candidate) => candidate.environmentId === project.environmentId,
);
const reachable = isEnvironmentReachable(project.environmentId);
const status = environment
? connectionStatusText(environment.connection)
: "Unavailable";
return (
<span className="flex min-w-0 items-center gap-1">
<span className="inline-flex min-w-0 items-center gap-1">
Expand All @@ -1290,34 +1334,24 @@ function OpenCommandPaletteDialog(props: {
<span className="truncate">{location.label}</span>
</span>
<CommandPaletteMetaDot />
<span className="truncate">{project.workspaceRoot}</span>
<span className="truncate">{reachable ? project.workspaceRoot : status}</span>
</span>
);
},
isDisabled: (project) => !isEnvironmentReachable(project.environmentId),
icon: projectFavicon,
runProject: async (project) => {
const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
const contextualRefBelongsToGroup =
contextualProjectRef !== null &&
group?.memberProjectRefs.some(
(projectRef) =>
projectRef.environmentId === contextualProjectRef.environmentId &&
projectRef.projectId === contextualProjectRef.projectId,
);
await handleNewThread(
contextualRefBelongsToGroup
? contextualProjectRef
: scopeProjectRef(project.environmentId, project.id),
);
await handleNewThread(scopeProjectRef(project.environmentId, project.id));
},
}),
),
[
contextualProjectRef,
environments,
handleNewThread,
pickerProjects,
isEnvironmentReachable,
newThreadGroupByTargetKey,
newThreadProjects,
projectEnvironmentLocationById,
projectGroupByTargetKey,
],
);

Expand Down
48 changes: 39 additions & 9 deletions apps/web/src/components/chat/DraftHeroHeadline.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { DraftId } from "~/composerDraftStore";
import { useComposerDraftStore } from "~/composerDraftStore";
import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
import {
resolveEnvironmentMachineKind,
type EnvironmentId,
type ScopedProjectRef,
} from "@t3tools/contracts";
import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
import { FolderPlusIcon } from "lucide-react";
import { useCallback, useMemo } from "react";

Expand Down Expand Up @@ -105,16 +110,37 @@ export function DraftHeroHeadline({
),
[environments],
);
const isEnvironmentReachable = useCallback(
(environmentId: EnvironmentId) =>
canCreateProjectInEnvironment(
environments.find((environment) => environment.environmentId === environmentId)?.connection
.phase,
),
[environments],
);
const projectPickerEntries = useMemo(
() =>
buildSidebarProjectPickerEntries({
groups: projectGroups,
preferredProjectRef: activeProjectRef,
expandMembers: true,
isEnvironmentReachable,
}),
[activeProjectRef, projectGroups],
[activeProjectRef, isEnvironmentReachable, projectGroups],
);
const projectEntryByKey = useMemo(
() => new Map(projectPickerEntries.map((entry) => [entry.group.projectKey, entry] as const)),
() =>
new Map(
projectPickerEntries.map(
(entry) =>
[
scopedProjectKey(
scopeProjectRef(entry.targetProject.environmentId, entry.targetProject.id),
),
entry,
] as const,
),
),
[projectPickerEntries],
);
const activeProjectGroup =
Expand All @@ -125,7 +151,7 @@ export function DraftHeroHeadline({
(projectRef) => scopedProjectKey(projectRef) === scopedProjectKey(activeProjectRef),
),
) ?? null);
const activeProjectKey = activeProjectGroup?.projectKey ?? "";
const activeProjectKey = activeProjectRef === null ? "" : scopedProjectKey(activeProjectRef);
const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle;
const hasResolvedProject = activeProjectTitle !== null;
const canChooseProject = projectPickerEntries.length > 0;
Expand Down Expand Up @@ -155,7 +181,7 @@ export function DraftHeroHeadline({
value={activeProjectKey}
onValueChange={(value) => {
const entry = projectEntryByKey.get(value as string);
if (!entry || value === activeProjectKey) {
if (!entry || !entry.reachable || value === activeProjectKey) {
return;
}
const project = entry.targetProject;
Expand Down Expand Up @@ -188,11 +214,15 @@ export function DraftHeroHeadline({
}
}}
>
{projectPickerEntries.map(({ group }) => {
{projectPickerEntries.map(({ group, targetProject, reachable }) => {
const value = scopedProjectKey(
scopeProjectRef(targetProject.environmentId, targetProject.id),
);
return (
<MenuRadioItem
key={group.projectKey}
value={group.projectKey}
key={value}
value={value}
disabled={!reachable}
closeOnClick
className="[&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"
>
Expand All @@ -207,7 +237,7 @@ export function DraftHeroHeadline({
</Tooltip>
{showProjectEnvironments ? (
<ProjectEnvironmentBadge
group={group}
group={{ memberProjects: [targetProject] }}
primaryEnvironmentId={primaryEnvironmentId}
machineByEnvironmentId={environmentMachineById}
/>
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/environmentGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,75 @@ describe("environment grouping", () => {
id: remote.id,
});
expect(entries[0]?.isPreferred).toBe(true);
expect(entries[0]?.reachable).toBe(true);
expect(entries[1]?.group.displayName).toBe("separate");
});

it("lists each checkout when expanding grouped members", () => {
const primary = makeProject({ repositoryIdentity });
const remote = makeProject({
id: ProjectId.make("project-remote"),
environmentId: remoteEnvironmentId,
repositoryIdentity,
});
const groups = buildSidebarProjectSnapshots({
projects: [primary, remote],
settings: defaultGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: () => null,
});

const entries = buildSidebarProjectPickerEntries({
groups,
preferredProjectRef: {
environmentId: remoteEnvironmentId,
projectId: remote.id,
},
expandMembers: true,
isEnvironmentReachable: (environmentId) => environmentId === primaryEnvironmentId,
});

expect(entries).toHaveLength(2);
expect(entries.map((entry) => entry.targetProject.environmentId)).toEqual([
primaryEnvironmentId,
remoteEnvironmentId,
]);
expect(entries[0]?.reachable).toBe(true);
expect(entries[1]?.reachable).toBe(false);
expect(entries[1]?.isPreferred).toBe(true);
});

it("targets a reachable sibling when the preferred environment is down", () => {
const primary = makeProject({ repositoryIdentity });
const remote = makeProject({
id: ProjectId.make("project-remote"),
environmentId: remoteEnvironmentId,
repositoryIdentity,
});
const groups = buildSidebarProjectSnapshots({
projects: [primary, remote],
settings: defaultGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: () => null,
});

const [entry] = buildSidebarProjectPickerEntries({
groups,
preferredProjectRef: {
environmentId: remoteEnvironmentId,
projectId: remote.id,
},
isEnvironmentReachable: (environmentId) => environmentId === primaryEnvironmentId,
});

expect(entry?.isPreferred).toBe(true);
expect(entry?.reachable).toBe(true);
expect(entry?.targetProject).toMatchObject({
environmentId: primaryEnvironmentId,
id: primary.id,
});
});

it("keeps the current environment when available and falls back otherwise", () => {
const currentPrimary = makeProject({ repositoryIdentity });
const currentRemote = makeProject({
Expand Down
Loading