-
+
+
) : (
diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
index cda00f0cf..70c29dda8 100644
--- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
+++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
@@ -1,14 +1,21 @@
import {
Archive,
ArchiveRestore,
+ BookOpenText,
+ ChevronLeft,
Copy,
DoorClosed,
DoorOpen,
FileText,
- Hash,
+ Fingerprint,
+ Eye,
Lock,
MessageSquare,
+ Pencil,
+ Radio,
+ Type,
Users,
+ X,
Zap,
} from "lucide-react";
import * as React from "react";
@@ -16,6 +23,7 @@ import { toast } from "sonner";
import {
useArchiveChannelMutation,
+ useCanvasQuery,
useChannelDetailsQuery,
useChannelMembersQuery,
useDeleteChannelMutation,
@@ -31,7 +39,6 @@ import {
formatTtlDuration,
parseTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
-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";
@@ -47,18 +54,36 @@ import {
AlertDialogTrigger,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import {
Sheet,
+ SheetClose,
SheetContent,
SheetDescription,
- SheetFooter,
SheetHeader,
SheetTitle,
} from "@/shared/ui/sheet";
-import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelCanvas } from "./ChannelCanvas";
+import {
+ ChannelHero,
+ ChannelQuickAction,
+ CopyFieldRow,
+ FieldGroup,
+ getMarkdownPreviewText,
+ InfoFieldRow,
+ IngressRow,
+ NarrativeField,
+ NarrativeGroup,
+ ToggleRow,
+} from "./ChannelManagementSheetRows";
type ChannelManagementSheetProps = {
channel: Channel | null;
@@ -70,50 +95,6 @@ type ChannelManagementSheetProps = {
const DEFAULT_EPHEMERAL_TTL_SECONDS = 24 * 60 * 60;
-function MetadataPill({
- icon: Icon,
- label,
-}: {
- icon: React.ComponentType<{ className?: string }>;
- label: string;
-}) {
- return (
-
-
- {label}
-
- );
-}
-
-function ChannelIdRow({ channelId }: { channelId: string }) {
- async function handleCopyChannelId() {
- await navigator.clipboard.writeText(channelId);
- toast.success("Copied channel ID to clipboard");
- }
-
- return (
-
{
- void handleCopyChannelId();
- }}
- title="Copy channel ID"
- type="button"
- >
-
-
- Channel ID
-
-
- {channelId}
-
-
-
-
- );
-}
-
export function ChannelManagementSheet({
channel,
currentPubkey,
@@ -125,8 +106,8 @@ export function ChannelManagementSheet({
const channelId = channel?.id ?? null;
const detailsQuery = useChannelDetailsQuery(channelId, open);
const membersQuery = useChannelMembersQuery(channelId, open);
+ const canvasQuery = useCanvasQuery(channelId, channelId !== null && open);
const updateChannelDetailsMutation = useUpdateChannelMutation(channelId);
- const updateChannelLifecycleMutation = useUpdateChannelMutation(channelId);
const setTopicMutation = useSetChannelTopicMutation(channelId);
const setPurposeMutation = useSetChannelPurposeMutation(channelId);
const archiveChannelMutation = useArchiveChannelMutation(channelId);
@@ -148,7 +129,8 @@ export function ChannelManagementSheet({
const isOwner = selfMember?.role === "owner";
const canManageChannel =
selfMember?.role === "owner" || selfMember?.role === "admin";
- const canEditNarrative = selfMember !== null && detail?.channelType !== "dm";
+ const canEditNarrative =
+ canManageChannel && selfMember !== null && detail?.channelType !== "dm";
const isArchived =
detail?.archivedAt !== null && detail?.archivedAt !== undefined;
const canJoin =
@@ -173,7 +155,10 @@ export function ChannelManagementSheet({
const [isEphemeralDraft, setIsEphemeralDraft] = React.useState(false);
const [ttlDraft, setTtlDraft] = React.useState("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
- const [isCreateWorkflowOpen, setIsCreateWorkflowOpen] = React.useState(false);
+ const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false);
+ const [activeView, setActiveView] = React.useState<"summary" | "canvas">(
+ "summary",
+ );
// Sync drafts from server only when the sheet opens or the channel changes —
// not on every background refetch, which would clobber in-flight edits.
@@ -183,7 +168,8 @@ export function ChannelManagementSheet({
// Reset on close so the next open re-syncs from server.
syncedForRef.current = null;
setIsDeleteDialogOpen(false);
- setIsCreateWorkflowOpen(false);
+ setIsEditDialogOpen(false);
+ setActiveView("summary");
return;
}
if (!detail) {
@@ -205,6 +191,7 @@ export function ChannelManagementSheet({
setTtlDraft(
detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "",
);
+ setActiveView("summary");
}, [detail, open]);
if (!channel) {
@@ -253,22 +240,69 @@ export function ChannelManagementSheet({
nextVisibility !== currentVisibility ||
nextTtlSeconds !== currentTtlSeconds;
- function handleSaveLifecycle() {
- void updateChannelLifecycleMutation.mutateAsync({
- visibility:
- nextVisibility !== currentVisibility ? nextVisibility : undefined,
- ttlSeconds:
- nextTtlSeconds !== currentTtlSeconds ? nextTtlSeconds : undefined,
- });
- }
-
const resolvedChannel = detail ?? channel;
+ const nameDirty = nameDraft.trim() !== resolvedChannel.name.trim();
+ const descriptionDirty =
+ descriptionDraft.trim() !== resolvedChannel.description.trim();
+ const topicDirty = topicDraft.trim() !== (resolvedChannel.topic ?? "").trim();
+ const purposeDirty =
+ purposeDraft.trim() !== (resolvedChannel.purpose ?? "").trim();
+ const isSavingChannelEdits =
+ updateChannelDetailsMutation.isPending ||
+ setTopicMutation.isPending ||
+ setPurposeMutation.isPending;
+ const hasChannelEditChanges =
+ nameDirty ||
+ descriptionDirty ||
+ lifecycleDirty ||
+ topicDirty ||
+ purposeDirty;
+ const canSaveChannelEdits =
+ nameDraft.trim().length > 0 &&
+ !ttlInvalid &&
+ hasChannelEditChanges &&
+ !isSavingChannelEdits;
+ const canvasContent = canvasQuery.data?.content?.trim() ?? "";
+ const hasCanvas = canvasContent.length > 0;
+ const canvasPreview = hasCanvas
+ ? getMarkdownPreviewText(canvasContent)
+ : undefined;
+ const canOpenCanvas = hasCanvas || canEditNarrative;
+
+ async function handleSaveChannelEdits() {
+ try {
+ if (nameDirty || descriptionDirty || lifecycleDirty) {
+ await updateChannelDetailsMutation.mutateAsync({
+ description: descriptionDirty ? descriptionDraft.trim() : undefined,
+ name: nameDirty ? nameDraft.trim() : undefined,
+ ttlSeconds:
+ nextTtlSeconds !== currentTtlSeconds ? nextTtlSeconds : undefined,
+ visibility:
+ lifecycleDirty && nextVisibility !== currentVisibility
+ ? nextVisibility
+ : undefined,
+ });
+ }
+
+ if (topicDirty) {
+ await setTopicMutation.mutateAsync({ topic: topicDraft.trim() });
+ }
+
+ if (purposeDirty) {
+ await setPurposeMutation.mutateAsync({ purpose: purposeDraft.trim() });
+ }
+
+ setIsEditDialogOpen(false);
+ } catch {
+ // React Query stores mutation errors; keep the dialog open and render them.
+ }
+ }
return (
button]:hidden",
isDark
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
: "bg-background",
@@ -278,473 +312,537 @@ export function ChannelManagementSheet({
>
- {channel.name}
+
+ {activeView === "canvas" ? (
+ setActiveView("summary")}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+ ) : null}
+
+ {activeView === "canvas" ? "Canvas" : "Channel"}
+
+
+
+
+
+
+
Channel settings
-
-
-
-
- {isArchived ? (
-
- ) : null}
-
-
-
- {detailsQuery.error instanceof Error ? (
-
- {detailsQuery.error.message}
-
- ) : null}
+
+ {activeView === "summary" ? (
+
+
- {membersQuery.error instanceof Error ? (
-
- {membersQuery.error.message}
-
- ) : null}
-
- {canJoin ? (
-
-
{
- void joinChannelMutation.mutateAsync();
- }}
- size="sm"
- type="button"
- >
-
- {joinChannelMutation.isPending ? "Joining..." : "Join channel"}
-
- {joinChannelMutation.error instanceof Error ? (
-
- {joinChannelMutation.error.message}
+ {detailsQuery.error instanceof Error ? (
+
+ {detailsQuery.error.message}
) : null}
-
- ) : null}
-
-
-
-
-
-
- {resolvedChannel.channelType !== "dm" ? (
-
-
-
-
Private
-
- Only members can find and join this channel.
-
-
-
-
-
-
-
-
Ephemeral
-
- Automatically delete this channel after a set time.
-
-
-
-
-
- {isEphemeralDraft ? (
-
-
- Timeout
-
-
setTtlDraft(event.target.value)}
- placeholder="e.g. 1d, 12h, 30m"
- value={ttlDraft}
- />
-
- {ttlInvalid
- ? "Enter a duration like 1d, 12h, or 30m."
- : "Defaults to 1d when left empty. Resets the deletion countdown from now whenever changed."}
-
-
+ {membersQuery.error instanceof Error ? (
+
+ {membersQuery.error.message}
+
) : null}
-
- {updateChannelLifecycleMutation.isPending
- ? "Saving..."
- : "Save visibility"}
-
-
- ) : null}
-
-
{
- event.preventDefault();
- void setTopicMutation.mutateAsync({
- topic: topicDraft.trim(),
- });
- }}
- >
-
-
- Topic
-
- setTopicDraft(event.target.value)}
- value={topicDraft}
- />
-
-
- {setTopicMutation.isPending ? "Saving..." : "Save topic"}
-
- {setTopicMutation.error instanceof Error ? (
-
- {setTopicMutation.error.message}
-
- ) : null}
-
-
-
{
- event.preventDefault();
- void setPurposeMutation.mutateAsync({
- purpose: purposeDraft.trim(),
- });
- }}
- >
-
-
- Purpose
-
- setPurposeDraft(event.target.value)}
- value={purposeDraft}
- />
-
-
- {setPurposeMutation.isPending ? "Saving..." : "Save purpose"}
-
- {setPurposeMutation.error instanceof Error ? (
-
- {setPurposeMutation.error.message}
-
- ) : null}
-
-
- {canEditNarrative ? (
-
setIsCreateWorkflowOpen(true)}
- size="sm"
- type="button"
- variant="outline"
- >
-
- Create workflow
-
- ) : null}
-
-
- {resolvedChannel.channelType !== "dm" ? (
-
-
-
+
+
{
+ void navigator.clipboard
+ .writeText(resolvedChannel.id)
+ .then(() => toast.success("Copied channel ID"));
+ }}
+ testId="channel-management-copy-id-action"
+ />
+ {canJoin ? (
+ {
+ void joinChannelMutation.mutateAsync();
+ }}
+ testId="channel-management-join"
+ />
+ ) : null}
{canLeave ? (
- {
void leaveChannelMutation.mutateAsync().then(() => {
onOpenChange(false);
});
}}
- size="sm"
- type="button"
- variant="outline"
- >
-
- {leaveChannelMutation.isPending ? "Leaving..." : "Leave"}
-
+ testId="channel-management-leave"
+ />
) : null}
- {isArchived ? (
- {
- void unarchiveChannelMutation.mutateAsync();
- }}
- size="sm"
- type="button"
- >
-
- {unarchiveChannelMutation.isPending
- ? "Restoring..."
- : "Unarchive"}
-
- ) : (
- {
- void archiveChannelMutation.mutateAsync();
- }}
- size="sm"
- type="button"
- variant="outline"
- >
-
- {archiveChannelMutation.isPending
- ? "Archiving..."
- : "Archive"}
-
- )}
-
- {isOwner ? (
-
-
-
- Delete
-
-
-
-
- Delete channel?
-
- Delete {resolvedChannel.name} from the workspace list.
- This action cannot be undone.
-
-
- {deleteChannelMutation.error instanceof Error ? (
-
- {deleteChannelMutation.error.message}
-
- ) : null}
-
-
-
- Cancel
-
-
-
- {
- event.preventDefault();
- void handleDeleteChannel();
- }}
- type="button"
- variant="destructive"
- >
- {deleteChannelMutation.isPending
- ? "Deleting..."
- : "Delete channel"}
-
-
-
-
-
+ {canManageChannel ? (
+ setIsEditDialogOpen(true)}
+ testId="channel-management-edit"
+ />
) : null}
+
+ {joinChannelMutation.error instanceof Error ? (
+
+ {joinChannelMutation.error.message}
+
+ ) : null}
{leaveChannelMutation.error instanceof Error ? (
-
+
{leaveChannelMutation.error.message}
) : null}
- {archiveChannelMutation.error instanceof Error ? (
-
- {archiveChannelMutation.error.message}
-
+
+ {resolvedChannel.description.trim() ||
+ resolvedChannel.topic?.trim() ||
+ resolvedChannel.purpose?.trim() ? (
+
+ {resolvedChannel.description.trim() ? (
+
+ ) : null}
+ {resolvedChannel.topic?.trim() ? (
+
+ ) : null}
+ {resolvedChannel.purpose?.trim() ? (
+
+ ) : null}
+
) : null}
- {unarchiveChannelMutation.error instanceof Error ? (
-
- {unarchiveChannelMutation.error.message}
-
+
+ {canOpenCanvas ? (
+
setActiveView("canvas")}
+ testId="channel-canvas-ingress"
+ trailing={canvasQuery.isLoading ? "Loading..." : undefined}
+ />
+ ) : null}
+
+
+
+
+
+
+
+ {isArchived ? (
+
+ ) : null}
+ {resolvedChannel.ttlSeconds !== null ? (
+
+ ) : null}
+
+
+ {canManageChannel && resolvedChannel.channelType !== "dm" ? (
+
+
+ {isArchived ? (
+
{
+ void unarchiveChannelMutation.mutateAsync();
+ }}
+ size="sm"
+ type="button"
+ >
+
+ {unarchiveChannelMutation.isPending
+ ? "Restoring..."
+ : "Unarchive"}
+
+ ) : (
+
{
+ void archiveChannelMutation.mutateAsync();
+ }}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+
+ {archiveChannelMutation.isPending
+ ? "Archiving..."
+ : "Archive"}
+
+ )}
+ {isOwner ? (
+
+
+
+ Delete
+
+
+
+
+ Delete channel?
+
+ Delete {resolvedChannel.name} from the workspace
+ list. This action cannot be undone.
+
+
+ {deleteChannelMutation.error instanceof Error ? (
+
+ {deleteChannelMutation.error.message}
+
+ ) : null}
+
+
+
+ Cancel
+
+
+
+ {
+ event.preventDefault();
+ void handleDeleteChannel();
+ }}
+ type="button"
+ variant="destructive"
+ >
+ {deleteChannelMutation.isPending
+ ? "Deleting..."
+ : "Delete channel"}
+
+
+
+
+
+ ) : null}
+
+ {archiveChannelMutation.error instanceof Error ? (
+
+ {archiveChannelMutation.error.message}
+
+ ) : null}
+ {unarchiveChannelMutation.error instanceof Error ? (
+
+ {unarchiveChannelMutation.error.message}
+
+ ) : null}
+
) : null}
-
- ) : null}
+ ) : (
+
+
+
+ )}
+
-
+ {canManageChannel ? (
+
+
+
+
+ Edit channel
+
+ Update settings for{" "}
+ {resolvedChannel.name} .
+
+
+
+
+
+
+
+ Name
+
+ setNameDraft(event.target.value)}
+ value={nameDraft}
+ />
+
+
+
+ Description
+
+
+ setDescriptionDraft(event.target.value)
+ }
+ value={descriptionDraft}
+ />
+
+
+
+ {resolvedChannel.channelType !== "dm" ? (
+
+
+
+
+
+
+ {isEphemeralDraft ? (
+
+
+ Timeout
+
+
setTtlDraft(event.target.value)}
+ placeholder="e.g. 1d, 12h, 30m"
+ value={ttlDraft}
+ />
+
+ {ttlInvalid
+ ? "Enter a duration like 1d, 12h, or 30m."
+ : "Defaults to 1d when left empty. Resets the deletion countdown from now whenever changed."}
+
+
+ ) : null}
+
+ ) : null}
+
+ {canEditNarrative ? (
+
+
+
+
+ Topic
+
+
+ setTopicDraft(event.target.value)
+ }
+ value={topicDraft}
+ />
+
+
+
+
+
+
+ Purpose
+
+
+ setPurposeDraft(event.target.value)
+ }
+ value={purposeDraft}
+ />
+
+
+
+ ) : null}
+
+ {updateChannelDetailsMutation.error instanceof Error ? (
+
+ {updateChannelDetailsMutation.error.message}
+
+ ) : null}
+ {setTopicMutation.error instanceof Error ? (
+
+ {setTopicMutation.error.message}
+
+ ) : null}
+ {setPurposeMutation.error instanceof Error ? (
+
+ {setPurposeMutation.error.message}
+
+ ) : null}
+
+
+
+ setIsEditDialogOpen(false)}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ Cancel
+
+ void handleSaveChannelEdits()}
+ size="sm"
+ type="button"
+ >
+ {isSavingChannelEdits ? "Saving..." : "Save changes"}
+
+
+
+
+
+ ) : null}
);
}
diff --git a/desktop/src/features/channels/ui/ChannelManagementSheetRows.tsx b/desktop/src/features/channels/ui/ChannelManagementSheetRows.tsx
new file mode 100644
index 000000000..825bad974
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelManagementSheetRows.tsx
@@ -0,0 +1,304 @@
+import {
+ 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";
+import { Switch } from "@/shared/ui/switch";
+
+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 (
+
+
+
+
+
+
+ {channel.name}
+
+
+
+ );
+}
+
+export function ChannelQuickAction({
+ active,
+ disabled,
+ icon: Icon,
+ label,
+ onClick,
+ testId,
+}: {
+ active?: boolean;
+ disabled?: boolean;
+ icon: LucideIcon;
+ label: string;
+ onClick: () => void;
+ testId?: string;
+}) {
+ return (
+
+
+
+
+
+ {label}
+
+
+ );
+}
+
+export function FieldGroup({ children }: { children: React.ReactNode }) {
+ return (
+ {children}
+ );
+}
+
+export function getMarkdownPreviewText(content: string) {
+ return content
+ .split("\n")
+ .map((line) =>
+ line
+ .trim()
+ .replace(/^#{1,6}\s+/, "")
+ .replace(/^>\s?/, "")
+ .replace(/^[-*+]\s+\[[ xX]\]\s+/, "")
+ .replace(/^[-*+]\s+/, "")
+ .replace(/^\d+\.\s+/, "")
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/(\*\*|__)(.*?)\1/g, "$2")
+ .replace(/(\*|_)(.*?)\1/g, "$2")
+ .replace(/~~(.*?)~~/g, "$1")
+ .trim(),
+ )
+ .filter(Boolean)
+ .join(" ");
+}
+
+export function CopyFieldRow({
+ icon: 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 (
+ {
+ void handleCopy();
+ }}
+ title={`Copy ${label}`}
+ type="button"
+ >
+
+
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+ );
+}
+
+export function InfoFieldRow({
+ icon: Icon,
+ label,
+ value,
+ testId,
+}: {
+ icon: LucideIcon;
+ label: string;
+ value: string;
+ testId?: string;
+}) {
+ return (
+
+
+
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+ );
+}
+
+export function NarrativeGroup({ children }: { children: React.ReactNode }) {
+ return (
+ {children}
+ );
+}
+
+export function NarrativeField({
+ icon: Icon,
+ label,
+ value,
+ testId,
+}: {
+ icon: LucideIcon;
+ label: string;
+ value: string;
+ testId: string;
+}) {
+ return (
+
+
+
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+ );
+}
+
+export function IngressRow({
+ description,
+ icon: Icon,
+ label,
+ onClick,
+ testId,
+ trailing,
+}: {
+ description?: string;
+ icon: LucideIcon;
+ label: string;
+ onClick: () => void;
+ testId: string;
+ trailing?: string;
+}) {
+ return (
+
+
+
+
+
+
+ {label}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {trailing ? (
+ {trailing}
+ ) : null}
+
+
+ );
+}
+
+export function ToggleRow({
+ checked,
+ description,
+ disabled,
+ label,
+ onCheckedChange,
+ testId,
+}: {
+ checked: boolean;
+ description: string;
+ disabled?: boolean;
+ label: string;
+ onCheckedChange: (checked: boolean) => void;
+ testId: string;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {description}
+
+
+
+
+ );
+}