mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): searchable workspace selector in agent chat input (#22656)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user