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
351 changes: 320 additions & 31 deletions apps/web/core/components/command-palette/command-modal.tsx

Large diffs are not rendered by default.

34 changes: 32 additions & 2 deletions apps/web/core/components/command-palette/command-palette.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { useCallback, useEffect, FC, useMemo } from "react";
import React, { useCallback, useEffect, FC, useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
Expand All @@ -10,11 +10,13 @@
// components
import { copyTextToClipboard } from "@plane/utils";
import { CommandModal, ShortcutsModal } from "@/components/command-palette";
import { COMMAND_CONFIG, CommandConfig } from "@/components/command-palette";

Check failure on line 13 in apps/web/core/components/command-palette/command-palette.tsx

View workflow job for this annotation

GitHub Actions / Build and lint web apps

'@/components/command-palette' import is duplicated
// helpers
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import type { CommandPaletteEntity } from "@/store/base-command-palette.store";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
Comment on lines +19 to 20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Remove unused type import.

Avoids lint noise.

-import type { CommandPaletteEntity } from "@/store/base-command-palette.store";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import type { CommandPaletteEntity } from "@/store/base-command-palette.store";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
🤖 Prompt for AI Agents
In apps/web/core/components/command-palette/command-palette.tsx around lines 19
to 20, the imported type CommandPaletteEntity is not used and triggers lint
noise; remove the unused type-only import (or delete it from the import list) so
the file no longer imports CommandPaletteEntity, then run lint/TS check to
confirm no remaining references.

import { useUser, useUserPermissions } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
Expand All @@ -41,7 +43,8 @@
const { toggleSidebar } = useAppTheme();
const { platform } = usePlatformOS();
const { data: currentUser, canPerformAnyCreateAction } = useUser();
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette();
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen, activateEntity } =
useCommandPalette();
const { allowPermissions } = useUserPermissions();

// derived values
Expand Down Expand Up @@ -158,6 +161,17 @@
[]
);

const keySequence = useRef("");
const sequenceTimeout = useRef<NodeJS.Timeout | null>(null);

Check failure on line 165 in apps/web/core/components/command-palette/command-palette.tsx

View workflow job for this annotation

GitHub Actions / Build and lint web apps

'NodeJS' is not defined

Comment on lines +164 to +166
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use browser-safe timer type.

Aligns with DOM setTimeout return type.

-  const sequenceTimeout = useRef<NodeJS.Timeout | null>(null);
+  const sequenceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const keySequence = useRef("");
const sequenceTimeout = useRef<NodeJS.Timeout | null>(null);
const keySequence = useRef("");
const sequenceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
🤖 Prompt for AI Agents
In apps/web/core/components/command-palette/command-palette.tsx around lines 164
to 166, the ref sequenceTimeout is typed as NodeJS.Timeout which is a
Node-specific type; change it to the browser-safe timer type (e.g. number | null
or ReturnType<typeof setTimeout>) so it matches DOM setTimeout return value.
Update the ref declaration to use that type and ensure any clearTimeout calls
remain compatible (no runtime changes needed), and remove any Node-specific type
imports if present.

const commandSequenceMap = useMemo(() => {
const map: Record<string, CommandConfig> = {};
COMMAND_CONFIG.forEach((cmd) => {
map[cmd.sequence] = cmd;
});
return map;
}, []);

const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
Expand Down Expand Up @@ -186,6 +200,21 @@
toggleShortcutModal(true);
}

if (!cmdClicked && !altKey && !shiftKey && !isAnyModalOpen) {
keySequence.current = (keySequence.current + keyPressed).slice(-2);
if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = setTimeout(() => {
keySequence.current = "";
}, 500);
const cmd = commandSequenceMap[keySequence.current];
if (cmd && (!cmd.enabled || cmd.enabled())) {
e.preventDefault();
activateEntity(cmd.entity);
keySequence.current = "";
return;
}
}

