feat(session): add per-message created_at timestamps
- Persistence layer (jsonl.go addMsg/SetHistory) normalizes CreatedAt when missing so the invariant is guaranteed at the storage boundary - API layer (session.go) exposes created_at on all transcript message types with session.updated fallback for legacy messages - Frontend uses per-message timestamps when available - messagesContentEqual ignores CreatedAt for tail-matching after JSONL roundtrip Fixes #2787
This commit is contained in:
parent
81a050555d
commit
80f6e2bbab
10 changed files with 208 additions and 18 deletions
|
|
@ -511,10 +511,25 @@ func (ts *turnState) restoreSession(agent *AgentInstance) error {
|
||||||
return agent.Sessions.Save(ts.sessionKey)
|
return agent.Sessions.Save(ts.sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// messagesContentEqual compares two message slices by content only, ignoring CreatedAt.
|
||||||
|
// JSON roundtrip loses the monotonic clock portion of time.Time, so direct
|
||||||
|
// reflect.DeepEqual would always differ on messages that roundtripped through
|
||||||
|
// the JSONL store.
|
||||||
|
func messagesContentEqual(a, b []providers.Message) bool {
|
||||||
|
for i := range a {
|
||||||
|
aCopy, bCopy := a[i], b[i]
|
||||||
|
aCopy.CreatedAt, bCopy.CreatedAt = nil, nil
|
||||||
|
if !reflect.DeepEqual(aCopy, bCopy) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func matchingTurnMessageTail(history, persisted []providers.Message) int {
|
func matchingTurnMessageTail(history, persisted []providers.Message) int {
|
||||||
maxMatch := min(len(history), len(persisted))
|
maxMatch := min(len(history), len(persisted))
|
||||||
for size := maxMatch; size > 0; size-- {
|
for size := maxMatch; size > 0; size-- {
|
||||||
if reflect.DeepEqual(history[len(history)-size:], persisted[len(persisted)-size:]) {
|
if messagesContentEqual(history[len(history)-size:], persisted[len(persisted)-size:]) {
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,12 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
|
||||||
l.Lock()
|
l.Lock()
|
||||||
defer l.Unlock()
|
defer l.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if msg.CreatedAt == nil {
|
||||||
|
msg.CreatedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
// Append the message as a single JSON line.
|
// Append the message as a single JSON line.
|
||||||
line, err := json.Marshal(msg)
|
line, err := json.Marshal(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -598,7 +604,6 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
|
||||||
if meta.Count == 0 && meta.CreatedAt.IsZero() {
|
if meta.Count == 0 && meta.CreatedAt.IsZero() {
|
||||||
meta.CreatedAt = now
|
meta.CreatedAt = now
|
||||||
}
|
}
|
||||||
|
|
@ -726,6 +731,12 @@ func (s *JSONLStore) SetHistory(
|
||||||
meta.Count = len(history)
|
meta.Count = len(history)
|
||||||
meta.UpdatedAt = now
|
meta.UpdatedAt = now
|
||||||
|
|
||||||
|
for i := range history {
|
||||||
|
if history[i].CreatedAt == nil {
|
||||||
|
history[i].CreatedAt = &now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Write meta BEFORE rewriting the JSONL file. If we crash between
|
// Write meta BEFORE rewriting the JSONL file. If we crash between
|
||||||
// the two writes, meta has Skip=0 and the old file is still intact,
|
// the two writes, meta has Skip=0 and the old file is still intact,
|
||||||
// so GetHistory reads from line 1 — returning "too many" messages
|
// so GetHistory reads from line 1 — returning "too many" messages
|
||||||
|
|
|
||||||
|
|
@ -1032,6 +1032,137 @@ func TestMultipleSessions_Isolation(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStore_SetsCreatedAtWhenNil(t *testing.T) {
|
||||||
|
type writeOp struct {
|
||||||
|
name string
|
||||||
|
fn func(store *JSONLStore, key string) (expectedCount int)
|
||||||
|
}
|
||||||
|
|
||||||
|
ops := []writeOp{
|
||||||
|
{
|
||||||
|
name: "AddMessage",
|
||||||
|
fn: func(store *JSONLStore, key string) int {
|
||||||
|
if err := store.AddMessage(context.Background(), key, "user", "hello"); err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AddFullMessage",
|
||||||
|
fn: func(store *JSONLStore, key string) int {
|
||||||
|
if err := store.AddFullMessage(context.Background(), key, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "hello from full",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage: %v", err)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetHistory",
|
||||||
|
fn: func(store *JSONLStore, key string) int {
|
||||||
|
if err := store.SetHistory(context.Background(), key, []providers.Message{
|
||||||
|
{Role: "user", Content: "msg1"},
|
||||||
|
{Role: "assistant", Content: "msg2"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetHistory: %v", err)
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, op := range ops {
|
||||||
|
t.Run(op.name, func(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
key := "s1"
|
||||||
|
|
||||||
|
before := time.Now().Add(-time.Second)
|
||||||
|
expectedCount := op.fn(store, key)
|
||||||
|
after := time.Now().Add(time.Second)
|
||||||
|
|
||||||
|
history, err := store.GetHistory(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != expectedCount {
|
||||||
|
t.Fatalf("expected %d messages, got %d", expectedCount, len(history))
|
||||||
|
}
|
||||||
|
for i := range history {
|
||||||
|
if history[i].CreatedAt == nil || history[i].CreatedAt.IsZero() {
|
||||||
|
t.Errorf("message %d CreatedAt is zero — not set by %s", i, op.name)
|
||||||
|
}
|
||||||
|
if history[i].CreatedAt.Before(before) || history[i].CreatedAt.After(after) {
|
||||||
|
t.Errorf("message %d CreatedAt %v outside expected window [%v, %v]", i, history[i].CreatedAt, before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_PreservesExistingCreatedAt(t *testing.T) {
|
||||||
|
t1 := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
t2 := time.Date(2026, 1, 1, 11, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
type writeOp struct {
|
||||||
|
name string
|
||||||
|
fn func(store *JSONLStore, key string)
|
||||||
|
wantTimes []time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
ops := []writeOp{
|
||||||
|
{
|
||||||
|
name: "AddFullMessage",
|
||||||
|
fn: func(store *JSONLStore, key string) {
|
||||||
|
if err := store.AddFullMessage(context.Background(), key, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "custom time",
|
||||||
|
CreatedAt: &t1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantTimes: []time.Time{t1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetHistory",
|
||||||
|
fn: func(store *JSONLStore, key string) {
|
||||||
|
if err := store.SetHistory(context.Background(), key, []providers.Message{
|
||||||
|
{Role: "user", Content: "msg1", CreatedAt: &t1},
|
||||||
|
{Role: "assistant", Content: "msg2", CreatedAt: &t2},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetHistory: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantTimes: []time.Time{t1, t2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, op := range ops {
|
||||||
|
t.Run(op.name, func(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
key := "s1"
|
||||||
|
|
||||||
|
op.fn(store, key)
|
||||||
|
|
||||||
|
history, err := store.GetHistory(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != len(op.wantTimes) {
|
||||||
|
t.Fatalf("expected %d messages, got %d", len(op.wantTimes), len(history))
|
||||||
|
}
|
||||||
|
for i, want := range op.wantTimes {
|
||||||
|
if history[i].CreatedAt == nil || !history[i].CreatedAt.Equal(want) {
|
||||||
|
t.Errorf("message %d CreatedAt = %v, want %v (should preserve caller-provided time)", i, history[i].CreatedAt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func BenchmarkAddMessage(b *testing.B) {
|
func BenchmarkAddMessage(b *testing.B) {
|
||||||
dir := b.TempDir()
|
dir := b.TempDir()
|
||||||
store, err := NewJSONLStore(dir)
|
store, err := NewJSONLStore(dir)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package protocoltypes
|
package protocoltypes
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type ToolCall struct {
|
type ToolCall struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
|
|
@ -81,6 +83,7 @@ type Attachment struct {
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
Media []string `json:"media,omitempty"`
|
Media []string `json:"media,omitempty"`
|
||||||
Attachments []Attachment `json:"attachments,omitempty"`
|
Attachments []Attachment `json:"attachments,omitempty"`
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ type sessionChatMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Kind string `json:"kind,omitempty"`
|
Kind string `json:"kind,omitempty"`
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
Media []string `json:"media,omitempty"`
|
Media []string `json:"media,omitempty"`
|
||||||
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
|
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
|
||||||
ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"`
|
||||||
|
|
@ -510,6 +511,7 @@ func sessionTranscriptMessages(
|
||||||
chatMsg := sessionChatMessage{
|
chatMsg := sessionChatMessage{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
|
CreatedAt: msg.CreatedAt,
|
||||||
Media: append([]string(nil), msg.Media...),
|
Media: append([]string(nil), msg.Media...),
|
||||||
Attachments: attachments,
|
Attachments: attachments,
|
||||||
}
|
}
|
||||||
|
|
@ -530,8 +532,9 @@ func sessionTranscriptMessages(
|
||||||
toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage(
|
toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage(
|
||||||
msg.ToolCalls,
|
msg.ToolCalls,
|
||||||
toolFeedbackMaxArgsLength,
|
toolFeedbackMaxArgsLength,
|
||||||
|
msg.CreatedAt,
|
||||||
)
|
)
|
||||||
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
|
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls, msg.CreatedAt)
|
||||||
|
|
||||||
// Pico web chat can persist both visible `message` tool output and a
|
// Pico web chat can persist both visible `message` tool output and a
|
||||||
// later plain assistant reply in the same turn. Hide only the fixed
|
// later plain assistant reply in the same turn. Hide only the fixed
|
||||||
|
|
@ -556,6 +559,7 @@ func sessionTranscriptMessages(
|
||||||
chatMsg := sessionChatMessage{
|
chatMsg := sessionChatMessage{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: content,
|
||||||
|
CreatedAt: msg.CreatedAt,
|
||||||
Media: append([]string(nil), msg.Media...),
|
Media: append([]string(nil), msg.Media...),
|
||||||
Attachments: attachments,
|
Attachments: attachments,
|
||||||
}
|
}
|
||||||
|
|
@ -685,12 +689,14 @@ func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) {
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: reasoning,
|
Content: reasoning,
|
||||||
Kind: "thought",
|
Kind: "thought",
|
||||||
|
CreatedAt: msg.CreatedAt,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func assistantToolCallsMessage(
|
func assistantToolCallsMessage(
|
||||||
toolCalls []providers.ToolCall,
|
toolCalls []providers.ToolCall,
|
||||||
toolFeedbackMaxArgsLength int,
|
toolFeedbackMaxArgsLength int,
|
||||||
|
createdAt *time.Time,
|
||||||
) (sessionChatMessage, bool) {
|
) (sessionChatMessage, bool) {
|
||||||
if len(toolCalls) == 0 {
|
if len(toolCalls) == 0 {
|
||||||
return sessionChatMessage{}, false
|
return sessionChatMessage{}, false
|
||||||
|
|
@ -707,6 +713,7 @@ func assistantToolCallsMessage(
|
||||||
return sessionChatMessage{
|
return sessionChatMessage{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Kind: "tool_calls",
|
Kind: "tool_calls",
|
||||||
|
CreatedAt: createdAt,
|
||||||
ToolCalls: visibleToolCalls,
|
ToolCalls: visibleToolCalls,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
@ -718,7 +725,7 @@ func visibleAssistantToolArgsPreview(
|
||||||
return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength)
|
return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
|
func visibleAssistantToolMessages(toolCalls []providers.ToolCall, createdAt *time.Time) []sessionChatMessage {
|
||||||
if len(toolCalls) == 0 {
|
if len(toolCalls) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -736,6 +743,7 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM
|
||||||
messages = append(messages, sessionChatMessage{
|
messages = append(messages, sessionChatMessage{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: content,
|
||||||
|
CreatedAt: createdAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -918,6 +926,11 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i := range sess.Messages {
|
||||||
|
if sess.Messages[i].CreatedAt == nil {
|
||||||
|
sess.Messages[i].CreatedAt = &sess.Updated
|
||||||
|
}
|
||||||
|
}
|
||||||
messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
|
messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export interface SessionDetail {
|
||||||
messages: {
|
messages: {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
content: string
|
content: string
|
||||||
|
created_at?: string
|
||||||
kind?: "normal" | "thought" | "tool_calls"
|
kind?: "normal" | "thought" | "tool_calls"
|
||||||
media?: string[]
|
media?: string[]
|
||||||
attachments?: {
|
attachments?: {
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,10 @@ export function AssistantMessage({
|
||||||
)}
|
)}
|
||||||
<span>{collapsedLabel}</span>
|
<span>{collapsedLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{formattedTimestamp && (
|
||||||
|
<span className="opacity-50">{formattedTimestamp}</span>
|
||||||
|
)}
|
||||||
<IconChevronDown
|
<IconChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
|
"size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
|
||||||
|
|
@ -139,6 +143,7 @@ export function AssistantMessage({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && (
|
{(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && (
|
||||||
<div className="space-y-3 px-3 pt-0 pb-3">
|
<div className="space-y-3 px-3 pt-0 pb-3">
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,7 @@ export function ChatPage() {
|
||||||
<UserMessage
|
<UserMessage
|
||||||
content={msg.content}
|
content={msg.content}
|
||||||
attachments={msg.attachments}
|
attachments={msg.attachments}
|
||||||
|
timestamp={msg.timestamp}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
|
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { ChatAttachment } from "@/store/chat"
|
import type { ChatAttachment } from "@/store/chat"
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
content: string
|
content: string
|
||||||
attachments?: ChatAttachment[]
|
attachments?: ChatAttachment[]
|
||||||
|
timestamp?: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
export function UserMessage({
|
||||||
|
content,
|
||||||
|
attachments = [],
|
||||||
|
timestamp = "",
|
||||||
|
}: UserMessageProps) {
|
||||||
const hasText = content.trim().length > 0
|
const hasText = content.trim().length > 0
|
||||||
const isCommand = content.trim().startsWith("/")
|
const isCommand = content.trim().startsWith("/")
|
||||||
const imageAttachments = attachments.filter(
|
const imageAttachments = attachments.filter(
|
||||||
(attachment) => attachment.type === "image",
|
(attachment) => attachment.type === "image",
|
||||||
)
|
)
|
||||||
|
const formattedTimestamp =
|
||||||
|
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-end gap-1.5">
|
<div className="flex w-full flex-col items-end gap-1.5">
|
||||||
|
|
@ -49,6 +57,10 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{formattedTimestamp && (
|
||||||
|
<span className="px-1 text-[12px] text-zinc-400">{formattedTimestamp}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,6 @@ export async function loadSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<ChatMessage[]> {
|
): Promise<ChatMessage[]> {
|
||||||
const detail = await getSessionHistory(sessionId)
|
const detail = await getSessionHistory(sessionId)
|
||||||
const fallbackTime = detail.updated
|
|
||||||
|
|
||||||
return detail.messages.map((message, index) => ({
|
return detail.messages.map((message, index) => ({
|
||||||
id: `hist-${index}-${Date.now()}`,
|
id: `hist-${index}-${Date.now()}`,
|
||||||
role: message.role,
|
role: message.role,
|
||||||
|
|
@ -58,7 +56,7 @@ export async function loadSessionMessages(
|
||||||
media: message.media,
|
media: message.media,
|
||||||
attachments: message.attachments,
|
attachments: message.attachments,
|
||||||
}),
|
}),
|
||||||
timestamp: fallbackTime,
|
timestamp: message.created_at ?? detail.updated,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue