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:
1 parent
02309546dc
commit
983d37937d
13 files changed
+173
-44
No files matched your search
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user