diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index 8d3320f3f..a44d30ca0 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -522,8 +522,9 @@ func (s *JSONLStore) AddMessage( _ context.Context, sessionKey, role, content string, ) error { return s.addMsg(sessionKey, providers.Message{ - Role: role, - Content: content, + Role: role, + Content: content, + Timestamp: time.Now().Format(time.RFC3339Nano), }) } @@ -539,6 +540,10 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { l.Lock() defer l.Unlock() + if strings.TrimSpace(msg.Timestamp) == "" { + msg.Timestamp = time.Now().Format(time.RFC3339Nano) + } + // Append the message as a single JSON line. line, err := json.Marshal(msg) if err != nil { diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index b64c1b25f..a53ffef94 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -61,9 +61,15 @@ func TestAddMessage_BasicRoundtrip(t *testing.T) { if history[0].Role != "user" || history[0].Content != "hello" { t.Errorf("msg[0] = %+v", history[0]) } + if history[0].Timestamp == "" { + t.Errorf("msg[0].Timestamp is empty") + } if history[1].Role != "assistant" || history[1].Content != "hi there" { t.Errorf("msg[1] = %+v", history[1]) } + if history[1].Timestamp == "" { + t.Errorf("msg[1].Timestamp is empty") + } } func TestAddMessage_AutoCreatesSession(t *testing.T) { diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 89f68928a..25177c9ad 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -73,6 +73,7 @@ type Attachment struct { type Message struct { Role string `json:"role"` Content string `json:"content"` + Timestamp string `json:"timestamp,omitempty"` Media []string `json:"media,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 7f87d460a..949cdc7db 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -72,6 +72,10 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag sm.mu.Lock() defer sm.mu.Unlock() + if strings.TrimSpace(msg.Timestamp) == "" { + msg.Timestamp = time.Now().Format(time.RFC3339Nano) + } + session, ok := sm.sessions[sessionKey] if !ok { session = &Session{ diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 0483b57cc..c45102cbb 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -48,6 +48,7 @@ type sessionListItem struct { type sessionChatMessage struct { Role string `json:"role"` Content string `json:"content"` + Timestamp string `json:"timestamp,omitempty"` Media []string `json:"media,omitempty"` Attachments []sessionChatAttachment `json:"attachments,omitempty"` } @@ -477,6 +478,7 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen for _, msg := range messages { attachments := sessionAttachments(msg) + timestamp := strings.TrimSpace(msg.Timestamp) switch msg.Role { case "tool": @@ -486,6 +488,7 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen chatMsg := sessionChatMessage{ Role: "user", Content: msg.Content, + Timestamp: timestamp, Media: append([]string(nil), msg.Media...), Attachments: attachments, } @@ -500,12 +503,12 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen continue } - toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength) + toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, timestamp, toolFeedbackMaxArgsLength) if len(toolSummaryMessages) > 0 { transcript = append(transcript, toolSummaryMessages...) } - visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls) + visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls, timestamp) if len(visibleToolMessages) > 0 { transcript = append(transcript, visibleToolMessages...) } @@ -524,6 +527,7 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen chatMsg := sessionChatMessage{ Role: "assistant", Content: content, + Timestamp: timestamp, Media: append([]string(nil), msg.Media...), Attachments: attachments, } @@ -639,6 +643,7 @@ func assistantMessageInternalOnly(msg providers.Message) bool { func visibleAssistantToolSummaryMessages( toolCalls []providers.ToolCall, + timestamp string, toolFeedbackMaxArgsLength int, ) []sessionChatMessage { if len(toolCalls) == 0 { @@ -669,15 +674,16 @@ func visibleAssistantToolSummaryMessages( } messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)), + Role: "assistant", + Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)), + Timestamp: timestamp, }) } return messages } -func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { +func visibleAssistantToolMessages(toolCalls []providers.ToolCall, timestamp string) []sessionChatMessage { if len(toolCalls) == 0 { return nil } @@ -693,8 +699,9 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM continue } messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: content, + Role: "assistant", + Content: content, + Timestamp: timestamp, }) } diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index d2efb3879..edda95747 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -166,8 +166,8 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { sessionKey := legacyPicoSessionPrefix + "detail-jsonl" for _, msg := range []providers.Message{ - {Role: "user", Content: "first"}, - {Role: "assistant", Content: "second"}, + {Role: "user", Content: "first", Timestamp: "2026-04-23T11:05:00+08:00"}, + {Role: "assistant", Content: "second", Timestamp: "2026-04-23T11:06:00+08:00"}, {Role: "tool", Content: "ignored"}, } { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { @@ -194,8 +194,9 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { ID string `json:"id"` Summary string `json:"summary"` Messages []struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content"` + Timestamp string `json:"timestamp"` } `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { @@ -213,9 +214,15 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "first" { t.Fatalf("first message = %#v, want user/first", resp.Messages[0]) } + if resp.Messages[0].Timestamp != "2026-04-23T11:05:00+08:00" { + t.Fatalf("resp.Messages[0].Timestamp = %q, want %q", resp.Messages[0].Timestamp, "2026-04-23T11:05:00+08:00") + } if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "second" { t.Fatalf("second message = %#v, want assistant/second", resp.Messages[1]) } + if resp.Messages[1].Timestamp != "2026-04-23T11:06:00+08:00" { + t.Fatalf("resp.Messages[1].Timestamp = %q, want %q", resp.Messages[1].Timestamp, "2026-04-23T11:06:00+08:00") + } } func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(t *testing.T) { diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index 912fbecd8..67d56692d 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -14,6 +14,7 @@ export interface SessionDetail { messages: { role: "user" | "assistant" content: string + timestamp?: string media?: string[] attachments?: { type?: "image" | "audio" | "video" | "file" diff --git a/web/frontend/src/features/chat/history.ts b/web/frontend/src/features/chat/history.ts index a3e6ce14d..a94df2265 100644 --- a/web/frontend/src/features/chat/history.ts +++ b/web/frontend/src/features/chat/history.ts @@ -50,7 +50,7 @@ export async function loadSessionMessages( media: message.media, attachments: message.attachments, }), - timestamp: fallbackTime, + timestamp: message.timestamp?.trim() || fallbackTime, })) } diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts index 02467bd60..5a6780667 100644 --- a/web/frontend/src/hooks/use-pico-chat.ts +++ b/web/frontend/src/hooks/use-pico-chat.ts @@ -38,20 +38,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string { if (!date.isValid()) { return "" } - const now = dayjs() - - const isToday = date.isSame(now, "day") - const isThisYear = date.isSame(now, "year") - - if (isToday) { - return date.format("LT") - } - - if (isThisYear) { - return date.format("MMM D LT") - } - - return date.format("ll LT") + return date.format("YYYY-MM-DD HH:mm") } export function usePicoChat() {