fix(web): keep command palette selection on first result (#845)
* fix(web): keep command palette selection on first result * test(web): fix launch session mock response shape * refactor(web): address command palette review feedback * fix(web): align command panel selection setter type
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { IconArchive, IconArrowRight, IconHammer, IconLoader2 } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -22,8 +23,8 @@ import type { Task } from "@/lib/types/http";
|
||||
import { FileIcon } from "@/components/ui/file-icon";
|
||||
|
||||
const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]);
|
||||
const MODE_COMMANDS: CommandPanelMode = "commands";
|
||||
const MODE_SEARCH_FILES: CommandPanelMode = "search-files";
|
||||
export const MODE_COMMANDS: CommandPanelMode = "commands";
|
||||
export const MODE_SEARCH_FILES: CommandPanelMode = "search-files";
|
||||
|
||||
const STEP_COLOR_MAP: Record<string, string> = {
|
||||
"bg-slate-500": "#64748b",
|
||||
@@ -41,6 +42,18 @@ function getFileName(filePath: string) {
|
||||
return filePath.split("/").pop() ?? filePath;
|
||||
}
|
||||
|
||||
function getCommandValue(cmd: CommandItemType) {
|
||||
return cmd.id + " " + cmd.label + " " + (cmd.keywords?.join(" ") ?? "");
|
||||
}
|
||||
|
||||
export function getTaskResultValue(task: Task) {
|
||||
return `__task:${task.id} ${task.title}`;
|
||||
}
|
||||
|
||||
export function getFileResultValue(filePath: string) {
|
||||
return `__file:${filePath}`;
|
||||
}
|
||||
|
||||
function CommandItemRow({
|
||||
cmd,
|
||||
onSelect,
|
||||
@@ -49,11 +62,7 @@ function CommandItemRow({
|
||||
onSelect: (cmd: CommandItemType) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandItem
|
||||
key={cmd.id}
|
||||
value={cmd.id + " " + cmd.label + " " + (cmd.keywords?.join(" ") ?? "")}
|
||||
onSelect={() => onSelect(cmd)}
|
||||
>
|
||||
<CommandItem key={cmd.id} value={getCommandValue(cmd)} onSelect={() => onSelect(cmd)}>
|
||||
{cmd.icon && <span className="text-muted-foreground">{cmd.icon}</span>}
|
||||
<span>{cmd.label}</span>
|
||||
{cmd.shortcut && <CommandShortcut>{formatShortcut(cmd.shortcut)}</CommandShortcut>}
|
||||
@@ -90,7 +99,7 @@ function TaskResultItem({ task, stepMap, repoMap, onSelect }: TaskResultItemProp
|
||||
return (
|
||||
<CommandItem
|
||||
key={task.id}
|
||||
value={`__task:${task.id} ${task.title}`}
|
||||
value={getTaskResultValue(task)}
|
||||
onSelect={() => onSelect(task)}
|
||||
className={isArchived ? "opacity-60" : ""}
|
||||
forceMount
|
||||
@@ -213,7 +222,7 @@ function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchC
|
||||
return (
|
||||
<CommandItem
|
||||
key={filePath}
|
||||
value={`__file:${filePath}`}
|
||||
value={getFileResultValue(filePath)}
|
||||
onSelect={() => onSelect(filePath)}
|
||||
forceMount
|
||||
>
|
||||
@@ -288,7 +297,7 @@ export type CommandPanelViewProps = {
|
||||
mode: CommandPanelMode;
|
||||
inputCommand: CommandItemType | null;
|
||||
selectedValue: string;
|
||||
setSelectedValue: (value: string) => void;
|
||||
setSelectedValue: Dispatch<SetStateAction<string>>;
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||
|
||||
@@ -15,14 +15,20 @@ import type { Task } from "@/lib/types/http";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { searchWorkspaceFiles } from "@/lib/ws/workspace-files";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
import { CommandPanelView } from "@/components/command-panel-footer";
|
||||
import {
|
||||
CommandPanelView,
|
||||
MODE_COMMANDS,
|
||||
MODE_SEARCH_FILES,
|
||||
getFileResultValue,
|
||||
getTaskResultValue,
|
||||
} from "@/components/command-panel-footer";
|
||||
|
||||
function getFileName(filePath: string) {
|
||||
return filePath.split("/").pop() ?? filePath;
|
||||
}
|
||||
|
||||
function useCommandPanelState() {
|
||||
const [mode, setMode] = useState<CommandPanelMode>("commands");
|
||||
const [mode, setMode] = useState<CommandPanelMode>(MODE_COMMANDS);
|
||||
const [search, setSearch] = useState("");
|
||||
const [inputCommand, setInputCommand] = useState<CommandItemType | null>(null);
|
||||
const [taskResults, setTaskResults] = useState<Task[]>([]);
|
||||
@@ -63,22 +69,14 @@ type FileSearchEffectOptions = {
|
||||
activeSessionId: string | null;
|
||||
setFileResults: (files: string[]) => void;
|
||||
setIsSearchingFiles: (searching: boolean) => void;
|
||||
setSelectedValue: (value: string) => void;
|
||||
fileDebounceRef: React.RefObject<ReturnType<typeof setTimeout> | null>;
|
||||
};
|
||||
|
||||
function useFileSearchEffect(opts: FileSearchEffectOptions) {
|
||||
const {
|
||||
mode,
|
||||
search,
|
||||
activeSessionId,
|
||||
setFileResults,
|
||||
setIsSearchingFiles,
|
||||
setSelectedValue,
|
||||
fileDebounceRef,
|
||||
} = opts;
|
||||
const { mode, search, activeSessionId, setFileResults, setIsSearchingFiles, fileDebounceRef } =
|
||||
opts;
|
||||
useEffect(() => {
|
||||
if (mode !== "search-files" || !search.trim() || !activeSessionId) {
|
||||
if (mode !== MODE_SEARCH_FILES || !search.trim() || !activeSessionId) {
|
||||
setFileResults([]);
|
||||
setIsSearchingFiles(false);
|
||||
return;
|
||||
@@ -97,7 +95,6 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) {
|
||||
if (!cancelled) {
|
||||
const files = res.files ?? [];
|
||||
setFileResults(files);
|
||||
if (files.length > 0) setSelectedValue(`__file:${files[0]}`);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setFileResults([]);
|
||||
@@ -109,15 +106,7 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) {
|
||||
cancelled = true;
|
||||
if (fileDebounceRef.current) clearTimeout(fileDebounceRef.current);
|
||||
};
|
||||
}, [
|
||||
activeSessionId,
|
||||
fileDebounceRef,
|
||||
mode,
|
||||
search,
|
||||
setFileResults,
|
||||
setIsSearchingFiles,
|
||||
setSelectedValue,
|
||||
]);
|
||||
}, [activeSessionId, fileDebounceRef, mode, search, setFileResults, setIsSearchingFiles]);
|
||||
}
|
||||
|
||||
const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]);
|
||||
@@ -154,7 +143,7 @@ function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) {
|
||||
const { visibleStepIds, stepPositionMap } = useStepMaps(steps);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "commands") return;
|
||||
if (mode !== MODE_COMMANDS) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
abortRef.current?.abort();
|
||||
|
||||
@@ -275,7 +264,7 @@ function useCommandPanelEffects(
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const t = setTimeout(() => {
|
||||
setMode("commands");
|
||||
setMode(MODE_COMMANDS);
|
||||
setSearch("");
|
||||
setInputCommand(null);
|
||||
setTaskResults([]);
|
||||
@@ -302,11 +291,37 @@ function useCommandPanelEffects(
|
||||
activeSessionId,
|
||||
setFileResults,
|
||||
setIsSearchingFiles,
|
||||
setSelectedValue,
|
||||
fileDebounceRef,
|
||||
});
|
||||
}
|
||||
|
||||
function useFirstResultSelection(open: boolean, state: ReturnType<typeof useCommandPanelState>) {
|
||||
const { mode, search, taskResults, fileResults, setSelectedValue } = state;
|
||||
|
||||
// `search` intentionally re-applies the first result while debounced results are loading.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === MODE_COMMANDS) {
|
||||
const firstTask = taskResults[0];
|
||||
if (firstTask) {
|
||||
setSelectedValue(getTaskResultValue(firstTask));
|
||||
return;
|
||||
}
|
||||
setSelectedValue((current) => (current.startsWith("__task:") ? "" : current));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === MODE_SEARCH_FILES) {
|
||||
const firstFile = fileResults[0];
|
||||
setSelectedValue(firstFile ? getFileResultValue(firstFile) : "");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedValue("");
|
||||
}, [fileResults, mode, open, search, setSelectedValue, taskResults]);
|
||||
}
|
||||
|
||||
function useCommandPanelHandlers(
|
||||
state: ReturnType<typeof useCommandPanelState>,
|
||||
setOpen: (open: boolean) => void,
|
||||
@@ -382,9 +397,9 @@ function useCommandPanelHandlers(
|
||||
inputCommand.onInputSubmit(search.trim());
|
||||
return;
|
||||
}
|
||||
if (mode !== "commands" && e.key === "Backspace" && !search) {
|
||||
if (mode !== MODE_COMMANDS && e.key === "Backspace" && !search) {
|
||||
e.preventDefault();
|
||||
setMode("commands");
|
||||
setMode(MODE_COMMANDS);
|
||||
setSearch("");
|
||||
setInputCommand(null);
|
||||
}
|
||||
@@ -393,7 +408,7 @@ function useCommandPanelHandlers(
|
||||
);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setMode("commands");
|
||||
setMode(MODE_COMMANDS);
|
||||
setSearch("");
|
||||
setInputCommand(null);
|
||||
}, [setMode, setSearch, setInputCommand]);
|
||||
@@ -434,6 +449,7 @@ export function CommandPanel() {
|
||||
} = state;
|
||||
|
||||
useCommandPanelEffects(open, state, workspaceId, activeSessionId, kanbanSteps);
|
||||
useFirstResultSelection(open, state);
|
||||
|
||||
const openRef = useRef(open);
|
||||
useEffect(() => {
|
||||
@@ -443,10 +459,10 @@ export function CommandPanel() {
|
||||
const toggleCommands = useCallback(() => setOpen(!openRef.current), [setOpen]);
|
||||
|
||||
const openFileSearch = useCallback(() => {
|
||||
if (openRef.current && state.mode === "search-files") {
|
||||
if (openRef.current && state.mode === MODE_SEARCH_FILES) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
state.setMode("search-files");
|
||||
state.setMode(MODE_SEARCH_FILES);
|
||||
state.setSearch("");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock("@/lib/links", () => ({
|
||||
replaceTaskUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
import { launchSession } from "@/lib/services/session-launch-service";
|
||||
import { launchSession, type LaunchSessionResponse } from "@/lib/services/session-launch-service";
|
||||
import { releaseLayoutToDefault } from "@/lib/state/dockview-store";
|
||||
import type { StoreApi } from "zustand";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
@@ -60,7 +60,7 @@ describe("prepareAndSwitchTask — outgoing-env panel cleanup", () => {
|
||||
it("releases the outgoing env's panels before awaiting launchSession", async () => {
|
||||
// Make launchSession return a deferred we control so we can observe
|
||||
// synchronous side effects that happen before the await resolves.
|
||||
let resolveLaunch: (v: { session_id: string }) => void = () => {};
|
||||
let resolveLaunch: (v: LaunchSessionResponse) => void = () => {};
|
||||
vi.mocked(launchSession).mockImplementation(
|
||||
() =>
|
||||
new Promise((res) => {
|
||||
@@ -80,7 +80,12 @@ describe("prepareAndSwitchTask — outgoing-env panel cleanup", () => {
|
||||
expect(releaseLayoutToDefault).toHaveBeenCalledTimes(1);
|
||||
expect(switchToSession).not.toHaveBeenCalled();
|
||||
|
||||
resolveLaunch({ session_id: "new-session" });
|
||||
resolveLaunch({
|
||||
success: true,
|
||||
task_id: NEW_TASK_ID,
|
||||
session_id: "new-session",
|
||||
state: "ready",
|
||||
});
|
||||
const result = await promise;
|
||||
|
||||
// Happy-path coverage: switchToSession must run with the new session id and
|
||||
|
||||
@@ -119,6 +119,43 @@ test.describe("Command Panel", () => {
|
||||
await expect(taskOption.getByText(startStep.name)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("inline task search selects the first matching task after loading", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.createTask(seedData.workspaceId, "Palette Selection Alpha", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await apiClient.createTask(seedData.workspaceId, "Palette Selection Beta", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCardByTitle("Palette Selection Alpha")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(kanban.taskCardByTitle("Palette Selection Beta")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await openCommandPanel(testPage);
|
||||
const dialog = commandDialog(testPage);
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const input = dialog.getByRole("combobox");
|
||||
await input.fill("Palette Selection");
|
||||
|
||||
const taskOptions = dialog.getByRole("option").filter({ hasText: /Palette Selection/ });
|
||||
await expect(taskOptions.first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(taskOptions.first()).toHaveAttribute("data-selected", "true", {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Escape closes the command panel", async ({ testPage }) => {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
Reference in New Issue
Block a user