Align channel management side panel behavior

This commit is contained in:
Thomas Petersen
2026-06-16 19:48:32 -04:00
parent 3473efb0df
commit 7dc6b7d600
9 changed files with 946 additions and 514 deletions

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useAppShell } from "@/app/AppShellContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { HomeScreen } from "@/features/home/ui/HomeScreen";
@@ -17,7 +16,6 @@ export const Route = createFileRoute("/")({
function HomeRouteComponent() {
const { goChannel } = useAppNavigation();
const { openChannelManagement } = useAppShell();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const channels = channelsQuery.data ?? [];
@@ -67,7 +65,6 @@ function HomeRouteComponent() {
<HomeScreen
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
onOpenChannel={openChannelManagement}
/>
);
}

View File

@@ -0,0 +1,171 @@
import { Archive, ArchiveRestore, Trash2 } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
type ChannelMutation<TArgs = void> = {
error: unknown;
isPending: boolean;
mutateAsync: (args: TArgs) => Promise<unknown>;
};
type ChannelManagementModerationActionsProps = {
archiveChannelMutation: ChannelMutation;
canManageChannel: boolean;
deleteChannelMutation: ChannelMutation;
handleDeleteChannel: () => Promise<void>;
handleDeleteDialogOpenChange: (open: boolean) => void;
isArchived: boolean;
isDark: boolean;
isDeleteDialogOpen: boolean;
isOwner: boolean;
resolvedChannelName: string;
unarchiveChannelMutation: ChannelMutation;
};
export function ChannelManagementModerationActions({
archiveChannelMutation,
canManageChannel,
deleteChannelMutation,
handleDeleteChannel,
handleDeleteDialogOpenChange,
isArchived,
isDark,
isDeleteDialogOpen,
isOwner,
resolvedChannelName,
unarchiveChannelMutation,
}: ChannelManagementModerationActionsProps) {
return (
<div
className={cn(
"absolute bottom-3 right-3 z-20 flex items-center gap-2 rounded-full border border-border/60 p-1 shadow-sm",
isDark
? "bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70"
: "bg-background/90 backdrop-blur-md supports-[backdrop-filter]:bg-background/80",
)}
data-testid="channel-management-footer"
>
{isArchived ? (
<Button
aria-label={
unarchiveChannelMutation.isPending
? "Restoring channel"
: "Unarchive channel"
}
data-testid="channel-management-unarchive"
disabled={!canManageChannel || unarchiveChannelMutation.isPending}
onClick={() => {
void unarchiveChannelMutation.mutateAsync();
}}
size="icon"
title={
unarchiveChannelMutation.isPending
? "Restoring channel"
: "Unarchive channel"
}
type="button"
variant="ghost"
>
<ArchiveRestore className="h-4 w-4" />
</Button>
) : (
<Button
aria-label={
archiveChannelMutation.isPending
? "Archiving channel"
: "Archive channel"
}
data-testid="channel-management-archive"
disabled={!canManageChannel || archiveChannelMutation.isPending}
onClick={() => {
void archiveChannelMutation.mutateAsync();
}}
size="icon"
title={
archiveChannelMutation.isPending
? "Archiving channel"
: "Archive channel"
}
type="button"
variant="ghost"
>
<Archive className="h-4 w-4" />
</Button>
)}
{isOwner ? (
<AlertDialog
onOpenChange={handleDeleteDialogOpenChange}
open={isDeleteDialogOpen}
>
<AlertDialogTrigger asChild>
<Button
aria-label="Delete channel"
data-testid="channel-management-delete"
disabled={deleteChannelMutation.isPending}
size="icon"
title="Delete channel"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent data-testid="channel-delete-confirmation-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Delete channel?</AlertDialogTitle>
<AlertDialogDescription>
Delete {resolvedChannelName} from the workspace list. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{deleteChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{deleteChannelMutation.error.message}
</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button
data-testid="channel-delete-cancel"
disabled={deleteChannelMutation.isPending}
type="button"
variant="outline"
>
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid="channel-delete-confirm"
disabled={deleteChannelMutation.isPending}
onClick={(event) => {
event.preventDefault();
void handleDeleteChannel();
}}
type="button"
variant="destructive"
>
{deleteChannelMutation.isPending
? "Deleting..."
: "Delete channel"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
);
}

View File

@@ -1,6 +1,5 @@
import {
Archive,
ArchiveRestore,
BookOpenText,
ChevronLeft,
Copy,
@@ -19,6 +18,7 @@ import {
Zap,
} from "lucide-react";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { toast } from "sonner";
import {
@@ -42,17 +42,6 @@ import {
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -62,15 +51,18 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/shared/ui/sheet";
import { Textarea } from "@/shared/ui/textarea";
import {
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
auxiliaryPanelContentPaddingClass,
} from "@/shared/layout/AuxiliaryPanelHeader";
import {
OverlayPanelBackdrop,
PANEL_BASE_CLASS,
PANEL_OVERLAY_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { ChannelCanvas } from "./ChannelCanvas";
import {
ChannelHero,
@@ -84,10 +76,12 @@ import {
NarrativeGroup,
ToggleRow,
} from "./ChannelManagementSheetRows";
import { ChannelManagementModerationActions } from "./ChannelManagementModerationActions";
type ChannelManagementSheetProps = {
channel: Channel | null;
currentPubkey?: string;
layout?: "overlay" | "split";
onDeleted?: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
@@ -98,11 +92,13 @@ const DEFAULT_EPHEMERAL_TTL_SECONDS = 24 * 60 * 60;
export function ChannelManagementSheet({
channel,
currentPubkey,
layout = "overlay",
onDeleted,
onOpenChange,
open,
}: ChannelManagementSheetProps) {
const { isDark } = useTheme();
const isSplitLayout = layout === "split";
const channelId = channel?.id ?? null;
const detailsQuery = useChannelDetailsQuery(channelId, open);
const membersQuery = useChannelMembersQuery(channelId, open);
@@ -214,7 +210,7 @@ export function ChannelManagementSheet({
}
}
function handleSheetOpenChange(next: boolean) {
function handlePanelOpenChange(next: boolean) {
if (!next) {
handleDeleteDialogOpenChange(false);
}
@@ -299,358 +295,111 @@ export function ChannelManagementSheet({
}
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-[380px] [&>button]:hidden",
isDark
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
: "bg-background",
)}
data-testid="channel-management-sheet"
side="right"
>
<SheetHeader
<DialogPrimitive.Root
modal={!isSplitLayout}
onOpenChange={handlePanelOpenChange}
open={open}
>
{!isSplitLayout ? (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay asChild>
<OverlayPanelBackdrop
onClose={() => handlePanelOpenChange(false)}
/>
</DialogPrimitive.Overlay>
</DialogPrimitive.Portal>
) : null}
{isSplitLayout ? (
<DialogPrimitive.Content
className={cn(
"relative z-10 min-h-11 flex-row items-center gap-3 space-y-0 border-b border-border/35 px-3 py-1.5 text-left shadow-none",
PANEL_BASE_CLASS,
"h-full w-full cursor-default overflow-hidden border-l-0 p-0",
isDark
? "bg-background/70 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
: "bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/70",
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
: "bg-background",
)}
data-testid="channel-management-sheet"
onEscapeKeyDown={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{activeView === "canvas" ? (
<Button
aria-label="Back to channel"
data-testid="channel-management-back"
onClick={() => setActiveView("summary")}
size="icon"
type="button"
variant="ghost"
>
<ChevronLeft />
</Button>
) : null}
<SheetTitle className="min-w-0 flex-1 translate-y-px truncate text-base font-semibold leading-6 tracking-tight">
{activeView === "canvas" ? "Canvas" : "Channel"}
</SheetTitle>
</div>
<SheetClose asChild>
<Button
aria-label="Close channel management"
data-testid="channel-management-close"
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
</SheetClose>
<SheetDescription className="sr-only">
Channel settings
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto bg-background px-4 pb-8 pt-4">
{activeView === "summary" ? (
<div className="space-y-6">
<ChannelHero channel={resolvedChannel} />
{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}
</p>
) : null}
{membersQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{membersQuery.error.message}
</p>
) : null}
<div className="flex flex-wrap items-start justify-center gap-6">
<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"
/>
{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}
{canManageChannel ? (
<ChannelQuickAction
icon={Pencil}
label="Edit"
onClick={() => setIsEditDialogOpen(true)}
testId="channel-management-edit"
/>
) : null}
</div>
{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() ? (
<NarrativeGroup>
{resolvedChannel.description.trim() ? (
<NarrativeField
icon={FileText}
label="Description"
testId="channel-management-description"
value={resolvedChannel.description.trim()}
/>
) : null}
{resolvedChannel.topic?.trim() ? (
<NarrativeField
icon={MessageSquare}
label="Topic"
testId="channel-management-topic"
value={resolvedChannel.topic.trim()}
/>
) : null}
{resolvedChannel.purpose?.trim() ? (
<NarrativeField
icon={Zap}
label="Purpose"
testId="channel-management-purpose"
value={resolvedChannel.purpose.trim()}
/>
) : null}
</NarrativeGroup>
) : null}
{canOpenCanvas ? (
<IngressRow
description={canvasPreview}
icon={BookOpenText}
label="Canvas"
onClick={() => setActiveView("canvas")}
testId="channel-canvas-ingress"
trailing={canvasQuery.isLoading ? "Loading..." : undefined}
/>
) : null}
<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"
/>
) : null}
{resolvedChannel.ttlSeconds !== null ? (
<InfoFieldRow
icon={Archive}
label="Ephemeral"
testId="channel-management-ephemeral-row"
value={formatTtlDuration(resolvedChannel.ttlSeconds)}
/>
) : null}
</FieldGroup>
{canManageChannel && resolvedChannel.channelType !== "dm" ? (
<div className="space-y-2">
<div
className="grid grid-cols-2 gap-2"
data-testid="channel-management-footer"
>
{isArchived ? (
<Button
className="w-full justify-center"
data-testid="channel-management-unarchive"
disabled={
!canManageChannel ||
unarchiveChannelMutation.isPending
}
onClick={() => {
void unarchiveChannelMutation.mutateAsync();
}}
size="sm"
type="button"
>
<ArchiveRestore className="h-4 w-4" />
{unarchiveChannelMutation.isPending
? "Restoring..."
: "Unarchive"}
</Button>
) : (
<Button
className="w-full justify-center"
data-testid="channel-management-archive"
disabled={
!canManageChannel || archiveChannelMutation.isPending
}
onClick={() => {
void archiveChannelMutation.mutateAsync();
}}
size="sm"
type="button"
variant="outline"
>
<Archive className="h-4 w-4" />
{archiveChannelMutation.isPending
? "Archiving..."
: "Archive"}
</Button>
)}
{isOwner ? (
<AlertDialog
onOpenChange={handleDeleteDialogOpenChange}
open={isDeleteDialogOpen}
>
<AlertDialogTrigger asChild>
<Button
className="w-full justify-center"
data-testid="channel-management-delete"
disabled={deleteChannelMutation.isPending}
size="sm"
type="button"
variant="destructive"
>
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent data-testid="channel-delete-confirmation-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Delete channel?</AlertDialogTitle>
<AlertDialogDescription>
Delete {resolvedChannel.name} from the workspace
list. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{deleteChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{deleteChannelMutation.error.message}
</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button
data-testid="channel-delete-cancel"
disabled={deleteChannelMutation.isPending}
type="button"
variant="outline"
>
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid="channel-delete-confirm"
disabled={deleteChannelMutation.isPending}
onClick={(event) => {
event.preventDefault();
void handleDeleteChannel();
}}
type="button"
variant="destructive"
>
{deleteChannelMutation.isPending
? "Deleting..."
: "Delete channel"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
{archiveChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{archiveChannelMutation.error.message}
</p>
) : null}
{unarchiveChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{unarchiveChannelMutation.error.message}
</p>
) : null}
</div>
) : null}
</div>
) : (
<div data-testid="channel-canvas-section">
<ChannelCanvas
canEdit={canEditNarrative}
channelId={channelId}
isArchived={isArchived}
/>
</div>
)}
</div>
</SheetContent>
<ChannelManagementPanelContent
activeView={activeView}
archiveChannelMutation={archiveChannelMutation}
canEditNarrative={canEditNarrative}
canJoin={canJoin}
canLeave={canLeave}
canManageChannel={canManageChannel}
canOpenCanvas={canOpenCanvas}
canvasPreview={canvasPreview}
canvasQuery={canvasQuery}
channelId={channelId}
deleteChannelMutation={deleteChannelMutation}
detailsError={detailsQuery.error}
handleDeleteChannel={handleDeleteChannel}
handleDeleteDialogOpenChange={handleDeleteDialogOpenChange}
isArchived={isArchived}
isDark={isDark}
isDeleteDialogOpen={isDeleteDialogOpen}
isOwner={isOwner}
isSplitLayout={isSplitLayout}
joinChannelMutation={joinChannelMutation}
leaveChannelMutation={leaveChannelMutation}
memberCount={memberCount}
membersError={membersQuery.error}
onOpenChange={onOpenChange}
resolvedChannel={resolvedChannel}
setActiveView={setActiveView}
setIsEditDialogOpen={setIsEditDialogOpen}
unarchiveChannelMutation={unarchiveChannelMutation}
/>
</DialogPrimitive.Content>
) : (
<DialogPrimitive.Portal>
<DialogPrimitive.Content
className={cn(
PANEL_BASE_CLASS,
PANEL_OVERLAY_CLASS,
"w-[380px] cursor-default overflow-hidden p-0 transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
isDark
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
: "bg-background",
)}
data-testid="channel-management-sheet"
>
<ChannelManagementPanelContent
activeView={activeView}
archiveChannelMutation={archiveChannelMutation}
canEditNarrative={canEditNarrative}
canJoin={canJoin}
canLeave={canLeave}
canManageChannel={canManageChannel}
canOpenCanvas={canOpenCanvas}
canvasPreview={canvasPreview}
canvasQuery={canvasQuery}
channelId={channelId}
deleteChannelMutation={deleteChannelMutation}
detailsError={detailsQuery.error}
handleDeleteChannel={handleDeleteChannel}
handleDeleteDialogOpenChange={handleDeleteDialogOpenChange}
isArchived={isArchived}
isDark={isDark}
isDeleteDialogOpen={isDeleteDialogOpen}
isOwner={isOwner}
isSplitLayout={isSplitLayout}
joinChannelMutation={joinChannelMutation}
leaveChannelMutation={leaveChannelMutation}
memberCount={memberCount}
membersError={membersQuery.error}
onOpenChange={onOpenChange}
resolvedChannel={resolvedChannel}
setActiveView={setActiveView}
setIsEditDialogOpen={setIsEditDialogOpen}
unarchiveChannelMutation={unarchiveChannelMutation}
/>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)}
{canManageChannel ? (
<Dialog onOpenChange={setIsEditDialogOpen} open={isEditDialogOpen}>
@@ -843,6 +592,373 @@ export function ChannelManagementSheet({
</DialogContent>
</Dialog>
) : null}
</Sheet>
</DialogPrimitive.Root>
);
}
type ChannelMutation<TArgs = void> = {
error: unknown;
isPending: boolean;
mutateAsync: (args: TArgs) => Promise<unknown>;
};
type ChannelManagementPanelContentProps = {
activeView: "summary" | "canvas";
archiveChannelMutation: ChannelMutation;
canEditNarrative: boolean;
canJoin: boolean;
canLeave: boolean;
canManageChannel: boolean;
canOpenCanvas: boolean;
canvasPreview?: string;
canvasQuery: { isLoading: boolean };
channelId: string | null;
deleteChannelMutation: ChannelMutation;
detailsError: unknown;
handleDeleteChannel: () => Promise<void>;
handleDeleteDialogOpenChange: (open: boolean) => void;
isArchived: boolean;
isDark: boolean;
isDeleteDialogOpen: boolean;
isOwner: boolean;
isSplitLayout: boolean;
joinChannelMutation: ChannelMutation;
leaveChannelMutation: ChannelMutation;
memberCount: number;
membersError: unknown;
onOpenChange: (open: boolean) => void;
resolvedChannel: Channel;
setActiveView: React.Dispatch<React.SetStateAction<"summary" | "canvas">>;
setIsEditDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
unarchiveChannelMutation: ChannelMutation;
};
function ChannelManagementPanelContent({
activeView,
archiveChannelMutation,
canEditNarrative,
canJoin,
canLeave,
canManageChannel,
canOpenCanvas,
canvasPreview,
canvasQuery,
channelId,
deleteChannelMutation,
detailsError,
handleDeleteChannel,
handleDeleteDialogOpenChange,
isArchived,
isDark,
isDeleteDialogOpen,
isOwner,
isSplitLayout,
joinChannelMutation,
leaveChannelMutation,
memberCount,
membersError,
onOpenChange,
resolvedChannel,
setActiveView,
setIsEditDialogOpen,
unarchiveChannelMutation,
}: ChannelManagementPanelContentProps) {
const showModerationActions =
activeView === "summary" &&
canManageChannel &&
resolvedChannel.channelType !== "dm";
return (
<>
{isSplitLayout ? (
<AuxiliaryPanelHeader>
<AuxiliaryPanelHeaderGroup>
{activeView === "canvas" ? (
<Button
aria-label="Back to channel"
data-testid="channel-management-back"
onClick={() => setActiveView("summary")}
size="icon"
type="button"
variant="ghost"
>
<ChevronLeft />
</Button>
) : null}
<DialogPrimitive.Title asChild>
<AuxiliaryPanelTitle>
{activeView === "canvas" ? "Canvas" : "Channel"}
</AuxiliaryPanelTitle>
</DialogPrimitive.Title>
</AuxiliaryPanelHeaderGroup>
<DialogPrimitive.Close asChild>
<Button
aria-label="Close channel management"
data-testid="channel-management-close"
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
</DialogPrimitive.Close>
<DialogPrimitive.Description className="sr-only">
Channel settings
</DialogPrimitive.Description>
</AuxiliaryPanelHeader>
) : (
<div
className={cn(
"relative z-10 flex min-h-11 flex-row items-center gap-3 space-y-0 border-b border-border/35 px-3 py-1.5 text-left shadow-none",
isDark
? "bg-background/70 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
: "bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/70",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{activeView === "canvas" ? (
<Button
aria-label="Back to channel"
data-testid="channel-management-back"
onClick={() => setActiveView("summary")}
size="icon"
type="button"
variant="ghost"
>
<ChevronLeft />
</Button>
) : null}
<DialogPrimitive.Title className="min-w-0 flex-1 translate-y-px truncate text-base font-semibold leading-6 tracking-tight">
{activeView === "canvas" ? "Canvas" : "Channel"}
</DialogPrimitive.Title>
</div>
<DialogPrimitive.Close asChild>
<Button
aria-label="Close channel management"
data-testid="channel-management-close"
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
</DialogPrimitive.Close>
<DialogPrimitive.Description className="sr-only">
Channel settings
</DialogPrimitive.Description>
</div>
)}
<div
className={cn(
"flex-1 overflow-y-auto bg-background px-4",
showModerationActions ? "pb-20" : "pb-8",
isSplitLayout ? auxiliaryPanelContentPaddingClass : "pt-4",
)}
>
{activeView === "summary" ? (
<div className="space-y-6">
<ChannelHero channel={resolvedChannel} />
{detailsError instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{detailsError.message}
</p>
) : null}
{membersError instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{membersError.message}
</p>
) : null}
<div className="flex flex-wrap items-start justify-center gap-6">
<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"
/>
{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}
{canManageChannel ? (
<ChannelQuickAction
icon={Pencil}
label="Edit"
onClick={() => setIsEditDialogOpen(true)}
testId="channel-management-edit"
/>
) : null}
</div>
{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() ? (
<NarrativeGroup>
{resolvedChannel.description.trim() ? (
<NarrativeField
icon={FileText}
label="Description"
testId="channel-management-description"
value={resolvedChannel.description.trim()}
/>
) : null}
{resolvedChannel.topic?.trim() ? (
<NarrativeField
icon={MessageSquare}
label="Topic"
testId="channel-management-topic"
value={resolvedChannel.topic.trim()}
/>
) : null}
{resolvedChannel.purpose?.trim() ? (
<NarrativeField
icon={Zap}
label="Purpose"
testId="channel-management-purpose"
value={resolvedChannel.purpose.trim()}
/>
) : null}
</NarrativeGroup>
) : null}
{canOpenCanvas ? (
<IngressRow
description={canvasPreview}
icon={BookOpenText}
label="Canvas"
onClick={() => setActiveView("canvas")}
testId="channel-canvas-ingress"
trailing={canvasQuery.isLoading ? "Loading..." : undefined}
/>
) : null}
<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"
/>
) : null}
{resolvedChannel.ttlSeconds !== null ? (
<InfoFieldRow
icon={Archive}
label="Ephemeral"
testId="channel-management-ephemeral-row"
value={formatTtlDuration(resolvedChannel.ttlSeconds)}
/>
) : null}
</FieldGroup>
{archiveChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{archiveChannelMutation.error.message}
</p>
) : null}
{unarchiveChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{unarchiveChannelMutation.error.message}
</p>
) : null}
</div>
) : (
<div data-testid="channel-canvas-section">
<ChannelCanvas
canEdit={canEditNarrative}
channelId={channelId}
isArchived={isArchived}
/>
</div>
)}
</div>
{showModerationActions ? (
<ChannelManagementModerationActions
archiveChannelMutation={archiveChannelMutation}
canManageChannel={canManageChannel}
deleteChannelMutation={deleteChannelMutation}
handleDeleteChannel={handleDeleteChannel}
handleDeleteDialogOpenChange={handleDeleteDialogOpenChange}
isArchived={isArchived}
isDark={isDark}
isDeleteDialogOpen={isDeleteDialogOpen}
isOwner={isOwner}
resolvedChannelName={resolvedChannel.name}
unarchiveChannelMutation={unarchiveChannelMutation}
/>
) : null}
</>
);
}

View File

@@ -21,6 +21,7 @@ import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import {
BotActivityComposerAction,
@@ -61,6 +62,7 @@ type ChannelPaneProps = {
agentSessionAgents: ChannelAgentSessionAgent[];
botTypingEntries: TypingIndicatorEntry[];
channelFind: ReturnType<typeof useChannelFind>;
channelManagementOpen?: boolean;
currentPubkey?: string;
editTarget?: {
author: string;
@@ -85,6 +87,8 @@ type ChannelPaneProps = {
onCancelEdit?: () => void;
onCancelThreadReply: () => void;
onCloseAgentSession: () => void;
onCloseChannelManagement?: () => void;
onChannelManagementDeleted?: () => void;
onCloseProfilePanel: () => void;
onAddAgent?: () => void;
onCreateChannel?: () => void;
@@ -164,6 +168,7 @@ export const ChannelPane = React.memo(function ChannelPane({
activityAgents = agentSessionAgents,
botTypingEntries,
channelFind,
channelManagementOpen = false,
currentPubkey,
editTarget = null,
fetchOlder,
@@ -184,6 +189,8 @@ export const ChannelPane = React.memo(function ChannelPane({
onCancelEdit,
onCancelThreadReply,
onCloseAgentSession,
onCloseChannelManagement,
onChannelManagementDeleted,
onCloseProfilePanel,
onAddAgent,
onCreateChannel,
@@ -773,61 +780,24 @@ export const ChannelPane = React.memo(function ChannelPane({
</section>
) : null}
{threadHeadMessage
{channelManagementOpen && activeChannel
? (() => {
const panel = (
<MessageThreadPanel
agentPubkeys={agentPubkeys}
<ChannelManagementSheet
channel={activeChannel}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
isFollowingThread={isFollowingThread}
isSending={isSending}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onExpandReplies={onExpandThreadReplies}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetId={threadScrollTargetId}
threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadTypingPubkeys={threadTypingPubkeys}
toolbarExtraActions={
hasThreadComposerBotActivity ? (
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={threadComposerBotTypingPubkeys}
variant="inline"
/>
) : null
layout={
useSplitAuxiliaryPane || isSinglePanelView
? "split"
: "overlay"
}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onCloseChannelManagement?.();
}
}}
onDeleted={onChannelManagementDeleted}
open={true}
/>
);
return useSplitAuxiliaryPane ? (
@@ -835,7 +805,7 @@ export const ChannelPane = React.memo(function ChannelPane({
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="message-thread-panel"
testId="channel-management-auxiliary-pane"
widthPx={threadPanelWidthPx}
>
{panel}
@@ -844,16 +814,61 @@ export const ChannelPane = React.memo(function ChannelPane({
panel
);
})()
: openThreadHeadId && activeChannel
: threadHeadMessage
? (() => {
const panel = (
<MessageThreadPanelSkeleton
<MessageThreadPanel
agentPubkeys={agentPubkeys}
channel={activeChannel}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
isFollowingThread={isFollowingThread}
isSending={isSending}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onExpandReplies={onExpandThreadReplies}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetId={threadScrollTargetId}
threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadTypingPubkeys={threadTypingPubkeys}
toolbarExtraActions={
hasThreadComposerBotActivity ? (
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={threadComposerBotTypingPubkeys}
variant="inline"
/>
) : null
}
/>
);
return useSplitAuxiliaryPane ? (
@@ -870,27 +885,15 @@ export const ChannelPane = React.memo(function ChannelPane({
panel
);
})()
: activeChannel && selectedAgent
: openThreadHeadId && activeChannel
? (() => {
const panel = (
<AgentSessionThreadPanel
agent={selectedAgent}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={activeChannel}
isWorking={botTypingEntries.some(
(entry) =>
entry.pubkey.toLowerCase() ===
selectedAgent.pubkey.toLowerCase(),
)}
<MessageThreadPanelSkeleton
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
profiles={profiles}
onBackToProfile={() =>
onOpenProfilePanel(selectedAgent.pubkey)
}
onClose={onCloseAgentSession}
onClose={onCloseThread}
widthPx={threadPanelWidthPx}
/>
);
@@ -899,7 +902,7 @@ export const ChannelPane = React.memo(function ChannelPane({
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="agent-session-thread-panel"
testId="message-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
@@ -908,19 +911,27 @@ export const ChannelPane = React.memo(function ChannelPane({
panel
);
})()
: profilePanelPubkey
: activeChannel && selectedAgent
? (() => {
const panel = (
<UserProfilePanel
currentPubkey={currentPubkey}
<AgentSessionThreadPanel
agent={selectedAgent}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={activeChannel}
isWorking={botTypingEntries.some(
(entry) =>
entry.pubkey.toLowerCase() ===
selectedAgent.pubkey.toLowerCase(),
)}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
pubkey={profilePanelPubkey}
splitPaneClamp
profiles={profiles}
onBackToProfile={() =>
onOpenProfilePanel(selectedAgent.pubkey)
}
onClose={onCloseAgentSession}
widthPx={threadPanelWidthPx}
/>
);
@@ -929,7 +940,7 @@ export const ChannelPane = React.memo(function ChannelPane({
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="user-profile-panel"
testId="agent-session-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
@@ -938,7 +949,37 @@ export const ChannelPane = React.memo(function ChannelPane({
panel
);
})()
: null}
: profilePanelPubkey
? (() => {
const panel = (
<UserProfilePanel
currentPubkey={currentPubkey}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
pubkey={profilePanelPubkey}
splitPaneClamp
widthPx={threadPanelWidthPx}
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="user-profile-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
: null}
</div>
);
});

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
import {
@@ -93,6 +94,7 @@ export function ChannelScreen({
targetMessageEvents,
targetMessageId,
}: ChannelScreenProps) {
const { goHome } = useAppNavigation();
const {
markChannelRead,
markChannelUnread,
@@ -101,7 +103,7 @@ export function ChannelScreen({
markThreadRead,
setContextParentResolver,
openCreateChannel,
openChannelManagement,
openChannelManagement: openGlobalChannelManagement,
followThread,
unfollowThread,
isFollowingThread,
@@ -111,6 +113,8 @@ export function ChannelScreen({
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
string | null
>(null);
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const {
canReset: canResetThreadPanelWidth,
onResetWidth: handleThreadPanelWidthReset,
@@ -143,14 +147,8 @@ export function ChannelScreen({
useChannelSubscription(activeChannel);
const { fetchOlder, hasOlderMessages, isFetchingOlder } =
useFetchOlderMessages(activeChannel);
// Newest TOP-LEVEL message only. The channel read-marker must clear the
// channel timeline without clearing its threads (NIP-RS Option 1): thread
// replies are kind-9 channel events, so taking the last message outright
// would advance the channel frontier past unread replies and the hierarchical
// effective(thread) = max(thread, channel) would silently clear every thread
// badge on channel entry. Scanning from the end for the last message with no
// reply tag keeps the frontier at the last top-level message, leaving thread
// badges intact until the thread itself is read.
// Newest top-level message only: opening a channel should clear the timeline
// without clearing unread thread replies.
const latestActiveMessage = React.useMemo(() => {
const messages = messagesQuery.data;
if (!messages) return null;
@@ -161,22 +159,13 @@ export function ChannelScreen({
}
return null;
}, [messagesQuery.data]);
// No `lastMessageAt` fallback: that timestamp is reply-inclusive (the backend
// takes MAX(created_at) over kind-9 events without a parent filter), so using
// it when the window has no top-level message would advance the channel
// marker past an unread reply and clear its thread/sidebar unread — the exact
// regression Fix A prevents. null suppresses the marker advance (markChannelRead
// early-returns on markAt === null) until a real top-level position is known.
// No `lastMessageAt` fallback: it is reply-inclusive and would clear unread
// thread/sidebar state before a real top-level position is known.
const activeReadAt = latestActiveMessage
? new Date(latestActiveMessage.created_at * 1_000).toISOString()
: null;
// Capture the read frontier as it stood the instant this channel was opened,
// BEFORE the mark-read effect below advances it to latest. Written during
// render (not in an effect) so the value is read prior to any effect for
// this commit — the divider must reflect "what was unread on open", not the
// post-open frontier. Keyed per channel and recomputed only when the channel
// id changes, never when activeReadAt advances, or the divider would vanish
// the moment the open marks the channel read.
// Capture the read frontier during render, before the mark-read effect
// advances it, so dividers reflect "what was unread on open."
const openFrontierRef = React.useRef(new Map<string, number | null>());
if (activeChannelId && !openFrontierRef.current.has(activeChannelId)) {
openFrontierRef.current.set(
@@ -187,12 +176,8 @@ export function ChannelScreen({
const openFrontierSeconds = activeChannelId
? (openFrontierRef.current.get(activeChannelId) ?? null)
: null;
// Channels the user manually marked unread this session. A deliberate
// mark-unread has no meaningful "new" boundary inside the timeline — the
// open-time snapshot already covers every message — so the pill and divider
// would otherwise render nothing while the sidebar dot says unread. Suppress
// the marker for such channels to avoid that visible contradiction. The flag
// is cleared on re-open (a fresh snapshot is recomputed for the channel).
// Manual mark-unread has no meaningful in-timeline boundary, so suppress the
// marker until the channel is reopened and a fresh snapshot is captured.
const forcedUnreadRef = React.useRef(new Set<string>());
const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0);
const isActiveChannelForcedUnread =
@@ -209,9 +194,7 @@ export function ChannelScreen({
};
}, [activeChannelId]);
// Clear the open-time frontier on channel leave so re-visiting captures a
// fresh read position. Without this, switching away and back would reuse the
// stale frontier from the first open, producing a phantom "New" divider over
// already-read messages.
// fresh read position.
React.useEffect(() => {
const channelId = activeChannelId;
if (!channelId) return;
@@ -223,19 +206,12 @@ export function ChannelScreen({
if (!activeChannelId || activeChannel?.isMember === false) {
return;
}
// Passive channel-open: advance the marker to the newest top-level message
// only (NIP-RS Option 1). `topLevelOnly` stops the read-state layer folding
// in observed thread replies, so opening a channel clears the timeline but
// leaves thread badges intact until each thread is opened — and leaves the
// channel's sidebar dot lit (the reply is still unread for the channel).
// Advance only to the newest top-level message; thread replies remain
// unread until their thread is opened.
markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true });
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
// Install the NIP-RS parent resolver: every `thread:<root>` context evaluated
// while this channel is active belongs to it (getThreadReadAt is only ever
// called on the active channel's timeline messages), so the parent is always
// the active channel. Non-thread keys (channels) have no parent → null, which
// degrades effective() to the own term. Cleared on channel leave / unmount so
// a stale channel id never becomes the parent of another channel's threads.
// Install the NIP-RS parent resolver for active-channel thread contexts and
// clear it on leave so a stale channel id never parents another channel.
React.useEffect(() => {
if (!activeChannelId) {
setContextParentResolver(null);
@@ -438,10 +414,8 @@ export function ChannelScreen({
() => buildCreatedAtByMessageId(timelineMessages),
[timelineMessages],
);
// Newest createdAt across an expanded branch (the message itself plus every
// descendant). Drilling into a branch advances the thread frontier to this,
// consuming everything chronologically up to the deepest reply read. Returns
// null when the message is absent so the caller skips the read-state write.
// Newest createdAt across an expanded branch, used to advance its thread
// frontier when the branch is drilled into.
const getSubtreeMaxCreatedAt = React.useCallback(
(messageId: string) =>
subtreeMaxCreatedAt(
@@ -758,6 +732,7 @@ export function ChannelScreen({
handleCloseAgentSession();
setEditTargetId(null);
setProfilePanelPubkey(null);
setIsChannelManagementOpen(false);
},
[handleCloseAgentSession],
);
@@ -810,7 +785,10 @@ export function ChannelScreen({
useLoadMissingAncestors(activeChannel, resolvedMessages);
const hasAuxiliaryPanel = Boolean(
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey,
openThreadHeadMessage ||
openAgentSessionPubkey ||
profilePanelPubkey ||
isChannelManagementOpen,
);
const isNarrowPanelViewport =
channelContentWidthPx > 0 &&
@@ -851,7 +829,20 @@ export function ChannelScreen({
isJoining={joinChannelMutation.isPending}
onAddBotOpenChange={setIsAddBotOpen}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={openChannelManagement}
onManageChannel={() => {
if (activeChannel?.channelType === "forum") {
openGlobalChannelManagement();
return;
}
setOpenThreadHeadId(null);
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
handleCloseAgentSession();
setProfilePanelPubkey(null);
setIsChannelManagementOpen(true);
}}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
showHeaderContent={!isSinglePanelView}
/>
@@ -891,6 +882,7 @@ export function ChannelScreen({
agentSessionAgents={channelAgentSessionAgents}
botTypingEntries={botTypingEntries}
channelFind={channelFind}
channelManagementOpen={isChannelManagementOpen}
currentPubkey={currentPubkey}
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
@@ -922,6 +914,10 @@ export function ChannelScreen({
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelThreadReply={handleCancelThreadReply}
onChannelManagementDeleted={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onFollowThread={
openThreadHeadId != null && !isNotifiedForCurrentThread
? () => followThread(openThreadHeadId)
@@ -933,6 +929,9 @@ export function ChannelScreen({
: undefined
}
onCloseAgentSession={handleCloseAgentSession}
onCloseChannelManagement={() =>
setIsChannelManagementOpen(false)
}
onCloseThread={handleCloseThread}
onDelete={
activeChannel?.archivedAt ? undefined : handleDelete

View File

@@ -7,6 +7,7 @@ import { cn } from "@/shared/lib/cn";
type RightAuxiliaryPaneProps = {
canResetWidth: boolean;
children: React.ReactNode;
constrainToAvailableSpace?: boolean;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
testId?: string;
@@ -16,6 +17,7 @@ type RightAuxiliaryPaneProps = {
export function RightAuxiliaryPane({
canResetWidth,
children,
constrainToAvailableSpace = true,
onResetWidth,
onResizeStart,
testId,
@@ -26,7 +28,9 @@ export function RightAuxiliaryPane({
className="group/right-pane relative flex h-full shrink-0 flex-col overflow-hidden bg-background before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-(--buzz-top-chrome-height,2.5rem) before:z-40 before:w-px before:bg-border/80 before:content-['']"
data-testid={testId}
style={{
maxWidth: `calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px)`,
maxWidth: constrainToAvailableSpace
? `calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px)`
: undefined,
width: widthPx,
}}
>

View File

@@ -13,13 +13,11 @@ import {
type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
currentPubkey?: string;
onOpenChannel: (channelId: string) => void;
};
export function HomeScreen({
availableChannelIds,
currentPubkey,
onOpenChannel,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
const { threadActivityItems } = useAppShell();
@@ -70,7 +68,6 @@ export function HomeScreen({
}
feed={augmentedFeed}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={onOpenChannel}
onRefresh={() => {
void homeFeedQuery.refetch();
}}

View File

@@ -3,6 +3,8 @@ import { RefreshCcw } from "lucide-react";
import { useAppShell } from "@/app/AppShellContext";
import { useChannelsQuery } from "@/features/channels/hooks";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
import {
type InboxFilter,
type InboxContextMessage,
@@ -44,6 +46,10 @@ import { topChromeInset } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import {
THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
useThreadPanelWidth,
} from "@/shared/hooks/useThreadPanelWidth";
import { Button } from "@/shared/ui/button";
type HomeViewProps = {
@@ -52,7 +58,6 @@ type HomeViewProps = {
errorMessage?: string;
currentPubkey?: string;
availableChannelIds: ReadonlySet<string>;
onOpenChannel: (channelId: string) => void;
onRefresh: () => void;
};
@@ -62,7 +67,6 @@ export function HomeView({
errorMessage,
currentPubkey,
availableChannelIds,
onOpenChannel,
onRefresh,
}: HomeViewProps) {
const [homeInboxRef, homeInboxWidthPx] = useElementWidth<HTMLDivElement>();
@@ -75,9 +79,18 @@ export function HomeView({
);
const [isDeletingMessage, setIsDeletingMessage] = React.useState(false);
const [isSendingReply, setIsSendingReply] = React.useState(false);
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
null,
);
const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState<
Record<string, InboxReply[]>
>({});
const {
canReset: canResetThreadPanelWidth,
onResetWidth: handleThreadPanelWidthReset,
onResizeStart: handleThreadPanelResizeStart,
widthPx: threadPanelWidthPx,
} = useThreadPanelWidth();
const {
canResetInboxListWidth,
handleInboxListResizeStart,
@@ -118,6 +131,15 @@ export function HomeView({
null
);
}, [channels, selectedChannelIdCandidate]);
const managedChannel = React.useMemo(() => {
if (!managedChannelId || !channels) return null;
return channels.find((channel) => channel.id === managedChannelId) ?? null;
}, [channels, managedChannelId]);
const isChannelManagementOpen = managedChannel !== null;
const isSinglePanelChannelManagementView =
isChannelManagementOpen &&
homeInboxWidthPx > 0 &&
homeInboxWidthPx < THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
const toggleReactionMutation = useToggleReactionMutation();
@@ -311,14 +333,24 @@ export function HomeView({
currentPubkey?.trim().toLowerCase() ===
selectedItem.item.pubkey.trim().toLowerCase();
const isSinglePanelDetailView =
isNarrowHomeViewport && selectedItemId !== null;
const showListPane = !isSinglePanelDetailView;
const showDetailPane = !isNarrowHomeViewport || isSinglePanelDetailView;
isNarrowHomeViewport &&
selectedItemId !== null &&
!isSinglePanelChannelManagementView;
const showListPane =
!isSinglePanelDetailView && !isSinglePanelChannelManagementView;
const showDetailPane =
!isSinglePanelChannelManagementView &&
(!isNarrowHomeViewport || isSinglePanelDetailView);
const channelManagementWidthPx = isSinglePanelChannelManagementView
? homeInboxWidthPx
: threadPanelWidthPx;
const maxEffectiveInboxListWidthPx =
homeInboxWidthPx > 0
? Math.max(
INBOX_COLUMN_MIN_WIDTH_PX,
homeInboxWidthPx - INBOX_COLUMN_MIN_WIDTH_PX,
homeInboxWidthPx -
INBOX_COLUMN_MIN_WIDTH_PX -
(isChannelManagementOpen ? channelManagementWidthPx : 0),
)
: undefined;
const effectiveInboxListWidthPx =
@@ -334,14 +366,21 @@ export function HomeView({
<div
className={cn(
"relative grid min-h-0 w-full flex-1",
showListPane && showDetailPane
? "grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)]"
: "grid-cols-1",
isSinglePanelChannelManagementView
? "grid-cols-1"
: showListPane && showDetailPane && isChannelManagementOpen
? "grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)_var(--home-channel-management-width)]"
: showListPane && showDetailPane
? "grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)]"
: isChannelManagementOpen
? "grid-cols-[minmax(0,1fr)_var(--home-channel-management-width)]"
: "grid-cols-1",
)}
data-testid="home-inbox"
ref={homeInboxRef}
style={
{
"--home-channel-management-width": `${channelManagementWidthPx}px`,
"--home-inbox-list-width": `${effectiveInboxListWidthPx}px`,
} as React.CSSProperties
}
@@ -430,7 +469,7 @@ export function HomeView({
setIsDeletingMessage(false);
});
}}
onOpenChannel={onOpenChannel}
onOpenChannel={setManagedChannelId}
onSendReply={async ({
content,
mediaTags,
@@ -513,6 +552,28 @@ export function HomeView({
replies={selectedItemReplies}
/>
) : null}
{isChannelManagementOpen ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
constrainToAvailableSpace={false}
onResetWidth={handleThreadPanelWidthReset}
onResizeStart={handleThreadPanelResizeStart}
testId="home-channel-management-auxiliary-pane"
widthPx={channelManagementWidthPx}
>
<ChannelManagementSheet
channel={managedChannel}
currentPubkey={currentPubkey}
layout="split"
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setManagedChannelId(null);
}
}}
open={true}
/>
</RightAuxiliaryPane>
) : null}
</div>
</div>
);

View File

@@ -31,7 +31,7 @@ async function openChannelManagement(
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
await page.keyboard.press("Escape");
await page.getByTestId("channel-management-close").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
}
@@ -1243,6 +1243,8 @@ test("manage channel keeps canvas near the top of the sheet", async ({
await openChannelManagement(page, "general");
const sheet = page.getByTestId("channel-management-sheet");
const sheetBox = await sheet.boundingBox();
const timelineBox = await page.getByTestId("message-timeline").boundingBox();
// Canvas ingress should appear before the channel metadata rows in the DOM.
const canvasBox = await sheet
@@ -1252,6 +1254,25 @@ test("manage channel keeps canvas near the top of the sheet", async ({
.getByTestId("channel-management-name-row")
.boundingBox();
expect(sheetBox).not.toBeNull();
expect(timelineBox).not.toBeNull();
if (!sheetBox || !timelineBox) {
throw new Error("Expected channel management panel and timeline boxes.");
}
expect(timelineBox.x + timelineBox.width).toBeLessThanOrEqual(sheetBox.x + 1);
await page.mouse.click(timelineBox.x + 24, timelineBox.y + 180);
await expect(sheet).toBeVisible();
await page.keyboard.press("Escape");
await expect(sheet).toBeVisible();
await page.setViewportSize({ height: 720, width: 820 });
await expect(page.getByTestId("message-timeline")).toHaveCount(0);
await expect(page.getByTestId("channel-drop-zone")).toHaveCount(0);
await expect(sheet).toBeVisible();
const narrowSheetBox = await sheet.boundingBox();
if (!narrowSheetBox) {
throw new Error("Expected narrow channel management panel box.");
}
expect(narrowSheetBox.width).toBeGreaterThan(500);
expect(canvasBox).not.toBeNull();
expect(nameBox).not.toBeNull();
expect(canvasBox?.y).toBeLessThan(nameBox?.y);
@@ -1312,6 +1333,31 @@ test("home inbox channel label opens management without leaving home", async ({
"general",
);
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
const detailBox = await page.getByTestId("home-inbox-detail").boundingBox();
const sheetBox = await page
.getByTestId("channel-management-sheet")
.boundingBox();
if (!detailBox || !sheetBox) {
throw new Error("Expected home detail pane and channel management boxes.");
}
expect(detailBox.x + detailBox.width).toBeLessThanOrEqual(sheetBox.x + 1);
expect(sheetBox.width).toBeGreaterThanOrEqual(300);
await page
.getByTestId("home-inbox-list")
.click({ position: { x: 24, y: 80 } });
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.setViewportSize({ height: 720, width: 820 });
await expect(page.getByTestId("home-inbox-list")).toHaveCount(0);
const narrowHomeBox = await page.getByTestId("home-inbox").boundingBox();
const narrowSheetBox = await page
.getByTestId("channel-management-sheet")
.boundingBox();
if (!narrowHomeBox || !narrowSheetBox) {
throw new Error("Expected narrow home and channel management boxes.");
}
expect(narrowSheetBox.width).toBeGreaterThanOrEqual(narrowHomeBox.width - 1);
await expect(page).not.toHaveURL(/#\/channels\//);
});