feat(desktop): add smart channel sections

Signed-off-by: Andrew Kuttig <akuttig@squareup.com>
This commit is contained in:
Andrew Kuttig
2026-08-12 10:17:36 -06:00
parent c966b862fe
commit 47a04d72cd
14 changed files with 1144 additions and 48 deletions

View File

@@ -41,6 +41,7 @@ export default defineConfig({
"**/custom-emoji-ui.spec.ts",
"**/channel-mute.spec.ts",
"**/channel-star.spec.ts",
"**/smart-channel-sections.spec.ts",
"**/channel-controls.spec.ts",
"**/channel-activity-popover.spec.ts",
"**/active-turn-resilience.spec.ts",

View File

@@ -388,3 +388,57 @@ test("parseChannelSectionPayload: omits icon field when empty or whitespace", ()
{ id: "s3", name: "C", order: 2 },
]);
});
test("parseChannelSectionPayload: preserves Smart Section rules and manual overrides", () => {
const payload = {
version: 1,
sections: [
{
id: "smart",
name: "Engineering",
order: 0,
smartRule: {
pattern: "^eng-",
isRegex: true,
caseSensitive: false,
},
},
],
assignments: {},
manuallyUnassignedChannelIds: ["channel-1", "channel-1", 42],
};
assert.deepEqual(parseChannelSectionPayload(payload), {
version: 1,
sections: [
{
id: "smart",
name: "Engineering",
order: 0,
smartRule: {
pattern: "^eng-",
isRegex: true,
caseSensitive: false,
},
},
],
assignments: {},
manuallyUnassignedChannelIds: ["channel-1"],
});
});
test("parseChannelSectionPayload: ignores malformed Smart Section rules", () => {
const result = parseChannelSectionPayload({
sections: [
{
id: "regular",
name: "Regular",
order: 0,
smartRule: { pattern: "eng", isRegex: "yes", caseSensitive: false },
},
],
assignments: {},
});
assert.deepEqual(result?.sections, [
{ id: "regular", name: "Regular", order: 0 },
]);
});

View File

@@ -3,18 +3,29 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1";
export const MAX_CHANNEL_SECTIONS = 100;
export const MAX_CHANNEL_SECTION_ASSIGNMENTS = 1_000;
export const MAX_MANUALLY_UNASSIGNED_CHANNELS = 1_000;
export const MAX_SMART_SECTION_PATTERN_LENGTH = 200;
export type SmartSectionRule = {
pattern: string;
isRegex: boolean;
caseSensitive: boolean;
};
export type ChannelSection = {
id: string;
name: string;
icon?: string;
order: number;
smartRule?: SmartSectionRule;
};
export type ChannelSectionStore = {
version: 1;
sections: ChannelSection[];
assignments: Record<string, string>;
/** Channels explicitly moved to the default Channels group by the user. */
manuallyUnassignedChannelIds?: string[];
};
export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({
@@ -52,13 +63,25 @@ export function boundChannelSectionsStore(
.filter(([, sectionId]) => sectionIds.has(sectionId))
.slice(-MAX_CHANNEL_SECTION_ASSIGNMENTS),
);
const manuallyUnassignedChannelIds = Array.from(
new Set(store.manuallyUnassignedChannelIds ?? []),
).slice(-MAX_MANUALLY_UNASSIGNED_CHANNELS);
if (
sections.length === store.sections.length &&
Object.keys(assignments).length === Object.keys(store.assignments).length
Object.keys(assignments).length === Object.keys(store.assignments).length &&
manuallyUnassignedChannelIds.length ===
(store.manuallyUnassignedChannelIds?.length ?? 0)
) {
return store;
}
return { ...store, sections, assignments };
return {
...store,
sections,
assignments,
...(manuallyUnassignedChannelIds.length > 0
? { manuallyUnassignedChannelIds }
: { manuallyUnassignedChannelIds: undefined }),
};
}
export function stripOrphanedAssignments(
@@ -95,12 +118,14 @@ export function parseChannelSectionPayload(
typeof section.icon === "string" && section.icon.trim().length > 0
? section.icon.trim()
: undefined;
const smartRule = parseSmartSectionRule(section.smartRule);
return [
{
id: section.id,
name: section.name,
...(icon ? { icon } : {}),
order: section.order,
...(smartRule ? { smartRule } : {}),
},
];
})
@@ -115,7 +140,44 @@ export function parseChannelSectionPayload(
),
)
: {};
return stripOrphanedAssignments({ version: 1, sections, assignments });
const manuallyUnassignedChannelIds = Array.isArray(
obj.manuallyUnassignedChannelIds,
)
? Array.from(
new Set(
obj.manuallyUnassignedChannelIds.filter(
(channelId): channelId is string => typeof channelId === "string",
),
),
).slice(-MAX_MANUALLY_UNASSIGNED_CHANNELS)
: [];
return stripOrphanedAssignments({
version: 1,
sections,
assignments,
...(manuallyUnassignedChannelIds.length > 0
? { manuallyUnassignedChannelIds }
: {}),
});
}
function parseSmartSectionRule(value: unknown): SmartSectionRule | undefined {
if (typeof value !== "object" || value === null) return undefined;
const rule = value as Record<string, unknown>;
if (
typeof rule.pattern !== "string" ||
rule.pattern.length === 0 ||
rule.pattern.length > MAX_SMART_SECTION_PATTERN_LENGTH ||
typeof rule.isRegex !== "boolean" ||
typeof rule.caseSensitive !== "boolean"
) {
return undefined;
}
return {
pattern: rule.pattern,
isRegex: rule.isRegex,
caseSensitive: rule.caseSensitive,
};
}
function parseRaw(raw: string | null): ChannelSectionStore | null {

View File

@@ -280,3 +280,46 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att
mock.reset();
}
});
test("publishes Smart Section rules and manual placement overrides", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
const tauri = installTauriMock("");
try {
const manager = new ChannelSectionSyncManager("pk-smart", RELAY);
manager.publishSections(
makeStore({
sections: [
{
id: "smart",
name: "Engineering",
order: 0,
smartRule: {
pattern: "^eng-",
isRegex: true,
caseSensitive: false,
},
},
],
manuallyUnassignedChannelIds: ["channel-1"],
}),
);
fw._fireTimer();
await new Promise((resolve) => setTimeout(resolve, 20));
const plaintext = tauri.capturedPlaintext();
assert.ok(plaintext, "Smart Section payload should be encrypted");
const payload = JSON.parse(plaintext);
assert.deepEqual(payload.sections[0].smartRule, {
pattern: "^eng-",
isRegex: true,
caseSensitive: false,
});
assert.deepEqual(payload.manuallyUnassignedChannelIds, ["channel-1"]);
} finally {
tauri.restore();
restore();
mock.reset();
}
});

