diff --git a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx index 6d56e4dbfcc..f9638ecd2b0 100644 --- a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx +++ b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx @@ -33,6 +33,8 @@ import { MODAL_SEARCH_SHELL_CLASS, } from "@/shared/ui/modalSearchStyles"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; + +import { ChannelRowIcon } from "@/features/channels/ui/ChannelRowIcon"; import { DropdownMenu, DropdownMenuContent, @@ -790,9 +792,10 @@ function ChannelCard({ >
- - # - +

{channel.name}

diff --git a/desktop/src/features/channels/ui/ChannelRowIcon.test.mjs b/desktop/src/features/channels/ui/ChannelRowIcon.test.mjs new file mode 100644 index 00000000000..e1fc3bd95f9 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelRowIcon.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +function makeChannel(overrides) { + return { + id: "channel-1", + name: "general", + description: null, + memberCount: 1, + isMember: true, + archivedAt: null, + channelType: "stream", + visibility: "open", + participantPubkeys: [], + ...overrides, + }; +} + +test("getChannelRowIconKind returns 'lock' for private channels regardless of type", async () => { + const { getChannelRowIconKind } = await import("./ChannelRowIcon.tsx"); + assert.equal( + getChannelRowIconKind(makeChannel({ visibility: "private" })), + "lock", + ); + // A private forum still locks — the sidebar behaves the same way and the + // two surfaces must not diverge (issue #6120). + assert.equal( + getChannelRowIconKind( + makeChannel({ visibility: "private", channelType: "forum" }), + ), + "lock", + ); +}); + +test("getChannelRowIconKind returns 'forum' for public forum channels", async () => { + const { getChannelRowIconKind } = await import("./ChannelRowIcon.tsx"); + assert.equal( + getChannelRowIconKind( + makeChannel({ channelType: "forum", visibility: "open" }), + ), + "forum", + ); +}); + +test("getChannelRowIconKind returns 'hash' for public streams", async () => { + const { getChannelRowIconKind } = await import("./ChannelRowIcon.tsx"); + assert.equal( + getChannelRowIconKind( + makeChannel({ channelType: "stream", visibility: "open" }), + ), + "hash", + ); +}); + +test("ChannelRowIcon renders a Lock svg for private channels", async () => { + const { createElement } = await import("react"); + const { render } = await import("@testing-library/react"); + const { ChannelRowIcon } = await import("./ChannelRowIcon.tsx"); + + const { container } = render( + createElement(ChannelRowIcon, { + channel: makeChannel({ visibility: "private" }), + }), + ); + + // Lucide icons render an inline svg with the relevant lucide-* class. + assert.match(container.innerHTML, /lucide-lock/); + assert.doesNotMatch(container.innerHTML, /lucide-file-text|lucide-hash/); +}); + +test("ChannelRowIcon renders a FileText svg for public forum channels", async () => { + const { createElement } = await import("react"); + const { render } = await import("@testing-library/react"); + const { ChannelRowIcon } = await import("./ChannelRowIcon.tsx"); + + const { container } = render( + createElement(ChannelRowIcon, { + channel: makeChannel({ + channelType: "forum", + visibility: "open", + }), + }), + ); + + assert.match(container.innerHTML, /lucide-file-text/); + assert.doesNotMatch(container.innerHTML, /lucide-lock|lucide-hash/); +}); + +test("ChannelRowIcon renders a Hash svg for public stream channels", async () => { + const { createElement } = await import("react"); + const { render } = await import("@testing-library/react"); + const { ChannelRowIcon } = await import("./ChannelRowIcon.tsx"); + + const { container } = render( + createElement(ChannelRowIcon, { + channel: makeChannel({ + channelType: "stream", + visibility: "open", + }), + }), + ); + + assert.match(container.innerHTML, /lucide-hash/); + assert.doesNotMatch(container.innerHTML, /lucide-lock|lucide-file-text/); +}); \ No newline at end of file diff --git a/desktop/src/features/channels/ui/ChannelRowIcon.tsx b/desktop/src/features/channels/ui/ChannelRowIcon.tsx new file mode 100644 index 00000000000..535382298a1 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelRowIcon.tsx @@ -0,0 +1,41 @@ +import { FileText, Hash, Lock } from "lucide-react"; + +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +export type ChannelRowIconKind = "hash" | "lock" | "forum"; + +/** + * Pure discriminator used by `` so the icon choice is + * unit-testable without rendering React. Channels whose visibility is + * `private` always show a lock, regardless of type — the same precedence + * the sidebar applies, so the two surfaces stay in sync. + */ +export function getChannelRowIconKind(channel: Channel): ChannelRowIconKind { + if (channel.visibility === "private") return "lock"; + if (channel.channelType === "forum") return "forum"; + return "hash"; +} + +/** + * Render the affordance that names a channel the same way the sidebar does: + * a lock for private channels, a file icon for forums, and a hash for + * everything else. Extracted so the channel browser and sidebar can't drift + * on which icon they show for which channel kind. + */ +export function ChannelRowIcon({ + channel, + className, +}: { + channel: Channel; + className?: string; +}) { + switch (getChannelRowIconKind(channel)) { + case "lock": + return ; + case "forum": + return ; + case "hash": + return ; + } +} \ No newline at end of file diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 1a6403fb24d..348a8d0d599 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -3,9 +3,6 @@ import { BellOff, ChevronDown, CircleDot, - FileText, - Hash, - Lock, X, } from "lucide-react"; @@ -20,6 +17,7 @@ import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurn import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge"; +import { ChannelRowIcon } from "@/features/channels/ui/ChannelRowIcon"; import { DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, ProfileAvatarWithStatus, @@ -239,15 +237,7 @@ function SidebarChannelIcon({ ); } - if (channel.visibility === "private") { - return ; - } - - if (channel.channelType === "forum") { - return ; - } - - return ; + return ; } export function ChannelMenuButton({