diff --git a/src/main/services/chat-anchor.ts b/src/main/services/chat-anchor.ts new file mode 100644 index 00000000..1f30e571 --- /dev/null +++ b/src/main/services/chat-anchor.ts @@ -0,0 +1,18 @@ +import type { DatabaseSync } from "node:sqlite"; + +/** The agent that answers a channel message naming no one: its most senior active member with an enabled provider. */ +export function channelAnchorAgentId(db: DatabaseSync, orgId: string, spaceId: string): string | null { + const anchor = db.prepare(` + select a.id as agent_id + from chat_memberships cm + join agents a on a.id = cm.principal_id and a.org_id = ? + join providers p on p.id = a.model_provider_id and p.org_id = a.org_id + where cm.space_id = ? and cm.principal_type = 'agent' + and a.lifecycle = 'active' + and p.enabled = 1 + order by case a.role_level when 'c_suite' then 0 when 'director' then 1 when 'lead' then 2 else 3 end, + a.created_at asc + limit 1 + `).get(orgId, spaceId) as { agent_id: string } | undefined; + return anchor?.agent_id ?? null; +} diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 2ce28ed4..897cedc8 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -1,3 +1,4 @@ +import { channelAnchorAgentId } from "./chat-anchor"; import { readVerifiedFile } from "./files/file-object-store"; import type { DatabaseSync } from "node:sqlite"; import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands"; @@ -313,24 +314,13 @@ export class ChatService { * exactly like a room going quiet until the highest-ranking person speaks. */ private wakeChannelAnchor(input: SendMessageInput, space: SpaceRow, messageId: string, threadId: string, now: string): void { - const anchor = this.db.prepare(` - select a.id as agent_id - from chat_memberships cm - join agents a on a.id = cm.principal_id and a.org_id = ? - join providers p on p.id = a.model_provider_id and p.org_id = a.org_id - where cm.space_id = ? and cm.principal_type = 'agent' - and a.lifecycle = 'active' - and p.enabled = 1 - order by case a.role_level when 'c_suite' then 0 when 'director' then 1 when 'lead' then 2 else 3 end, - a.created_at asc - limit 1 - `).get(input.orgId, input.spaceId) as { agent_id: string } | undefined; - if (!anchor) { + const anchorId = channelAnchorAgentId(this.db, input.orgId, input.spaceId); + if (!anchorId) { console.info(`[chat] no runnable agent member in #${space.name}; the message stays unanswered until one is hired or a provider is enabled.`); return; } try { - this.agentWake.wakeFromMention(input, anchor.agent_id, messageId, threadId, now); + this.agentWake.wakeFromMention(input, anchorId, messageId, threadId, now); } catch (error) { console.warn(`[chat] waking channel anchor for #${space.name} failed: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index 8d8bb23a..7a203efc 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -6,6 +6,7 @@ import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain import { buildUsageStats } from "./usage-stats"; import { taskHolds } from "./task-holds"; import { chatReplyStatuses } from "./chat-reply-status"; +import { channelAnchorAgentId } from "./chat-anchor"; type Row = Record; type ProviderUsageWindowRow = Pick< @@ -100,6 +101,11 @@ export class SnapshotService { chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id asc"), chatMessages, chatReplyStatuses: chatReplyStatuses(this.db, chatMessages.map((message) => message.id), holds), + chatSpaceAnchors: (this.db.prepare("select id, org_id from chat_spaces where space_type <> 'direct'").all() as Array<{ id: string; org_id: string }>) + .flatMap((space) => { + const agentId = channelAnchorAgentId(this.db, space.org_id, space.id); + return agentId ? [{ spaceId: space.id, agentId }] : []; + }), // Every thread a loaded message belongs to, plus the most recently active ones, so no shown message loses its thread. chatThreads: all(this.db.prepare(` select * from chat_threads diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 0c83bd27..f885aa64 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -38,6 +38,14 @@ export const zh: Record = { "Waiting on you.": "等待你处理。", "Not sent: {reason}": "未发送:{reason}", "Not saved: {reason}": "未保存:{reason}", + "Members ({count})": "成员({count})", + "Reads and writes": "可读可写", + "Replies to every message": "回复每条消息", + "Replies when @mentioned, in threads it joined, and to messages that name no one": "被 @ 时、在参与过的话题中、以及消息未点名任何人时回复", + "Replies when @mentioned and in threads it joined": "被 @ 时和在参与过的话题中回复", + "Only the people in this conversation can read it.": "只有本会话中的人可以读取。", + "Everyone in the company can read this channel.": "公司里的每个人都可以读取此频道。", + "Only members can read this channel.": "只有成员可以读取此频道。", "Remove {name}": "移除 {name}", "Not attached: {reason}": "未能附加:{reason}", "Reply in thread": "在话题中回复", diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index 5525ed01..60ac71b1 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -7,7 +7,7 @@ import { ChatConversation } from "./chat/ChatConversation"; import type { Draft, SendState } from "./chat/ChatComposer"; import { buildConversations, - collectParticipants, + spaceMembers, filterApprovalsForSpace, messageReaders, } from "./chat/chat-helpers"; @@ -99,10 +99,11 @@ export function ChatPage({ snapshot, actions }: PageProps) { () => (activeSpace ? messagesAll.filter((m) => m.spaceId === activeSpace.id) : []), [messagesAll, activeSpace], ); - const participants = useMemo( - () => collectParticipants(messagesInSpace, employees, agents), - [messagesInSpace, employees, agents], - ); + const members = useMemo(() => { + if (!activeSpace) return []; + const anchor = snapshot.chatSpaceAnchors.find((item) => item.spaceId === activeSpace.id)?.agentId ?? null; + return spaceMembers(activeSpace, snapshot.chatMemberships, employees, agents, anchor); + }, [activeSpace, snapshot.chatSpaceAnchors, snapshot.chatMemberships, employees, agents]); const pendingApprovalsInSpace = filterApprovalsForSpace(approvalsAll, activeSpace); const sendInChannel = (event: FormEvent) => { @@ -236,7 +237,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
- {space && } + {space && }
void; }) { @@ -320,9 +321,39 @@ function ConversationHeader({ style={{ width: 200, height: 28, padding: "0 10px" }} /> )} - {!searchOpen && participants.length > 0 && } + {!searchOpen && }
); } + +/** The real member list: who is in the conversation, how each agent replies, and who else can read it. */ +function MembersButton({ space, members }: { space: ChatSpaceRecord; members: SpaceMember[] }) { + return ( + ( + + )} + > + {() => ( + // A large roster scrolls inside the window instead of running past it. +
+
{spaceReaders(space)}
+ {members.map((member) => ( +
+ +
+
{member.name}
+
{member.duty}
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/renderer/src/pages/chat/chat-helpers.ts b/src/renderer/src/pages/chat/chat-helpers.ts index 7c215864..d364b008 100644 --- a/src/renderer/src/pages/chat/chat-helpers.ts +++ b/src/renderer/src/pages/chat/chat-helpers.ts @@ -66,24 +66,40 @@ export function messageAttachments( .filter((file): file is FileObjectRecord => Boolean(file)); } -export function collectParticipants( - messages: ChatMessageRecord[], +/** One member of a conversation and what they do in it. */ +export interface SpaceMember { + id: string; + name: string; + bot: boolean; + duty: string; +} + +/** Every member of the conversation, spoken or not, with the rule by which each agent replies (the same rules the server wakes them by). */ +export function spaceMembers( + space: ChatSpaceRecord, + memberships: ChatMembershipRecord[], employees: EmployeeRecord[], agents: AgentRecord[], -): string[] { - const seen = new Map(); - for (const message of messages) { - if (!message.senderId) continue; - if (seen.has(message.senderId)) continue; - if (message.senderType === "employee") { - const employee = employees.find((item) => item.id === message.senderId); - if (employee) seen.set(message.senderId, employee.displayName); - } else if (message.senderType === "agent") { - const agent = agents.find((item) => item.id === message.senderId); - if (agent) seen.set(message.senderId, agent.name); + anchorAgentId: string | null, +): SpaceMember[] { + const direct = space.spaceType === "direct"; + return memberships.filter((membership) => membership.spaceId === space.id).map((membership) => { + if (membership.principalType === "employee") { + const employee = employees.find((item) => item.id === membership.principalId); + return { id: membership.principalId, name: employee ? employee.displayName : t("Former member"), bot: false, duty: t("Reads and writes") }; } - } - return [...seen.values()]; + const agent = agents.find((item) => item.id === membership.principalId); + const duty = direct ? t("Replies to every message") + : membership.principalId === anchorAgentId ? t("Replies when @mentioned, in threads it joined, and to messages that name no one") + : t("Replies when @mentioned and in threads it joined"); + return { id: membership.principalId, name: agent ? agent.name : t("Former member"), bot: true, duty }; + }); +} + +/** Who can read the conversation beyond its listed members. */ +export function spaceReaders(space: ChatSpaceRecord): string { + if (space.spaceType === "direct") return t("Only the people in this conversation can read it."); + return space.visibility === "workspace" ? t("Everyone in the company can read this channel.") : t("Only members can read this channel."); } export function filterApprovalsForSpace( diff --git a/src/shared/chat-domain.ts b/src/shared/chat-domain.ts index 9e2db632..fe2be0c6 100644 --- a/src/shared/chat-domain.ts +++ b/src/shared/chat-domain.ts @@ -113,3 +113,9 @@ export type ChatReplyStatus = { messageId: string; agentId: string } & ( | { state: "expired" | "failed"; detail: string; resumesAt: null } | { state: "stopped"; detail: string | null; resumesAt: null } ); + +/** The agent that answers a channel message naming no one, per channel that has one. */ +export interface ChatSpaceAnchor { + spaceId: string; + agentId: string; +} diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 175f3dba..8be36b57 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -1,6 +1,6 @@ import type { ProviderDataPolicy } from "./provider-policy-domain"; export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain"; -export type { ChatReplyStatus, ChatSpaceRecord, ChatMessageRecord, ChatMembershipRecord, ChatThreadRecord, ChatAttachmentRecord, ChatMentionRecord, ChatSlashInvocationRecord, ChatPresenceRecord, ChatReadStateRecord } from "./chat-domain"; +export type { ChatReplyStatus, ChatSpaceAnchor, ChatSpaceRecord, ChatMessageRecord, ChatMembershipRecord, ChatThreadRecord, ChatAttachmentRecord, ChatMentionRecord, ChatSlashInvocationRecord, ChatPresenceRecord, ChatReadStateRecord } from "./chat-domain"; export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain"; export { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type AccessDecision, type AccessDecisionRecord, type EmployeeIdentityRecord, type IdentitySource, type PrincipalType, type RoleGrantRecord, type RoleKey, type ServiceIdentityRecord } from "./identity-domain"; export { ROUTE_TO_TYPE, type ApprovalAssignmentRecord, type ApprovalRoutingRuleRecord, type HiringPolicyRecord, type OrgDepartmentRecord, type RouteToType } from "./org-governance-domain"; diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index 6ecaf141..bc8aecc7 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -12,6 +12,7 @@ import type { ChatSpaceRecord, ChatThreadRecord, ChatReplyStatus, + ChatSpaceAnchor, } from "./chat-domain"; import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain"; import type { @@ -86,6 +87,7 @@ export interface AppSnapshot { chatMessages: ChatMessageRecord[]; chatThreads: ChatThreadRecord[]; chatReplyStatuses: ChatReplyStatus[]; + chatSpaceAnchors: ChatSpaceAnchor[]; chatAttachments: ChatAttachmentRecord[]; chatMentions: ChatMentionRecord[]; chatSlashInvocations: ChatSlashInvocationRecord[]; diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index 0e8a4210..27892ff5 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -204,6 +204,17 @@ test("the pinned CEO conversation is the Messages front door", async () => { } await expect(start).toBeVisible(); await expect(chatLog.getByText("General line 0", { exact: true })).toBeVisible(); + + // The header lists the real members, how each agent replies, and who can read. + await page.getByRole("button", { name: /^Members \(/ }).click(); + const roster = page.getByRole("menu"); + await expect(roster).toContainText("Everyone in the company can read this channel."); + await expect(roster.locator(".members-roster")).toHaveCSS("overflow-y", "auto"); + await expect(roster).toContainText("Replies when @mentioned, in threads it joined, and to messages that name no one"); + await page.keyboard.press("Escape"); + await page.locator(".convo-item").first().click(); + await page.getByRole("button", { name: /^Members \(/ }).click(); + await expect(page.getByRole("menu")).toContainText("Replies to every message"); } finally { await app.close(); } diff --git a/tests/integration/chat-reply-status.test.ts b/tests/integration/chat-reply-status.test.ts index 9359bd1d..1127b644 100644 --- a/tests/integration/chat-reply-status.test.ts +++ b/tests/integration/chat-reply-status.test.ts @@ -41,6 +41,9 @@ describe("a message's reply status", () => { const status = () => new SnapshotService(connection.db).load().chatReplyStatuses.filter((item) => item.messageId === messageId); expect(status()).toEqual([{ messageId, agentId: ceoId, state: "queued", detail: null, resumesAt: null }]); + // The CEO answers channel messages that name no one. + const general = (connection.db.prepare("select id from chat_spaces where org_id = ? and name = 'general'").get(orgId) as { id: string }).id; + expect(new SnapshotService(connection.db).load().chatSpaceAnchors.find((item) => item.spaceId === general)).toEqual({ spaceId: general, agentId: ceoId }); // A second message folded into the same queued reply gets the same status. const follow = connection.transaction(() => new ChatService(connection.db).sendMessage({ orgId, spaceId: dm, threadId: (connection.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string }).thread_id, diff --git a/tests/unit/space-members.test.ts b/tests/unit/space-members.test.ts new file mode 100644 index 00000000..db1e2709 --- /dev/null +++ b/tests/unit/space-members.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; +import { spaceMembers, spaceReaders } from "../../src/renderer/src/pages/chat/chat-helpers"; +import type { AgentRecord, ChatMembershipRecord, ChatSpaceRecord, EmployeeRecord } from "../../src/shared/domain"; + +const ORG = "org_1"; + +function agent(id: string, name: string): AgentRecord { + return { + id, orgId: ORG, name, role: name, department: "Engineering", modelProviderId: null, modelId: null, + budgetCents: 0, managerAgentId: null, title: name, roleLevel: "staff", + 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: "emp_yang", orgId: ORG, displayName: "Yang", role: "founder", avatarEmoji: null, avatarColor: null, avatarImage: null, createdAt: "2026-01-01T00:00:00Z" }]; +const agents = [agent("agt_ceo", "CEO"), agent("agt_dana", "Dana"), agent("agt_eve", "Eve")]; +const member = (spaceId: string, principalType: "employee" | "agent", principalId: string): ChatMembershipRecord => ({ spaceId, principalType, principalId, role: "member", muteState: "unmuted" }); +const space = (id: string, spaceType: string, visibility: string): ChatSpaceRecord => ({ id, orgId: ORG, spaceType, name: id, topic: "t", visibility, retentionPolicy: "standard", createdAt: "2026-01-01T00:00:00Z" }); + +describe("who is in a conversation", () => { + test("lists every member, spoken or not, with how each agent replies", () => { + const memberships = [member("eng", "employee", "emp_yang"), member("eng", "agent", "agt_ceo"), member("eng", "agent", "agt_dana"), member("dm", "employee", "emp_yang"), member("dm", "agent", "agt_dana")]; + expect(spaceMembers(space("eng", "department", "workspace"), memberships, employees, agents, "agt_ceo")).toEqual([ + { id: "emp_yang", name: "Yang", bot: false, duty: "Reads and writes" }, + { id: "agt_ceo", name: "CEO", bot: true, duty: "Replies when @mentioned, in threads it joined, and to messages that name no one" }, + { id: "agt_dana", name: "Dana", bot: true, duty: "Replies when @mentioned and in threads it joined" }, + ]); + expect(spaceMembers(space("dm", "direct", "restricted"), memberships, employees, agents, null).map((item) => item.duty)).toEqual(["Reads and writes", "Replies to every message"]); + }); + + test("says who can read beyond the member list", () => { + expect(spaceReaders(space("eng", "department", "workspace"))).toBe("Everyone in the company can read this channel."); + expect(spaceReaders(space("ops", "project", "restricted"))).toBe("Only members can read this channel."); + expect(spaceReaders(space("dm", "direct", "restricted"))).toBe("Only the people in this conversation can read it."); + }); +});