Merge remote-tracking branch 'origin/main' into kennylopez-agent-catalog-sharing

# Conflicts:
#	desktop/tests/helpers/bridge.ts
This commit is contained in:
kenny lopez
2026-07-24 08:48:57 -07:00
4 changed files with 119 additions and 23 deletions
@@ -1,6 +1,15 @@
import { ChevronDown } from "lucide-react";
import type { ChannelTemplate } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
@@ -14,6 +23,7 @@ import type { CreateChannelFormState } from "@/features/sidebar/lib/useCreateCha
const CREATE_LABEL_OPTIONAL_CLASS =
"ml-1 text-xs font-normal text-muted-foreground/50";
const NO_TEMPLATE_VALUE = "__no-template__";
export const CREATE_CHANNEL_FORM_ID = "create-channel-form";
@@ -29,6 +39,9 @@ export function CreateChannelFormFields({
form: CreateChannelFormState;
}) {
const { channelKind, kindLabel, isCreating } = form;
const selectedTemplate = form.templates.find(
(template) => template.id === form.selectedTemplateId,
);
return (
<div className="space-y-5">
@@ -104,29 +117,59 @@ export function CreateChannelFormFields({
/>
{form.templates.length > 0 ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-template"
>
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3",
isCreating && "opacity-50",
)}
>
<span className="text-sm font-medium text-foreground">
Template
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<select
className="flex min-h-11 w-full rounded-xl border border-input bg-muted/40 px-3 py-2 text-sm text-muted-foreground/55 shadow-none transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus:border-muted-foreground/50 focus:text-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
data-testid="create-channel-template"
disabled={isCreating}
id="create-channel-template"
onChange={(event) => form.handleTemplateChange(event.target.value)}
value={form.selectedTemplateId ?? ""}
>
<option value="">No template</option>
{form.templates.map((template: ChannelTemplate) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Template: ${selectedTemplate?.name ?? "No template"}`}
className="-mr-2.5 ml-auto h-9 min-w-0 max-w-[60%] justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid="create-channel-template"
disabled={isCreating}
id="create-channel-template"
type="button"
variant="ghost"
>
<span className="truncate text-right">
{selectedTemplate?.name ?? "No template"}
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={(templateId) =>
form.handleTemplateChange(
templateId === NO_TEMPLATE_VALUE ? "" : templateId,
)
}
value={form.selectedTemplateId ?? NO_TEMPLATE_VALUE}
>
<DropdownMenuRadioItem value={NO_TEMPLATE_VALUE}>
No template
</DropdownMenuRadioItem>
{form.templates.map((template: ChannelTemplate) => (
<DropdownMenuRadioItem key={template.id} value={template.id}>
{template.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
+15 -1
View File
@@ -6,7 +6,7 @@ import { parse as yamlParse } from "yaml";
import { relayClient } from "@/shared/api/relayClient";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
import type { ChannelTemplate, RelayEvent } from "@/shared/api/types";
import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache";
import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore";
import { recordTimeoutFromRejection } from "@/features/moderation/lib/timeoutStore";
@@ -207,6 +207,7 @@ type E2eConfig = {
addChannelMembersErrors?: (string | null)[];
channelMembersReadDelayMs?: number;
createManagedAgentDelayMs?: number;
channelTemplates?: ChannelTemplate[];
channelsReadError?: string;
/** Reject successive mock `get_channels` calls, then resume. */
channelsReadErrors?: (string | null)[];
@@ -10147,6 +10148,19 @@ export function maybeInstallE2eTauriMocks() {
);
case "list_teams":
return handleListTeams();
case "list_channel_templates":
return (activeConfig?.mock?.channelTemplates ?? []).map((template) => ({
id: template.id,
name: template.name,
description: template.description,
channel_type: template.channelType,
visibility: template.visibility,
canvas_template: template.canvasTemplate,
agents: template.agents,
is_builtin: template.isBuiltin,
created_at: template.createdAt,
updated_at: template.updatedAt,
}));
case "create_team":
return handleCreateTeam(
payload as Parameters<typeof handleCreateTeam>[0],
+38
View File
@@ -1294,6 +1294,44 @@ test("create stream with name and description", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("create channel template selector matches the lifecycle controls", async ({
page,
}) => {
await installMockBridge(page, {
channelTemplates: [
{
id: "project-kickoff",
name: "Project kickoff",
description: "Coordinate a new project from planning through launch.",
channelType: "stream",
visibility: "private",
canvasTemplate: null,
agents: { personas: [], teams: [] },
isBuiltin: false,
createdAt: "2026-07-23T00:00:00Z",
updatedAt: "2026-07-23T00:00:00Z",
},
],
});
await page.goto("/");
await openCreateChannelDialog(page);
const templateControl = page.getByTestId("create-channel-template");
await expect(templateControl).toHaveRole("button");
await expect(templateControl).toHaveText("No template");
await templateControl.click();
await page.getByRole("menuitemradio", { name: "Project kickoff" }).click();
await expect(templateControl).toHaveText("Project kickoff");
await expect(page.getByTestId("create-channel-description")).toHaveValue(
"Coordinate a new project from planning through launch.",
);
await expect(page.getByTestId("create-channel-permissions")).toContainText(
"Private",
);
});
test("create ephemeral stream shows sidebar and header affordances", async ({
page,
}) => {
+2 -1
View File
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test";
import type { RelayEvent } from "@/shared/api/types";
import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types";
import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features";
export const TEST_IDENTITIES = {
@@ -218,6 +218,7 @@ type MockBridgeOptions = {
relayAgents?: MockRelayAgentSeed[];
agentListDelayMs?: number;
createManagedAgentDelayMs?: number;
channelTemplates?: ChannelTemplate[];
addChannelMembersDelayMs?: number;
/** Sequenced add-member failures. A string fails that call; null succeeds. */
addChannelMembersErrors?: (string | null)[];