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.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-06 22:56:02 -04:00
1 parent cab7b01831
commit d528cd6f75
5 files changed
+231 -96

No files matched your search

@@ -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 = ?
@@ -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<string>("");
const composerRef = useRef<HTMLTextAreaElement | null>(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 (
<div className="split-main">
<ChatSidebar
channels={channels}
directs={directs}
agents={agents}
messages={messagesAll}
conversations={conversations}
presence={snapshot.chatPresence}
activeSpaceId={activeSpaceId}
onSelect={(spaceId) => {
@@ -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({
<IconButton icon="plus" label="New space" onClick={onCreateSpace} />
</div>
<div className="pane-scroll">
<NavGroup label="Channels">
{channels.length === 0 ? (
<Empty>No channels.</Empty>
) : channels.map((space) => (
<ChannelItem
key={space.id}
space={space}
active={space.id === activeSpaceId}
count={countMessagesIn(space.id, messages)}
onClick={() => onSelect(space.id)}
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
{conversations.map((conversation) => (
<ConversationRow
key={conversation.space.id}
conversation={conversation}
active={conversation.space.id === activeSpaceId}
presenceStatus={conversation.counterpart ? presenceByPrincipal.get(conversation.counterpart.id) : undefined}
onClick={() => onSelect(conversation.space.id)}
/>
))}
</NavGroup>
{directs.length > 0 && (
<NavGroup label="Direct">
{directs.map((space) => (
<DirectItem
key={space.id}
space={space}
agents={agents}
presenceByPrincipal={presenceByPrincipal}
active={space.id === activeSpaceId}
count={countMessagesIn(space.id, messages)}
onClick={() => onSelect(space.id)}
/>
))}
</NavGroup>
)}
</div>
</div>
</aside>
);
}
function NavGroup({ label, children }: { label: string; children: ReactNode }) {
return (
<div style={{ marginTop: 10 }}>
<div style={{ fontSize: 12, color: "var(--text-3)", padding: "6px 8px", fontWeight: 540, letterSpacing: "0.02em" }}>{label}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>{children}</div>
</div>
);
}
function Empty({ children }: { children: ReactNode }) {
return <div style={{ padding: "4px 8px", color: "var(--text-3)", fontSize: 12 }}>{children}</div>;
}
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 (
<button type="button" className={`nav-item ${active ? "active" : ""}`.trim()} onClick={onClick}>
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} size={16} color={active ? "var(--text-1)" : "var(--text-3)"} />
<span>{space.name}</span>
{count > 0 && <span className="nav-badge">{count}</span>}
</button>
);
}
function DirectItem({
space,
agents,
presenceByPrincipal,
active,
count,
onClick,
}: {
space: ChatSpaceRecord;
agents: AgentRecord[];
presenceByPrincipal: Map<string, string>;
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 (
<button type="button" className={`nav-item ${active ? "active" : ""}`.trim()} onClick={onClick}>
{counterpart ? (
<span className="presence">
<Avatar name={counterpart.name} size="sm" bot />
<button
type="button"
className={`convo-item ${active ? "active" : ""}`.trim()}
onClick={onClick}
>
{conversation.isDirect ? (
<span className="presence" style={{ marginTop: 2 }}>
<Avatar name={conversation.counterpart?.name ?? conversation.title} size="sm" bot={conversation.counterpart?.type !== "employee"} />
{dotColor && <span className="presence-dot" style={{ background: dotColor }} />}
</span>
) : (
<Icon name="user" size={16} />
<span
style={{
width: 22,
height: 22,
borderRadius: 6,
background: "var(--panel-3)",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
marginTop: 2,
}}
>
<Icon name={conversation.space.visibility === "restricted" ? "lock" : "hash"} size={13} color="var(--text-3)" />
</span>
)}
<span>{space.name}</span>
{count > 0 && <span className="nav-badge">{count}</span>}
<span style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 1 }}>
<span className="row" style={{ gap: 8 }}>
<span
style={{
fontWeight: conversation.unread > 0 ? 600 : 520,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0,
}}
>
{conversation.title}
</span>
<span style={{ fontSize: 11, color: "var(--text-3)", flexShrink: 0 }}>
{conversation.preview ? formatRelative(conversation.lastActivityAt) : ""}
</span>
</span>
<span className="row" style={{ gap: 8 }}>
<span
style={{
fontSize: 12,
color: "var(--text-3)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0,
}}
>
{conversation.preview ?? conversation.space.topic}
</span>
{conversation.unread > 0 && (
<span className="nav-badge" style={{ flexShrink: 0 }}>{conversation.unread > 99 ? "99+" : conversation.unread}</span>
)}
</span>
</span>
</button>
);
}
@@ -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<string, ChatMessageRecord>();
const countsSince = new Map<string, number>();
const lastReadBySpace = new Map<string, string>();
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<string, ChatMembershipRecord[]>();
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;
}
@@ -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. */