Keep companywide channel view visible

This commit is contained in:
ZacharyZhang-NY committed 2026-04-22 20:39:08 -04:00
1 parent 26c933fad7
commit b84162246a
5 files changed
+155 -52

No files matched your search

@@ -4,6 +4,8 @@ import { EnvelopeSimple, Hash, Buildings, Megaphone, Fire, MagnifyingGlass, Funn
import type { AgentRecord, MessageChannel } from "@shared/types"
import { AgentAvatar } from "./MessageItem"
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
import type { MessageFilters } from "./message-filters"
import { DEFAULT_MESSAGE_FILTERS, hasActiveMessageFilters } from "./message-filters"
import { useT } from "../../i18n"
import { Eyebrow, Select } from "../ui"
@@ -169,12 +171,7 @@ export function ChannelSidebar({
)
}
export interface FilterState {
search: string
channelType: MessageChannel | "all"
priority: string
unreadOnly: boolean
}
export type FilterState = MessageFilters
export function FilterBar({
filters,
@@ -209,7 +206,7 @@ export function FilterBar({
type="button"
onClick={() => setExpanded(!expanded)}
className={`flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-[11px] font-medium transition ${
expanded || filters.channelType !== "all" || filters.priority !== "all" || filters.unreadOnly
expanded || hasActiveMessageFilters(filters)
? "border-transparent bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:border-border"
}`}
@@ -227,19 +224,6 @@ export function FilterBar({
exit={{ opacity: 0, height: 0 }}
className="flex flex-wrap items-center gap-2 overflow-hidden"
>
<Select
compact
value={filters.channelType}
onChange={(v) => onChangeFilters({ ...filters, channelType: v as MessageChannel | "all" })}
options={[
{ value: "all", label: t("comm.allChannels") },
{ value: "direct", label: t("comm.direct") },
{ value: "department", label: t("comm.department") },
{ value: "company", label: t("comm.company") },
{ value: "project", label: t("nav.projects") },
{ value: "incident", label: t("comm.incident") },
]}
/>
<Select
compact
value={filters.priority}
@@ -251,19 +235,10 @@ export function FilterBar({
{ value: "low", label: t("comm.low") },
]}
/>
<button
type="button"
onClick={() => onChangeFilters({ ...filters, unreadOnly: !filters.unreadOnly })}
className={`rounded-full px-2.5 py-1.5 text-[11px] font-semibold transition ${
filters.unreadOnly ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
}`}
>
{t("comm.unreadOnly")}
</button>
{(filters.channelType !== "all" || filters.priority !== "all" || filters.unreadOnly) ? (
{filters.priority !== "all" ? (
<button
type="button"
onClick={() => onChangeFilters({ search: filters.search, channelType: "all", priority: "all", unreadOnly: false })}
onClick={() => onChangeFilters({ ...DEFAULT_MESSAGE_FILTERS, search: filters.search })}
className="text-[11px] font-medium text-muted-foreground underline transition hover:text-foreground"
>
{t("comm.clearFilters")}
@@ -11,6 +11,7 @@ import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./Me
import { SearchResults } from "./SearchResults"
import ThreadPanel from "./ThreadPanel"
import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils"
import { DEFAULT_MESSAGE_FILTERS, filterChannelMessages } from "./message-filters"
import { useT, type TranslationFn } from "../../i18n"
@@ -66,7 +67,7 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
const [optimisticReadIds, setOptimisticReadIds] = useState<Set<string>>(new Set())
const pendingReadIdsRef = useRef<Set<string>>(new Set())
const [filters, setFilters] = useState<FilterState>({ search: "", channelType: "all", priority: "all", unreadOnly: false })
const [filters, setFilters] = useState<FilterState>(DEFAULT_MESSAGE_FILTERS)
// Thread panel state from Zustand store
const activeThreadId = useAppStore((s) => s.activeThreadId)
@@ -96,26 +97,33 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks, forcedTaskId), [comments, forcedTaskId, tasks])
const channels: AnyChannel[] = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
useEffect(() => { if (channels.length === 0) { if (selectedChannelKey) setSelectedChannelKey(null); return }; if (!selectedChannelKey || !channels.some((c) => c.key === selectedChannelKey)) setSelectedChannelKey(channels[0].key) }, [channels, selectedChannelKey])
const handleSelectChannel = useCallback((key: string) => {
setSelectedChannelKey(key)
setReplyTo(null)
setFilters(DEFAULT_MESSAGE_FILTERS)
clearSearch()
}, [clearSearch])
useEffect(() => { if (channels.length === 0) { if (selectedChannelKey) setSelectedChannelKey(null); return }; if (!selectedChannelKey || !channels.some((c) => c.key === selectedChannelKey)) handleSelectChannel(channels[0].key) }, [channels, selectedChannelKey, handleSelectChannel])
useEffect(() => {
if (!selectedEntityId) return
const matchMsg = effectiveMessages.find((m) => m.id === selectedEntityId)
if (matchMsg) {
const ch = messageChannels.find((ch) => getChannelMessages(effectiveMessages, ch).some((m) => m.id === selectedEntityId))
if (ch) setSelectedChannelKey(ch.key)
if (ch) handleSelectChannel(ch.key)
onEntityConsumed?.()
return
}
const matchCom = comments.find((c) => c.id === selectedEntityId)
if (matchCom) {
setSelectedChannelKey(`task-${matchCom.taskId}`)
handleSelectChannel(`task-${matchCom.taskId}`)
onEntityConsumed?.()
return
}
const matchTask = taskDiscussionChannels.find((d) => d.taskId === selectedEntityId)
if (matchTask) {
setSelectedChannelKey(matchTask.key)
handleSelectChannel(matchTask.key)
onEntityConsumed?.()
return
}
@@ -125,21 +133,14 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
// the effect refire on every snapshot and block the user from
// navigating manually inside the communication hub.
onEntityConsumed?.()
}, [selectedEntityId, effectiveMessages, comments, messageChannels, onEntityConsumed, taskDiscussionChannels])
}, [selectedEntityId, effectiveMessages, comments, messageChannels, onEntityConsumed, taskDiscussionChannels, handleSelectChannel])
const activeChannel = useMemo(() => channels.find((c) => c.key === selectedChannelKey) ?? null, [channels, selectedChannelKey])
const channelMessages = useMemo(() => { if (!activeChannel || activeChannel.kind !== "message") return []; return getChannelMessages(effectiveMessages, activeChannel).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) }, [effectiveMessages, activeChannel])
const taskComments = useMemo(() => { if (!activeChannel || activeChannel.kind !== "task") return []; return comments.filter((c) => c.taskId === activeChannel.taskId).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) }, [comments, activeChannel])
const activeTask = useMemo(() => { if (!activeChannel || activeChannel.kind !== "task") return null; return tasks.find((t) => t.id === activeChannel.taskId) ?? null }, [tasks, activeChannel])
const filteredMessages = useMemo(() => {
let r = channelMessages
if (filters.channelType !== "all") r = r.filter((m) => m.channel === filters.channelType)
if (filters.search && !searchQuery) { const q = filters.search.toLowerCase(); r = r.filter((m) => m.subject.toLowerCase().includes(q) || m.body.toLowerCase().includes(q) || (agentMap.get(m.fromAgentId)?.name ?? "").toLowerCase().includes(q)) }
if (filters.priority !== "all") r = r.filter((m) => m.priority === filters.priority)
if (filters.unreadOnly) r = r.filter((m) => !m.readAt)
return r
}, [channelMessages, filters, agentMap, searchQuery])
const filteredMessages = useMemo(() => filterChannelMessages(channelMessages, filters, agentMap, searchQuery), [channelMessages, filters, agentMap, searchQuery])
// Debounced search via IPC
useEffect(() => {
@@ -200,13 +201,13 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
})
if (matchingChannel) {
setSelectedChannelKey(matchingChannel.key)
handleSelectChannel(matchingChannel.key)
}
// Clear search to return to normal view
setFilters((prev) => ({ ...prev, search: "" }))
clearSearch()
}, [messageChannels, effectiveMessages, clearSearch])
}, [messageChannels, effectiveMessages, handleSelectChannel, clearSearch])
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
@@ -271,7 +272,7 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
return (
<div className="flex h-full min-h-0 overflow-hidden">
<AnimatePresence initial={false}>
{showSidebar ? <ChannelSidebar channelGroups={channelGroups} taskDiscussionChannels={taskDiscussionChannels} selectedChannelKey={selectedChannelKey} onSelectChannel={setSelectedChannelKey} unreadByChannel={unreadByChannel} agents={agents} totalChannels={channels.length} onHideSidebar={() => setShowSidebar(false)} /> : null}
{showSidebar ? <ChannelSidebar channelGroups={channelGroups} taskDiscussionChannels={taskDiscussionChannels} selectedChannelKey={selectedChannelKey} onSelectChannel={handleSelectChannel} unreadByChannel={unreadByChannel} agents={agents} totalChannels={channels.length} onHideSidebar={() => setShowSidebar(false)} /> : null}
</AnimatePresence>
<div className="flex min-w-0 flex-1 flex-col">
<ChannelHeader activeChannel={activeChannel} showSidebar={showSidebar} onShowSidebar={() => setShowSidebar(true)} channelMessages={channelMessages} taskDiscussionComments={taskComments} activeTask={activeTask} unreadByChannel={unreadByChannel} onNavigateToTask={onNavigateToTask} filterBar={<FilterBar filters={filters} onChangeFilters={setFilters} scopeToggle={scopeToggleElement} onSearchKeyDown={handleSearchKeyDown} />} />
@@ -9,9 +9,7 @@ import { useRovingTabIndex } from "../../lib/keyboard"
interface FilterState {
search: string
channelType: string
priority: string
unreadOnly: boolean
}
interface MessageListProps {
@@ -128,11 +126,11 @@ export function MessageList({
<div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">
{filters.search || filters.unreadOnly || filters.priority !== "all"
{filters.search || filters.priority !== "all"
? t("comm.noMatchFilters")
: t("comm.noMessagesChannel")}
</div>
{(filters.search || filters.unreadOnly || filters.priority !== "all") ? (
{(filters.search || filters.priority !== "all") ? (
<div className="mt-1 text-[13px] text-muted-foreground">{t("comm.adjustFilters")}</div>
) : null}
</div>
@@ -0,0 +1,36 @@
import type { AgentMessageRecord, AgentRecord } from "@shared/types"
export interface MessageFilters {
search: string
priority: string
}
export const DEFAULT_MESSAGE_FILTERS: MessageFilters = {
search: "",
priority: "all",
}
export function hasActiveMessageFilters(filters: MessageFilters): boolean {
return filters.search.length > 0 || filters.priority !== "all"
}
export function filterChannelMessages(
messages: AgentMessageRecord[],
filters: MessageFilters,
agentMap: Map<string, AgentRecord>,
searchQuery: string,
): AgentMessageRecord[] {
let result = messages
if (filters.search && !searchQuery) {
const query = filters.search.toLowerCase()
result = result.filter((message) =>
message.subject.toLowerCase().includes(query)
|| message.body.toLowerCase().includes(query)
|| (agentMap.get(message.fromAgentId)?.name ?? "").toLowerCase().includes(query)
)
}
if (filters.priority !== "all") {
result = result.filter((message) => message.priority === filters.priority)
}
return result
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { AgentMessageRecord, AgentRecord } from "../../src/shared/types";
import {
DEFAULT_MESSAGE_FILTERS,
filterChannelMessages,
hasActiveMessageFilters,
} from "../../src/renderer/components/communication/message-filters";
function agent(id: string, name: string): AgentRecord {
return {
id,
companyId: "company-1",
name,
role: name,
title: name,
department: null,
status: "active",
reportsTo: null,
connectorId: "openai-codex",
workspaceId: null,
model: null,
capabilities: "",
budgetMonthlyUsd: 0,
spentMonthlyUsd: 0,
metadataJson: "{}",
heartbeatEnabled: false,
heartbeatIntervalSec: 300,
lastHeartbeatAt: null,
createdAt: "2026-04-22T00:00:00.000Z",
updatedAt: "2026-04-22T00:00:00.000Z",
};
}
function message(input: Partial<AgentMessageRecord>): AgentMessageRecord {
return {
id: input.id ?? "msg-1",
companyId: "company-1",
fromAgentId: input.fromAgentId ?? "agent-ceo",
toAgentId: input.toAgentId ?? null,
senderKind: input.senderKind ?? "agent",
channel: input.channel ?? "company",
channelTargetId: input.channelTargetId ?? null,
subject: input.subject ?? "Subject",
body: input.body ?? "Body",
priority: input.priority ?? "normal",
parentMessageId: input.parentMessageId ?? null,
attachmentsJson: input.attachmentsJson ?? "[]",
createdAt: input.createdAt ?? "2026-04-22T00:00:00.000Z",
readAt: input.readAt ?? null,
};
}
describe("message filters", () => {
it("keeps read and unread channel messages in the default channel view", () => {
const read = message({ id: "read", readAt: "2026-04-22T00:00:01.000Z" });
const unread = message({ id: "unread", readAt: null });
const result = filterChannelMessages(
[read, unread],
DEFAULT_MESSAGE_FILTERS,
new Map(),
"",
);
expect(result.map((entry) => entry.id)).toEqual(["read", "unread"]);
});
it("filters by text and sender inside the selected channel", () => {
const ceo = agent("agent-ceo", "Ada CEO");
const cto = agent("agent-cto", "Grace CTO");
const messages = [
message({ id: "ceo", fromAgentId: ceo.id, subject: "Launch", body: "Board update" }),
message({ id: "cto", fromAgentId: cto.id, subject: "Infra", body: "Runtime ready" }),
];
const result = filterChannelMessages(
messages,
{ ...DEFAULT_MESSAGE_FILTERS, search: "grace" },
new Map([
[ceo.id, ceo],
[cto.id, cto],
]),
"",
);
expect(result.map((entry) => entry.id)).toEqual(["cto"]);
});
it("detects active message filters", () => {
expect(hasActiveMessageFilters(DEFAULT_MESSAGE_FILTERS)).toBe(false);
expect(hasActiveMessageFilters({ ...DEFAULT_MESSAGE_FILTERS, priority: "urgent" })).toBe(true);
});
});