Q147: the conversation header lists its real members, how each agent replies, and who can read

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 00:45:05 -04:00
1 parent 02309546dc
commit 983d37937d
13 files changed
+173 -44

No files matched your search

+18
View File
@@ -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;
}
+4 -14
View File
@@ -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)}`);
}
+6
View File
@@ -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<string, unknown>;
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<Row>(this.db.prepare(`
select * from chat_threads
+8
View File
@@ -38,6 +38,14 @@ export const zh: Record<string, string> = {
"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": "在话题中回复",
+7 -6
View File
@@ -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<HTMLFormElement>) => {
@@ -236,7 +237,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
<ChatConversation
key={activeSpace?.id ?? "none"}
space={activeSpace}
participants={participants}
members={members}
messagesInSpace={messagesInSpace}
threads={snapshot.chatThreads}
replyStatuses={snapshot.chatReplyStatuses}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type MutableRefObject } from "react";
import { AvatarStack, Icon, IconButton } from "../../shared/ui";
import { Avatar, AvatarStack, Icon, IconButton } from "../../shared/ui";
import type {
ApprovalRecord,
ChatAttachmentRecord,
@@ -18,12 +18,13 @@ import type { ChatReplyStatus, SpaceHistoryPage } from "../../../../shared/chat-
/** Older messages fetched per "Show earlier messages". */
const HISTORY_PAGE = 150;
import type { MessageContext } from "./chat-helpers";
import { spaceReaders, type MessageContext, type SpaceMember } from "./chat-helpers";
import { Popover } from "../../shared/Popover";
import { t } from "../../i18n/i18n";
export function ChatConversation({
space,
participants,
members,
messagesInSpace,
threads,
replyStatuses,
@@ -51,7 +52,7 @@ export function ChatConversation({
composerRef,
}: {
space: ChatSpaceRecord | null;
participants: string[];
members: SpaceMember[];
messagesInSpace: ChatMessageRecord[];
threads: ChatThreadRecord[];
replyStatuses: ChatReplyStatus[];
@@ -196,7 +197,7 @@ export function ChatConversation({
return (
<>
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
{space && <ConversationHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
{space && <ConversationHeader space={space} members={members} query={query} onQueryChange={setQuery} />}
<div style={{ position: "relative", flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div
ref={scrollRef}
@@ -272,12 +273,12 @@ export function ChatConversation({
function ConversationHeader({
space,
participants,
members,
query,
onQueryChange,
}: {
space: ChatSpaceRecord;
participants: string[];
members: SpaceMember[];
query: string;
onQueryChange: (next: string) => void;
}) {
@@ -320,9 +321,39 @@ function ConversationHeader({
style={{ width: 200, height: 28, padding: "0 10px" }}
/>
)}
{!searchOpen && participants.length > 0 && <AvatarStack names={participants.slice(0, 5)} bot />}
{!searchOpen && <MembersButton space={space} members={members} />}
<IconButton icon="search" label={t("Search")} onClick={toggleSearch} />
</div>
</header>
);
}
/** 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 (
<Popover
trigger={({ toggle }) => (
<button type="button" onClick={toggle} aria-label={t("Members ({count})", { count: String(members.length) })} style={{ display: "flex", alignItems: "center", gap: 6, border: 0, background: "transparent", cursor: "pointer", padding: 0 }}>
<AvatarStack names={members.slice(0, 5).map((member) => member.name)} />
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{members.length}</span>
</button>
)}
>
{() => (
// A large roster scrolls inside the window instead of running past it.
<div className="members-roster" style={{ padding: 12, width: 320, maxHeight: "min(480px, calc(100vh - 120px))", overflowY: "auto", display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{spaceReaders(space)}</div>
{members.map((member) => (
<div key={member.id} className="row" style={{ gap: 8, alignItems: "flex-start" }}>
<Avatar name={member.name} size="sm" bot={member.bot} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 540 }}>{member.name}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{member.duty}</div>
</div>
</div>
))}
</div>
)}
</Popover>
);
}
+31 -15
View File
@@ -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<string, string>();
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(
+6
View File
@@ -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;
}
+1 -1
View File
@@ -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";
+2
View File
@@ -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[];
+11
View File
@@ -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();
}
@@ -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,
+37
View File
@@ -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.");
});
});