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. */