Show threads as compact rows instead of inline full messages
The channel timeline now renders each thread as a single compact row with sender avatar, message preview (truncated to one line), reply count with participant avatars, and last reply time. Clicking a row opens the thread in the right panel where the full conversation lives. This matches Slack's thread-list pattern instead of dumping every message body into the channel view.
This commit is contained in:
1 parent
e37faf1a2e
commit
7a85f73dbb
1 file changed
+115
-33
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, Icon, Pill } from "../../shared/ui";
|
||||
import { Avatar, Button, Icon, Pill } from "../../shared/ui";
|
||||
import type {
|
||||
ApprovalRecord,
|
||||
ChatAttachmentRecord,
|
||||
@@ -9,8 +9,7 @@ import type {
|
||||
ChatThreadRecord,
|
||||
FileObjectRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { ChatMessageRow } from "./ChatMessageRow";
|
||||
import { formatDay, type MessageContext } from "./chat-helpers";
|
||||
import { formatDay, senderName, type MessageContext } from "./chat-helpers";
|
||||
|
||||
export function ChatTimeline({
|
||||
rootMessages,
|
||||
@@ -49,35 +48,32 @@ export function ChatTimeline({
|
||||
}) {
|
||||
const elements: ReactNode[] = [];
|
||||
let lastDay = "";
|
||||
rootMessages.forEach((message) => {
|
||||
for (const message of rootMessages) {
|
||||
const day = formatDay(message.createdAt);
|
||||
if (day !== lastDay) {
|
||||
lastDay = day;
|
||||
elements.push(<DayDivider key={`day-${day}`} label={day} />);
|
||||
}
|
||||
const thread = threadsInSpace.find((t) => t.rootMessageId === message.id) ?? null;
|
||||
const replyCount = thread
|
||||
? messagesInSpace.filter((m) => m.threadId === thread.id && m.id !== thread.rootMessageId).length
|
||||
: 0;
|
||||
const replies = thread
|
||||
? messagesInSpace.filter((m) => m.threadId === thread.id && m.id !== thread.rootMessageId)
|
||||
: [];
|
||||
const lastReply = replies.length > 0 ? replies[replies.length - 1] : null;
|
||||
const participantNames = threadParticipants(replies, context);
|
||||
elements.push(
|
||||
<ChatMessageRow
|
||||
<ThreadRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
attachments={attachments}
|
||||
mentions={mentions}
|
||||
slashInvocations={slashInvocations}
|
||||
files={files}
|
||||
replyCount={replyCount}
|
||||
onOpenThread={thread ? () => onOpenThread(thread.id) : null}
|
||||
thread={thread}
|
||||
replyCount={replies.length}
|
||||
lastReply={lastReply}
|
||||
participantNames={participantNames}
|
||||
context={context}
|
||||
canModify={message.senderType === "employee" && message.senderId === currentEmployeeId && !message.deletedAt}
|
||||
onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)}
|
||||
onDelete={() => onDeleteMessage(message.id)}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpen={thread ? () => onOpenThread(thread.id) : null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
pendingApprovals.forEach((approval) => {
|
||||
}
|
||||
for (const approval of pendingApprovals) {
|
||||
elements.push(
|
||||
<ApprovalInline
|
||||
key={`approval-${approval.id}`}
|
||||
@@ -86,13 +82,108 @@ export function ChatTimeline({
|
||||
onDecline={() => onDecline(approval.id)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
return <>{elements}</>;
|
||||
}
|
||||
|
||||
function ThreadRow({
|
||||
message,
|
||||
thread,
|
||||
replyCount,
|
||||
lastReply,
|
||||
participantNames,
|
||||
context,
|
||||
onOpen,
|
||||
}: {
|
||||
message: ChatMessageRecord;
|
||||
thread: ChatThreadRecord | null;
|
||||
replyCount: number;
|
||||
lastReply: ChatMessageRecord | null;
|
||||
participantNames: string[];
|
||||
context: MessageContext;
|
||||
onOpen: (() => void) | null;
|
||||
}) {
|
||||
const name = senderName(message, context);
|
||||
const bot = message.senderType === "agent";
|
||||
const preview = message.bodyMd.replace(/\n+/g, " ").slice(0, 120);
|
||||
const time = formatTime(message.createdAt);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen ?? undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: 0,
|
||||
borderRadius: 8,
|
||||
background: "transparent",
|
||||
cursor: onOpen ? "pointer" : "default",
|
||||
textAlign: "left",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => { if (onOpen) e.currentTarget.style.background = "var(--hover)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
|
||||
>
|
||||
<Avatar name={name} size="sm" bot={bot} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 6, marginBottom: 2 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 560 }}>{name}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-4)" }}>{time}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-2)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{preview}
|
||||
</div>
|
||||
{replyCount > 0 && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 4 }}>
|
||||
{participantNames.slice(0, 3).map((n) => (
|
||||
<Avatar key={n} name={n} size="sm" bot />
|
||||
))}
|
||||
<span style={{ fontSize: 11, color: "var(--accent)", fontWeight: 500 }}>
|
||||
{replyCount} {replyCount === 1 ? "reply" : "replies"}
|
||||
</span>
|
||||
{lastReply && (
|
||||
<span style={{ fontSize: 11, color: "var(--text-4)" }}>
|
||||
{formatTime(lastReply.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function threadParticipants(replies: ChatMessageRecord[], context: MessageContext): string[] {
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
for (const r of replies) {
|
||||
const key = `${r.senderType}:${r.senderId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
names.push(senderName(r, context));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
|
||||
function DayDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="row" style={{ gap: 12, margin: "8px 0 16px" }}>
|
||||
<div className="row" style={{ gap: 12, margin: "8px 0 12px" }}>
|
||||
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", fontWeight: 540 }}>{label}</div>
|
||||
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
||||
@@ -110,19 +201,10 @@ function ApprovalInline({
|
||||
onDecline: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: "14px 0 18px 44px",
|
||||
maxWidth: 480,
|
||||
border: "1px solid var(--border-strong)",
|
||||
borderRadius: 12,
|
||||
background: "var(--panel)",
|
||||
padding: 14,
|
||||
}}
|
||||
>
|
||||
<div style={{ margin: "8px 0 12px 12px", maxWidth: 480, border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: 14 }}>
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<Icon name="approve" size={14} color="var(--amber)" />
|
||||
<span style={{ fontSize: 12, color: "var(--amber)", fontWeight: 540, letterSpacing: "0.01em" }}>Approval needed</span>
|
||||
<span style={{ fontSize: 12, color: "var(--amber)", fontWeight: 540 }}>Approval needed</span>
|
||||
<Pill tone={approval.risk === "high" ? "red" : approval.risk === "medium" ? "amber" : undefined}>{approval.risk}</Pill>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
|
||||
|
||||
Reference in new issue
Block a user