feat: add message timestamps, day-divider rows, and stable createdAt across stream updates
This commit is contained in:
@@ -28,6 +28,10 @@ import MultiQuestionWidget from "@/components/MultiQuestionWidget";
|
||||
import ParallelSubagentBubble, {
|
||||
type SubagentGroup,
|
||||
} from "@/components/ParallelSubagentBubble";
|
||||
import {
|
||||
formatMessageTime,
|
||||
formatDayDividerLabel,
|
||||
} from "@/lib/chat-helpers";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
@@ -514,10 +518,11 @@ const MessageBubble = memo(
|
||||
{msg.content && (
|
||||
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
)}
|
||||
{msg.queued && (
|
||||
<span className="block text-[10px] opacity-60 mt-1 text-right">
|
||||
queued
|
||||
</span>
|
||||
{(msg.queued || msg.createdAt) && (
|
||||
<div className="flex justify-end items-center gap-1.5 mt-1 text-[10px] opacity-60">
|
||||
{msg.queued && <span>queued</span>}
|
||||
{msg.createdAt && <span>{formatMessageTime(msg.createdAt)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,6 +576,11 @@ const MessageBubble = memo(
|
||||
: "Worker"}
|
||||
</span>
|
||||
)}
|
||||
{msg.createdAt && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatMessageTime(msg.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm leading-relaxed rounded-2xl rounded-tl-md px-4 py-3 ${
|
||||
@@ -654,7 +664,8 @@ export default function ChatPanel({
|
||||
// so interleaved queen/tool/system messages don't fragment the bubble.
|
||||
type RenderItem =
|
||||
| { kind: "message"; msg: ChatMessage }
|
||||
| { kind: "parallel"; groupId: string; groups: SubagentGroup[] };
|
||||
| { kind: "parallel"; groupId: string; groups: SubagentGroup[] }
|
||||
| { kind: "day_divider"; key: string; createdAt: number };
|
||||
|
||||
const renderItems = useMemo<RenderItem[]>(() => {
|
||||
const items: RenderItem[] = [];
|
||||
@@ -727,6 +738,41 @@ export default function ChatPanel({
|
||||
return items;
|
||||
}, [threadMessages, contextUsage]);
|
||||
|
||||
// Inject day-separator dividers between items that cross a calendar-day
|
||||
// boundary, and one before the very first item. Helps the user see when
|
||||
// activity resumed after a gap — important since some answers take hours.
|
||||
const itemsWithDividers = useMemo<RenderItem[]>(() => {
|
||||
const getTime = (item: RenderItem): number | undefined => {
|
||||
if (item.kind === "message") return item.msg.createdAt;
|
||||
if (item.kind === "parallel") {
|
||||
for (const g of item.groups) {
|
||||
for (const m of g.messages) {
|
||||
if (m.createdAt) return m.createdAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const dayKey = (ts: number) => {
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
};
|
||||
const out: RenderItem[] = [];
|
||||
let lastDay: string | null = null;
|
||||
for (const item of renderItems) {
|
||||
const ts = getTime(item);
|
||||
if (ts) {
|
||||
const key = dayKey(ts);
|
||||
if (key !== lastDay) {
|
||||
out.push({ kind: "day_divider", key: `day-${ts}`, createdAt: ts });
|
||||
lastDay = key;
|
||||
}
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}, [renderItems]);
|
||||
|
||||
// Mark current thread as read
|
||||
useEffect(() => {
|
||||
const count = messages.filter((m) => m.thread === activeThread).length;
|
||||
@@ -801,7 +847,21 @@ export default function ChatPanel({
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-auto px-5 py-4 space-y-3"
|
||||
>
|
||||
{renderItems.map((item) => {
|
||||
{itemsWithDividers.map((item) => {
|
||||
if (item.kind === "day_divider") {
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center gap-3 py-2 my-1"
|
||||
>
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
{formatDayDividerLabel(item.createdAt)}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === "parallel") {
|
||||
return (
|
||||
<div key={item.groupId}>
|
||||
|
||||
@@ -27,6 +27,51 @@ export function formatAgentDisplayName(raw: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a message timestamp Slack-style: time-of-day for messages from today,
|
||||
* date + time for older messages.
|
||||
*/
|
||||
export function formatMessageTime(createdAt: number): string {
|
||||
const d = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
const time = d.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (sameDay) return time;
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
const date = d.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(sameYear ? {} : { year: "numeric" }),
|
||||
});
|
||||
return `${date}, ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the label shown on a day-separator divider. Always absolute date + time
|
||||
* (no "Today" / "Yesterday") so the user can see exactly when activity resumed.
|
||||
*/
|
||||
export function formatDayDividerLabel(createdAt: number): string {
|
||||
const d = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
const date = d.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
...(sameYear ? {} : { year: "numeric" }),
|
||||
});
|
||||
const time = d.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${date}, ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SSE AgentEvent into a ChatMessage, or null if the event
|
||||
* doesn't produce a visible chat message.
|
||||
|
||||
@@ -448,7 +448,13 @@ export default function QueenDM() {
|
||||
setMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === chatMsg.id);
|
||||
if (idx >= 0) {
|
||||
return prev.map((m, i) => (i === idx ? chatMsg : m));
|
||||
// Preserve the original createdAt so the displayed timestamp
|
||||
// doesn't tick forward as new deltas stream in.
|
||||
return prev.map((m, i) =>
|
||||
i === idx
|
||||
? { ...chatMsg, createdAt: m.createdAt ?? chatMsg.createdAt }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
return [...prev, chatMsg];
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user