diff --git a/src/__tests__/unit/components/TargetSelector.test.tsx b/src/__tests__/unit/components/TargetSelector.test.tsx
index 2155b8cbce..d517b877db 100644
--- a/src/__tests__/unit/components/TargetSelector.test.tsx
+++ b/src/__tests__/unit/components/TargetSelector.test.tsx
@@ -3,14 +3,13 @@
*/
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
-let mockSlug = "other-workspace";
let mockWorkspace: { slug: string; repositories: { id: string; name: string }[] } = {
slug: "other-workspace",
repositories: [{ id: "repo-1", name: "my-repo" }],
@@ -20,14 +19,15 @@ vi.mock("@/hooks/useWorkspace", () => ({
useWorkspace: () => ({ workspace: mockWorkspace }),
}));
-let mockWorkflows: { id: number; name: string; updated_at: string | null; last_modified_by: string | null }[] = [];
+let mockWorkflows: { ref_id: string; node_type: "Workflow"; properties: { workflow_id: number; workflow_name?: string; workflow_json: string } }[] = [];
let mockWorkflowsLoading = false;
-vi.mock("@/hooks/useRecentWorkflows", () => ({
- useRecentWorkflows: () => ({
+vi.mock("@/hooks/useWorkflowNodes", () => ({
+ useWorkflowNodes: () => ({
workflows: mockWorkflows,
isLoading: mockWorkflowsLoading,
error: null,
+ refetch: vi.fn(),
}),
}));
@@ -36,7 +36,7 @@ vi.mock("@/lib/runtime", () => ({
isDevelopmentMode: () => false,
}));
-// Minimal Select UI mock — must include every named export used by TargetSelector
+// Minimal Select UI mock
vi.mock("@/components/ui/select", () => ({
Select: ({ children, onValueChange, value }: any) => (
@@ -75,6 +75,56 @@ vi.mock("@/components/ui/select", () => ({
SelectScrollDownButton: () => null,
}));
+// Minimal Popover mock
+vi.mock("@/components/ui/popover", () => ({
+ Popover: ({ children, open, onOpenChange }: any) => (
+
+ {React.Children.map(children, (child) =>
+ child ? React.cloneElement(child, { open, onOpenChange }) : null
+ )}
+
+ ),
+ PopoverTrigger: ({ children, open, onOpenChange, disabled, asChild }: any) => {
+ const child = React.Children.only(children) as React.ReactElement;
+ return React.cloneElement(child, {
+ onClick: () => !disabled && onOpenChange?.(!open),
+ disabled,
+ });
+ },
+ PopoverContent: ({ children, open }: any) =>
+ open ?
{children}
: null,
+}));
+
+// Minimal Command mock
+vi.mock("@/components/ui/command", () => ({
+ Command: ({ children }: any) =>
{children}
,
+ CommandInput: ({ value, onValueChange, placeholder }: any) => (
+
onValueChange?.(e.target.value)}
+ placeholder={placeholder}
+ />
+ ),
+ CommandList: ({ children }: any) =>
{children}
,
+ CommandEmpty: ({ children }: any) =>
{children}
,
+ CommandGroup: ({ children, heading }: any) => (
+
+
{heading}
+ {children}
+
+ ),
+ CommandItem: ({ children, onSelect, value, ...props }: any) => (
+
+ ),
+ CommandSeparator: () =>
,
+}));
+
// ---------------------------------------------------------------------------
// Subject under test
// ---------------------------------------------------------------------------
@@ -84,6 +134,28 @@ import {
decodeTargetValue,
type TargetSelection,
} from "@/components/shared/TargetSelector";
+import type { WorkflowNode } from "@/hooks/useWorkflowNodes";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeWorkflowNode(id: number, name?: string): WorkflowNode {
+ return {
+ ref_id: `ref-${id}`,
+ node_type: "Workflow",
+ properties: {
+ workflow_id: id,
+ workflow_name: name,
+ workflow_json: "{}",
+ },
+ };
+}
+
+// Opens the popover by clicking the trigger
+async function openPopover() {
+ await userEvent.click(screen.getByTestId("target-selector-trigger"));
+}
// ---------------------------------------------------------------------------
// Tests
@@ -92,7 +164,6 @@ import {
describe("TargetSelector", () => {
beforeEach(() => {
vi.clearAllMocks();
- mockSlug = "other-workspace";
mockWorkspace = {
slug: "other-workspace",
repositories: [{ id: "repo-1", name: "my-repo" }],
@@ -101,8 +172,11 @@ describe("TargetSelector", () => {
mockWorkflowsLoading = false;
});
+ // -------------------------------------------------------------------------
+ // Non-stakwork workspace — uses plain Select
+ // -------------------------------------------------------------------------
describe("non-stakwork workspace", () => {
- it("renders only the Repositories group", () => {
+ it("renders only the Repositories group via Select", () => {
mockWorkspace = {
slug: "other-workspace",
repositories: [
@@ -110,25 +184,32 @@ describe("TargetSelector", () => {
{ id: "repo-2", name: "repo-beta" },
],
};
- mockWorkflows = [{ id: 10, name: "wf-foo", updated_at: null, last_modified_by: null }];
+ mockWorkflows = [makeWorkflowNode(10, "wf-foo")];
render(
);
- // No "Stak Workflows" label
- expect(screen.queryByText("Stak Workflows")).toBeNull();
+ // Uses plain Select (no popover)
+ expect(screen.getByTestId("select")).toBeTruthy();
+ expect(screen.queryByTestId("popover")).toBeNull();
+
// Repos rendered
expect(screen.getByTestId("target-repo-repo-1")).toBeTruthy();
expect(screen.getByTestId("target-repo-repo-2")).toBeTruthy();
- // Workflow NOT rendered
+
+ // No workflow items
expect(screen.queryByTestId("target-workflow-10")).toBeNull();
+ expect(screen.queryByText("Stak Workflows")).toBeNull();
});
- it("does NOT render Repositories label when no workflow section", () => {
+ it("does NOT render Repositories label (no workflow section)", () => {
render(
);
expect(screen.queryByText("Repositories")).toBeNull();
});
});
+ // -------------------------------------------------------------------------
+ // Stakwork workspace — uses Popover + Command combobox
+ // -------------------------------------------------------------------------
describe("stakwork workspace", () => {
beforeEach(() => {
mockWorkspace = {
@@ -136,24 +217,38 @@ describe("TargetSelector", () => {
repositories: [{ id: "repo-1", name: "hive" }],
};
mockWorkflows = [
- { id: 42, name: "my-workflow", updated_at: null, last_modified_by: null },
- { id: 99, name: "another-flow", updated_at: null, last_modified_by: null },
+ makeWorkflowNode(42, "my-workflow"),
+ makeWorkflowNode(99, "another-flow"),
];
});
- it("renders both Repositories and Stak Workflows sections", () => {
+ it("renders Popover trigger (not plain Select)", () => {
render(
);
+ expect(screen.getByTestId("popover")).toBeTruthy();
+ expect(screen.queryByTestId("select")).toBeNull();
+ });
+
+ it("renders both Repositories and Stak Workflows sections after opening", async () => {
+ render(
);
+ await openPopover();
- expect(screen.getByText("Repositories")).toBeTruthy();
- expect(screen.getByText("Stak Workflows")).toBeTruthy();
expect(screen.getByTestId("target-repo-repo-1")).toBeTruthy();
expect(screen.getByTestId("target-workflow-42")).toBeTruthy();
expect(screen.getByTestId("target-workflow-99")).toBeTruthy();
});
+ it("shows workflow ID (#42) alongside workflow name", async () => {
+ render(
);
+ await openPopover();
+
+ expect(screen.getByText("#42")).toBeTruthy();
+ expect(screen.getByText("#99")).toBeTruthy();
+ });
+
it("emits correct repo selection shape on repo click", async () => {
const onChange = vi.fn();
render(
);
+ await openPopover();
await userEvent.click(screen.getByTestId("target-repo-repo-1"));
@@ -163,9 +258,10 @@ describe("TargetSelector", () => {
});
});
- it("emits correct workflow selection shape on workflow click", async () => {
+ it("emits correct workflow selection shape with workflowRefId from ref_id", async () => {
const onChange = vi.fn();
render(
);
+ await openPopover();
await userEvent.click(screen.getByTestId("target-workflow-42"));
@@ -173,20 +269,94 @@ describe("TargetSelector", () => {
type: "workflow",
workflowId: 42,
workflowName: "my-workflow",
- workflowRefId: "", // refId not in RecentWorkflow; caller fetches separately
+ workflowRefId: "ref-42",
});
});
- it("shows loading text while workflows are loading", () => {
+ it("shows loading text while workflows are loading", async () => {
mockWorkflowsLoading = true;
mockWorkflows = [];
render(
);
+ await openPopover();
expect(screen.getByText("Loading workflows…")).toBeTruthy();
});
+
+ // -----------------------------------------------------------------------
+ // Search / filtering
+ // -----------------------------------------------------------------------
+ it("filters workflows by name (case-insensitive substring)", async () => {
+ render(
);
+ await openPopover();
+
+ const input = screen.getByTestId("command-input");
+ fireEvent.change(input, { target: { value: "another" } });
+
+ // only "another-flow" (99) should be visible
+ expect(screen.queryByTestId("target-workflow-99")).toBeTruthy();
+ expect(screen.queryByTestId("target-workflow-42")).toBeNull();
+ });
+
+ it("filters workflows by partial numeric ID", async () => {
+ mockWorkflows = [makeWorkflowNode(12345, "alpha"), makeWorkflowNode(999, "beta")];
+
+ render(
);
+ await openPopover();
+
+ const input = screen.getByTestId("command-input");
+ fireEvent.change(input, { target: { value: "123" } });
+
+ expect(screen.queryByTestId("target-workflow-12345")).toBeTruthy();
+ expect(screen.queryByTestId("target-workflow-999")).toBeNull();
+ });
+
+ it("renders CommandEmpty when no workflows match search", async () => {
+ render(
);
+ await openPopover();
+
+ const input = screen.getByTestId("command-input");
+ fireEvent.change(input, { target: { value: "xyzzy-no-match" } });
+
+ // Both workflow items hidden
+ expect(screen.queryByTestId("target-workflow-42")).toBeNull();
+ expect(screen.queryByTestId("target-workflow-99")).toBeNull();
+ // CommandEmpty present
+ expect(screen.getByTestId("command-empty")).toBeTruthy();
+ });
+
+ it("shows all workflows when search is empty", async () => {
+ render(
);
+ await openPopover();
+
+ const input = screen.getByTestId("command-input");
+ fireEvent.change(input, { target: { value: "my" } });
+ fireEvent.change(input, { target: { value: "" } });
+
+ expect(screen.getByTestId("target-workflow-42")).toBeTruthy();
+ expect(screen.getByTestId("target-workflow-99")).toBeTruthy();
+ });
+
+ it("uses fallback name 'Workflow N' for unnamed workflows", async () => {
+ mockWorkflows = [makeWorkflowNode(777)]; // no name
+
+ render(
);
+ await openPopover();
+
+ expect(screen.getByText("Workflow 777")).toBeTruthy();
+ });
+
+ it("disabled prop prevents popover from opening", async () => {
+ render(
);
+
+ const trigger = screen.getByTestId("target-selector-trigger");
+ expect(trigger).toHaveProperty("disabled", true);
+ });
});
+ // -------------------------------------------------------------------------
+ // Helper functions
+ // -------------------------------------------------------------------------
describe("helper functions", () => {
it("encodeTargetValue for repo", () => {
const sel: TargetSelection = { type: "repo", repositoryId: "abc" };
@@ -208,10 +378,25 @@ describe("TargetSelector", () => {
expect(result).toEqual({ type: "repo", repositoryId: "xyz" });
});
- it("decodeTargetValue for workflow string with lookup", () => {
- const workflows = [{ id: 5, name: "flow-five", updated_at: null, last_modified_by: null }];
+ it("decodeTargetValue for workflow string with WorkflowNode lookup", () => {
+ const workflows: WorkflowNode[] = [makeWorkflowNode(5, "flow-five")];
const result = decodeTargetValue("workflow:5", workflows);
- expect(result).toMatchObject({ type: "workflow", workflowId: 5, workflowName: "flow-five" });
+ expect(result).toMatchObject({
+ type: "workflow",
+ workflowId: 5,
+ workflowName: "flow-five",
+ workflowRefId: "ref-5",
+ });
+ });
+
+ it("decodeTargetValue uses fallback name when workflow not in list", () => {
+ const result = decodeTargetValue("workflow:999", []);
+ expect(result).toMatchObject({
+ type: "workflow",
+ workflowId: 999,
+ workflowName: "Workflow 999",
+ workflowRefId: "",
+ });
});
it("decodeTargetValue returns null for unknown prefix", () => {
diff --git a/src/components/shared/TargetSelector.tsx b/src/components/shared/TargetSelector.tsx
index 409d376ac3..6a29574cf1 100644
--- a/src/components/shared/TargetSelector.tsx
+++ b/src/components/shared/TargetSelector.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useMemo } from "react";
+import React, { useMemo, useState } from "react";
import { FolderOpen, GitBranch } from "lucide-react";
import {
Select,
@@ -11,8 +11,18 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
import { useWorkspace } from "@/hooks/useWorkspace";
-import { useRecentWorkflows, type RecentWorkflow } from "@/hooks/useRecentWorkflows";
+import { useWorkflowNodes, type WorkflowNode } from "@/hooks/useWorkflowNodes";
import { isDevelopmentMode } from "@/lib/runtime";
export type TargetSelection =
@@ -43,10 +53,10 @@ export function encodeTargetValue(selection: TargetSelection): string {
return `workflow:${selection.workflowId}`;
}
-/** Decode a Select value string back to a TargetSelection, using workflows list for lookup */
+/** Decode a Select value string back to a TargetSelection, using WorkflowNode[] for lookup */
export function decodeTargetValue(
value: string,
- workflows: RecentWorkflow[]
+ workflows: WorkflowNode[]
): TargetSelection | null {
if (value.startsWith("repo:")) {
const repositoryId = value.slice(5);
@@ -55,12 +65,12 @@ export function decodeTargetValue(
if (value.startsWith("workflow:")) {
const workflowId = parseInt(value.slice(9), 10);
if (isNaN(workflowId)) return null;
- const wf = workflows.find((w) => w.id === workflowId);
+ const wf = workflows.find((w) => w.properties.workflow_id === workflowId);
return {
type: "workflow",
workflowId,
- workflowName: wf?.name ?? `Workflow ${workflowId}`,
- workflowRefId: "", // ref is not available in RecentWorkflow; caller should update separately
+ workflowName: wf?.properties.workflow_name ?? `Workflow ${workflowId}`,
+ workflowRefId: wf?.ref_id ?? "",
};
}
return null;
@@ -70,6 +80,9 @@ export function decodeTargetValue(
* A unified target selector that lists repositories always,
* and additionally lists Stak workflows when in the stakwork workspace or dev mode.
*
+ * In stakwork/dev mode: renders a Popover + Command combobox with search.
+ * Otherwise: renders a plain Select for repos only.
+ *
* Emits typed onChange payloads: `{ type: 'repo', repositoryId }` or
* `{ type: 'workflow', workflowId, workflowName, workflowRefId }`.
*/
@@ -84,62 +97,180 @@ export function TargetSelector({
}: TargetSelectorProps) {
const { workspace } = useWorkspace();
const isStakwork = workspace?.slug === "stakwork" || isDevelopmentMode();
+ const showWorkflows = isStakwork;
const repos = useMemo(
() => repoProp ?? (workspace?.repositories ?? []).map((r) => ({ id: r.id, name: r.name })),
[repoProp, workspace?.repositories]
);
- const { workflows, isLoading: isLoadingWorkflows } = useRecentWorkflows();
+ const { workflows, isLoading: isLoadingWorkflows } = useWorkflowNodes(
+ workspace?.slug ?? null,
+ showWorkflows
+ );
- // Only fetch / show workflows in stakwork workspace
- const showWorkflows = isStakwork;
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
- const handleValueChange = (raw: string) => {
- if (raw.startsWith("repo:")) {
- const repositoryId = raw.slice(5);
- onChange({ type: "repo", repositoryId });
- } else if (raw.startsWith("workflow:")) {
- const workflowId = parseInt(raw.slice(9), 10);
- if (!isNaN(workflowId)) {
- const wf = workflows.find((w) => w.id === workflowId);
- onChange({
- type: "workflow",
- workflowId,
- workflowName: wf?.name ?? `Workflow ${workflowId}`,
- workflowRefId: "", // refId must be fetched separately by caller
- });
- }
- }
- };
+ const filteredWorkflows = useMemo(() => {
+ if (!search.trim()) return workflows;
+ const lower = search.toLowerCase();
+ const numericSearch = search.trim();
+ return workflows.filter((wf) => {
+ const name = (wf.properties.workflow_name ?? "").toLowerCase();
+ const id = String(wf.properties.workflow_id);
+ return name.includes(lower) || id.startsWith(numericSearch);
+ });
+ }, [workflows, search]);
const triggerClass =
size === "sm"
? "h-5 text-[10px] px-1.5 py-0 w-auto max-w-[140px] border-muted bg-muted/50 gap-1 [&>svg]:h-3 [&>svg]:w-3"
: "w-[200px] h-8 text-xs rounded-lg shadow-sm";
- const selectedWorkflow =
- value?.startsWith("workflow:")
- ? workflows.find((w) => w.id === parseInt(value.slice(9), 10))
+ // --- Combobox mode (stakwork / dev) ---
+ if (showWorkflows) {
+ const selectedWorkflow = value?.startsWith("workflow:")
+ ? workflows.find((w) => w.properties.workflow_id === parseInt(value.slice(9), 10))
+ : null;
+ const selectedWorkflowId = value?.startsWith("workflow:")
+ ? parseInt(value.slice(9), 10)
: null;
- const selectedRepo =
- value?.startsWith("repo:") ? repos.find((r) => r.id === value.slice(5)) : null;
+ const selectedRepo = value?.startsWith("repo:")
+ ? repos.find((r) => r.id === value.slice(5))
+ : null;
+
+ const label = selectedWorkflow
+ ? (selectedWorkflow.properties.workflow_name ?? `Workflow ${selectedWorkflow.properties.workflow_id}`)
+ : selectedWorkflowId && !selectedWorkflow
+ ? `Workflow ${selectedWorkflowId}`
+ : selectedRepo
+ ? selectedRepo.name
+ : placeholder ?? "Select target";
+
+ const isWorkflowSelected = !!selectedWorkflow || !!selectedWorkflowId;
+
+ return (
+
{
+ setOpen(v);
+ if (!v) setSearch("");
+ }}
+ >
+
+
+
+
+
+
+
+
+ No workflows found.
+
+ {repos.length > 0 && (
+
+ {repos.map((repo) => (
+ {
+ onChange({ type: "repo", repositoryId: repo.id });
+ setOpen(false);
+ setSearch("");
+ }}
+ data-testid={`target-repo-${repo.id}`}
+ >
+
+ {repo.name}
+
+ ))}
+
+ )}
+
+ {repos.length > 0 && filteredWorkflows.length > 0 && }
+
+ {isLoadingWorkflows ? (
+
+ Loading workflows…
+
+ ) : (
+
+ {filteredWorkflows.map((wf) => (
+ {
+ onChange({
+ type: "workflow",
+ workflowId: wf.properties.workflow_id,
+ workflowName:
+ wf.properties.workflow_name ?? `Workflow ${wf.properties.workflow_id}`,
+ workflowRefId: wf.ref_id,
+ });
+ setOpen(false);
+ setSearch("");
+ }}
+ data-testid={`target-workflow-${wf.properties.workflow_id}`}
+ >
+
+
+ {wf.properties.workflow_name ?? `Workflow ${wf.properties.workflow_id}`}
+
+
+ #{wf.properties.workflow_id}
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+ }
+
+ // --- Plain Select mode (non-stakwork) ---
+ const selectedRepo = value?.startsWith("repo:") ? repos.find((r) => r.id === value.slice(5)) : null;
+
+ const handleValueChange = (raw: string) => {
+ if (raw.startsWith("repo:")) {
+ onChange({ type: "repo", repositoryId: raw.slice(5) });
+ }
+ };
return (