View File

@@ -162,7 +162,10 @@ export class ChannelSectionSyncManager {
last.id !== current.id ||
last.name !== current.name ||
last.icon !== current.icon ||
last.order !== current.order
last.order !== current.order ||
last.smartRule?.pattern !== current.smartRule?.pattern ||
last.smartRule?.isRegex !== current.smartRule?.isRegex ||
last.smartRule?.caseSensitive !== current.smartRule?.caseSensitive
)
return false;
}
@@ -173,6 +176,13 @@ export class ChannelSectionSyncManager {
if (this.lastPublishedStore.assignments[key] !== store.assignments[key])
return false;
}
const lastManual =
this.lastPublishedStore.manuallyUnassignedChannelIds ?? [];
const currentManual = store.manuallyUnassignedChannelIds ?? [];
if (lastManual.length !== currentManual.length) return false;
for (let i = 0; i < currentManual.length; i++) {
if (lastManual[i] !== currentManual[i]) return false;
}
return true;
}
@@ -191,6 +201,11 @@ export class ChannelSectionSyncManager {
version: 1,
sections: merged.sections,
assignments: merged.assignments,
...(merged.manuallyUnassignedChannelIds
? {
manuallyUnassignedChannelIds: merged.manuallyUnassignedChannelIds,
}
: {}),
};
const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload));
const createdAt = Math.max(

View File

@@ -0,0 +1,148 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
applyAutomaticSmartSectionAssignments,
assignChannelsToSection,
channelMatchesSmartRule,
getSmartSectionRuleError,
matchingChannelsForSection,
manuallyUnassignChannel,
} from "./smartChannelSections.ts";
function smartSection(overrides = {}) {
return {
id: "smart-1",
name: "Engineering",
order: 0,
smartRule: {
pattern: "eng-",
isRegex: false,
caseSensitive: false,
},
...overrides,
};
}
function store(overrides = {}) {
return {
version: 1,
sections: [smartSection()],
assignments: {},
...overrides,
};
}
test("literal smart rules match channel names by substring", () => {
assert.equal(
channelMatchesSmartRule("ENG-Platform", {
pattern: "eng-",
isRegex: false,
caseSensitive: false,
}),
true,
);
assert.equal(
channelMatchesSmartRule("ENG-Platform", {
pattern: "eng-",
isRegex: false,
caseSensitive: true,
}),
false,
);
});
test("regex rules honor case sensitivity and reject invalid expressions", () => {
assert.equal(
channelMatchesSmartRule("eng-platform", {
pattern: "^ENG-(platform|mobile)$",
isRegex: true,
caseSensitive: false,
}),
true,
);
assert.equal(
channelMatchesSmartRule("eng-platform", {
pattern: "^ENG-(platform|mobile)$",
isRegex: true,
caseSensitive: true,
}),
false,
);
assert.equal(
getSmartSectionRuleError({
pattern: "[",
isRegex: true,
caseSensitive: false,
}),
"Enter a valid regular expression.",
);
});
test("automatic assignment places only channels without a saved location", () => {
const original = store({
assignments: { manual: "other", already: "smart-1" },
manuallyUnassignedChannelIds: ["kept-in-channels"],
});
const result = applyAutomaticSmartSectionAssignments(original, [
{ id: "new-match", name: "eng-infra" },
{ id: "manual", name: "eng-manual" },
{ id: "already", name: "eng-existing" },
{ id: "kept-in-channels", name: "eng-opted-out" },
{ id: "not-a-match", name: "design" },
]);
assert.deepEqual(result.assignments, {
manual: "other",
already: "smart-1",
"new-match": "smart-1",
});
assert.deepEqual(result.manuallyUnassignedChannelIds, ["kept-in-channels"]);
});
test("the first matching Smart Section by sidebar order wins", () => {
const result = applyAutomaticSmartSectionAssignments(
store({
sections: [
smartSection({ id: "second", order: 2 }),
smartSection({ id: "first", order: 1 }),
],
}),
[{ id: "channel", name: "eng-platform" }],
);
assert.equal(result.assignments.channel, "first");
});
test("bulk matching returns matches regardless of current placement", () => {
const channels = [
{ id: "one", name: "eng-one" },
{ id: "two", name: "design" },
{ id: "three", name: "ENG-three" },
];
assert.deepEqual(
matchingChannelsForSection(smartSection(), channels).map(
(channel) => channel.id,
),
["one", "three"],
);
});
test("manual moves override automatic placement until explicitly moved back", () => {
const assigned = store({ assignments: { channel: "smart-1" } });
const unassigned = manuallyUnassignChannel(assigned, "channel");
assert.deepEqual(unassigned.assignments, {});
assert.deepEqual(unassigned.manuallyUnassignedChannelIds, ["channel"]);
const afterAutomaticScan = applyAutomaticSmartSectionAssignments(unassigned, [
{ id: "channel", name: "eng-platform" },
]);
assert.equal(afterAutomaticScan, unassigned);
const movedBack = assignChannelsToSection(
afterAutomaticScan,
["channel"],
"smart-1",
);
assert.equal(movedBack.assignments.channel, "smart-1");
assert.equal(movedBack.manuallyUnassignedChannelIds, undefined);
});

