feat(desktop): use segmented controls for channel creation (#6845)

## Summary

- replace the two-option Type and Visibility dropdowns in the Create
channel dialog with single-click segmented controls
- keep Expires after as a dropdown and preserve the existing dropdown
controls in edit and management dialogs
- update channel creation end-to-end coverage for the direct controls

## Before

Default Ongoing/Public state:

![Create channel before - default Ongoing and
Public](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/before-default.png)

Type dropdown open, showing the extra selection click:

![Create channel before - Type dropdown
open](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/before-type-dropdown.png)

## After

Default Ongoing/Public state:

![Create channel after - default Ongoing and
Public](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/after-default-reversed.png)

Temporary/Private state with the Expires after row visible:

![Create channel after - Temporary and
Private](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/after-temporary-private-reversed.png)

## Verification

- Biome and TypeScript checks pass
- 5,508 desktop unit tests pass
- 88 channel smoke tests pass
- source guards pass

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-08-31 12:14:35 +10:00
committed by GitHub
parent eed74bde2f
commit c3132c3ee9
7 changed files with 160 additions and 97 deletions

View File

@@ -1,4 +1,4 @@
import { ChevronDown } from "lucide-react";
import { ChevronDown, Globe, Lock } from "lucide-react";
import type { ChannelVisibility } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
@@ -10,17 +10,25 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { cn } from "@/shared/lib/cn";
import { SegmentedControl } from "@/shared/ui/segmented-control";
const VISIBILITY_OPTIONS = [
{ value: "private", label: "Private", Icon: Lock },
{ value: "open", label: "Public", Icon: Globe },
] as const;
export function ChannelPermissionsSettings({
disabled,
onVisibilityChange,
testIdPrefix,
visibility,
variant = "dropdown",
}: {
disabled?: boolean;
onVisibilityChange: (visibility: ChannelVisibility) => void;
testIdPrefix: string;
visibility: ChannelVisibility;
variant?: "dropdown" | "segmented";
}) {
const visibilityLabel = visibility === "private" ? "Private" : "Public";
@@ -28,57 +36,76 @@ export function ChannelPermissionsSettings({
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3",
disabled && "opacity-50",
disabled && variant === "dropdown" && "opacity-50",
)}
data-testid={`${testIdPrefix}-permissions-container`}
>
<span className="text-sm font-medium text-foreground">Visibility</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Visibility: ${visibilityLabel}`}
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid={`${testIdPrefix}-permissions`}
disabled={disabled}
type="button"
variant="ghost"
>
<span aria-live="polite" className="text-right">
{visibilityLabel}
</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={(nextVisibility) =>
onVisibilityChange(
nextVisibility === "private" ? "private" : "open",
)
}
value={visibility}
>
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-permissions-option-open`}
value="open"
<span
className={cn(
"text-sm font-medium text-foreground",
disabled && variant === "segmented" && "opacity-50",
)}
>
Visibility
</span>
{variant === "segmented" ? (
<SegmentedControl
disabled={disabled}
legend="Visibility"
onValueChange={onVisibilityChange}
optionTestIdPrefix={`${testIdPrefix}-permissions-option`}
options={VISIBILITY_OPTIONS}
testId={`${testIdPrefix}-permissions`}
value={visibility}
/>
) : (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Visibility: ${visibilityLabel}`}
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid={`${testIdPrefix}-permissions`}
disabled={disabled}
type="button"
variant="ghost"
>
Public
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-permissions-option-private`}
value="private"
<span aria-live="polite" className="text-right">
{visibilityLabel}
</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={(nextVisibility) =>
onVisibilityChange(
nextVisibility === "private" ? "private" : "open",
)
}
value={visibility}
>
Private
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-permissions-option-open`}
value="open"
>
Public
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-permissions-option-private`}
value="private"
>
Private
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { ChevronDown } from "lucide-react";
import { ChevronDown, ClockFading, Hash } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
@@ -11,6 +11,7 @@ import {
} from "@/features/channels/lib/ephemeralChannel";
import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -19,9 +20,15 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { SegmentedControl } from "@/shared/ui/segmented-control";
import { EditableInfoFieldRow } from "./ChannelManagementSheetRows";
import { ChannelTypePicker } from "./ChannelTypePicker";
const CHANNEL_TYPE_OPTIONS = [
{ value: "temporary", label: "Temporary", Icon: ClockFading },
{ value: "ongoing", label: "Ongoing", Icon: Hash },
] as const;
const EPHEMERAL_TIMEOUT_OPTIONS = [
{ label: "30 minutes", seconds: 30 * 60 },
{ label: "1 hour", seconds: 60 * 60 },
@@ -76,6 +83,7 @@ export function ChannelTypeSettings({
temporary,
testIdPrefix,
ttlSeconds,
variant = "dropdown",
}: {
channelId?: string | null;
disabled?: boolean;
@@ -87,6 +95,7 @@ export function ChannelTypeSettings({
temporary: boolean;
testIdPrefix: string;
ttlSeconds: number;
variant?: "dropdown" | "segmented";
}) {
const projectHome = useIsProjectHomeChannel(channelId);
const lifecycle = channelLifecycle({ projectHome, temporary });
@@ -116,18 +125,39 @@ export function ChannelTypeSettings({
className="flex items-center justify-between gap-3 px-3 py-3"
data-testid={`${testIdPrefix}-channel-type-row`}
>
<span className="text-sm font-medium text-foreground">{label}</span>
<ChannelTypePicker
align="end"
allowProject={projectHome}
className="-mr-2.5"
disabled={disabled}
lifecycle={lifecycle}
onLifecycleChange={(next) => onTemporaryChange(next === "temporary")}
onOpenChange={onOpenChange}
open={open}
testId={`${testIdPrefix}-channel-type`}
/>
<span
className={cn(
"text-sm font-medium text-foreground",
disabled && variant === "segmented" && "opacity-50",
)}
>
{label}
</span>
{variant === "segmented" ? (
<SegmentedControl
disabled={disabled}
legend="Channel type"
onValueChange={(value) => onTemporaryChange(value === "temporary")}
optionTestIdPrefix={`${testIdPrefix}-channel-type-option`}
options={CHANNEL_TYPE_OPTIONS}
testId={`${testIdPrefix}-channel-type`}
value={temporary ? "temporary" : "ongoing"}
/>
) : (
<ChannelTypePicker
align="end"
allowProject={projectHome}
className="-mr-2.5"
disabled={disabled}
lifecycle={lifecycle}
onLifecycleChange={(next) =>
onTemporaryChange(next === "temporary")
}
onOpenChange={onOpenChange}
open={open}
testId={`${testIdPrefix}-channel-type`}
/>
)}
</div>
<AnimatePresence initial={false}>
{temporary && !projectHome ? (
@@ -144,7 +174,10 @@ export function ChannelTypeSettings({
data-testid={`${testIdPrefix}-ephemeral-settings`}
>
<label
className="text-sm font-medium"
className={cn(
"text-sm font-medium",
disabled && variant === "segmented" && "opacity-50",
)}
htmlFor={`${testIdPrefix}-ttl`}
>
Expires after

View File

@@ -41,8 +41,6 @@ export type CreateChannelFormState = {
setEphemeral: (value: boolean) => void;
ttlSeconds: number;
setTtlSeconds: (value: number) => void;
typePopoverOpen: boolean;
setTypePopoverOpen: (open: boolean) => void;
errorMessage: string | null;
selectedTemplateId: string | null;
handleTemplateChange: (templateId: string) => void;
@@ -79,7 +77,6 @@ export function useCreateChannelForm({
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | null
>(null);
const [typePopoverOpen, setTypePopoverOpen] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement>(null);
const visibilityTouchedRef = React.useRef(false);
@@ -97,7 +94,6 @@ export function useCreateChannelForm({
setTtlSeconds(DEFAULT_EPHEMERAL_TTL_SECONDS);
setErrorMessage(null);
setSelectedTemplateId(null);
setTypePopoverOpen(false);
visibilityTouchedRef.current = false;
if (!autoFocusName) return;
@@ -211,8 +207,6 @@ export function useCreateChannelForm({
setEphemeral,
ttlSeconds,
setTtlSeconds,
typePopoverOpen,
setTypePopoverOpen,
errorMessage,
selectedTemplateId,
handleTemplateChange,

View File

@@ -129,13 +129,12 @@ export function CreateChannelFormFields({
<ChannelTypeSettings
disabled={isCreating}
label="Type"
onOpenChange={form.setTypePopoverOpen}
onTemporaryChange={form.setEphemeral}
onTtlSecondsChange={form.setTtlSeconds}
open={form.typePopoverOpen}
temporary={form.ephemeral}
testIdPrefix="create-channel"
ttlSeconds={form.ttlSeconds}
variant="segmented"
/>
<ChannelPermissionsSettings
@@ -143,16 +142,19 @@ export function CreateChannelFormFields({
onVisibilityChange={form.setVisibility}
testIdPrefix="create-channel"
visibility={form.visibility}
variant="segmented"
/>
<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",
)}
className="flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3"
data-testid="create-channel-template-container"
>
<span className="text-sm font-medium text-foreground">
<span
className={cn(
"text-sm font-medium text-foreground",
isCreating && "opacity-50",
)}
>
Template
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</span>

View File

@@ -19,6 +19,7 @@ const SIZE_CLASSES: Record<SegmentedControlSize, string> = {
/** A mutually exclusive control with equal-width, optionally scrubbable options. */
export function SegmentedControl<Value extends string>({
className,
disabled = false,
indicatorTestId,
legend,
onPreviewChange,
@@ -30,6 +31,7 @@ export function SegmentedControl<Value extends string>({
value,
}: {
className?: string;
disabled?: boolean;
indicatorTestId?: string;
legend: string;
onPreviewChange?: (value: Value | null) => void;
@@ -161,10 +163,12 @@ export function SegmentedControl<Value extends string>({
"relative isolate h-8 max-w-full shrink-0 overflow-hidden rounded-md bg-muted/45 p-0.5",
SIZE_CLASSES[size],
onPreviewChange && "touch-none select-none cursor-ew-resize",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
data-slot="segmented-control"
data-testid={testId}
disabled={disabled}
onLostPointerCapture={handleLostPointerCapture}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}

View File

@@ -1472,10 +1472,9 @@ test("create channel template selector matches the lifecycle controls", async ({
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",
);
await page.getByTestId("create-channel-permissions").click();
await expect(
page.getByTestId("create-channel-permissions-option-private"),
).toHaveAttribute("aria-pressed", "true");
await page.getByTestId("create-channel-permissions-option-open").click();
await expect(page.getByTestId("create-channel-template-summary")).toHaveText(
"Open · Canvas included · 1 agent · 1 team",
@@ -1498,6 +1497,12 @@ test("create channel exposes templates when the library is empty", async ({
const templateContainer = page.getByTestId(
"create-channel-template-container",
);
await expect(
page.getByTestId("create-channel-channel-type").getByRole("button"),
).toHaveText(["Temporary", "Ongoing"]);
await expect(
page.getByTestId("create-channel-permissions").getByRole("button"),
).toHaveText(["Private", "Public"]);
await expect(templateContainer).toContainText("TemplateOptional");
const typeBox = await typeContainer.boundingBox();
const visibilityBox = await visibilityContainer.boundingBox();
@@ -1539,8 +1544,9 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
await page
.getByTestId("create-channel-description")
.fill("Auto-cleaned test stream");
await page.getByTestId("create-channel-channel-type").click();
await page.getByLabel("Temporary channel").click();
await page
.getByTestId("create-channel-channel-type-option-temporary")
.click();
const channelTypeContainer = page.getByTestId(
"create-channel-channel-type-container",
);
@@ -1550,22 +1556,20 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
await expect(
page.getByTestId("create-channel-permissions-container"),
).toBeVisible();
await expect(page.getByTestId("create-channel-permissions")).toContainText(
"Public",
);
await page.getByTestId("create-channel-permissions").click();
await page.getByTestId("create-channel-permissions-option-private").click();
await expect(page.getByTestId("create-channel-permissions")).toContainText(
"Private",
);
await expect(
page.getByTestId("create-channel-permissions-option-private"),
).toHaveCount(0);
await page.getByTestId("create-channel-permissions").click();
await expect(
page.getByTestId("create-channel-permissions-option-open"),
).toBeVisible();
).toHaveAttribute("aria-pressed", "true");
await page.getByTestId("create-channel-permissions-option-private").click();
await expect(
page.getByTestId("create-channel-permissions-option-private"),
).toHaveAttribute("aria-pressed", "true");
await expect(
page.getByTestId("create-channel-permissions-option-open"),
).toHaveAttribute("aria-pressed", "false");
await page.getByTestId("create-channel-permissions-option-open").click();
await expect(
page.getByTestId("create-channel-permissions-option-open"),
).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("create-channel-ttl")).toContainText("7 days");
await page.getByTestId("create-channel-ttl").click();
await page.getByTestId("create-channel-ttl-option-1209600").click();
@@ -1674,8 +1678,9 @@ test("ephemeral countdown refreshes when switching channels after a clock jump",
await page
.getByTestId("create-channel-description")
.fill("Auto-cleaned test stream");
await page.getByTestId("create-channel-channel-type").click();
await page.getByLabel("Temporary channel").click();
await page
.getByTestId("create-channel-channel-type-option-temporary")
.click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toContainText(channelName);
}

View File

@@ -69,7 +69,6 @@ test.describe("welcome and channel agent entry points", () => {
await page
.getByTestId("create-channel-description")
.fill("A private channel for getting oriented in this workspace.");
await page.getByTestId("create-channel-permissions").click();
await page.getByTestId("create-channel-permissions-option-private").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");
@@ -109,7 +108,6 @@ test.describe("welcome and channel agent entry points", () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill("Welcome");
await page.getByTestId("create-channel-permissions").click();
await page.getByTestId("create-channel-permissions-option-private").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");