From 3c44ff959d7f82044ff64ddc9facacd03583265e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 14 Jul 2026 00:16:38 -0400 Subject: [PATCH] Messages pins the founder's CEO conversation at the top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/renderer/src/i18n/zh.ts | 2 + src/renderer/src/pages/ChatPage.tsx | 9 +- src/renderer/src/pages/chat/ChatSidebar.tsx | 3 +- src/renderer/src/pages/chat/chat-helpers.ts | 12 ++- src/renderer/src/styles.css | 9 ++ tests/unit/conversation-pinning.test.ts | 105 ++++++++++++++++++++ 6 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/unit/conversation-pinning.test.ts diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 454f40e2..db805cb0 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -52,6 +52,7 @@ export const zh: Record = { "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 = { "Budget utilization": "预算使用率", "Busy": "忙碌", "By agent": "按智能体", + "CEO": "CEO", "CEO OFFICE": "CEO 办公室", "Cancel": "取消", "Catalog": "目录", diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index c6d4fc53..953522d6 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -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(null); const [createOpen, setCreateOpen] = useState(false); diff --git a/src/renderer/src/pages/chat/ChatSidebar.tsx b/src/renderer/src/pages/chat/ChatSidebar.tsx index 7f7c6af3..6d0b3641 100644 --- a/src/renderer/src/pages/chat/ChatSidebar.tsx +++ b/src/renderer/src/pages/chat/ChatSidebar.tsx @@ -156,6 +156,7 @@ function ConversationRow({ > {conversation.title} + {conversation.isCeo && {t("CEO")}} {conversation.preview ? formatRelative(conversation.lastActivityAt) : ""} @@ -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)} {conversation.unread > 0 && ( {conversation.unread > 99 ? "99+" : conversation.unread} diff --git a/src/renderer/src/pages/chat/chat-helpers.ts b/src/renderer/src/pages/chat/chat-helpers.ts index 84e68b58..4d8e4682 100644 --- a/src/renderer/src/pages/chat/chat-helpers.ts +++ b/src/renderer/src/pages/chat/chat-helpers.ts @@ -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(); const countsSince = new Map(); @@ -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. */ diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 983450f3..f6a4dd5a 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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); } diff --git a/tests/unit/conversation-pinning.test.ts b/tests/unit/conversation-pinning.test.ts new file mode 100644 index 00000000..ed55af5f --- /dev/null +++ b/tests/unit/conversation-pinning.test.ts @@ -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[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:"); + }); +});