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:
José Almeida
2026-05-08 16:32:06 +01:00
committed by GitHub
parent 14e80c8904
commit cf3dbc9582
4 changed files with 111 additions and 44 deletions
+19 -10
View File
@@ -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;
+47 -31
View File
@@ -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
+37
View File
@@ -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();