Keep companywide channel view visible
This commit is contained in:
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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user