if (deleteKey) {
if (performProjectBulkDeleteActions()) {
shortcutsList.project.delete.action();
Expand Down Expand Up @@ -240,6 +269,7 @@
projectId,
shortcutsList,
toggleCommandPaletteModal,
activateEntity,
toggleShortcutModal,
toggleSidebar,
workspaceSlug,
Expand Down
52 changes: 52 additions & 0 deletions apps/web/core/components/command-palette/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { CommandPaletteEntity } from "@/store/base-command-palette.store";

export interface CommandConfig {
/**
* Unique identifier for the command
*/
id: string;
/**
* Key sequence that triggers the command. Should be lowercase.
*/
sequence: string;
/**
* Display label shown in the command palette.
*/
title: string;
/**
* Keys displayed as shortcut hint.
*/
keys: string[];
/**
* Entity that the command opens.
*/
entity: CommandPaletteEntity;
/**
* Optional predicate controlling command availability
*/
enabled?: () => boolean;
}

export const COMMAND_CONFIG: CommandConfig[] = [
{
id: "open-project",
sequence: "op",
title: "Open project...",
keys: ["O", "P"],
entity: "project",
},
{
id: "open-cycle",
sequence: "oc",
title: "Open cycle...",
keys: ["O", "C"],
entity: "cycle",
},
{
id: "open-issue",
sequence: "oi",
title: "Open issue...",
keys: ["O", "I"],
entity: "issue",
},
];
21 changes: 21 additions & 0 deletions apps/web/core/components/command-palette/cycle-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import React from "react";
import type { ICycle } from "@plane/types";
import { CommandPaletteEntityList } from "./entity-list";

interface Props {
cycles: ICycle[];
onSelect: (cycle: ICycle) => void;
}

export const CommandPaletteCycleSelector: React.FC<Props> = ({ cycles, onSelect }) => (
<CommandPaletteEntityList
heading="Cycles"
items={cycles}
getKey={(cycle) => cycle.id}
getLabel={(cycle) => cycle.name}
onSelect={onSelect}
emptyText="No cycles found"
/>
);
42 changes: 42 additions & 0 deletions apps/web/core/components/command-palette/entity-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import React from "react";
import { Command } from "cmdk";
import { cn } from "@plane/utils";

interface CommandPaletteEntityListProps<T> {
heading: string;
items: T[];
onSelect: (item: T) => void;
getKey?: (item: T) => string;
getLabel: (item: T) => string;
renderItem?: (item: T) => React.ReactNode;
emptyText?: string;
}

export const CommandPaletteEntityList = <T,>({
heading,
items,
onSelect,
getKey,
getLabel,
renderItem,
emptyText = "No results found",
}: CommandPaletteEntityListProps<T>) => {
if (items.length === 0) return <div className="px-3 py-8 text-center text-sm text-custom-text-300">{emptyText}</div>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Locate the lint “Unexpected any” offender.

If ESLint is flagging this area, it’s likely from generic inference at JSX call sites. Let’s confirm the exact file/line.


🏁 Script executed:

#!/bin/bash
# Find explicit 'any' in the affected surfaces
rg -nP '\b(any|as any|<any>)\b' apps/web/core/components/command-palette apps/web/core/store | sed 's/^/ANY: /'
# Also flag NodeJS.Timeout usages in web code
rg -nP 'NodeJS\.Timeout' apps/web/core/components/command-palette apps/web/core/store | sed 's/^/TIMEOUT: /'

Length of output: 5539


'Unexpected any' isn't coming from entity-list.tsx — explicit any found elsewhere

No any in apps/web/core/components/command-palette/entity-list.tsx; search found explicit any and NodeJS.Timeout usages you must fix:

  • apps/web/core/components/command-palette/command-modal.tsx:288 — (results.results as any)[key]...
  • apps/web/core/components/command-palette/command-modal.tsx:349 — onKeyDown={(e: any) => ...}
  • apps/web/core/components/command-palette/actions/search-results.tsx:32 — (results.results as any)[key]
  • apps/web/core/components/command-palette/actions/search-results.tsx:38 — section.map((item: any) => ...)
  • apps/web/core/components/command-palette/use-key-sequence.ts:7 — useRef<NodeJS.Timeout | null>
  • apps/web/core/components/command-palette/command-palette.tsx:165 — useRef<NodeJS.Timeout | null>

Fixes: define proper types for search results/items (avoid (results as any)), type event handlers (e.g., React.KeyboardEvent), and replace NodeJS.Timeout with number or ReturnType<typeof setTimeout> for browser code.


return (
<Command.Group heading={heading}>
{items.map((item) => (
<Command.Item
key={getKey ? getKey(item) : getLabel(item)}
value={getLabel(item)}
onSelect={() => onSelect(item)}
className={cn("focus:outline-none")}
>
{renderItem ? renderItem(item) : getLabel(item)}
</Command.Item>
))}
</Command.Group>
);
};
5 changes: 5 additions & 0 deletions apps/web/core/components/command-palette/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ export * from "./actions";
export * from "./shortcuts-modal";
export * from "./command-modal";
export * from "./command-palette";
export * from "./project-selector";
export * from "./cycle-selector";
export * from "./entity-list";
export * from "./use-key-sequence";
export * from "./commands";
21 changes: 21 additions & 0 deletions apps/web/core/components/command-palette/project-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import React from "react";
import type { TPartialProject } from "@/plane-web/types";
import { CommandPaletteEntityList } from "./entity-list";

interface Props {
projects: TPartialProject[];
onSelect: (project: TPartialProject) => void;
}

export const CommandPaletteProjectSelector: React.FC<Props> = ({ projects, onSelect }) => (
<CommandPaletteEntityList
heading="Projects"
items={projects}
getKey={(project) => project.id}
getLabel={(project) => project.name}
onSelect={onSelect}
emptyText="No projects found"
/>
);
25 changes: 25 additions & 0 deletions apps/web/core/components/command-palette/use-key-sequence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";

import { useRef } from "react";

export const useKeySequence = (handlers: Record<string, () => void>, timeout = 500) => {
const sequence = useRef("");
const sequenceTimeout = useRef<NodeJS.Timeout | null>(null);

Check failure on line 7 in apps/web/core/components/command-palette/use-key-sequence.ts

View workflow job for this annotation

GitHub Actions / Build and lint web apps

'NodeJS' is not defined

return (e: React.KeyboardEvent) => {
const key = e.key.toLowerCase();
sequence.current = (sequence.current + key).slice(-2);

if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = setTimeout(() => {
sequence.current = "";
}, timeout);

const action = handlers[sequence.current];
if (action) {
e.preventDefault();
action();
sequence.current = "";
}
};
};
Comment on lines +5 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix browser timer type, ignore modifier/non-char keys, and clear timer on hit.

Prevents type noise in web envs, avoids accidental matches (e.g., Arrow keys), and stops a dangling timeout after a match.

-import { useRef } from "react";
+import { useRef } from "react";
+import type React from "react";

 export const useKeySequence = (handlers: Record<string, () => void>, timeout = 500) => {
   const sequence = useRef("");
-  const sequenceTimeout = useRef<NodeJS.Timeout | null>(null);
+  const sequenceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);

-  return (e: React.KeyboardEvent) => {
-    const key = e.key.toLowerCase();
+  return (e: React.KeyboardEvent) => {
+    if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.repeat) return;
+    const keyRaw = e.key;
+    if (!keyRaw || keyRaw.length !== 1) return; // ignore non-character keys
+    const key = keyRaw.toLowerCase();
     sequence.current = (sequence.current + key).slice(-2);

-    if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current);
+    if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current);
     sequenceTimeout.current = setTimeout(() => {
       sequence.current = "";
     }, timeout);

     const action = handlers[sequence.current];
     if (action) {
       e.preventDefault();
       action();
       sequence.current = "";
+      if (sequenceTimeout.current) {
+        clearTimeout(sequenceTimeout.current);
+        sequenceTimeout.current = null;
+      }
     }
   };
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const useKeySequence = (handlers: Record<string, () => void>, timeout = 500) => {
const sequence = useRef("");
const sequenceTimeout = useRef<NodeJS.Timeout | null>(null);
return (e: React.KeyboardEvent) => {
const key = e.key.toLowerCase();
sequence.current = (sequence.current + key).slice(-2);
if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = setTimeout(() => {
sequence.current = "";
}, timeout);
const action = handlers[sequence.current];
if (action) {
e.preventDefault();
action();
sequence.current = "";
}
};
};
import { useRef } from "react";
import type React from "react";
export const useKeySequence = (handlers: Record<string, () => void>, timeout = 500) => {
const sequence = useRef("");
const sequenceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
return (e: React.KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.repeat) return;
const keyRaw = e.key;
if (!keyRaw || keyRaw.length !== 1) return; // ignore non-character keys
const key = keyRaw.toLowerCase();
sequence.current = (sequence.current + key).slice(-2);
if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = setTimeout(() => {
sequence.current = "";
}, timeout);
const action = handlers[sequence.current];
if (action) {
e.preventDefault();
action();
sequence.current = "";
if (sequenceTimeout.current) {
clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = null;
}
}
};
};
🤖 Prompt for AI Agents
In apps/web/core/components/command-palette/use-key-sequence.ts around lines 5
to 25, the hook currently uses NodeJS.Timeout (server type), listens to all keys
(including modifiers and non-character keys like Arrow/Shift), and leaves a
pending timeout after a match; change the timeout ref type to number | null for
browser setTimeout, ignore keys that are modifiers or non-printable (e.g., check
e.key length > 1 or match against a whitelist/regex of single-char keys and
return early for others), convert the key to lower-case only for printable
chars, and when a handler matches clear the active timeout (clearTimeout and set
ref to null) before invoking the action and resetting sequence.current to "" to
avoid dangling timers and accidental matches.