View File

@@ -0,0 +1,131 @@
import type {
ChannelSection,
ChannelSectionStore,
SmartSectionRule,
} from "./channelSectionsStorage";
import { MAX_SMART_SECTION_PATTERN_LENGTH } from "./channelSectionsStorage";
export type SmartSectionChannel = {
id: string;
name: string;
};
export function getSmartSectionRuleError(
rule: SmartSectionRule,
): string | null {
if (rule.pattern.trim().length === 0) return "Enter a channel name pattern.";
if (rule.pattern.length > MAX_SMART_SECTION_PATTERN_LENGTH) {
return `Keep the pattern under ${MAX_SMART_SECTION_PATTERN_LENGTH} characters.`;
}
if (!rule.isRegex) return null;
try {
new RegExp(rule.pattern, rule.caseSensitive ? "" : "i");
return null;
} catch {
return "Enter a valid regular expression.";
}
}
export function channelMatchesSmartRule(
channelName: string,
rule: SmartSectionRule,
): boolean {
if (getSmartSectionRuleError(rule)) return false;
if (rule.isRegex) {
return new RegExp(rule.pattern, rule.caseSensitive ? "" : "i").test(
channelName,
);
}
const pattern = rule.caseSensitive
? rule.pattern
: rule.pattern.toLocaleLowerCase();
const candidate = rule.caseSensitive
? channelName
: channelName.toLocaleLowerCase();
return candidate.includes(pattern);
}
export function matchingChannelsForSection<T extends SmartSectionChannel>(
section: ChannelSection,
channels: readonly T[],
): T[] {
const rule = section.smartRule;
if (!rule) return [];
return channels.filter((channel) =>
channelMatchesSmartRule(channel.name, rule),
);
}
/**
* Assigns only channels without a saved placement. Persisting the assignment
* makes the rule a one-time arrival decision: later manual moves remain stable.
*/
export function applyAutomaticSmartSectionAssignments(
store: ChannelSectionStore,
channels: readonly SmartSectionChannel[],
): ChannelSectionStore {
const smartSections = store.sections
.filter((section) => section.smartRule)
.slice()
.sort((left, right) => left.order - right.order);
if (smartSections.length === 0) return store;
const manuallyUnassigned = new Set(store.manuallyUnassignedChannelIds ?? []);
let assignments: Record<string, string> | null = null;
for (const channel of channels) {
if (store.assignments[channel.id] || manuallyUnassigned.has(channel.id)) {
continue;
}
const matchingSection = smartSections.find(
(section) =>
section.smartRule &&
channelMatchesSmartRule(channel.name, section.smartRule),
);
if (!matchingSection) continue;
assignments ??= { ...store.assignments };
assignments[channel.id] = matchingSection.id;
}
return assignments ? { ...store, assignments } : store;
}
export function assignChannelsToSection(
store: ChannelSectionStore,
channelIds: readonly string[],
sectionId: string,
): ChannelSectionStore {
if (channelIds.length === 0) return store;
const movedIds = new Set(channelIds);
const assignments = { ...store.assignments };
for (const channelId of channelIds) {
delete assignments[channelId];
assignments[channelId] = sectionId;
}
const manuallyUnassignedChannelIds = (
store.manuallyUnassignedChannelIds ?? []
).filter((channelId) => !movedIds.has(channelId));
return {
...store,
assignments,
...(manuallyUnassignedChannelIds.length > 0
? { manuallyUnassignedChannelIds }
: { manuallyUnassignedChannelIds: undefined }),
};
}
export function manuallyUnassignChannel(
store: ChannelSectionStore,
channelId: string,
): ChannelSectionStore {
const assignments = { ...store.assignments };
delete assignments[channelId];
return {
...store,
assignments,
manuallyUnassignedChannelIds: [
...(store.manuallyUnassignedChannelIds ?? []).filter(
(existingId) => existingId !== channelId,
),
channelId,
],
};
}

View File

