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:
ZacharyZhang-NY committed 2026-06-30 22:27:11 -04:00
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>