From 92d936394e99b4396cd11b35afefd3ee380aa76d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 26 Aug 2026 12:01:45 +1000 Subject: [PATCH 1/3] feat(desktop): use segmented controls for channel creation Signed-off-by: Matt Toohey --- .../ui/ChannelPermissionsSettings.tsx | 112 +++++++++++------- .../channels/ui/ChannelTypeSettings.tsx | 46 +++++-- .../sidebar/ui/CreateChannelFormFields.tsx | 2 + desktop/src/shared/ui/segmented-control.tsx | 4 + desktop/tests/e2e/channels.spec.ts | 33 +++--- .../welcome-agent-modal-screenshots.spec.ts | 2 - 6 files changed, 122 insertions(+), 77 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx index eb7ee4739c8..73fb2ce4f30 100644 --- a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx @@ -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: "open", label: "Public", Icon: Globe }, + { value: "private", label: "Private", Icon: Lock }, +] 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,69 @@ export function ChannelPermissionsSettings({
Visibility - - - - - event.preventDefault()} - style={{ - minWidth: "var(--radix-dropdown-menu-trigger-width)", - }} - > - - onVisibilityChange( - nextVisibility === "private" ? "private" : "open", - ) - } - value={visibility} - > - + ) : ( + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + onVisibilityChange( + nextVisibility === "private" ? "private" : "open", + ) + } + value={visibility} > - Private - - - - + + Public + + + Private + + + + + )}
); } diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx index 82fd1c9f7a1..20c323ebbda 100644 --- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, ClockFading, Hash } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { @@ -19,9 +19,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: "ongoing", label: "Ongoing", Icon: Hash }, + { value: "temporary", label: "Temporary", Icon: ClockFading }, +] as const; + const EPHEMERAL_TIMEOUT_OPTIONS = [ { label: "30 minutes", seconds: 30 * 60 }, { label: "1 hour", seconds: 60 * 60 }, @@ -76,6 +82,7 @@ export function ChannelTypeSettings({ temporary, testIdPrefix, ttlSeconds, + variant = "dropdown", }: { channelId?: string | null; disabled?: boolean; @@ -87,6 +94,7 @@ export function ChannelTypeSettings({ temporary: boolean; testIdPrefix: string; ttlSeconds: number; + variant?: "dropdown" | "segmented"; }) { const projectHome = useIsProjectHomeChannel(channelId); const lifecycle = channelLifecycle({ projectHome, temporary }); @@ -117,17 +125,31 @@ export function ChannelTypeSettings({ data-testid={`${testIdPrefix}-channel-type-row`} > {label} - onTemporaryChange(next === "temporary")} - onOpenChange={onOpenChange} - open={open} - testId={`${testIdPrefix}-channel-type`} - /> + {variant === "segmented" ? ( + onTemporaryChange(value === "temporary")} + optionTestIdPrefix={`${testIdPrefix}-channel-type-option`} + options={CHANNEL_TYPE_OPTIONS} + testId={`${testIdPrefix}-channel-type`} + value={temporary ? "temporary" : "ongoing"} + /> + ) : ( + + onTemporaryChange(next === "temporary") + } + onOpenChange={onOpenChange} + open={open} + testId={`${testIdPrefix}-channel-type`} + /> + )} {temporary && !projectHome ? ( diff --git a/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx b/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx index 01f5af478e5..69ac0b20939 100644 --- a/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx +++ b/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx @@ -136,6 +136,7 @@ export function CreateChannelFormFields({ temporary={form.ephemeral} testIdPrefix="create-channel" ttlSeconds={form.ttlSeconds} + variant="segmented" />
= { /** A mutually exclusive control with equal-width, optionally scrubbable options. */ export function SegmentedControl({ className, + disabled = false, indicatorTestId, legend, onPreviewChange, @@ -30,6 +31,7 @@ export function SegmentedControl({ value, }: { className?: string; + disabled?: boolean; indicatorTestId?: string; legend: string; onPreviewChange?: (value: Value | null) => void; @@ -161,10 +163,12 @@ export function SegmentedControl({ "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} diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index c05c6cf2358..8eae364bf09 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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", @@ -1539,8 +1538,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 +1550,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 expect( + page.getByTestId("create-channel-permissions-option-open"), + ).toHaveAttribute("aria-pressed", "true"); 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(); + ).toHaveAttribute("aria-pressed", "true"); await expect( page.getByTestId("create-channel-permissions-option-open"), ).toBeVisible(); 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 +1672,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); } diff --git a/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts b/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts index 0115b608bfa..d15690a8572 100644 --- a/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts +++ b/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts @@ -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"); From 9bc88aa6ebdae345776913d34dfcb1365835aba8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 26 Aug 2026 15:58:50 +1000 Subject: [PATCH 2/3] fix(desktop): reverse channel creation segments Signed-off-by: Matt Toohey --- .../src/features/channels/ui/ChannelPermissionsSettings.tsx | 2 +- desktop/src/features/channels/ui/ChannelTypeSettings.tsx | 2 +- desktop/tests/e2e/channels.spec.ts | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx index 73fb2ce4f30..96e0540cf99 100644 --- a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx @@ -13,8 +13,8 @@ import { cn } from "@/shared/lib/cn"; import { SegmentedControl } from "@/shared/ui/segmented-control"; const VISIBILITY_OPTIONS = [ - { value: "open", label: "Public", Icon: Globe }, { value: "private", label: "Private", Icon: Lock }, + { value: "open", label: "Public", Icon: Globe }, ] as const; export function ChannelPermissionsSettings({ diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx index 20c323ebbda..c97b77d2805 100644 --- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx @@ -24,8 +24,8 @@ import { EditableInfoFieldRow } from "./ChannelManagementSheetRows"; import { ChannelTypePicker } from "./ChannelTypePicker"; const CHANNEL_TYPE_OPTIONS = [ - { value: "ongoing", label: "Ongoing", Icon: Hash }, { value: "temporary", label: "Temporary", Icon: ClockFading }, + { value: "ongoing", label: "Ongoing", Icon: Hash }, ] as const; const EPHEMERAL_TIMEOUT_OPTIONS = [ diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 8eae364bf09..79758812bd2 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1497,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(); From 10a3a75b76a3cb28e9da90e00cf7b008134b3848 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 26 Aug 2026 15:44:06 +1000 Subject: [PATCH 3/3] fix(desktop): address review feedback on channel-create segmented controls Scope-limited fixes from the segmented-control review; the shared SegmentedControl radiogroup-semantics upgrade stays deferred to the existing follow-up plan since that behavior predates this branch. - Drop the now write-only typePopoverOpen state from useCreateChannelForm and stop passing open/onOpenChange into the segmented ChannelTypeSettings, which never renders ChannelTypePicker. - Use one disabled-during-submit convention across the create dialog rows: each label dims via opacity-50 on its span while the control dims itself, replacing the Template row's whole-row dim that double-dimmed its button. Scoped to the segmented variant so the channel management sheet's dropdown rows are unchanged. - Replace the vacuous toBeVisible() in the ephemeral-stream e2e test with an aria-pressed="false" check, restoring the mutual-exclusion coverage the old dropdown assertions provided. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../channels/ui/ChannelPermissionsSettings.tsx | 9 ++++++++- .../features/channels/ui/ChannelTypeSettings.tsx | 15 +++++++++++++-- .../features/sidebar/lib/useCreateChannelForm.ts | 6 ------ .../sidebar/ui/CreateChannelFormFields.tsx | 14 +++++++------- desktop/tests/e2e/channels.spec.ts | 2 +- 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx index 96e0540cf99..8914af29331 100644 --- a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx @@ -40,7 +40,14 @@ export function ChannelPermissionsSettings({ )} data-testid={`${testIdPrefix}-permissions-container`} > - Visibility + + Visibility + {variant === "segmented" ? ( - {label} + + {label} + {variant === "segmented" ? (