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 type { ReactNode } from "react";
|
||||||
import { Button, Icon, Pill } from "../../shared/ui";
|
import { Avatar, Button, Icon, Pill } from "../../shared/ui";
|
||||||
import type {
|
import type {
|
||||||
ApprovalRecord,
|
ApprovalRecord,
|
||||||
ChatAttachmentRecord,
|
ChatAttachmentRecord,
|
||||||
@@ -9,8 +9,7 @@ import type {
|
|||||||
ChatThreadRecord,
|
ChatThreadRecord,
|
||||||
FileObjectRecord,
|
FileObjectRecord,
|
||||||
} from "../../../../shared/domain";
|
} from "../../../../shared/domain";
|
||||||
import { ChatMessageRow } from "./ChatMessageRow";
|
import { formatDay, senderName, type MessageContext } from "./chat-helpers";
|
||||||
import { formatDay, type MessageContext } from "./chat-helpers";
|
|
||||||
|
|
||||||
export function ChatTimeline({
|
export function ChatTimeline({
|
||||||
rootMessages,
|
rootMessages,
|
||||||
@@ -49,35 +48,32 @@ export function ChatTimeline({
|
|||||||
}) {
|
}) {
|
||||||
const elements: ReactNode[] = [];
|
const elements: ReactNode[] = [];
|
||||||
let lastDay = "";
|
let lastDay = "";
|
||||||
rootMessages.forEach((message) => {
|
for (const message of rootMessages) {
|
||||||
const day = formatDay(message.createdAt);
|
const day = formatDay(message.createdAt);
|
||||||
if (day !== lastDay) {
|
if (day !== lastDay) {
|
||||||
lastDay = day;
|
lastDay = day;
|
||||||
elements.push(<DayDivider key={`day-${day}`} label={day} />);
|
elements.push(<DayDivider key={`day-${day}`} label={day} />);
|
||||||
}
|
}
|
||||||
const thread = threadsInSpace.find((t) => t.rootMessageId === message.id) ?? null;
|
const thread = threadsInSpace.find((t) => t.rootMessageId === message.id) ?? null;
|
||||||
const replyCount = thread
|
const replies = thread
|
||||||
? messagesInSpace.filter((m) => m.threadId === thread.id && m.id !== thread.rootMessageId).length
|
? messagesInSpace.filter((m) => m.threadId === thread.id && m.id !== thread.rootMessageId)
|
||||||
: 0;
|
: [];
|
||||||
|
const lastReply = replies.length > 0 ? replies[replies.length - 1] : null;
|
||||||
|
const participantNames = threadParticipants(replies, context);
|
||||||
elements.push(
|
elements.push(
|
||||||
<ChatMessageRow
|
<ThreadRow
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
attachments={attachments}
|
thread={thread}
|
||||||
mentions={mentions}
|
replyCount={replies.length}
|
||||||
slashInvocations={slashInvocations}
|
lastReply={lastReply}
|
||||||
files={files}
|
participantNames={participantNames}
|
||||||
replyCount={replyCount}
|
|
||||||
onOpenThread={thread ? () => onOpenThread(thread.id) : null}
|
|
||||||
context={context}
|
context={context}
|
||||||
canModify={message.senderType === "employee" && message.senderId === currentEmployeeId && !message.deletedAt}
|
onOpen={thread ? () => onOpenThread(thread.id) : null}
|
||||||
onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)}
|
|
||||||
onDelete={() => onDeleteMessage(message.id)}
|
|
||||||
onOpenFile={onOpenFile}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
pendingApprovals.forEach((approval) => {
|
for (const approval of pendingApprovals) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<ApprovalInline
|
<ApprovalInline
|
||||||
key={`approval-${approval.id}`}
|
key={`approval-${approval.id}`}
|
||||||
@@ -86,13 +82,108 @@ export function ChatTimeline({
|
|||||||
onDecline={() => onDecline(approval.id)}
|
onDecline={() => onDecline(approval.id)}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
return <>{elements}</>;
|
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 }) {
|
function DayDivider({ label }: { label: string }) {
|
||||||
return (
|
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={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
||||||
<div style={{ fontSize: 12, color: "var(--text-3)", fontWeight: 540 }}>{label}</div>
|
<div style={{ fontSize: 12, color: "var(--text-3)", fontWeight: 540 }}>{label}</div>
|
||||||
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
||||||
@@ -110,19 +201,10 @@ function ApprovalInline({
|
|||||||
onDecline: () => void;
|
onDecline: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ margin: "8px 0 12px 12px", maxWidth: 480, border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: 14 }}>
|
||||||
style={{
|
|
||||||
margin: "14px 0 18px 44px",
|
|
||||||
maxWidth: 480,
|
|
||||||
border: "1px solid var(--border-strong)",
|
|
||||||
borderRadius: 12,
|
|
||||||
background: "var(--panel)",
|
|
||||||
padding: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="row" style={{ marginBottom: 8 }}>
|
<div className="row" style={{ marginBottom: 8 }}>
|
||||||
<Icon name="approve" size={14} color="var(--amber)" />
|
<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>
|
<Pill tone={approval.risk === "high" ? "red" : approval.risk === "medium" ? "amber" : undefined}>{approval.risk}</Pill>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
|
<div style={{ fontSize: 14, fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
|
||||||
|
|||||||
Reference in new issue
Block a user