From d528cd6f75a2372af2128ade8326308897976a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 6 Jul 2026 22:56:02 -0400 Subject: [PATCH] Turn the Messages rail into a real conversation list Every channel and direct conversation is now its own row the way messengers work: avatar or channel glyph, name, last-message preview, relative time, and a true unread count from per-employee read state (the old badge showed total message count). Rows sort by latest activity and the newest conversation opens by default. Direct rows title themselves after the counterpart resolved from memberships; agent-to-agent conversations the board observes keep their two-name title, and opening one clears its unread because read state is per-employee bookkeeping, not access control. Conversation rows get their own .convo-item style instead of fighting the sidebar's single-line nav-item rules. --- .../src/main/services/chat-service.ts | 4 +- .../src/renderer/src/pages/ChatPage.tsx | 22 ++- .../renderer/src/pages/chat/ChatSidebar.tsx | 173 +++++++++--------- .../renderer/src/pages/chat/chat-helpers.ts | 109 +++++++++++ AgentCompany-New/src/renderer/src/styles.css | 19 ++ 5 files changed, 231 insertions(+), 96 deletions(-) diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index aea32b71..bbb6e577 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -212,7 +212,9 @@ export class ChatService { const employee = this.db.prepare("select id from employees where id = ? and org_id = ?") .get(input.employeeId, input.orgId) as { id: string } | undefined; if (!employee) throw new Error("Employee was not found."); - this.requireMembership(input.spaceId, { type: "employee", id: input.employeeId }); + // Read state is per-employee bookkeeping, not access control: the board + // observes every conversation in its org (agent↔agent directs included), + // so opening one clears its unread count even without membership. const latest = this.db.prepare(` select id from chat_messages where space_id = ? and org_id = ? diff --git a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx index 5c88bc31..6975c4db 100644 --- a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx @@ -5,6 +5,7 @@ import { ChatSidebar } from "./chat/ChatSidebar"; import { ChatConversation } from "./chat/ChatConversation"; import { ChatThreadPanel } from "./chat/ChatThreadPanel"; import { + buildConversations, collectParticipants, filterApprovalsForSpace, } from "./chat/chat-helpers"; @@ -45,8 +46,18 @@ export function ChatPage({ snapshot, actions }: PageProps) { () => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"), [snapshot.approvals, orgId], ); - const channels = useMemo(() => spaces.filter((space) => space.spaceType !== "direct"), [spaces]); - const directs = useMemo(() => spaces.filter((space) => space.spaceType === "direct"), [spaces]); + const conversations = useMemo( + () => buildConversations({ + spaces, + memberships: snapshot.chatMemberships, + messages: messagesAll, + readStates: snapshot.chatReadStates, + employees, + agents, + currentEmployeeId: employee.id, + }), + [spaces, snapshot.chatMemberships, messagesAll, snapshot.chatReadStates, employees, agents, employee.id], + ); const canBoardIntervene = useMemo( () => snapshot.roleGrants.some( @@ -66,7 +77,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { const [threadAttachmentFileId, setThreadAttachmentFileId] = useState(""); const composerRef = useRef(null); - const firstAvailableSpaceId = channels[0]?.id ?? directs[0]?.id ?? null; + const firstAvailableSpaceId = conversations[0]?.space.id ?? null; const activeSpaceId = selectedSpaceId && spaces.some((space) => space.id === selectedSpaceId) ? selectedSpaceId : firstAvailableSpaceId; @@ -245,10 +256,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { return (
{ diff --git a/AgentCompany-New/src/renderer/src/pages/chat/ChatSidebar.tsx b/AgentCompany-New/src/renderer/src/pages/chat/ChatSidebar.tsx index c222f11b..72c3534e 100644 --- a/AgentCompany-New/src/renderer/src/pages/chat/ChatSidebar.tsx +++ b/AgentCompany-New/src/renderer/src/pages/chat/ChatSidebar.tsx @@ -1,7 +1,7 @@ -import type { ReactNode } from "react"; import { Avatar, Icon, IconButton } from "../../shared/ui"; -import type { AgentRecord, ChatMessageRecord, ChatPresenceRecord, ChatSpaceRecord } from "../../../../shared/domain"; -import { countMessagesIn } from "./chat-helpers"; +import type { ChatPresenceRecord } from "../../../../shared/domain"; +import { formatRelative } from "../types"; +import type { ConversationItem } from "./chat-helpers"; function presenceColor(status: string | undefined): string | null { switch (status) { @@ -18,20 +18,19 @@ function presenceColor(status: string | undefined): string | null { } } +/** + * The conversation list: every channel and direct conversation is one row — + * avatar, name, last-message preview, time, unread — newest first, the way + * messengers do it. + */ export function ChatSidebar({ - channels, - directs, - agents, - messages, + conversations, presence, activeSpaceId, onSelect, onCreateSpace, }: { - channels: ChatSpaceRecord[]; - directs: ChatSpaceRecord[]; - agents: AgentRecord[]; - messages: ChatMessageRecord[]; + conversations: ConversationItem[]; presence: ChatPresenceRecord[]; activeSpaceId: string | null; onSelect: (spaceId: string) => void; @@ -45,101 +44,99 @@ export function ChatSidebar({
- - {channels.length === 0 ? ( - No channels. - ) : channels.map((space) => ( - onSelect(space.id)} +
+ {conversations.map((conversation) => ( + onSelect(conversation.space.id)} /> ))} - - {directs.length > 0 && ( - - {directs.map((space) => ( - onSelect(space.id)} - /> - ))} - - )} +
); } -function NavGroup({ label, children }: { label: string; children: ReactNode }) { - return ( -
-
{label}
-
{children}
-
- ); -} - -function Empty({ children }: { children: ReactNode }) { - return
{children}
; -} - -function ChannelItem({ - space, +function ConversationRow({ + conversation, active, - count, + presenceStatus, onClick, }: { - space: ChatSpaceRecord; + conversation: ConversationItem; active: boolean; - count: number; + presenceStatus: string | undefined; onClick: () => void; }) { + const dotColor = presenceColor(presenceStatus); return ( - - ); -} - -function DirectItem({ - space, - agents, - presenceByPrincipal, - active, - count, - onClick, -}: { - space: ChatSpaceRecord; - agents: AgentRecord[]; - presenceByPrincipal: Map; - active: boolean; - count: number; - onClick: () => void; -}) { - const counterpart = agents.find((agent) => space.name.includes(agent.name)); - const dotColor = counterpart ? presenceColor(presenceByPrincipal.get(counterpart.id)) : null; - return ( - ); } diff --git a/AgentCompany-New/src/renderer/src/pages/chat/chat-helpers.ts b/AgentCompany-New/src/renderer/src/pages/chat/chat-helpers.ts index e5f088d7..29824119 100644 --- a/AgentCompany-New/src/renderer/src/pages/chat/chat-helpers.ts +++ b/AgentCompany-New/src/renderer/src/pages/chat/chat-helpers.ts @@ -2,7 +2,9 @@ import type { AgentRecord, ApprovalRecord, ChatAttachmentRecord, + ChatMembershipRecord, ChatMessageRecord, + ChatReadStateRecord, ChatSpaceRecord, ChatThreadRecord, EmployeeRecord, @@ -76,6 +78,113 @@ export function filterApprovalsForSpace( return []; } +export interface ConversationCounterpart { + type: "agent" | "employee"; + id: string; + name: string; +} + +/** One row of the WeChat-style conversation list. */ +export interface ConversationItem { + space: ChatSpaceRecord; + title: string; + isDirect: boolean; + /** The other party of a direct conversation, when there is exactly one. */ + counterpart: ConversationCounterpart | null; + preview: string | null; + lastActivityAt: string; + unread: number; +} + +/** + * Folds spaces, messages, memberships, and per-employee read state into the + * conversation list: every channel and direct conversation as its own row, + * newest activity first, with a preview line and an unread count. + */ +export function buildConversations(input: { + spaces: ChatSpaceRecord[]; + memberships: ChatMembershipRecord[]; + messages: ChatMessageRecord[]; + readStates: ChatReadStateRecord[]; + employees: EmployeeRecord[]; + agents: AgentRecord[]; + currentEmployeeId: string; +}): ConversationItem[] { + const lastBySpace = new Map(); + const countsSince = new Map(); + const lastReadBySpace = new Map(); + for (const state of input.readStates) { + if (state.employeeId === input.currentEmployeeId) lastReadBySpace.set(state.spaceId, state.lastReadAt); + } + for (const message of input.messages) { + lastBySpace.set(message.spaceId, message); + if (message.senderType === "employee" && message.senderId === input.currentEmployeeId) continue; + const lastRead = lastReadBySpace.get(message.spaceId); + if (lastRead && message.createdAt <= lastRead) continue; + countsSince.set(message.spaceId, (countsSince.get(message.spaceId) ?? 0) + 1); + } + const membersBySpace = new Map(); + for (const membership of input.memberships) { + const group = membersBySpace.get(membership.spaceId); + if (group) group.push(membership); + else membersBySpace.set(membership.spaceId, [membership]); + } + const context: MessageContext = { employees: input.employees, agents: input.agents }; + + const items = input.spaces.map((space) => { + const last = lastBySpace.get(space.id) ?? null; + const isDirect = space.spaceType === "direct"; + const counterpart = isDirect + ? resolveCounterpart(membersBySpace.get(space.id) ?? [], input) + : null; + return { + space, + title: isDirect && counterpart ? counterpart.name : space.name, + isDirect, + counterpart, + preview: last ? `${senderName(last, context)}: ${previewText(last)}` : null, + lastActivityAt: last?.createdAt ?? space.createdAt, + unread: countsSince.get(space.id) ?? 0, + }; + }); + return items.sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt)); +} + +/** + * The single other party of a direct conversation, from the viewer's seat. + * Conversations between two agents have no single counterpart for the + * observing board member; those rows keep the space's own two-name title. + */ +function resolveCounterpart( + members: ChatMembershipRecord[], + input: { employees: EmployeeRecord[]; agents: AgentRecord[]; currentEmployeeId: string }, +): ConversationCounterpart | null { + const others = members.filter( + (member) => !(member.principalType === "employee" && member.principalId === input.currentEmployeeId), + ); + if (others.length !== members.length - 1) return null; + const named = others + .map((member): ConversationCounterpart | null => { + if (member.principalType === "agent") { + const agent = input.agents.find((item) => item.id === member.principalId); + return agent ? { type: "agent", id: agent.id, name: agent.name } : null; + } + if (member.principalType === "employee") { + const employee = input.employees.find((item) => item.id === member.principalId); + return employee ? { type: "employee", id: employee.id, name: employee.displayName } : null; + } + return null; + }) + .filter((item): item is ConversationCounterpart => item !== null); + return named.length === 1 ? named[0] : null; +} + +function previewText(message: ChatMessageRecord): string { + if (message.deletedAt) return "Message deleted"; + const firstLine = message.bodyMd.split("\n").find((line) => line.trim()) ?? ""; + return firstLine.trim(); +} + export function countMessagesIn(spaceId: string, messages: ChatMessageRecord[]): number { return messages.filter((message) => message.spaceId === spaceId).length; } diff --git a/AgentCompany-New/src/renderer/src/styles.css b/AgentCompany-New/src/renderer/src/styles.css index b1929c46..c4b91ff9 100644 --- a/AgentCompany-New/src/renderer/src/styles.css +++ b/AgentCompany-New/src/renderer/src/styles.css @@ -207,6 +207,25 @@ a { color: inherit; } } .nav-divider { height: 1px; background: var(--border-soft); margin: var(--sp-2); } +.convo-item { + display: flex; + align-items: flex-start; + gap: var(--sp-2); + padding: 8px var(--sp-2); + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--text-2); + font-size: var(--fs-body); + text-align: left; + width: 100%; + min-width: 0; + cursor: pointer; + transition: background var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); +} +.convo-item:hover { background: var(--hover); color: var(--text-1); } +.convo-item.active { background: var(--panel-3); color: var(--text-1); } + /* Selectable list row used by master-detail panes (Runs, etc.). width:100% + min-width:0 lets the inner flex child ellipsize instead of pushing the row past the pane and forcing a horizontal scrollbar. */