diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx index ca7b002340..da32d0a9f4 100644 --- a/apps/web/components/command-panel-footer.tsx +++ b/apps/web/components/command-panel-footer.tsx @@ -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 = { "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 ( - onSelect(cmd)} - > + onSelect(cmd)}> {cmd.icon && {cmd.icon}} {cmd.label} {cmd.shortcut && {formatShortcut(cmd.shortcut)}} @@ -90,7 +99,7 @@ function TaskResultItem({ task, stepMap, repoMap, onSelect }: TaskResultItemProp return ( onSelect(task)} className={isArchived ? "opacity-60" : ""} forceMount @@ -213,7 +222,7 @@ function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchC return ( onSelect(filePath)} forceMount > @@ -288,7 +297,7 @@ export type CommandPanelViewProps = { mode: CommandPanelMode; inputCommand: CommandItemType | null; selectedValue: string; - setSelectedValue: (value: string) => void; + setSelectedValue: Dispatch>; search: string; setSearch: (value: string) => void; handleKeyDown: (e: React.KeyboardEvent) => void; diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx index 0cfe0952d3..668aae3621 100644 --- a/apps/web/components/command-panel.tsx +++ b/apps/web/components/command-panel.tsx @@ -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("commands"); + const [mode, setMode] = useState(MODE_COMMANDS); const [search, setSearch] = useState(""); const [inputCommand, setInputCommand] = useState(null); const [taskResults, setTaskResults] = useState([]); @@ -63,22 +69,14 @@ type FileSearchEffectOptions = { activeSessionId: string | null; setFileResults: (files: string[]) => void; setIsSearchingFiles: (searching: boolean) => void; - setSelectedValue: (value: string) => void; fileDebounceRef: React.RefObject | 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) { + 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, 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); } diff --git a/apps/web/components/task/task-select-helpers.test.ts b/apps/web/components/task/task-select-helpers.test.ts index 78c5a460b1..ec836e6b63 100644 --- a/apps/web/components/task/task-select-helpers.test.ts +++ b/apps/web/components/task/task-select-helpers.test.ts @@ -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 diff --git a/apps/web/e2e/tests/command-panel.spec.ts b/apps/web/e2e/tests/command-panel.spec.ts index a4577c4026..65f5454426 100644 --- a/apps/web/e2e/tests/command-panel.spec.ts +++ b/apps/web/e2e/tests/command-panel.spec.ts @@ -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();