@@ -21,9 +21,11 @@ after(() => dom.window.close());
test("assignChannel refreshes an existing assignment before the next eviction", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
const { MAX_CHANNEL_SECTION_ASSIGNMENTS, storageKey } = await import(
"./channelSectionsStorage.ts"
);
const {
MAX_CHANNEL_SECTION_ASSIGNMENTS,
readChannelSectionsStore,
storageKey,
} = await import("./channelSectionsStorage.ts");
const { useChannelSections } = await import("./useChannelSections.ts");
const originalFetchEvents = relayClient.fetchEvents;
@@ -68,6 +70,19 @@ test("assignChannel refreshes an existing assignment before the next eviction",
Object.keys(result.current.assignments).length,
MAX_CHANNEL_SECTION_ASSIGNMENTS,
);
act(() => result.current.unassignChannel("chan-0000"));
assert.equal(result.current.assignments["chan-0000"], undefined);
assert.deepEqual(
readChannelSectionsStore(pubkey, relayUrl).manuallyUnassignedChannelIds,
["chan-0000"],
);
act(() => result.current.assignChannel("chan-0000", "section-2"));
assert.equal(
readChannelSectionsStore(pubkey, relayUrl).manuallyUnassignedChannelIds,
undefined,
);
unmount();
} finally {
cleanup();

View File

@@ -11,27 +11,50 @@ import {
import { ChannelSectionSyncManager } from "./channelSectionsSync";
import type { RemoteSections } from "./channelSectionsSync";
import { swapSectionOrder } from "./channelSectionsHelpers";
import {
applyAutomaticSmartSectionAssignments,
assignChannelsToSection,
manuallyUnassignChannel,
type SmartSectionChannel,
} from "./smartChannelSections";
export type { ChannelSection } from "./channelSectionsStorage";
const EMPTY_SMART_SECTION_CHANNELS: readonly SmartSectionChannel[] = [];
export type {
ChannelSection,
SmartSectionRule,
} from "./channelSectionsStorage";
import type {
ChannelSection,
ChannelSectionStore,
SmartSectionRule,
} from "./channelSectionsStorage";
export function useChannelSections(
pubkey: string | undefined,
relayUrl?: string,
channels: readonly SmartSectionChannel[] = EMPTY_SMART_SECTION_CHANNELS,
): {
sections: ChannelSection[];
assignments: Record<string, string>;
createSection: (name: string, icon?: string) => ChannelSection | null;
renameSection: (sectionId: string, newName: string, icon?: string) => void;
createSection: (
name: string,
icon?: string,
smartRule?: SmartSectionRule,
) => ChannelSection | null;
renameSection: (
sectionId: string,
newName: string,
icon?: string,
smartRule?: SmartSectionRule,
) => void;
deleteSection: (sectionId: string) => void;
moveSectionUp: (sectionId: string) => void;
moveSectionDown: (sectionId: string) => void;
reorderSections: (orderedIds: string[]) => void;
assignChannel: (channelId: string, sectionId: string) => void;
assignChannels: (channelIds: string[], sectionId: string) => void;
unassignChannel: (channelId: string) => void;
} {
const [store, setStore] = React.useState<ChannelSectionStore>(() => {
@@ -168,7 +191,11 @@ export function useChannelSections(
);
const createSection = React.useCallback(
(name: string, icon?: string): ChannelSection | null => {
(
name: string,
icon?: string,
smartRule?: SmartSectionRule,
): ChannelSection | null => {
if (!pubkey) return null;
const prev = readChannelSectionsStore(pubkey, relayUrl);
const maxOrder =
@@ -180,6 +207,7 @@ export function useChannelSections(
name,
...(icon ? { icon } : {}),
order: maxOrder + 1,
...(smartRule ? { smartRule } : {}),
};
setStore((current) => {
const next = boundChannelSectionsStore({
@@ -196,7 +224,12 @@ export function useChannelSections(
);
const renameSection = React.useCallback(
(sectionId: string, newName: string, icon?: string) => {
(
sectionId: string,
newName: string,
icon?: string,
smartRule?: SmartSectionRule,
) => {
if (!pubkey) {
return;
}
@@ -206,10 +239,11 @@ export function useChannelSections(
sections: prev.sections.map((s) =>
s.id === sectionId
? {
id: s.id,
...s,
name: newName,
...(icon ? { icon } : {}),
order: s.order,
...(icon ? {} : { icon: undefined }),
...(smartRule ? { smartRule } : {}),
}
: s,
),
@@ -302,13 +336,9 @@ export function useChannelSections(
return;
}
setStore((prev) => {
const assignments = { ...prev.assignments };
delete assignments[channelId];
assignments[channelId] = sectionId;
const next = boundChannelSectionsStore({
...prev,
assignments,
});
const next = boundChannelSectionsStore(
assignChannelsToSection(prev, [channelId], sectionId),
);
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
@@ -319,15 +349,30 @@ export function useChannelSections(
[pubkey, relayUrl],
);
const assignChannels = React.useCallback(
(channelIds: string[], sectionId: string) => {
if (!pubkey || channelIds.length === 0) return;
setStore((prev) => {
const next = boundChannelSectionsStore(
assignChannelsToSection(prev, channelIds, sectionId),
);
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev;
managerRef.current?.publishSections(next);
return next;
});
},
[pubkey, relayUrl],
);
const unassignChannel = React.useCallback(
(channelId: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const assignments = { ...prev.assignments };
delete assignments[channelId];
const next: ChannelSectionStore = { ...prev, assignments };
const next = boundChannelSectionsStore(
manuallyUnassignChannel(prev, channelId),
);
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
@@ -338,6 +383,26 @@ export function useChannelSections(
[pubkey, relayUrl],
);
const smartRulesKey = JSON.stringify(
store.sections.flatMap((section) =>
section.smartRule
? [[section.id, section.order, section.smartRule] as const]
: [],
),
);
React.useEffect(() => {
if (!pubkey || channels.length === 0 || smartRulesKey === "[]") return;
setStore((prev) => {
const matched = applyAutomaticSmartSectionAssignments(prev, channels);
if (matched === prev) return prev;
const next = boundChannelSectionsStore(matched);
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev;
managerRef.current?.publishSections(next);
return next;
});
}, [pubkey, relayUrl, channels, smartRulesKey]);
return {
sections,
assignments: store.assignments,
@@ -348,6 +413,7 @@ export function useChannelSections(
moveSectionDown,
reorderSections,
assignChannel,
assignChannels,
unassignChannel,
};
}

View File

@@ -45,6 +45,7 @@ import {
SectionQuickAction,
} from "@/features/sidebar/ui/CustomChannelSection";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { useSmartSectionDialogs } from "@/features/sidebar/ui/useSmartSectionDialogs";
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
import { HuddleProfileControl } from "@/features/huddle";
import type {
@@ -346,18 +347,12 @@ export function AppSidebar({
}));
}, []);
const {
sections: channelSections,
assignments: channelAssignments,
createSection,
renameSection,
deleteSection,
moveSectionUp,
moveSectionDown,
reorderSections,
assignChannel,
unassignChannel,
} = useChannelSections(currentPubkey, activeCommunity?.relayUrl);
const streamChannels = React.useMemo(
() => channels.filter((channel) => channel.channelType === "stream"),
[channels],
);
// biome-ignore format: keep compact to stay within file size limit
const { sections: channelSections, assignments: channelAssignments, createSection, renameSection, deleteSection, moveSectionUp, moveSectionDown, reorderSections, assignChannel, assignChannels, unassignChannel } = useChannelSections(currentPubkey, activeCommunity?.relayUrl, streamChannels);
const sectionIds = React.useMemo(
() => channelSections.map((s) => s.id),
@@ -385,11 +380,8 @@ export function AppSidebar({
if (channel.id === selectedChannelId) onSelectHome();
});
const streamChannels = React.useMemo(
() => channels.filter((channel) => channel.channelType === "stream"),
[channels],
);
// biome-ignore format: keep compact to stay within file size limit
const smartSectionDialogs = useSmartSectionDialogs({ sections: channelSections, channels: streamChannels, assignments: channelAssignments, createSection, renameSection, assignChannels });
const sectionBuckets = React.useMemo(() => {
const bySection: Record<string, Channel[]> = {};
const unassigned: Channel[] = [];
@@ -717,6 +709,10 @@ export function AppSidebar({
handleCreateChannelInSection(section.id)
}
onRenameSection={() => setRenameSectionTarget(section)}
onEditSmartSection={smartSectionDialogs.openEditDialog}
onFindMatchingChannels={
smartSectionDialogs.openMatchesDialog
}
onDeleteSection={() => setDeleteSectionTarget(section)}
onMoveSectionUp={() => moveSectionUp(section.id)}
onMoveSectionDown={() => moveSectionDown(section.id)}
@@ -760,6 +756,9 @@ export function AppSidebar({
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={handleCreateSectionForChannel}
onCreateSmartSection={
smartSectionDialogs.openCreateDialog
}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
@@ -952,7 +951,7 @@ export function AppSidebar({
}}
onConfirm={handleCreateSectionConfirm}
/>
{smartSectionDialogs.dialogs}
<RenameSectionDialog
open={renameSectionTarget !== null}
onOpenChange={(open) => {

View File

@@ -7,6 +7,7 @@ import {
EllipsisVertical,
Pencil,
Plus,
Search,
Trash2,
} from "lucide-react";
@@ -128,9 +129,11 @@ export function SectionActionsMenu({
browseLabel,
onCreate,
createLabel,
onCreateSmartSection,
onNewMessage,
newMessageLabel,
onRenameSection,
onRunSmartSection,
onMoveSectionUp,
onMoveSectionDown,
onDeleteSection,
@@ -149,9 +152,11 @@ export function SectionActionsMenu({
browseLabel?: string;
onCreate?: () => void;
createLabel?: string;
onCreateSmartSection?: () => void;
onNewMessage?: () => void;
newMessageLabel?: string;
onRenameSection?: () => void;
onRunSmartSection?: () => void;
onMoveSectionUp?: () => void;
onMoveSectionDown?: () => void;
onDeleteSection?: () => void;
@@ -213,6 +218,14 @@ export function SectionActionsMenu({
<span>{createLabel ?? "Create channel"}</span>
</DropdownMenuItem>
) : null}
{onCreateSmartSection ? (
<DropdownMenuItem
onSelect={() => deferMenuAction(onCreateSmartSection)}
>
<Search className="h-4 w-4" />
<span>New smart section</span>
</DropdownMenuItem>
) : null}
{showSectionManagement ? (
<>
{onRenameSection ? (
@@ -220,7 +233,17 @@ export function SectionActionsMenu({
onSelect={() => deferMenuAction(onRenameSection)}
>
<Pencil className="h-4 w-4" />
<span>Rename section</span>
<span>
{onRunSmartSection ? "Edit smart section" : "Rename section"}
</span>
</DropdownMenuItem>
) : null}
{onRunSmartSection ? (
<DropdownMenuItem
onSelect={() => deferMenuAction(onRunSmartSection)}
>
<Search className="h-4 w-4" />
<span>Find matching channels</span>
</DropdownMenuItem>
) : null}
{onMoveSectionUp ? (
@@ -345,6 +368,7 @@ export function ChannelGroupSection({
listTestId,
onBrowseClick,
onCreateClick,
onCreateSmartSection,
onQuickCreateClick,
quickCreateLabel,
showQuickCreate,
@@ -385,6 +409,7 @@ export function ChannelGroupSection({
listTestId: string;
onBrowseClick?: () => void;
onCreateClick?: () => void;
onCreateSmartSection?: () => void;
/**
* Overrides the quick-create (`+`) button's click handler. Defaults to
* `onCreateClick`. Used to point the sidebar `+` at the unified
@@ -521,6 +546,7 @@ export function ChannelGroupSection({
browseLabel={browseLabel}
onCreate={onCreateClick}
createLabel={createLabel}
onCreateSmartSection={onCreateSmartSection}
sortMode={sortMode}
onSortModeChange={onSortModeChange}
/>
@@ -566,6 +592,8 @@ export function CustomChannelSection({
onCreateSectionForChannel,
onCreateChannel,
onRenameSection,
onEditSmartSection,
onFindMatchingChannels,
onDeleteSection,
onMoveSectionUp,
onMoveSectionDown,
@@ -606,6 +634,8 @@ export function CustomChannelSection({
onCreateSectionForChannel: (channelId: string) => void;
onCreateChannel: () => void;
onRenameSection: () => void;
onEditSmartSection: (section: ChannelSection) => void;
onFindMatchingChannels: (section: ChannelSection) => void;
onDeleteSection: () => void;
onMoveSectionUp: () => void;
onMoveSectionDown: () => void;
@@ -620,6 +650,12 @@ export function CustomChannelSection({
}) {
const contentId = `sidebar-section-${section.id}`;
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
const onEditSection = section.smartRule
? () => onEditSmartSection(section)
: onRenameSection;
const onRunSmartSection = section.smartRule
? () => onFindMatchingChannels(section)
: undefined;
return (
<SortableSectionShell sectionId={section.id}>
@@ -638,14 +674,16 @@ export function CustomChannelSection({
<div className="relative" {...dragHandleProps}>
<SidebarGroupLabel
asChild
className={section.icon ? undefined : "pl-8"}
className={
section.icon || section.smartRule ? undefined : "pl-8"
}
>
<button
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={cn(
SECTION_LABEL_BUTTON_CLASS,
section.icon && "gap-2",
(section.icon || section.smartRule) && "gap-2",
)}
onClick={onToggleCollapsed}
type="button"
@@ -661,6 +699,15 @@ export function CustomChannelSection({
value={section.icon}
/>
</span>
) : section.smartRule ? (
<span
aria-label="Smart section"
className="flex h-4 w-4 shrink-0 items-center justify-center text-sidebar-foreground/60"
role="img"
title="Smart section"
>
<Search className="h-3.5 w-3.5" />
</span>
) : null}
<span
className="truncate"
@@ -694,7 +741,8 @@ export function CustomChannelSection({
onOpenChange={setActionsMenuOpen}
hasUnread={hasUnread}
onMarkAllRead={onMarkSectionRead}
onRenameSection={onRenameSection}
onRenameSection={onEditSection}
onRunSmartSection={onRunSmartSection}
onMoveSectionUp={onMoveSectionUp}
onMoveSectionDown={onMoveSectionDown}
onDeleteSection={onDeleteSection}
@@ -707,10 +755,16 @@ export function CustomChannelSection({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onRenameSection}>
<ContextMenuItem onClick={onEditSection}>
<Pencil className="h-4 w-4" />
Rename section
{section.smartRule ? "Edit smart section" : "Rename section"}
</ContextMenuItem>
{onRunSmartSection ? (
<ContextMenuItem onClick={onRunSmartSection}>
<Search className="h-4 w-4" />
Find matching channels
</ContextMenuItem>
) : null}
<ContextMenuItem disabled={isFirst} onClick={onMoveSectionUp}>
<ArrowUp className="h-4 w-4" />
Move up

View File

@@ -0,0 +1,326 @@
import { CaseSensitive, Hash, Regex, Search } from "lucide-react";
import * as React from "react";
import type {
ChannelSection,
SmartSectionRule,
} from "@/features/sidebar/lib/channelSectionsStorage";
import {
getSmartSectionRuleError,
matchingChannelsForSection,
} from "@/features/sidebar/lib/smartChannelSections";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Toggle } from "@/shared/ui/toggle";
export type SmartSectionDialogValue = {
name: string;
smartRule: SmartSectionRule;
};
export function SmartSectionDialog({
open,
onOpenChange,
section,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
section?: ChannelSection | null;
onConfirm: (value: SmartSectionDialogValue) => void;
}) {
const [name, setName] = React.useState("");
const [pattern, setPattern] = React.useState("");
const [isRegex, setIsRegex] = React.useState(false);
const [caseSensitive, setCaseSensitive] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement>(null);
const isEditing = Boolean(section);
const smartRule = React.useMemo(
() => ({ pattern, isRegex, caseSensitive }),
[pattern, isRegex, caseSensitive],
);
const ruleError = getSmartSectionRuleError(smartRule);
React.useEffect(() => {
if (!open) return;
setName(section?.name ?? "");
setPattern(section?.smartRule?.pattern ?? "");
setIsRegex(section?.smartRule?.isRegex ?? false);
setCaseSensitive(section?.smartRule?.caseSensitive ?? false);
const timerId = globalThis.setTimeout(
() => nameInputRef.current?.focus(),
50,
);
return () => globalThis.clearTimeout(timerId);
}, [open, section]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedName = name.trim();
const trimmedPattern = pattern.trim();
const nextRule = { ...smartRule, pattern: trimmedPattern };
if (!trimmedName || getSmartSectionRuleError(nextRule)) return;
onConfirm({ name: trimmedName, smartRule: nextRule });
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isEditing ? "Edit smart section" : "Create smart section"}
</DialogTitle>
<DialogDescription>
New matching channels are placed here automatically. Manual moves
always take precedence.
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="smart-section-name">
Section name
</label>
<Input
autoComplete="off"
id="smart-section-name"
onChange={(event) => setName(event.target.value)}
placeholder="Engineering"
ref={nameInputRef}
value={name}
/>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="smart-section-pattern"
>
Channel name pattern
</label>
<div className="relative">
<Input
aria-describedby="smart-section-pattern-help"
aria-invalid={Boolean(ruleError)}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className="pr-20"
id="smart-section-pattern"
onChange={(event) => setPattern(event.target.value)}
placeholder={isRegex ? "^eng-.*" : "eng-"}
spellCheck={false}
value={pattern}
/>
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5">
<Toggle
aria-label="Use regular expression"
className="h-7 min-w-7 rounded-md px-1.5"
data-testid="smart-section-regex-toggle"
onPressedChange={setIsRegex}
pressed={isRegex}
size="sm"
title="Regular expression"
type="button"
variant="ghost"
>
<Regex />
</Toggle>
<Toggle
aria-label="Match case"
className="h-7 min-w-7 rounded-md px-1.5"
data-testid="smart-section-case-toggle"
onPressedChange={setCaseSensitive}
pressed={caseSensitive}
size="sm"
title="Case sensitive"
type="button"
variant="ghost"
>
<CaseSensitive />
</Toggle>
</div>
</div>
<p
className={
ruleError
? "text-xs text-destructive"
: "text-xs text-muted-foreground"
}
id="smart-section-pattern-help"
>
{ruleError ??
(isRegex
? "Matches channel names using a regular expression."
: "Matches channel names containing this text.")}
</p>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
Cancel
</Button>
</DialogClose>
<Button disabled={!name.trim() || Boolean(ruleError)} type="submit">
{isEditing ? "Save" : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function SmartSectionMatchesDialog({
open,
onOpenChange,
section,
sections,
channels,
assignments,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
section: ChannelSection | null;
sections: ChannelSection[];
channels: Channel[];
assignments: Record<string, string>;
onConfirm: (channelIds: string[]) => void;
}) {
const matches = React.useMemo(
() => (section ? matchingChannelsForSection(section, channels) : []),
[section, channels],
);
const movableIds = React.useMemo(
() =>
matches
.filter((channel) => assignments[channel.id] !== section?.id)
.map((channel) => channel.id),
[matches, assignments, section?.id],
);
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
const sectionNames = React.useMemo(
() => new Map(sections.map((candidate) => [candidate.id, candidate.name])),
[sections],
);
React.useEffect(() => {
if (open) setSelectedIds(new Set(movableIds));
}, [open, movableIds]);
function toggleChannel(channelId: string, checked: boolean) {
setSelectedIds((current) => {
const next = new Set(current);
if (checked) next.add(channelId);
else next.delete(channelId);
return next;
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Matching channels</DialogTitle>
<DialogDescription>
Review every channel matching{" "}
{section?.name ?? "this smart section"}
{section?.smartRule ? ` (${section.smartRule.pattern})` : ""}.
Selected channels will be moved into the section.
</DialogDescription>
</DialogHeader>
{matches.length > 0 ? (
<>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{matches.length} matching channels</span>
{movableIds.length > 0 ? (
<button
className="font-medium text-foreground hover:underline"
onClick={() =>
setSelectedIds(
selectedIds.size === movableIds.length
? new Set()
: new Set(movableIds),
)
}
type="button"
>
{selectedIds.size === movableIds.length
? "Clear selection"
: "Select all"}
</button>
) : null}
</div>
<div className="max-h-72 space-y-1 overflow-y-auto rounded-lg border border-border p-1">
{matches.map((channel) => {
const currentSectionId = assignments[channel.id];
const alreadyInSection = currentSectionId === section?.id;
const location = alreadyInSection
? "Already in section"
: currentSectionId
? (sectionNames.get(currentSectionId) ?? "Another section")
: "Channels";
return (
<label
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/70"
htmlFor={`smart-section-match-${channel.id}`}
key={channel.id}
>
<Checkbox
checked={alreadyInSection || selectedIds.has(channel.id)}
disabled={alreadyInSection}
id={`smart-section-match-${channel.id}`}
onCheckedChange={(checked) =>
toggleChannel(channel.id, checked === true)
}
/>
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm">
{channel.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{location}
</span>
</label>
);
})}
</div>
</>
) : (
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border py-10 text-center">
<Search className="h-6 w-6 text-muted-foreground" />
<p className="text-sm font-medium">No matching channels</p>
<p className="text-xs text-muted-foreground">
Edit the smart section to broaden its pattern.
</p>
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
Cancel
</Button>
</DialogClose>
<Button
disabled={selectedIds.size === 0}
onClick={() => onConfirm(Array.from(selectedIds))}
type="button"
>
Move {selectedIds.size}{" "}
{selectedIds.size === 1 ? "channel" : "channels"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,96 @@
import * as React from "react";
import type {
ChannelSection,
SmartSectionRule,
} from "@/features/sidebar/lib/channelSectionsStorage";
import type { Channel } from "@/shared/api/types";
import {
SmartSectionDialog,
SmartSectionMatchesDialog,
} from "./SmartSectionDialogs";
export function useSmartSectionDialogs({
sections,
channels,
assignments,
createSection,
renameSection,
assignChannels,
}: {
sections: ChannelSection[];
channels: Channel[];
assignments: Record<string, string>;
createSection: (
name: string,
icon?: string,
smartRule?: SmartSectionRule,
) => ChannelSection | null;
renameSection: (
sectionId: string,
name: string,
icon?: string,
smartRule?: SmartSectionRule,
) => void;
assignChannels: (channelIds: string[], sectionId: string) => void;
}) {
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [editTarget, setEditTarget] = React.useState<ChannelSection | null>(
null,
);
const [matchesTarget, setMatchesTarget] =
React.useState<ChannelSection | null>(null);
const openCreateDialog = React.useCallback(() => setIsCreateOpen(true), []);
const dialogs = (
<>
<SmartSectionDialog
onConfirm={(value) => {
const created = createSection(value.name, undefined, value.smartRule);
if (created) setIsCreateOpen(false);
}}
onOpenChange={setIsCreateOpen}
open={isCreateOpen}
/>
<SmartSectionDialog
onConfirm={(value) => {
if (!editTarget) return;
renameSection(
editTarget.id,
value.name,
editTarget.icon,
value.smartRule,
);
setEditTarget(null);
}}
onOpenChange={(open) => {
if (!open) setEditTarget(null);
}}
open={editTarget !== null}
section={editTarget}
/>
<SmartSectionMatchesDialog
assignments={assignments}
channels={channels}
onConfirm={(channelIds) => {
if (!matchesTarget) return;
assignChannels(channelIds, matchesTarget.id);
setMatchesTarget(null);
}}
onOpenChange={(open) => {
if (!open) setMatchesTarget(null);
}}
open={matchesTarget !== null}
section={matchesTarget}
sections={sections}
/>
</>
);
return {
dialogs,
openCreateDialog,
openEditDialog: setEditTarget,
openMatchesDialog: setMatchesTarget,
};
}

View File

@@ -0,0 +1,86 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/smart-channel-sections";
test("creates a smart section and restores a manually removed match", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
const channelsGroup = page
.getByTestId("stream-list-section-label")
.locator('xpath=ancestor::*[@data-sidebar="group"]');
await expect(channelsGroup).toBeVisible();
await waitForAnimations(page);
await channelsGroup.screenshot({ path: `${SHOTS}/00-before.png` });
await page.getByTestId("section-actions-channels").click();
await page.getByRole("menuitem", { name: "New smart section" }).click();
const createDialog = page.getByRole("dialog", {
name: "Create smart section",
});
await expect(createDialog).toBeVisible();
await createDialog.getByLabel("Section name").fill("Product & Engineering");
await createDialog
.getByLabel("Channel name pattern")
.fill("^(engineering|agents)$");
await createDialog.getByLabel("Use regular expression").click();
await createDialog.getByLabel("Match case").click();
await expect(
createDialog.getByLabel("Use regular expression"),
).toHaveAttribute("data-state", "on");
await expect(createDialog.getByLabel("Match case")).toHaveAttribute(
"data-state",
"on",
);
await waitForAnimations(page);
await createDialog.screenshot({
path: `${SHOTS}/01-create-smart-section.png`,
});
await createDialog.getByRole("button", { name: "Create" }).click();
const smartSection = page
.getByText("Product & Engineering", { exact: true })
.locator('xpath=ancestor::*[@data-sidebar="group"]');
await expect(smartSection).toBeVisible();
await expect(smartSection.getByTestId("channel-agents")).toBeVisible();
await expect(smartSection.getByTestId("channel-engineering")).toBeVisible();
await waitForAnimations(page);
await smartSection.screenshot({ path: `${SHOTS}/02-matched-section.png` });
await smartSection.getByTestId("channel-engineering").click({
button: "right",
});
await page.getByRole("menuitem", { name: "Move to section" }).hover();
await page.getByRole("menuitem", { name: "Remove from section" }).click();
await expect(smartSection.getByTestId("channel-engineering")).toHaveCount(0);
const sectionActions = page.locator(
'button[aria-label="More actions for Product & Engineering"]',
);
await sectionActions.click();
await page.getByRole("menuitem", { name: "Find matching channels" }).click();
const matchesDialog = page.getByRole("dialog", {
name: "Matching channels",
});
await expect(matchesDialog).toContainText("2 matching channels");
await expect(matchesDialog).toContainText("Already in section");
await expect(matchesDialog).toContainText("Channels");
await expect(
matchesDialog.getByRole("button", { name: "Move 1 channel" }),
).toBeEnabled();
await waitForAnimations(page);
await matchesDialog.screenshot({
path: `${SHOTS}/03-review-matches.png`,
});
await matchesDialog.getByRole("button", { name: "Move 1 channel" }).click();
await expect(smartSection.getByTestId("channel-engineering")).toBeVisible();
});