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

+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(