feat: add message timestamps, day-divider rows, and stable createdAt across stream updates

This commit is contained in:
bryan
2026-04-15 15:45:31 -07:00
parent 70e3eb539b
commit 900d94e49f
3 changed files with 118 additions and 7 deletions
+66 -6
View File
@@ -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}>
+45
View File
@@ -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.
+7 -1
View File
@@ -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];
});