Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions desktop/src/features/channels/ui/ChannelBrowserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -790,9 +792,10 @@ function ChannelCard({
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0 text-sm font-normal text-muted-foreground">
#
</span>
<ChannelRowIcon
channel={channel}
className="shrink-0 text-muted-foreground"
/>
<p className="min-w-0 truncate text-base font-medium tracking-tight">
{channel.name}
</p>
Expand Down
132 changes: 132 additions & 0 deletions desktop/src/features/channels/ui/ChannelRowIcon.test.mjs
Original file line number Diff line number Diff line change
@@ -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("<!doctype html><html><body></body></html>", {
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/);
});
41 changes: 41 additions & 0 deletions desktop/src/features/channels/ui/ChannelRowIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 `<ChannelRowIcon />` 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 <Lock className={cn("h-4 w-4", className)} />;
case "forum":
return <FileText className={cn("h-4 w-4", className)} />;
case "hash":
return <Hash className={cn("h-4 w-4", className)} />;
}
}
14 changes: 2 additions & 12 deletions desktop/src/features/sidebar/ui/SidebarSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import {
BellOff,
ChevronDown,
CircleDot,
FileText,
Hash,
Lock,
X,
} from "lucide-react";

Expand All @@ -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,
Expand Down Expand Up @@ -239,15 +237,7 @@ function SidebarChannelIcon({
);
}

if (channel.visibility === "private") {
return <Lock className={cn("h-4 w-4", className)} />;
}

if (channel.channelType === "forum") {
return <FileText className={cn("h-4 w-4", className)} />;
}

return <Hash className={cn("h-4 w-4", className)} />;
return <ChannelRowIcon channel={channel} className={className} />;
}

export function ChannelMenuButton({
Expand Down