Skip to content
Open
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/commandPaletteBus.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { ScopedProjectRef } from "@t3tools/contracts";

// Tiny event bus allowing components to programmatically open the command palette
// without owning its React state.
const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette";
const COMMAND_PALETTE_CLOSE_EVENT = "t3code:close-command-palette";

export interface CommandPaletteOpenDetail {
readonly open?: "add-project" | "new-thread-in";
readonly preferredProjectRef?: ScopedProjectRef | null;
}

export function openCommandPalette(detail?: CommandPaletteOpenDetail): void {
Expand All @@ -22,6 +26,15 @@ export function onOpenCommandPalette(
return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler);
}

export function closeCommandPalette(): void {
window.dispatchEvent(new Event(COMMAND_PALETTE_CLOSE_EVENT));
}

export function onCloseCommandPalette(listener: () => void): () => void {
window.addEventListener(COMMAND_PALETTE_CLOSE_EVENT, listener);
return () => window.removeEventListener(COMMAND_PALETTE_CLOSE_EVENT, listener);
}

/** Read at event time so consumers do not subscribe to transient dialog state. */
export function isCommandPaletteOpen(): boolean {
return (
Expand Down
137 changes: 137 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import type { Thread } from "../types";
import {
buildNewThreadProjectView,
buildNewThreadPickerGroups,
buildThreadActionItems,
enumerateCommandPaletteItems,
filterCommandPaletteGroups,
isNewThreadProjectView,
NEW_THREAD_PROJECT_VIEW_GROUP,
prioritizeScopedProject,
resolveNewThreadIntentViewStack,
type CommandPaletteGroup,
type CommandPaletteView,
} from "./CommandPalette.logic";

describe("enumerateCommandPaletteItems", () => {
Expand Down Expand Up @@ -38,6 +46,135 @@ describe("enumerateCommandPaletteItems", () => {
const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
const PROJECT_ID = ProjectId.make("project-1");

describe("resolveNewThreadIntentViewStack", () => {
const newThreadView = buildNewThreadProjectView(null);

it("marks the shared view as the dynamic new-thread project picker", () => {
expect(newThreadView.groups).toEqual([
{ value: NEW_THREAD_PROJECT_VIEW_GROUP, label: "Projects", items: [] },
]);
expect(isNewThreadProjectView(newThreadView)).toBe(true);
});

it("replaces another submenu instead of stacking on it", () => {
const rootView: CommandPaletteView = {
addonIcon: null,
groups: [{ value: "root", label: "Root", items: [] }],
};
const staleView: CommandPaletteView = {
addonIcon: null,
groups: [{ value: "add-project", label: "Add project", items: [] }],
};

expect(resolveNewThreadIntentViewStack([rootView, staleView], newThreadView)).toEqual([
newThreadView,
]);
});

it("keeps the active picker for refresh while discarding stale back-stack views", () => {
const staleView: CommandPaletteView = {
addonIcon: null,
groups: [{ value: "browse", label: "Browse", items: [] }],
};
const currentNewThreadView: CommandPaletteView = {
...newThreadView,
initialQuery: "preserved-current-view",
};

expect(
resolveNewThreadIntentViewStack([staleView, currentNewThreadView], newThreadView),
).toEqual([currentNewThreadView]);
});
});

describe("prioritizeScopedProject", () => {
it("moves the preferred scoped project to the front without disturbing the remaining order", () => {
const otherEnvironmentId = EnvironmentId.make("environment-remote");
const otherProjectId = ProjectId.make("project-2");
const preferredProjectId = ProjectId.make("project-3");
const projects = [
{ environmentId: LOCAL_ENVIRONMENT_ID, id: PROJECT_ID },
{ environmentId: otherEnvironmentId, id: otherProjectId },
{ environmentId: LOCAL_ENVIRONMENT_ID, id: preferredProjectId },
];

expect(
prioritizeScopedProject(projects, scopeProjectRef(LOCAL_ENVIRONMENT_ID, preferredProjectId)),
).toEqual([projects[2], projects[0], projects[1]]);
});

it("preserves order when the preferred scoped project is unavailable", () => {
const projects = [{ environmentId: LOCAL_ENVIRONMENT_ID, id: PROJECT_ID }];

expect(
prioritizeScopedProject(
projects,
scopeProjectRef(LOCAL_ENVIRONMENT_ID, ProjectId.make("missing-project")),
),
).toEqual(projects);
});
});

describe("buildNewThreadPickerGroups", () => {
const addProjectItem = {
kind: "action" as const,
value: "action:add-project",
searchTerms: ["add project"],
title: "Add project",
icon: null,
run: async () => undefined,
};

it("keeps Add project available when there are no projects", () => {
expect(buildNewThreadPickerGroups({ projectItems: [], addProjectItem })).toEqual([
{ value: "new-thread-actions", label: "Actions", items: [addProjectItem] },
]);
});

it("stays empty while projects are still loading", () => {
expect(
buildNewThreadPickerGroups({ projectItems: [], addProjectItem, areProjectsLoading: true }),
).toEqual([]);
});

it("keeps Add project available during partial loading when projects are visible", () => {
const projectItem = {
...addProjectItem,
value: "new-thread-in:environment-local:project-1",
title: "Project",
};

expect(
buildNewThreadPickerGroups({
projectItems: [projectItem],
addProjectItem,
areProjectsLoading: true,
}).map((group) => group.value),
).toEqual(["new-thread-projects", "new-thread-actions"]);
});

it("places Add project after available project choices", () => {
const projectItem = {
...addProjectItem,
value: "new-thread-in:environment-local:project-1",
title: "Project",
};

expect(
buildNewThreadPickerGroups({ projectItems: [projectItem], addProjectItem }).map((group) => ({
value: group.value,
items: group.items.map((item) => item.value),
})),
).toEqual([
{
value: "new-thread-projects",
items: ["new-thread-in:environment-local:project-1"],
},
{ value: "new-thread-actions", items: ["action:add-project"] },
]);
});
});

function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
id: ThreadId.make("thread-1"),
Expand Down
72 changes: 71 additions & 1 deletion apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type KeybindingCommand,
type FilesystemBrowseEntry,
type KeybindingCommand,
type ScopedProjectRef,
THREAD_JUMP_KEYBINDING_COMMANDS,
} from "@t3tools/contracts";
import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
Expand All @@ -14,6 +15,7 @@ import { type Project, type SidebarThreadSummary, type Thread } from "../types";
export const RECENT_THREAD_LIMIT = 12;
export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
export const ADDON_ICON_CLASS = "size-4";
export const NEW_THREAD_PROJECT_VIEW_GROUP = "new-thread-projects";

export interface CommandPaletteItem {
readonly kind: "action" | "submenu";
Expand Down Expand Up @@ -68,6 +70,27 @@ export function enumerateCommandPaletteItems(
});
}

export function isNewThreadProjectView(view: CommandPaletteView | null): boolean {
return view?.groups[0]?.value === NEW_THREAD_PROJECT_VIEW_GROUP;
}

export function buildNewThreadProjectView(addonIcon: ReactNode): CommandPaletteView {
return {
addonIcon,
groups: [{ value: NEW_THREAD_PROJECT_VIEW_GROUP, label: "Projects", items: [] }],
};
}

export function resolveNewThreadIntentViewStack(
currentStack: ReadonlyArray<CommandPaletteView>,
newThreadView: CommandPaletteView,
): CommandPaletteView[] {
const currentView = currentStack.at(-1) ?? null;
return [
currentView !== null && isNewThreadProjectView(currentView) ? currentView : newThreadView,
];
}

export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";

export function filterBrowseEntries(input: {
Expand Down Expand Up @@ -127,6 +150,34 @@ export function buildProjectActionItems(input: {
}));
}

export function prioritizeScopedProject<T extends Pick<Project, "environmentId" | "id">>(
projects: ReadonlyArray<T>,
preferredProjectRef: ScopedProjectRef | null,
): T[] {
if (preferredProjectRef === null) {
return [...projects];
}

const preferredIndex = projects.findIndex(
(project) =>
project.environmentId === preferredProjectRef.environmentId &&
project.id === preferredProjectRef.projectId,
);
if (preferredIndex <= 0) {
return [...projects];
}

const preferredProject = projects[preferredIndex];
if (preferredProject === undefined) {
return [...projects];
}
return [
preferredProject,
...projects.slice(0, preferredIndex),
...projects.slice(preferredIndex + 1),
];
}

export type BuildThreadActionItemsThread = Pick<
SidebarThreadSummary,
"archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"
Expand Down Expand Up @@ -365,6 +416,25 @@ export function buildRootGroups(input: {
return groups;
}

export function buildNewThreadPickerGroups(input: {
projectItems: ReadonlyArray<CommandPaletteActionItem>;
addProjectItem: CommandPaletteActionItem;
areProjectsLoading?: boolean;
}): CommandPaletteGroup[] {
const groups: CommandPaletteGroup[] = [];
if (input.projectItems.length > 0) {
groups.push({
value: NEW_THREAD_PROJECT_VIEW_GROUP,
label: "Projects",
items: input.projectItems,
});
}
if (input.projectItems.length > 0 || input.areProjectsLoading !== true) {
groups.push({ value: "new-thread-actions", label: "Actions", items: [input.addProjectItem] });
}
return groups;
}

export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string {
switch (mode) {
case "root":
Expand Down
Loading
Loading