Messages pins the founder's CEO conversation at the top

buildConversations learns the org's CEO (c_suite, the runtime predicate)
and ranks the viewer's own direct conversation with them above recency —
counterpart resolution already works from the viewer's seat, so agent-to-
agent CEO chats never match. The row carries a CEO badge and, before the
first exchange, invites asking about progress, goals, or direction.
Being first, it is also the default conversation the page opens on.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-14 00:16:38 -04:00
1 parent 4a7fac654e
commit 3c44ff959d
6 files changed
+136 -4

No files matched your search

+2
View File
@@ -52,6 +52,7 @@ export const zh: Record<string, string> = {
"Approve all low risk": "批准全部低风险",
"Approved": "已批准",
"Approved (7d)": "已批准(7 天)",
"Ask about progress, goals, or direction": "问进度、聊方向、随时调整目标",
"Assignee": "负责人",
"Attach file": "附加文件",
"Auto-approve hires": "自动批准招聘",
@@ -70,6 +71,7 @@ export const zh: Record<string, string> = {
"Budget utilization": "预算使用率",
"Busy": "忙碌",
"By agent": "按智能体",
"CEO": "CEO",
"CEO OFFICE": "CEO 办公室",
"Cancel": "取消",
"Catalog": "目录",
+8 -1
View File
@@ -40,6 +40,12 @@ export function ChatPage({ snapshot, actions }: PageProps) {
() => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"),
[snapshot.approvals, orgId],
);
// The CEO is the c_suite agent — the same predicate the runtime enforces.
// Snapshot agents arrive oldest-first, so .find is the founding CEO.
const ceoAgentId = useMemo(
() => agents.find((agent) => agent.roleLevel === "c_suite" && agent.lifecycle === "active")?.id ?? null,
[agents],
);
const conversations = useMemo(
() => buildConversations({
spaces,
@@ -49,8 +55,9 @@ export function ChatPage({ snapshot, actions }: PageProps) {
employees,
agents,
currentEmployeeId: employee.id,
ceoAgentId,
}),
[spaces, snapshot.chatMemberships, messagesAll, snapshot.chatReadStates, employees, agents, employee.id],
[spaces, snapshot.chatMemberships, messagesAll, snapshot.chatReadStates, employees, agents, employee.id, ceoAgentId],
);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
+2 -1
View File
@@ -156,6 +156,7 @@ function ConversationRow({
>
{conversation.title}
</span>
{conversation.isCeo && <span className="convo-ceo-badge">{t("CEO")}</span>}
<span style={{ fontSize: 11, color: "var(--text-3)", flexShrink: 0 }}>
{conversation.preview ? formatRelative(conversation.lastActivityAt) : ""}
</span>
@@ -172,7 +173,7 @@ function ConversationRow({
minWidth: 0,
}}
>
{conversation.preview ?? conversation.space.topic}
{conversation.preview ?? (conversation.isCeo ? t("Ask about progress, goals, or direction") : conversation.space.topic)}
</span>
{conversation.unread > 0 && (
<span className="nav-badge" style={{ flexShrink: 0 }}>{conversation.unread > 99 ? "99+" : conversation.unread}</span>
+10 -2
View File
@@ -85,6 +85,8 @@ export interface ConversationItem {
isDirect: boolean;
/** The other party of a direct conversation, when there is exactly one. */
counterpart: ConversationCounterpart | null;
/** The viewer's own conversation with the CEO — pinned above everything. */
isCeo: boolean;
preview: string | null;
lastActivityAt: string;
unread: number;
@@ -93,7 +95,9 @@ export interface ConversationItem {
/**
* 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.
* newest activity first, with a preview line and an unread count. The
* viewer's direct conversation with the CEO outranks recency — it is the
* company's front door and stays pinned at the top.
*/
export function buildConversations(input: {
spaces: ChatSpaceRecord[];
@@ -103,6 +107,7 @@ export function buildConversations(input: {
employees: EmployeeRecord[];
agents: AgentRecord[];
currentEmployeeId: string;
ceoAgentId: string | null;
}): ConversationItem[] {
const lastBySpace = new Map<string, ChatMessageRecord>();
const countsSince = new Map<string, number>();
@@ -140,12 +145,15 @@ export function buildConversations(input: {
title: isDirect ? directTitle : space.name,
isDirect,
counterpart,
// The counterpart resolves only from the viewer's own seat, so agent⇄agent
// conversations that happen to involve the CEO never match here.
isCeo: counterpart !== null && counterpart.type === "agent" && counterpart.id === input.ceoAgentId,
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));
return items.sort((a, b) => Number(b.isCeo) - Number(a.isCeo) || b.lastActivityAt.localeCompare(a.lastActivityAt));
}
/** Current display names of a space's members, in membership order. */
+9
View File
@@ -273,6 +273,15 @@ a { color: inherit; }
}
.convo-item:hover { background: var(--hover); color: var(--text-1); }
.convo-item.active { background: var(--panel-3); color: var(--text-1); }
.convo-ceo-badge {
font-size: var(--fs-caption);
font-weight: 560;
color: var(--accent);
background: var(--accent-soft);
padding: 1px 6px;
border-radius: 999px;
flex-shrink: 0;
}
/* Controls that appear on intent: hidden until the row is hovered or focused. */
.reveal-row .reveal { opacity: 0; transition: opacity var(--motion-fast) var(--ease-out); }
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, test } from "vitest";
import { buildConversations } from "../../src/renderer/src/pages/chat/chat-helpers";
import type {
AgentRecord,
ChatMembershipRecord,
ChatMessageRecord,
ChatSpaceRecord,
EmployeeRecord,
} from "../../src/shared/domain";
const ORG = "org_1";
const FOUNDER = "emp_founder";
const CEO = "agt_ceo";
const ENGINEER = "agt_engineer";
function space(id: string, spaceType: string, name: string, createdAt: string): ChatSpaceRecord {
return { id, orgId: ORG, spaceType, name, topic: "t", visibility: "restricted", retentionPolicy: "standard", createdAt };
}
function member(spaceId: string, principalType: "employee" | "agent", principalId: string): ChatMembershipRecord {
return { spaceId, principalType, principalId, role: "member", muteState: "unmuted" };
}
function message(id: string, spaceId: string, senderId: string, createdAt: string): ChatMessageRecord {
return {
id, orgId: ORG, spaceId, threadId: `thr_${id}`, senderType: "agent", senderId,
bodyMd: "hello", bodyStructuredJson: "{}", deliveryState: "sent", createdAt, editedAt: null, deletedAt: null,
};
}
function agent(id: string, name: string, roleLevel: string): AgentRecord {
return {
id, orgId: ORG, name, role: name, department: "Executive", modelProviderId: null, modelId: null,
budgetCents: 0, managerAgentId: roleLevel === "c_suite" ? null : CEO, title: name, roleLevel,
modelStrategy: "explicit", workspaceId: null, heartbeatEnabled: false, heartbeatIntervalSec: 0,
lifecycle: "active", avatarEmoji: null, avatarColor: null, avatarImage: null, createdAt: "2026-01-01T00:00:00Z",
};
}
const employees: EmployeeRecord[] = [{
id: FOUNDER, orgId: ORG, displayName: "Yang", role: "founder",
avatarEmoji: null, avatarColor: null, avatarImage: null, createdAt: "2026-01-01T00:00:00Z",
}];
const agents: AgentRecord[] = [agent(CEO, "CEO", "c_suite"), agent(ENGINEER, "Dana", "staff")];
// The founder⇄CEO conversation is the OLDEST and QUIETEST space; the busy
// channel and the CEO⇄engineer conversation both out-recency it. Pinning
// must beat recency, and only from the founder's own seat.
function fixtures() {
const spaces = [
space("spc_ceo_dm", "direct", "Yang · CEO", "2026-01-01T00:00:00Z"),
space("spc_general", "company", "general", "2026-01-02T00:00:00Z"),
space("spc_agents_dm", "direct", "CEO · Dana", "2026-01-03T00:00:00Z"),
];
const memberships = [
member("spc_ceo_dm", "employee", FOUNDER),
member("spc_ceo_dm", "agent", CEO),
member("spc_general", "employee", FOUNDER),
member("spc_general", "agent", CEO),
member("spc_general", "agent", ENGINEER),
member("spc_agents_dm", "agent", CEO),
member("spc_agents_dm", "agent", ENGINEER),
];
const messages = [
message("m1", "spc_ceo_dm", CEO, "2026-01-04T00:00:00Z"),
message("m2", "spc_general", ENGINEER, "2026-01-05T00:00:00Z"),
message("m3", "spc_agents_dm", CEO, "2026-01-06T00:00:00Z"),
];
return { spaces, memberships, messages };
}
function build(overrides: Partial<Parameters<typeof buildConversations>[0]> = {}) {
const { spaces, memberships, messages } = fixtures();
return buildConversations({
spaces, memberships, messages, readStates: [], employees, agents,
currentEmployeeId: FOUNDER, ceoAgentId: CEO, ...overrides,
});
}
describe("conversation pinning", () => {
test("the founder's CEO conversation outranks recency and sits first", () => {
const conversations = build();
expect(conversations.map((item) => item.space.id)).toEqual(["spc_ceo_dm", "spc_agents_dm", "spc_general"]);
expect(conversations[0].isCeo).toBe(true);
expect(conversations[0].title).toBe("CEO");
});
test("a CEO⇄agent conversation is not the founder's pinned line", () => {
const conversations = build();
const agentsDm = conversations.find((item) => item.space.id === "spc_agents_dm");
expect(agentsDm?.isCeo).toBe(false);
});
test("without a CEO the list is pure recency", () => {
const conversations = build({ ceoAgentId: null });
expect(conversations.every((item) => !item.isCeo)).toBe(true);
expect(conversations.map((item) => item.space.id)).toEqual(["spc_agents_dm", "spc_general", "spc_ceo_dm"]);
});
test("the pinned row still counts unread like any conversation", () => {
const conversations = build();
expect(conversations[0].unread).toBe(1);
expect(conversations[0].preview).toContain("CEO:");
});
});