25 changes: 25 additions & 0 deletions apps/web/core/store/base-command-palette.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
} from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";

export type CommandPaletteEntity = "project" | "cycle" | "module" | "issue";

export interface ModalData {
store: EIssuesStoreType;
viewId: string;
Expand All @@ -30,6 +32,9 @@ export interface IBaseCommandPaletteStore {
allStickiesModal: boolean;
projectListOpenMap: Record<string, boolean>;
getIsProjectListOpen: (projectId: string) => boolean;
activeEntity: CommandPaletteEntity | null;
activateEntity: (entity: CommandPaletteEntity) => void;
clearActiveEntity: () => void;
// toggle actions
toggleCommandPaletteModal: (value?: boolean) => void;
toggleShortcutModal: (value?: boolean) => void;
Expand Down Expand Up @@ -61,6 +66,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
createWorkItemAllowedProjectIds: IBaseCommandPaletteStore["createWorkItemAllowedProjectIds"] = undefined;
allStickiesModal: boolean = false;
projectListOpenMap: Record<string, boolean> = {};
activeEntity: CommandPaletteEntity | null = null;

constructor() {
makeObservable(this, {
Expand All @@ -79,6 +85,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
createWorkItemAllowedProjectIds: observable,
allStickiesModal: observable,
projectListOpenMap: observable,
activeEntity: observable,
// projectPages: computed,
// toggle actions
toggleCommandPaletteModal: action,
Expand All @@ -93,6 +100,8 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
toggleBulkDeleteIssueModal: action,
toggleAllStickiesModal: action,
toggleProjectListOpen: action,
activateEntity: action,
clearActiveEntity: action,
});
}

Expand Down Expand Up @@ -127,6 +136,22 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
else this.projectListOpenMap[projectId] = !this.projectListOpenMap[projectId];
};

/**
* Opens the command palette with a specific entity pre-selected
* @param entity
*/
activateEntity = (entity: CommandPaletteEntity) => {
this.isCommandPaletteOpen = true;
this.activeEntity = entity;
};

/**
* Clears the active entity trigger
*/
clearActiveEntity = () => {
this.activeEntity = null;
};

/**
* Toggles the command palette modal
* @param value
Expand Down
Loading