feat(desktop): add channel settings modal

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-06-16 20:58:42 -04:00
parent d879fac4c4
commit 881dcca4f8
6 changed files with 757 additions and 423 deletions

View File

@@ -4,10 +4,14 @@ import {
Copy,
DoorClosed,
DoorOpen,
Eye,
FileText,
Hash,
Fingerprint,
Lock,
MessageSquare,
Pencil,
Radio,
Type,
Users,
Zap,
} from "lucide-react";
@@ -31,10 +35,13 @@ import {
formatTtlDuration,
parseTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
import {
SettingsOptionGroup,
SettingsOptionRow,
} from "@/features/settings/ui/SettingsOptionGroup";
import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import {
AlertDialog,
AlertDialogAction,
@@ -47,18 +54,26 @@ import {
AlertDialogTrigger,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/shared/ui/sheet";
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelCanvas } from "./ChannelCanvas";
import {
CanvasSummaryRow,
ChannelHero,
ChannelQuickAction,
CopyFieldRow,
FieldGroup,
InfoFieldRow,
NarrativeField,
} from "./ChannelManagementSheetRows";
type ChannelManagementSheetProps = {
channel: Channel | null;
@@ -70,50 +85,6 @@ type ChannelManagementSheetProps = {
const DEFAULT_EPHEMERAL_TTL_SECONDS = 24 * 60 * 60;
function MetadataPill({
icon: Icon,
label,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
}) {
return (
<div className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground">
<Icon className="h-4 w-4" />
<span>{label}</span>
</div>
);
}
function ChannelIdRow({ channelId }: { channelId: string }) {
async function handleCopyChannelId() {
await navigator.clipboard.writeText(channelId);
toast.success("Copied channel ID to clipboard");
}
return (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-border/70 bg-muted/20 px-3 py-2.5 text-left transition-colors hover:border-border hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="channel-management-channel-id"
onClick={() => {
void handleCopyChannelId();
}}
title="Copy channel ID"
type="button"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
Channel ID
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{channelId}
</div>
</div>
<Copy className="h-4 w-4 shrink-0 text-muted-foreground/45 transition-colors group-hover:text-muted-foreground" />
</button>
);
}
export function ChannelManagementSheet({
channel,
currentPubkey,
@@ -121,7 +92,6 @@ export function ChannelManagementSheet({
onOpenChange,
open,
}: ChannelManagementSheetProps) {
const { isDark } = useTheme();
const channelId = channel?.id ?? null;
const detailsQuery = useChannelDetailsQuery(channelId, open);
const membersQuery = useChannelMembersQuery(channelId, open);
@@ -164,7 +134,6 @@ export function ChannelManagementSheet({
selfMember !== null;
const memberCount =
members.length || detail?.memberCount || channel?.memberCount || 0;
const [nameDraft, setNameDraft] = React.useState("");
const [descriptionDraft, setDescriptionDraft] = React.useState("");
const [topicDraft, setTopicDraft] = React.useState("");
@@ -174,6 +143,7 @@ export function ChannelManagementSheet({
const [ttlDraft, setTtlDraft] = React.useState("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isCreateWorkflowOpen, setIsCreateWorkflowOpen] = React.useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false);
// Sync drafts from server only when the sheet opens or the channel changes —
// not on every background refetch, which would clobber in-flight edits.
@@ -184,6 +154,7 @@ export function ChannelManagementSheet({
syncedForRef.current = null;
setIsDeleteDialogOpen(false);
setIsCreateWorkflowOpen(false);
setIsEditDialogOpen(false);
return;
}
if (!detail) {
@@ -263,55 +234,70 @@ export function ChannelManagementSheet({
}
const resolvedChannel = detail ?? channel;
const canEditChannel = canManageChannel || canEditNarrative;
return (
<Sheet onOpenChange={handleSheetOpenChange} open={open}>
<SheetContent
className={cn(
"flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 p-0 shadow-none sm:max-w-xl",
isDark
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
: "bg-background",
)}
data-testid="channel-management-sheet"
side="right"
<Dialog onOpenChange={handleSheetOpenChange} open={open}>
<DialogContent
className="flex max-h-[min(860px,calc(100vh-2rem))] max-w-3xl flex-col gap-0 overflow-hidden p-0"
data-testid="channel-management-modal"
>
<SheetHeader
className={cn(
"relative z-10 space-y-4 px-6 py-6 text-left shadow-none",
isDark
? "bg-background/60 backdrop-blur-xl supports-[backdrop-filter]:bg-background/50"
: "bg-background",
)}
>
<SheetTitle className="pr-8">{channel.name}</SheetTitle>
<SheetDescription className="sr-only">
<DialogHeader className="border-b border-border/80 px-7 py-6 pr-14 sm:px-8">
<DialogTitle>Channel</DialogTitle>
<DialogDescription className="sr-only">
Channel settings
</SheetDescription>
<div className="flex flex-wrap items-center gap-2">
<MetadataPill
icon={
channel.channelType === "forum"
? FileText
: channel.channelType === "dm"
? MessageSquare
: Hash
}
label={channel.channelType}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-6 overflow-y-auto bg-background px-7 py-6 sm:px-8">
<ChannelHero channel={resolvedChannel} />
<div className="flex flex-wrap items-start justify-center gap-4">
<ChannelQuickAction
icon={Copy}
label="Copy ID"
onClick={() => {
void navigator.clipboard
.writeText(resolvedChannel.id)
.then(() => toast.success("Copied channel ID"));
}}
testId="channel-management-copy-id-action"
/>
<MetadataPill
icon={channel.visibility === "private" ? Lock : DoorOpen}
label={channel.visibility}
/>
<MetadataPill icon={Users} label={`${memberCount} members`} />
{isArchived ? (
<MetadataPill icon={Archive} label="archived" />
{canJoin ? (
<ChannelQuickAction
active
disabled={joinChannelMutation.isPending}
icon={DoorOpen}
label={joinChannelMutation.isPending ? "Joining..." : "Join"}
onClick={() => {
void joinChannelMutation.mutateAsync();
}}
testId="channel-management-join"
/>
) : null}
{canLeave ? (
<ChannelQuickAction
disabled={leaveChannelMutation.isPending}
icon={DoorClosed}
label={leaveChannelMutation.isPending ? "Leaving..." : "Leave"}
onClick={() => {
void leaveChannelMutation.mutateAsync().then(() => {
onOpenChange(false);
});
}}
testId="channel-management-leave"
/>
) : null}
{canEditChannel ? (
<ChannelQuickAction
icon={Pencil}
label="Edit"
onClick={() => setIsEditDialogOpen(true)}
testId="channel-management-edit"
/>
) : null}
</div>
</SheetHeader>
<div className="flex-1 space-y-6 overflow-y-auto bg-background px-6 py-6">
<ChannelIdRow channelId={resolvedChannel.id} />
{detailsQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{detailsQuery.error.message}
@@ -324,263 +310,116 @@ export function ChannelManagementSheet({
</p>
) : null}
{canJoin ? (
<div className="space-y-3">
<Button
data-testid="channel-management-join"
disabled={joinChannelMutation.isPending}
onClick={() => {
void joinChannelMutation.mutateAsync();
}}
size="sm"
type="button"
>
<DoorOpen className="h-4 w-4" />
{joinChannelMutation.isPending ? "Joining..." : "Join channel"}
</Button>
{joinChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{joinChannelMutation.error.message}
</p>
{joinChannelMutation.error instanceof Error ? (
<p className="text-center text-sm text-destructive">
{joinChannelMutation.error.message}
</p>
) : null}
{leaveChannelMutation.error instanceof Error ? (
<p className="text-center text-sm text-destructive">
{leaveChannelMutation.error.message}
</p>
) : null}
{resolvedChannel.description.trim() ||
resolvedChannel.topic?.trim() ||
resolvedChannel.purpose?.trim() ? (
<FieldGroup>
{resolvedChannel.description.trim() ? (
<NarrativeField
icon={FileText}
label="Description"
testId="channel-management-description-summary"
value={resolvedChannel.description.trim()}
/>
) : null}
</div>
{resolvedChannel.topic?.trim() ? (
<NarrativeField
icon={MessageSquare}
label="Topic"
testId="channel-management-topic-summary"
value={resolvedChannel.topic.trim()}
/>
) : null}
{resolvedChannel.purpose?.trim() ? (
<NarrativeField
icon={Zap}
label="Purpose"
testId="channel-management-purpose-summary"
value={resolvedChannel.purpose.trim()}
/>
) : null}
</FieldGroup>
) : null}
<div data-testid="channel-canvas-section">
<ChannelCanvas
canEdit={canEditNarrative}
channelId={channelId}
isArchived={isArchived}
/>
<CanvasSummaryRow
onClick={() => {
setIsEditDialogOpen(true);
}}
>
<span className="block text-sm font-medium text-foreground">
Canvas
</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
Open editor
</span>
</CanvasSummaryRow>
</div>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void updateChannelDetailsMutation.mutateAsync({
description: descriptionDraft.trim() || undefined,
name: nameDraft.trim() || undefined,
});
}}
>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="channel-name">
Name
</label>
<Input
data-testid="channel-management-name"
disabled={
!canManageChannel || updateChannelDetailsMutation.isPending
}
id="channel-name"
onChange={(event) => setNameDraft(event.target.value)}
value={nameDraft}
<FieldGroup>
<CopyFieldRow
icon={Fingerprint}
label="Channel ID"
testId="channel-management-channel-id"
value={resolvedChannel.id}
/>
<InfoFieldRow
icon={Type}
label="Name"
testId="channel-management-name-row"
value={resolvedChannel.name}
/>
<InfoFieldRow
icon={Radio}
label="Type"
testId="channel-management-type"
value={resolvedChannel.channelType}
/>
<InfoFieldRow
icon={resolvedChannel.visibility === "private" ? Lock : Eye}
label="Visibility"
testId="channel-management-visibility"
value={resolvedChannel.visibility}
/>
<InfoFieldRow
icon={Users}
label="Members"
testId="channel-management-member-count"
value={`${memberCount}`}
/>
{isArchived ? (
<InfoFieldRow
icon={Archive}
label="Status"
testId="channel-management-archived"
value="Archived"
/>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-description"
>
Description
</label>
<Textarea
className="min-h-24"
data-testid="channel-management-description"
disabled={
!canManageChannel || updateChannelDetailsMutation.isPending
}
id="channel-description"
onChange={(event) => setDescriptionDraft(event.target.value)}
value={descriptionDraft}
/>
</div>
<Button
data-testid="channel-management-save-details"
disabled={
!canManageChannel || updateChannelDetailsMutation.isPending
}
size="sm"
type="submit"
>
{updateChannelDetailsMutation.isPending
? "Saving..."
: "Save details"}
</Button>
{updateChannelDetailsMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{updateChannelDetailsMutation.error.message}
</p>
) : null}
</form>
{resolvedChannel.channelType !== "dm" ? (
<div
className="space-y-4"
data-testid="channel-management-lifecycle"
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Private</p>
<p className="text-xs text-muted-foreground">
Only members can find and join this channel.
</p>
</div>
<Switch
checked={isPrivateDraft}
data-testid="channel-management-private-toggle"
disabled={
!canManageChannel ||
updateChannelLifecycleMutation.isPending
}
onCheckedChange={setIsPrivateDraft}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Ephemeral</p>
<p className="text-xs text-muted-foreground">
Automatically delete this channel after a set time.
</p>
</div>
<Switch
checked={isEphemeralDraft}
data-testid="channel-management-ephemeral-toggle"
disabled={
!canManageChannel ||
updateChannelLifecycleMutation.isPending
}
onCheckedChange={setIsEphemeralDraft}
/>
</div>
{isEphemeralDraft ? (
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="channel-ttl">
Timeout
</label>
<Input
aria-invalid={ttlInvalid}
data-testid="channel-management-ttl"
disabled={
!canManageChannel ||
updateChannelLifecycleMutation.isPending
}
id="channel-ttl"
onChange={(event) => setTtlDraft(event.target.value)}
placeholder="e.g. 1d, 12h, 30m"
value={ttlDraft}
/>
<p
className={cn(
"text-xs",
ttlInvalid ? "text-destructive" : "text-muted-foreground",
)}
>
{ttlInvalid
? "Enter a duration like 1d, 12h, or 30m."
: "Defaults to 1d when left empty. Resets the deletion countdown from now whenever changed."}
</p>
</div>
) : null}
<Button
data-testid="channel-management-save-lifecycle"
disabled={
!canManageChannel ||
updateChannelLifecycleMutation.isPending ||
ttlInvalid ||
!lifecycleDirty
}
onClick={handleSaveLifecycle}
size="sm"
type="button"
>
{updateChannelLifecycleMutation.isPending
? "Saving..."
: "Save visibility"}
</Button>
</div>
) : null}
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void setTopicMutation.mutateAsync({
topic: topicDraft.trim(),
});
}}
>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="channel-topic">
Topic
</label>
<Input
data-testid="channel-management-topic"
disabled={!canEditNarrative || setTopicMutation.isPending}
id="channel-topic"
onChange={(event) => setTopicDraft(event.target.value)}
value={topicDraft}
{resolvedChannel.ttlSeconds !== null ? (
<InfoFieldRow
icon={Archive}
label="Ephemeral"
testId="channel-management-ephemeral-row"
value={formatTtlDuration(resolvedChannel.ttlSeconds)}
/>
</div>
<Button
data-testid="channel-management-save-topic"
disabled={!canEditNarrative || setTopicMutation.isPending}
size="sm"
type="submit"
variant="outline"
>
{setTopicMutation.isPending ? "Saving..." : "Save topic"}
</Button>
{setTopicMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{setTopicMutation.error.message}
</p>
) : null}
</form>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void setPurposeMutation.mutateAsync({
purpose: purposeDraft.trim(),
});
}}
>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="channel-purpose">
Purpose
</label>
<Input
data-testid="channel-management-purpose"
disabled={!canEditNarrative || setPurposeMutation.isPending}
id="channel-purpose"
onChange={(event) => setPurposeDraft(event.target.value)}
value={purposeDraft}
/>
</div>
<Button
data-testid="channel-management-save-purpose"
disabled={!canEditNarrative || setPurposeMutation.isPending}
size="sm"
type="submit"
variant="outline"
>
{setPurposeMutation.isPending ? "Saving..." : "Save purpose"}
</Button>
{setPurposeMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{setPurposeMutation.error.message}
</p>
) : null}
</form>
</FieldGroup>
{canEditNarrative ? (
<Button
className="w-full justify-start"
data-testid="channel-management-create-workflow"
onClick={() => setIsCreateWorkflowOpen(true)}
size="sm"
@@ -594,34 +433,12 @@ export function ChannelManagementSheet({
</div>
{resolvedChannel.channelType !== "dm" ? (
<SheetFooter
className={cn(
"border-t border-border/80 px-6 py-4 sm:flex-row sm:justify-start sm:space-x-0",
isDark
? "bg-background/60 backdrop-blur-xl supports-[backdrop-filter]:bg-background/50"
: "bg-background",
)}
<div
className="border-t border-border/80 bg-background px-7 py-4 sm:px-8"
data-testid="channel-management-footer"
>
<div className="w-full space-y-3">
<div className="flex items-center gap-2">
{canLeave ? (
<Button
data-testid="channel-management-leave"
disabled={leaveChannelMutation.isPending}
onClick={() => {
void leaveChannelMutation.mutateAsync().then(() => {
onOpenChange(false);
});
}}
size="sm"
type="button"
variant="outline"
>
<DoorClosed className="h-4 w-4" />
{leaveChannelMutation.isPending ? "Leaving..." : "Leave"}
</Button>
) : null}
{isArchived ? (
<Button
data-testid="channel-management-unarchive"
@@ -720,11 +537,6 @@ export function ChannelManagementSheet({
</AlertDialog>
) : null}
</div>
{leaveChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{leaveChannelMutation.error.message}
</p>
) : null}
{archiveChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{archiveChannelMutation.error.message}
@@ -736,15 +548,279 @@ export function ChannelManagementSheet({
</p>
) : null}
</div>
</SheetFooter>
</div>
) : null}
</SheetContent>
</DialogContent>
<Dialog onOpenChange={setIsEditDialogOpen} open={isEditDialogOpen}>
<DialogContent
className="flex max-h-[min(760px,calc(100vh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden p-0"
data-testid="channel-management-edit-dialog"
>
<DialogHeader className="border-b border-border/80 px-6 py-5 pr-14">
<DialogTitle>Edit channel</DialogTitle>
<DialogDescription className="sr-only">
Edit channel details
</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-5 overflow-y-auto px-6 py-5">
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void updateChannelDetailsMutation.mutateAsync({
description: descriptionDraft.trim() || undefined,
name: nameDraft.trim() || undefined,
});
}}
>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="channel-name">
Name
</label>
<Input
data-testid="channel-management-name"
disabled={
!canManageChannel || updateChannelDetailsMutation.isPending
}
id="channel-name"
onChange={(event) => setNameDraft(event.target.value)}
value={nameDraft}
/>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-description"
>
Description
</label>
<Textarea
className="min-h-24"
data-testid="channel-management-description"
disabled={
!canManageChannel || updateChannelDetailsMutation.isPending
}
id="channel-description"
onChange={(event) => setDescriptionDraft(event.target.value)}
value={descriptionDraft}
/>
</div>
<Button
data-testid="channel-management-save-details"
disabled={
!canManageChannel || updateChannelDetailsMutation.isPending
}
size="sm"
type="submit"
>
{updateChannelDetailsMutation.isPending
? "Saving..."
: "Save details"}
</Button>
{updateChannelDetailsMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{updateChannelDetailsMutation.error.message}
</p>
) : null}
</form>
<div data-testid="channel-canvas-editor-section">
<ChannelCanvas
canEdit={canEditNarrative}
channelId={channelId}
isArchived={isArchived}
/>
</div>
{resolvedChannel.channelType !== "dm" ? (
<div
className="space-y-3"
data-testid="channel-management-lifecycle"
>
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="space-y-0.5">
<p className="text-sm font-medium">Private</p>
<p className="text-xs text-muted-foreground">
Only members can find and join this channel.
</p>
</div>
<Switch
checked={isPrivateDraft}
data-testid="channel-management-private-toggle"
disabled={
!canManageChannel ||
updateChannelLifecycleMutation.isPending
}
onCheckedChange={setIsPrivateDraft}
/>
</SettingsOptionRow>
<SettingsOptionRow className="border-t border-border/60">
<div className="space-y-0.5">
<p className="text-sm font-medium">Ephemeral</p>
<p className="text-xs text-muted-foreground">
Automatically delete this channel after a set time.
</p>
</div>
<Switch
checked={isEphemeralDraft}
data-testid="channel-management-ephemeral-toggle"
disabled={
!canManageChannel ||
updateChannelLifecycleMutation.isPending
}
onCheckedChange={setIsEphemeralDraft}
/>
</SettingsOptionRow>
</SettingsOptionGroup>
{isEphemeralDraft ? (
<div className="space-y-1.5 rounded-2xl bg-muted/20 p-4">
<label
className="text-sm font-medium"
htmlFor="channel-ttl"
>
Timeout
</label>
<Input
aria-invalid={ttlInvalid}
data-testid="channel-management-ttl"
disabled={
!canManageChannel ||
updateChannelLifecycleMutation.isPending
}
id="channel-ttl"
onChange={(event) => setTtlDraft(event.target.value)}
placeholder="e.g. 1d, 12h, 30m"
value={ttlDraft}
/>
<p
className={cn(
"text-xs",
ttlInvalid
? "text-destructive"
: "text-muted-foreground",
)}
>
{ttlInvalid
? "Enter a duration like 1d, 12h, or 30m."
: "Defaults to 1d when left empty. Resets the deletion countdown from now whenever changed."}
</p>
</div>
) : null}
<Button
data-testid="channel-management-save-lifecycle"
disabled={
!canManageChannel ||
updateChannelLifecycleMutation.isPending ||
ttlInvalid ||
!lifecycleDirty
}
onClick={handleSaveLifecycle}
size="sm"
type="button"
>
{updateChannelLifecycleMutation.isPending
? "Saving..."
: "Save visibility"}
</Button>
</div>
) : null}
<div className="space-y-3">
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void setTopicMutation.mutateAsync({
topic: topicDraft.trim(),
});
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-topic"
>
Topic
</label>
<Input
data-testid="channel-management-topic"
disabled={!canEditNarrative || setTopicMutation.isPending}
id="channel-topic"
onChange={(event) => setTopicDraft(event.target.value)}
value={topicDraft}
/>
</div>
<Button
data-testid="channel-management-save-topic"
disabled={!canEditNarrative || setTopicMutation.isPending}
size="sm"
type="submit"
variant="outline"
>
{setTopicMutation.isPending ? "Saving..." : "Save topic"}
</Button>
{setTopicMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{setTopicMutation.error.message}
</p>
) : null}
</form>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void setPurposeMutation.mutateAsync({
purpose: purposeDraft.trim(),
});
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-purpose"
>
Purpose
</label>
<Input
data-testid="channel-management-purpose"
disabled={!canEditNarrative || setPurposeMutation.isPending}
id="channel-purpose"
onChange={(event) => setPurposeDraft(event.target.value)}
value={purposeDraft}
/>
</div>
<Button
data-testid="channel-management-save-purpose"
disabled={!canEditNarrative || setPurposeMutation.isPending}
size="sm"
type="submit"
variant="outline"
>
{setPurposeMutation.isPending ? "Saving..." : "Save purpose"}
</Button>
{setPurposeMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{setPurposeMutation.error.message}
</p>
) : null}
</form>
</div>
</div>
</DialogContent>
</Dialog>
<CreateWorkflowDialog
channels={[channel]}
onOpenChange={setIsCreateWorkflowOpen}
open={isCreateWorkflowOpen}
/>
</Sheet>
</Dialog>
);
}

View File

@@ -0,0 +1,211 @@
import {
BookOpenText,
ChevronRight,
Copy,
FileText,
Hash,
MessageSquare,
type LucideIcon,
} from "lucide-react";
import type * as React from "react";
import { toast } from "sonner";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
function getChannelIcon(channelType: Channel["channelType"]): LucideIcon {
if (channelType === "forum") {
return FileText;
}
if (channelType === "dm") {
return MessageSquare;
}
return Hash;
}
export function ChannelHero({ channel }: { channel: Channel }) {
const Icon = getChannelIcon(channel.channelType);
return (
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-8 w-8 text-foreground" />
</div>
<h3 className="max-w-full truncate text-xl font-semibold tracking-tight">
{channel.name}
</h3>
</div>
);
}
export function ChannelQuickAction({
active,
disabled,
icon: Icon,
label,
onClick,
testId,
}: {
active?: boolean;
disabled?: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
testId?: string;
}) {
return (
<button
className="flex w-20 flex-col items-center gap-2 disabled:cursor-not-allowed disabled:opacity-50"
data-testid={testId}
disabled={disabled}
onClick={onClick}
type="button"
>
<span
className={cn(
"flex h-14 w-14 items-center justify-center rounded-full transition-colors",
active
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted/60 text-foreground hover:bg-muted/80",
)}
>
<Icon className="h-5 w-5" />
</span>
<span className="max-w-full truncate text-sm text-muted-foreground">
{label}
</span>
</button>
);
}
export function FieldGroup({ children }: { children: React.ReactNode }) {
return (
<div className="overflow-hidden rounded-2xl bg-muted/20">{children}</div>
);
}
function FieldIcon({ icon: Icon }: { icon: LucideIcon }) {
return (
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
);
}
export function InfoFieldRow({
icon,
label,
value,
testId,
}: {
icon: LucideIcon;
label: string;
value: string;
testId?: string;
}) {
return (
<div
className="flex w-full items-center gap-3 px-4 py-3"
data-testid={testId}
>
<FieldIcon icon={icon} />
<span className="min-w-0 flex-1 text-left">
<span className="block text-sm font-medium text-foreground">
{label}
</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
{value}
</span>
</span>
</div>
);
}
export function CopyFieldRow({
icon,
label,
value,
testId,
}: {
icon: LucideIcon;
label: string;
value: string;
testId?: string;
}) {
async function handleCopy() {
await navigator.clipboard.writeText(value);
toast.success(`Copied ${label.toLowerCase()}`);
}
return (
<button
aria-label={`Copy ${label}`}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid={testId}
onClick={() => {
void handleCopy();
}}
title={`Copy ${label}`}
type="button"
>
<FieldIcon icon={icon} />
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{label}
</span>
<span className="mt-0.5 block truncate font-mono text-sm text-muted-foreground">
{value}
</span>
</span>
<Copy className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
}
export function NarrativeField({
icon,
label,
value,
testId,
}: {
icon: LucideIcon;
label: string;
value: string;
testId: string;
}) {
return (
<div
className="flex w-full items-start gap-3 px-4 py-3"
data-testid={testId}
>
<FieldIcon icon={icon} />
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{label}
</span>
<span className="mt-1 block whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
{value}
</span>
</span>
</div>
);
}
export function CanvasSummaryRow({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<button
className="flex w-full items-center gap-3 rounded-2xl bg-muted/20 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onClick}
type="button"
>
<FieldIcon icon={BookOpenText} />
<span className="min-w-0 flex-1">{children}</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
}

View File

@@ -6,12 +6,19 @@ const SHOTS = "test-results/channel-controls";
// `general` seeds the mock identity as owner, so the owner/admin-gated
// visibility + ephemeral controls are live and interactive.
async function openManagementSheet(page: import("@playwright/test").Page) {
async function openManagementModal(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-modal")).toBeVisible();
}
async function openManagementEdit(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByTestId("channel-management-edit-dialog"),
).toBeVisible();
}
async function settle(page: import("@playwright/test").Page) {
@@ -25,7 +32,8 @@ test.describe("channel controls screenshots", () => {
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openManagementModal(page);
await openManagementEdit(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
@@ -42,7 +50,8 @@ test.describe("channel controls screenshots", () => {
test("02 — Private toggled on", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openManagementModal(page);
await openManagementEdit(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
@@ -61,7 +70,8 @@ test.describe("channel controls screenshots", () => {
test("03 — Ephemeral on with friendly timeout field", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openManagementModal(page);
await openManagementEdit(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
@@ -82,7 +92,8 @@ test.describe("channel controls screenshots", () => {
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openManagementModal(page);
await openManagementEdit(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
@@ -101,7 +112,7 @@ test.describe("channel controls screenshots", () => {
test("05 — sticky footer pins lifecycle buttons", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openManagementModal(page);
const footer = page.getByTestId("channel-management-footer");
await expect(footer).toBeVisible();
@@ -111,22 +122,23 @@ test.describe("channel controls screenshots", () => {
await footer.screenshot({ path: `${SHOTS}/05-sticky-footer.png` });
});
test("06 — full sheet with new controls", async ({ page }) => {
test("06 — full modal with new controls", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openManagementModal(page);
const sheet = page.getByTestId("channel-management-sheet");
await expect(sheet).toBeVisible();
const modal = page.getByTestId("channel-management-modal");
await expect(modal).toBeVisible();
await settle(page);
await sheet.screenshot({ path: `${SHOTS}/06-management-sheet.png` });
await modal.screenshot({ path: `${SHOTS}/06-management-modal.png` });
});
test("07 — saving lifecycle leaves details save idle", async ({ page }) => {
await installMockBridge(page, { updateChannelDelayMs: 500 });
await openManagementSheet(page);
await openManagementModal(page);
await openManagementEdit(page);
const sheet = page.getByTestId("channel-management-sheet");
const modal = page.getByTestId("channel-management-edit-dialog");
await page.getByTestId("channel-management-ephemeral-toggle").click();
await expect(
page.getByTestId("channel-management-save-lifecycle"),
@@ -139,7 +151,7 @@ test.describe("channel controls screenshots", () => {
await expect(
page.getByTestId("channel-management-save-details"),
).toHaveText("Save details");
await sheet.screenshot({
await modal.screenshot({
path: `${SHOTS}/07-lifecycle-saving-details-idle.png`,
});
@@ -152,7 +164,8 @@ test.describe("channel controls screenshots", () => {
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openManagementModal(page);
await openManagementEdit(page);
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
@@ -163,9 +176,14 @@ test.describe("channel controls screenshots", () => {
await page.keyboard.press("Escape");
await expect(
page.getByTestId("channel-management-sheet"),
page.getByTestId("channel-management-edit-dialog"),
).not.toBeVisible();
await page.keyboard.press("Escape");
await expect(
page.getByTestId("channel-management-modal"),
).not.toBeVisible();
await page.getByTestId("channel-management-trigger").click();
await openManagementEdit(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();

View File

@@ -12,12 +12,27 @@ async function openChannelManagement(
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-modal")).toBeVisible();
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
if (await page.getByTestId("channel-management-edit-dialog").isVisible()) {
await page.keyboard.press("Escape");
await expect(
page.getByTestId("channel-management-edit-dialog"),
).not.toBeVisible();
}
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
await expect(page.getByTestId("channel-management-modal")).not.toBeVisible();
}
async function openChannelManagementEdit(
page: import("@playwright/test").Page,
) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByTestId("channel-management-edit-dialog"),
).toBeVisible();
}
async function openMembersSidebar(
@@ -1055,6 +1070,7 @@ test("manage channel updates details and context", async ({ page }) => {
await page.goto("/");
await openChannelManagement(page, "general");
await openChannelManagementEdit(page);
await page.getByTestId("channel-management-name").fill(newName);
await page.getByTestId("channel-management-description").fill(newDescription);
@@ -1088,7 +1104,8 @@ test("manage channel updates details and context", async ({ page }) => {
await page.getByTestId("stream-list").getByText(newName).click();
await expect(page.getByTestId("chat-title")).toHaveText(newName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-modal")).toBeVisible();
await openChannelManagementEdit(page);
await expect(page.getByTestId("channel-management-name")).toHaveValue(
newName,
@@ -1109,6 +1126,7 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
}) => {
await page.goto("/");
await openChannelManagement(page, "general");
await openChannelManagementEdit(page);
const saveDetailsButton = page.getByTestId("channel-management-save-details");
const saveLifecycleButton = page.getByTestId(
@@ -1152,6 +1170,7 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await closeChannelManagement(page);
await openChannelManagement(page, "general");
await openChannelManagementEdit(page);
await expect(
page.getByTestId("channel-management-private-toggle"),
@@ -1196,6 +1215,7 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await closeChannelManagement(page);
await openChannelManagement(page, "general");
await openChannelManagementEdit(page);
await expect(
page.getByTestId("channel-management-private-toggle"),
@@ -1206,25 +1226,17 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await expect(page.getByTestId("channel-management-ttl")).toHaveCount(0);
});
test("manage channel keeps canvas near the top of the sheet", async ({
test("manage channel includes the canvas section in the modal", async ({
page,
}) => {
await page.goto("/");
await openChannelManagement(page, "general");
const sheet = page.getByTestId("channel-management-sheet");
// Canvas section should appear before the name input in the DOM.
const canvasBox = await sheet
.getByTestId("channel-canvas-section")
.boundingBox();
const nameBox = await sheet
.getByTestId("channel-management-name")
.boundingBox();
expect(canvasBox).not.toBeNull();
expect(nameBox).not.toBeNull();
expect(canvasBox?.y).toBeLessThan(nameBox?.y);
await expect(
page
.getByTestId("channel-management-modal")
.getByTestId("channel-canvas-section"),
).toBeVisible();
});
test("members sidebar can invite and remove members", async ({ page }) => {
@@ -1583,13 +1595,13 @@ test("open channel management supports join and leave", async ({ page }) => {
// Open channel management — should show Leave since we just joined
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-modal")).toBeVisible();
await expect(page.getByTestId("channel-management-join")).toHaveCount(0);
await expect(page.getByTestId("channel-management-leave")).toBeVisible();
// Leave the channel
await page.getByTestId("channel-management-leave").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
await expect(page.getByTestId("channel-management-modal")).not.toBeVisible();
// After leaving, the app navigates away — re-open browser and find design
await page.getByTestId("browse-channels").click();
@@ -1628,7 +1640,7 @@ test("manage channel can archive and unarchive a stream", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-modal")).toBeVisible();
await page.getByTestId("channel-management-unarchive").click();
await expect(page.getByTestId("channel-management-archive")).toBeVisible();
@@ -1650,7 +1662,7 @@ test("manage channel can delete an owned stream", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-modal")).toBeVisible();
await page.getByTestId("channel-management-delete").click();
await expect(
page.getByTestId("channel-delete-confirmation-dialog"),
@@ -1671,7 +1683,7 @@ test("canceling channel deletion keeps the owned stream", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-modal")).toBeVisible();
await page.getByTestId("channel-management-delete").click();
await expect(
page.getByTestId("channel-delete-confirmation-dialog"),

View File

@@ -25,12 +25,27 @@ async function createStream(
async function openChannelManagement(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-modal")).toBeVisible();
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
if (await page.getByTestId("channel-management-edit-dialog").isVisible()) {
await page.keyboard.press("Escape");
await expect(
page.getByTestId("channel-management-edit-dialog"),
).not.toBeVisible();
}
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
await expect(page.getByTestId("channel-management-modal")).not.toBeVisible();
}
async function openChannelManagementEdit(
page: import("@playwright/test").Page,
) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByTestId("channel-management-edit-dialog"),
).toBeVisible();
}
async function assertDesktopNotificationsEnabled(
@@ -439,7 +454,7 @@ test("multiple channels independent", async ({ page }) => {
);
});
test("manage sheet updates channel details and context through the relay", async ({
test("manage modal updates channel details and context through the relay", async ({
page,
}) => {
const stamp = Date.now();
@@ -455,6 +470,7 @@ test("manage sheet updates channel details and context through the relay", async
await createStream(page, initialName, initialDescription);
await openChannelManagement(page);
await openChannelManagementEdit(page);
await page.getByTestId("channel-management-name").fill(renamedChannel);
await page
.getByTestId("channel-management-description")
@@ -493,6 +509,7 @@ test("manage sheet updates channel details and context through the relay", async
);
await openChannelManagement(page);
await openChannelManagementEdit(page);
await expect(page.getByTestId("channel-management-name")).toHaveValue(
renamedChannel,
);
@@ -507,7 +524,7 @@ test("manage sheet updates channel details and context through the relay", async
);
});
test("manage sheet archive and unarchive survives a reload through the relay", async ({
test("manage modal archive and unarchive survives a reload through the relay", async ({
page,
}) => {
const channelName = `archive-integration-${Date.now()}`;

View File

@@ -105,14 +105,14 @@ test.describe("relay connectivity screenshots", () => {
});
});
test("04 — canvas unreachable in management sheet", async ({ page }) => {
test("04 — canvas unreachable in management modal", async ({ page }) => {
await installMockBridge(page, { canvasReadError: RELAY_UNREACHABLE });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-modal")).toBeVisible();
// ChannelCanvas shows the destructive error paragraph when the query fails.
const canvasSection = page.getByTestId("channel-canvas-section");
@@ -121,9 +121,9 @@ test.describe("relay connectivity screenshots", () => {
canvasSection.getByText("Can't reach the relay."),
).toBeVisible();
// Await Radix sheet animations before screenshotting.
const sheet = page.getByTestId("channel-management-sheet");
await sheet.evaluate((el) =>
// Await Radix dialog animations before screenshotting.
const modal = page.getByTestId("channel-management-modal");
await modal.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")
@@ -133,8 +133,8 @@ test.describe("relay connectivity screenshots", () => {
);
await settle(page);
// Capture the whole sheet so the error renders in its Canvas-section context.
await sheet.screenshot({
// Capture the whole modal so the error renders in its Canvas-section context.
await modal.screenshot({
path: `${SHOTS}/04-canvas-unreachable.png`,
});
});