feat(site): searchable workspace selector in agent chat input (#22656)

This commit is contained in:
Danielle Maywood
2026-03-05 11:38:45 +00:00
committed by GitHub
parent 89cee2dd81
commit b0e10402c8
2 changed files with 145 additions and 61 deletions
@@ -55,34 +55,36 @@ type Story = StoryObj<typeof AgentsEmptyState>;
export const Default: Story = {};
const mockWorkspaces = [
{
...MockWorkspace,
id: "ws-1",
name: "my-project",
owner_name: "johndoe",
owner_id: "user-1",
},
{
...MockWorkspace,
id: "ws-2",
name: "my-project",
owner_name: "janedoe",
owner_id: "user-2",
},
{
...MockWorkspace,
id: "ws-3",
name: "backend-api",
owner_name: "johndoe",
owner_id: "user-1",
},
];
export const WithWorkspaces: Story = {
beforeEach: () => {
localStorage.clear();
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: [
{
...MockWorkspace,
id: "ws-1",
name: "my-project",
owner_name: "johndoe",
owner_id: "user-1",
},
{
...MockWorkspace,
id: "ws-2",
name: "my-project",
owner_name: "janedoe",
owner_id: "user-2",
},
{
...MockWorkspace,
id: "ws-3",
name: "backend-api",
owner_name: "johndoe",
owner_id: "user-1",
},
],
count: 3,
workspaces: mockWorkspaces,
count: mockWorkspaces.length,
});
},
play: async ({ canvasElement }) => {
@@ -92,8 +94,79 @@ export const WithWorkspaces: Story = {
expect(trigger).toBeEnabled();
});
await userEvent.click(canvas.getByText("Workspace").closest("button")!);
// Wait for the portalled dropdown to appear so Chromatic captures it.
await within(canvasElement.ownerDocument.body).findByRole("listbox");
// Wait for the portalled combobox dropdown to appear so Chromatic
// captures it.
await within(canvasElement.ownerDocument.body).findByRole("dialog");
},
};
export const SearchWorkspaces: Story = {
beforeEach: () => {
localStorage.clear();
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: mockWorkspaces,
count: mockWorkspaces.length,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
const trigger = canvas.getByText("Workspace").closest("button")!;
expect(trigger).toBeEnabled();
});
await userEvent.click(canvas.getByText("Workspace").closest("button")!);
const body = within(canvasElement.ownerDocument.body);
await body.findByRole("dialog");
// Type in the search input to filter workspaces.
const searchInput = body.getByPlaceholderText("Search workspaces...");
await userEvent.type(searchInput, "backend");
// Only the matching workspace should remain visible.
await waitFor(() => {
const options = body.getAllByRole("option");
// "Auto-create Workspace" is filtered out, only
// "johndoe/backend-api" matches.
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent("johndoe/backend-api");
});
},
};
export const SelectWorkspaceViaSearch: Story = {
beforeEach: () => {
localStorage.clear();
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: mockWorkspaces,
count: mockWorkspaces.length,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
const trigger = canvas.getByText("Workspace").closest("button")!;
expect(trigger).toBeEnabled();
});
await userEvent.click(canvas.getByText("Workspace").closest("button")!);
const body = within(canvasElement.ownerDocument.body);
await body.findByRole("dialog");
// Search for "janedoe" and select the result.
const searchInput = body.getByPlaceholderText("Search workspaces...");
await userEvent.type(searchInput, "janedoe");
await waitFor(() => {
expect(body.getAllByRole("option")).toHaveLength(1);
});
await userEvent.click(body.getByRole("option", { name: /janedoe/ }));
// The trigger should now show the selected workspace.
await waitFor(() => {
expect(canvas.getByText("janedoe/my-project")).toBeInTheDocument();
});
},
};
+46 -35
View File
@@ -15,17 +15,20 @@ import {
import { workspaces } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ChevronDownIcon } from "components/AnimatedIcons/ChevronDown";
import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "components/Combobox/Combobox";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { useAuthenticated } from "hooks";
import { MonitorIcon, PanelLeftIcon } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
@@ -698,7 +701,7 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
useState(initialSystemPrompt);
const [systemPromptDraft, setSystemPromptDraft] =
useState(initialSystemPrompt);
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 50 }));
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
() => {
if (typeof window === "undefined") return null;
@@ -850,38 +853,46 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
leftActions={
<Select
<Combobox
value={selectedWorkspaceId ?? autoCreateWorkspaceValue}
onValueChange={handleWorkspaceChange}
disabled={isCreating || workspacesQuery.isLoading}
onValueChange={(value) =>
handleWorkspaceChange(value ?? autoCreateWorkspaceValue)
}
>
<SelectTrigger className="h-8 w-auto gap-1.5 border-none bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary [&>svg]:transition-colors [&>svg]:hover:text-content-primary focus:ring-0 focus-visible:ring-0">
<MonitorIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary group-hover:text-content-primary" />
<SelectValue>
{selectedWorkspaceLabel ?? "Workspace"}
</SelectValue>
</SelectTrigger>
<SelectContent
<ComboboxTrigger asChild>
<button
type="button"
disabled={isCreating || workspacesQuery.isLoading}
className="group flex h-8 items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:bg-transparent hover:text-content-primary cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
>
<MonitorIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
<span>{selectedWorkspaceLabel ?? "Workspace"}</span>
<ChevronDownIcon className="size-icon-sm text-content-secondary transition-colors group-hover:text-content-primary" />
</button>
</ComboboxTrigger>
<ComboboxContent
side="top"
align="center"
className="[&_[role=option]]:text-xs"
className="w-72 [&_[cmdk-item]]:text-xs"
>
<SelectItem value={autoCreateWorkspaceValue}>
Auto-create Workspace
</SelectItem>
{workspaceOptions.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.owner_name}/{workspace.name}
</SelectItem>
))}
{workspaceOptions.length === 0 &&
!workspacesQuery.isLoading && (
<SelectItem value="no-workspaces" disabled>
No workspaces found
</SelectItem>
)}
</SelectContent>
</Select>
<ComboboxInput placeholder="Search workspaces..." />
<ComboboxList>
<ComboboxItem value={autoCreateWorkspaceValue}>
Auto-create Workspace
</ComboboxItem>
{workspaceOptions.map((workspace) => (
<ComboboxItem
key={workspace.id}
value={workspace.id}
keywords={[workspace.owner_name, workspace.name]}
>
{workspace.owner_name}/{workspace.name}
</ComboboxItem>
))}
</ComboboxList>
<ComboboxEmpty>No workspaces found</ComboboxEmpty>
</ComboboxContent>
</Combobox>
}
/>
</div>