From 9825b4782f1f62170d6581f4552fc347c34e488b Mon Sep 17 00:00:00 2001
From: lc6464
Date: Tue, 26 May 2026 14:05:20 +0800
Subject: [PATCH 1/2] fix(seahorse,session): preserve created_at across history
bootstrap
---
pkg/agent/context_seahorse.go | 9 ++
pkg/agent/context_seahorse_test.go | 5 ++
pkg/seahorse/short_engine.go | 132 +++++++++++++++++++++++------
pkg/seahorse/short_engine_test.go | 86 +++++++++++++++++--
pkg/seahorse/store.go | 92 ++++++++++++++++----
pkg/seahorse/store_test.go | 14 +++
pkg/session/manager.go | 29 ++++---
pkg/session/manager_test.go | 29 +++++++
8 files changed, 340 insertions(+), 56 deletions(-)
diff --git a/pkg/agent/context_seahorse.go b/pkg/agent/context_seahorse.go
index 2a10d2457..6bd32c216 100644
--- a/pkg/agent/context_seahorse.go
+++ b/pkg/agent/context_seahorse.go
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
@@ -200,6 +201,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
ModelName: msg.ModelName,
ReasoningContent: msg.ReasoningContent,
TokenCount: tokenizer.EstimateMessageTokens(msg),
+ CreatedAt: normalizeSeahorseMessageCreatedAt(msg.CreatedAt),
}
// Convert ToolCalls → MessageParts
@@ -235,6 +237,13 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
return result
}
+func normalizeSeahorseMessageCreatedAt(createdAt *time.Time) time.Time {
+ if createdAt == nil || createdAt.IsZero() {
+ return time.Time{}
+ }
+ return createdAt.UTC().Truncate(time.Second)
+}
+
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
messages := make([]protocoltypes.Message, 0, len(result.Messages))
diff --git a/pkg/agent/context_seahorse_test.go b/pkg/agent/context_seahorse_test.go
index 101f72ee2..497e1fc44 100644
--- a/pkg/agent/context_seahorse_test.go
+++ b/pkg/agent/context_seahorse_test.go
@@ -171,11 +171,13 @@ func TestProviderToSeahorseMessageWithMedia(t *testing.T) {
}
func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
+ createdAt := time.Date(2026, 5, 6, 7, 8, 9, 123000000, time.UTC)
msg := protocoltypes.Message{
Role: "assistant",
Content: "response text",
ModelName: "gpt-5.4-mini",
ReasoningContent: "I thought about this carefully",
+ CreatedAt: &createdAt,
}
result := providerToSeahorseMessage(msg)
@@ -185,6 +187,9 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
if result.ModelName != "gpt-5.4-mini" {
t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini")
}
+ if !result.CreatedAt.Equal(time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)) {
+ t.Errorf("CreatedAt = %v, want 2026-05-06 07:08:09 UTC", result.CreatedAt)
+ }
}
func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) {
diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go
index d275da516..ef493bd18 100644
--- a/pkg/seahorse/short_engine.go
+++ b/pkg/seahorse/short_engine.go
@@ -9,6 +9,7 @@ import (
"regexp"
"strings"
"sync"
+ "time"
_ "modernc.org/sqlite"
@@ -261,6 +262,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
msg.ModelName,
msg.ReasoningContent,
msg.TokenCount,
+ msg.CreatedAt,
)
} else {
added, err = e.store.AddMessageWithReasoning(
@@ -271,6 +273,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
msg.ModelName,
msg.ReasoningContent,
msg.TokenCount,
+ msg.CreatedAt,
)
}
if err != nil {
@@ -445,10 +448,14 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
if err != nil {
return fmt.Errorf("bootstrap: repair model_name: %w", err)
}
- if (repairedReasoning || repairedModelName) && len(dbMsgs) == len(messages) {
+ repairedCreatedAt, err := e.repairBootstrapCreatedAt(ctx, dbMsgs, messages)
+ if err != nil {
+ return fmt.Errorf("bootstrap: repair created_at: %w", err)
+ }
+ if (repairedReasoning || repairedModelName || repairedCreatedAt) && len(dbMsgs) == len(messages) {
matched := true
for i := range messages {
- if !messageMatches(dbMsgs[i], messages[i]) {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
matched = false
break
}
@@ -462,7 +469,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
if len(dbMsgs) == len(messages) {
matched := true
for i := range messages {
- if !messageMatches(dbMsgs[i], messages[i]) {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
matched = false
break
}
@@ -477,7 +484,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
compareLen := min(len(dbMsgs), len(messages))
for i := range compareLen {
- if messageMatches(dbMsgs[i], messages[i]) {
+ if messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
anchor = i
} else {
// Mismatch detected - log details and rebuild
@@ -578,7 +585,11 @@ func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, me
}
for i := range overlap {
- if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
+ IgnoreReasoningContent: true,
+ IgnoreModelName: true,
+ IgnoreCreatedAt: true,
+ }) {
return false, nil
}
if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent {
@@ -629,7 +640,11 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages
}
for i := range overlap {
- if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
+ IgnoreReasoningContent: true,
+ IgnoreModelName: true,
+ IgnoreCreatedAt: true,
+ }) {
return false, nil
}
if dbMsgs[i].ModelName == messages[i].ModelName {
@@ -666,6 +681,64 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages
return true, nil
}
+func (e *Engine) repairBootstrapCreatedAt(ctx context.Context, dbMsgs, messages []Message) (bool, error) {
+ if len(dbMsgs) == 0 || len(messages) == 0 {
+ return false, nil
+ }
+
+ overlap := min(len(messages), len(dbMsgs))
+
+ var updates []struct {
+ index int
+ messageID int64
+ createdAt time.Time
+ }
+
+ for i := range overlap {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
+ IgnoreReasoningContent: true,
+ IgnoreModelName: true,
+ IgnoreCreatedAt: true,
+ }) {
+ return false, nil
+ }
+
+ wantCreatedAt := normalizeMessageCreatedAt(messages[i].CreatedAt)
+ if wantCreatedAt.IsZero() {
+ return false, nil
+ }
+ if dbMsgs[i].CreatedAt.Equal(wantCreatedAt) {
+ continue
+ }
+
+ updates = append(updates, struct {
+ index int
+ messageID int64
+ createdAt time.Time
+ }{
+ index: i,
+ messageID: dbMsgs[i].ID,
+ createdAt: wantCreatedAt,
+ })
+ }
+
+ if len(updates) == 0 {
+ return false, nil
+ }
+
+ for _, update := range updates {
+ if err := e.store.UpdateMessageCreatedAt(ctx, update.messageID, update.createdAt); err != nil {
+ return false, err
+ }
+ dbMsgs[update.index].CreatedAt = update.createdAt
+ }
+
+ logger.InfoCF("seahorse", "bootstrap: repaired message created_at", map[string]any{
+ "messages": len(updates),
+ })
+ return true, nil
+}
+
// truncate shortens a string for logging.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
@@ -674,29 +747,28 @@ func truncate(s string, maxLen int) string {
return s[:maxLen] + "..."
}
-// messageMatches compares two messages using role + reasoning_content and then
-// either content or parts. TokenCount is NOT compared because it may be
-// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
-// For messages with Parts (tool_use, tool_result), compare Parts instead of Content
-// because structured messages are matched by their parts payload.
-func messageMatches(a, b Message) bool {
- if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent || a.ModelName != b.ModelName {
- return false
- }
- return messageMatchesIgnoringReasoning(a, b)
+type messageMatchOptions struct {
+ IgnoreReasoningContent bool
+ IgnoreModelName bool
+ IgnoreCreatedAt bool
}
-func messageMatchesIgnoringReasoning(a, b Message) bool {
- if a.ModelName != b.ModelName {
- return false
- }
- return messageMatchesIgnoringReasoningAndModelName(a, b)
-}
-
-func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool {
+// messagesMatch compares two messages by role and payload, plus the optional
+// metadata fields used by bootstrap repair. TokenCount is intentionally ignored
+// because bootstrap may re-estimate it differently.
+func messagesMatch(a, b Message, opts messageMatchOptions) bool {
if a.Role != b.Role {
return false
}
+ if !opts.IgnoreReasoningContent && a.ReasoningContent != b.ReasoningContent {
+ return false
+ }
+ if !opts.IgnoreModelName && a.ModelName != b.ModelName {
+ return false
+ }
+ if !opts.IgnoreCreatedAt && !messageCreatedAtMatches(a.CreatedAt, b.CreatedAt) {
+ return false
+ }
// If either message has Parts, compare Parts
if len(a.Parts) > 0 || len(b.Parts) > 0 {
return partsMatch(a.Parts, b.Parts)
@@ -705,6 +777,18 @@ func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool {
return a.Content == b.Content
}
+// messageCreatedAtMatches treats missing timestamps as compatible so bootstrap
+// can preserve legacy histories while still enforcing exact equality once both
+// sides carry canonical created_at values.
+func messageCreatedAtMatches(a, b time.Time) bool {
+ na := normalizeMessageCreatedAt(a)
+ nb := normalizeMessageCreatedAt(b)
+ if na.IsZero() || nb.IsZero() {
+ return true
+ }
+ return na.Equal(nb)
+}
+
// partsMatch compares two slices of MessagePart for equality.
func partsMatch(a, b []MessagePart) bool {
if len(a) != len(b) {
diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go
index 337416f6f..2e198673d 100644
--- a/pkg/seahorse/short_engine_test.go
+++ b/pkg/seahorse/short_engine_test.go
@@ -57,8 +57,8 @@ func prepareBootstrapRepairConversation(
}
return conv, []Message{
- {Role: "user", Content: "hello", TokenCount: 3},
- {Role: "assistant", Content: "world", TokenCount: 3},
+ {Role: "user", Content: "hello", TokenCount: 3, CreatedAt: userMsg.CreatedAt},
+ {Role: "assistant", Content: "world", TokenCount: 3, CreatedAt: assistantMsg.CreatedAt},
}
}
@@ -464,13 +464,19 @@ func TestBootstrapRepairsReasoningContentAndModelNameTogether(t *testing.T) {
}
err = eng.Bootstrap(ctx, sessionKey, []Message{
- {Role: "user", Content: "hello", TokenCount: 3},
+ {
+ Role: "user",
+ Content: "hello",
+ TokenCount: 3,
+ CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
+ },
{
Role: "assistant",
Content: "world",
ModelName: "gpt-5.4",
ReasoningContent: "let me think this through",
TokenCount: 3,
+ CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC),
},
})
if err != nil {
@@ -515,6 +521,7 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) {
"wrong-model",
"",
3,
+ time.Time{},
)
if err != nil {
t.Fatalf("AddMessageWithReasoning assistant: %v", err)
@@ -545,6 +552,64 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) {
}
}
+func TestBootstrapRepairsCreatedAt(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+ sessionKey := "agent:repair-created-at"
+ conv, msgs := prepareBootstrapRepairConversation(t, eng, ctx, sessionKey)
+
+ wantCreatedAt := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
+ msgs[1].CreatedAt = wantCreatedAt
+
+ err := eng.Bootstrap(ctx, sessionKey, msgs)
+ if err != nil {
+ t.Fatalf("Bootstrap: %v", err)
+ }
+
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 2 {
+ t.Fatalf("stored messages = %d, want 2", len(stored))
+ }
+ if !stored[1].CreatedAt.Equal(wantCreatedAt) {
+ t.Fatalf("stored[1].CreatedAt = %v, want %v", stored[1].CreatedAt, wantCreatedAt)
+ }
+}
+
+func TestEngineIngestPreservesCreatedAt(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+ wantCreatedAt := time.Date(2026, 4, 5, 6, 7, 8, 0, time.UTC)
+
+ msgs := []Message{
+ {
+ Role: "assistant",
+ Content: "world",
+ TokenCount: 4,
+ CreatedAt: wantCreatedAt,
+ },
+ }
+
+ _, err := eng.Ingest(ctx, "agent:created-at", msgs)
+ if err != nil {
+ t.Fatalf("Ingest: %v", err)
+ }
+
+ conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:created-at")
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 1 {
+ t.Fatalf("stored messages = %d, want 1", len(stored))
+ }
+ if !stored[0].CreatedAt.Equal(wantCreatedAt) {
+ t.Fatalf("stored[0].CreatedAt = %v, want %v", stored[0].CreatedAt, wantCreatedAt)
+ }
+}
+
func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()
@@ -864,8 +929,19 @@ func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *test
}
err = eng.Bootstrap(ctx, sessionKey, []Message{
- {Role: "user", Content: "hello", TokenCount: 3},
- {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
+ {
+ Role: "user",
+ Content: "hello",
+ TokenCount: 3,
+ CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
+ },
+ {
+ Role: "assistant",
+ Content: "world",
+ ReasoningContent: "let me think this through",
+ TokenCount: 3,
+ CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC),
+ },
})
if err != nil {
t.Fatalf("Bootstrap: %v", err)
diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go
index b5e32e89d..0a0d07044 100644
--- a/pkg/seahorse/store.go
+++ b/pkg/seahorse/store.go
@@ -8,6 +8,8 @@ import (
"time"
)
+const sqliteTimeLayout = "2006-01-02 15:04:05"
+
// Store provides SQLite storage for seahorse.
type Store struct {
db *sql.DB
@@ -75,8 +77,8 @@ func (s *Store) GetConversationBySessionKey(ctx context.Context, sessionKey stri
if err != nil {
return nil, fmt.Errorf("get conversation by session key: %w", err)
}
- conv.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
- conv.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
+ conv.CreatedAt = parseSQLiteTime(createdAt)
+ conv.UpdatedAt = parseSQLiteTime(updatedAt)
return &conv, nil
}
@@ -153,8 +155,8 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim
if err != nil || minTime == "" {
return time.Time{}, time.Time{}, err
}
- oldest, _ := time.Parse("2006-01-02 15:04:05", minTime)
- newest, _ := time.Parse("2006-01-02 15:04:05", maxTime)
+ oldest := parseSQLiteTime(minTime)
+ newest := parseSQLiteTime(maxTime)
return oldest, newest, nil
}
@@ -162,7 +164,7 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim
// AddMessage appends a message to a conversation.
func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) {
- return s.AddMessageWithReasoning(ctx, convID, role, content, "", "", tokenCount)
+ return s.AddMessageWithReasoning(ctx, convID, role, content, "", "", tokenCount, time.Time{})
}
// AddMessageWithReasoning appends a message with reasoning content to a conversation.
@@ -171,16 +173,22 @@ func (s *Store) AddMessageWithReasoning(
convID int64,
role, content, modelName, reasoningContent string,
tokenCount int,
+ createdAt time.Time,
) (*Message, error) {
+ storedCreatedAt := normalizeMessageCreatedAt(createdAt)
+ if storedCreatedAt.IsZero() {
+ storedCreatedAt = normalizeMessageCreatedAt(time.Now())
+ }
result, err := s.db.ExecContext(
ctx,
- "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
convID,
role,
content,
modelName,
reasoningContent,
tokenCount,
+ formatSQLiteTime(storedCreatedAt),
)
if err != nil {
return nil, fmt.Errorf("add message: %w", err)
@@ -194,6 +202,7 @@ func (s *Store) AddMessageWithReasoning(
ModelName: modelName,
ReasoningContent: reasoningContent,
TokenCount: tokenCount,
+ CreatedAt: storedCreatedAt,
}, nil
}
@@ -231,7 +240,7 @@ func (s *Store) AddMessageWithParts(
parts []MessagePart,
tokenCount int,
) (*Message, error) {
- return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", "", tokenCount)
+ return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", "", tokenCount, time.Time{})
}
// AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content.
@@ -243,6 +252,7 @@ func (s *Store) AddMessageWithPartsAndReasoning(
modelName string,
reasoningContent string,
tokenCount int,
+ createdAt time.Time,
) (*Message, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@@ -250,18 +260,24 @@ func (s *Store) AddMessageWithPartsAndReasoning(
}
defer tx.Rollback()
+ storedCreatedAt := normalizeMessageCreatedAt(createdAt)
+ if storedCreatedAt.IsZero() {
+ storedCreatedAt = normalizeMessageCreatedAt(time.Now())
+ }
+
// Derive readable content from Parts for FTS5 indexing and summary formatting
readableContent := partsToReadableContent(parts)
result, err := tx.ExecContext(
ctx,
- "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
convID,
role,
readableContent,
modelName,
reasoningContent,
tokenCount,
+ formatSQLiteTime(storedCreatedAt),
)
if err != nil {
return nil, fmt.Errorf("add message: %w", err)
@@ -299,6 +315,7 @@ func (s *Store) AddMessageWithPartsAndReasoning(
ModelName: modelName,
ReasoningContent: reasoningContent,
TokenCount: tokenCount,
+ CreatedAt: storedCreatedAt,
Parts: make([]MessagePart, len(parts)),
}
for i, p := range parts {
@@ -344,7 +361,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before
); err != nil {
return nil, err
}
- msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msg.CreatedAt = parseSQLiteTime(createdAt)
msgs = append(msgs, msg)
}
if err := rows.Err(); err != nil {
@@ -387,7 +404,7 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message,
if err != nil {
return nil, err
}
- msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msg.CreatedAt = parseSQLiteTime(createdAt)
msg.Parts, _ = s.loadMessageParts(ctx, msg.ID)
return &msg, nil
}
@@ -435,6 +452,32 @@ func (s *Store) UpdateMessageModelName(ctx context.Context, messageID int64, mod
return nil
}
+func (s *Store) UpdateMessageCreatedAt(ctx context.Context, messageID int64, createdAt time.Time) error {
+ storedCreatedAt := normalizeMessageCreatedAt(createdAt)
+ if storedCreatedAt.IsZero() {
+ return fmt.Errorf("message %d created_at cannot be zero", messageID)
+ }
+
+ result, err := s.db.ExecContext(
+ ctx,
+ "UPDATE messages SET created_at = ? WHERE message_id = ?",
+ formatSQLiteTime(storedCreatedAt),
+ messageID,
+ )
+ if err != nil {
+ return fmt.Errorf("update message created_at: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("update message created_at rows affected: %w", err)
+ }
+ if rowsAffected == 0 {
+ return fmt.Errorf("message %d not found", messageID)
+ }
+ return nil
+}
+
func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type
@@ -648,7 +691,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string)
); err != nil {
return nil, err
}
- msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msg.CreatedAt = parseSQLiteTime(createdAt)
msgs = append(msgs, msg)
}
if err := rows.Err(); err != nil {
@@ -714,8 +757,7 @@ func (s *Store) GetContextItems(ctx context.Context, convID int64) ([]ContextIte
item.MessageID = messageID.Int64
}
if createdAt.Valid {
- t, _ := time.Parse("2006-01-02 15:04:05", createdAt.String)
- item.CreatedAt = t
+ item.CreatedAt = parseSQLiteTime(createdAt.String)
}
items = append(items, item)
}
@@ -1449,7 +1491,7 @@ func (s *Store) scanSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult
}
}
r.Kind = SummaryKind(kind)
- r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ r.CreatedAt = parseSQLiteTime(createdAt)
results = append(results, r)
}
return results, nil
@@ -1573,7 +1615,7 @@ func (s *Store) scanMessageSearchResults(rows *sql.Rows, withRank bool) ([]Searc
}
}
r.Snippet = content
- r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ r.CreatedAt = parseSQLiteTime(createdAt)
results = append(results, r)
}
if err := rows.Err(); err != nil {
@@ -1606,7 +1648,7 @@ func (s *Store) scanSummary(ctx context.Context, where string, args ...any) (*Su
return nil, err
}
sum.Kind = SummaryKind(kind)
- sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ sum.CreatedAt = parseSQLiteTime(createdAt)
if earliestAt.Valid {
t, _ := time.Parse(time.RFC3339, earliestAt.String)
sum.EarliestAt = &t
@@ -1633,7 +1675,7 @@ func (s *Store) scanSummaries(rows *sql.Rows) ([]Summary, error) {
return nil, err
}
sum.Kind = SummaryKind(kind)
- sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ sum.CreatedAt = parseSQLiteTime(createdAt)
if earliestAt.Valid {
t, _ := time.Parse(time.RFC3339, earliestAt.String)
sum.EarliestAt = &t
@@ -1659,6 +1701,22 @@ func isUniqueViolation(err error) bool {
contains(err.Error(), "constraint failed"))
}
+func normalizeMessageCreatedAt(createdAt time.Time) time.Time {
+ if createdAt.IsZero() {
+ return time.Time{}
+ }
+ return createdAt.UTC().Truncate(time.Second)
+}
+
+func formatSQLiteTime(t time.Time) string {
+ return normalizeMessageCreatedAt(t).Format(sqliteTimeLayout)
+}
+
+func parseSQLiteTime(raw string) time.Time {
+ parsed, _ := time.Parse(sqliteTimeLayout, raw)
+ return parsed
+}
+
func contains(s, sub string) bool {
return len(s) >= len(sub) && searchSubstring(s, sub)
}
diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go
index 4ed2bb3bb..785873150 100644
--- a/pkg/seahorse/store_test.go
+++ b/pkg/seahorse/store_test.go
@@ -213,6 +213,7 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
"gpt-5.4-mini",
"let me think",
5,
+ time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
)
if err != nil {
t.Fatalf("AddMessageWithReasoning: %v", err)
@@ -223,6 +224,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
if msg.ModelName != "gpt-5.4-mini" {
t.Fatalf("ModelName = %q, want %q", msg.ModelName, "gpt-5.4-mini")
}
+ if !msg.CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
+ t.Fatalf("CreatedAt = %v, want 2026-01-02 03:04:05 UTC", msg.CreatedAt)
+ }
msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
if err != nil {
@@ -237,6 +241,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
if msgs[0].ModelName != "gpt-5.4-mini" {
t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4-mini")
}
+ if !msgs[0].CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
+ t.Errorf("CreatedAt = %v, want 2026-01-02 03:04:05 UTC", msgs[0].CreatedAt)
+ }
found, err := s.GetMessageByID(ctx, msg.ID)
if err != nil {
@@ -248,6 +255,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
if found.ModelName != "gpt-5.4-mini" {
t.Errorf("GetMessageByID ModelName = %q, want %q", found.ModelName, "gpt-5.4-mini")
}
+ if !found.CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
+ t.Errorf("GetMessageByID CreatedAt = %v, want 2026-01-02 03:04:05 UTC", found.CreatedAt)
+ }
}
func TestStoreAddMessageWithParts(t *testing.T) {
@@ -301,6 +311,7 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) {
"gpt-5.4",
"need to inspect the file first",
10,
+ time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC),
)
if err != nil {
t.Fatalf("AddMessageWithPartsAndReasoning: %v", err)
@@ -323,6 +334,9 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) {
if msgs[0].ModelName != "gpt-5.4" {
t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4")
}
+ if !msgs[0].CreatedAt.Equal(time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC)) {
+ t.Errorf("CreatedAt = %v, want 2026-02-03 04:05:06 UTC", msgs[0].CreatedAt)
+ }
}
func TestStoreGetMessageCount(t *testing.T) {
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index 7ca03744d..f75e3e883 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -60,6 +60,21 @@ func (sm *SessionManager) GetOrCreate(key string) *Session {
return session
}
+func ensureMessageCreatedAt(msg *providers.Message, fallback time.Time) {
+ if msg.CreatedAt != nil && !msg.CreatedAt.IsZero() {
+ return
+ }
+ ts := fallback
+ msg.CreatedAt = &ts
+}
+
+func normalizeHistoryCreatedAt(history []providers.Message) {
+ now := time.Now()
+ for i := range history {
+ ensureMessageCreatedAt(&history[i], now)
+ }
+}
+
func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
sm.AddFullMessage(sessionKey, providers.Message{
Role: role,
@@ -88,9 +103,7 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag
}
now := time.Now()
- if msg.CreatedAt == nil {
- msg.CreatedAt = &now
- }
+ ensureMessageCreatedAt(&msg, now)
session.Messages = append(session.Messages, msg)
session.Updated = now
@@ -280,6 +293,7 @@ func (sm *SessionManager) loadSessions() error {
continue
}
session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages)
+ normalizeHistoryCreatedAt(session.Messages)
sm.sessions[session.Key] = &session
}
@@ -305,13 +319,8 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
// from the caller's slice.
msgs := make([]providers.Message, len(history))
copy(msgs, history)
- now := time.Now()
- for i := range msgs {
- if msgs[i].CreatedAt == nil {
- msgs[i].CreatedAt = &now
- }
- }
+ normalizeHistoryCreatedAt(msgs)
session.Messages = msgs
- session.Updated = now
+ session.Updated = time.Now()
}
}
diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go
index bc5615966..e167941e7 100644
--- a/pkg/session/manager_test.go
+++ b/pkg/session/manager_test.go
@@ -83,3 +83,32 @@ func TestSave_RejectsPathTraversal(t *testing.T) {
t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)")
}
}
+
+func TestLoadSessions_NormalizesMissingCreatedAt(t *testing.T) {
+ tmpDir := t.TempDir()
+ sessionPath := filepath.Join(tmpDir, "telegram_legacy.json")
+ legacy := `{
+ "key": "telegram:legacy",
+ "messages": [
+ {
+ "role": "user",
+ "content": "hello"
+ }
+ ],
+ "created": "2026-01-01T00:00:00Z",
+ "updated": "2026-01-01T00:00:00Z"
+}`
+
+ if err := os.WriteFile(sessionPath, []byte(legacy), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ sm := NewSessionManager(tmpDir)
+ history := sm.GetHistory("telegram:legacy")
+ if len(history) != 1 {
+ t.Fatalf("history = %d, want 1", len(history))
+ }
+ if history[0].CreatedAt == nil || history[0].CreatedAt.IsZero() {
+ t.Fatalf("history[0].CreatedAt = %v, want non-zero timestamp", history[0].CreatedAt)
+ }
+}
From 28ec5793a8cd0826e9a91e62e7b2f636a9eb1258 Mon Sep 17 00:00:00 2001
From: LC
Date: Tue, 26 May 2026 14:57:52 +0800
Subject: [PATCH 2/2] feat(web): add line numbers and wrap toggle for code
blocks (#2933)
* feat(web): add line numbers and wrap toggle for code blocks
* fix(web): preserve markdown code block copy semantics
---
.../components/agent/skills/detail-sheet.tsx | 18 +-
.../channels/channel-forms/mqtt-form.tsx | 19 +-
.../src/components/chat/assistant-message.tsx | 1 -
.../components/chat/message-code-block.tsx | 122 +++++++--
.../chat/message-code-block.utils.ts | 257 +++++++++++++++++-
web/frontend/src/i18n/locales/en.json | 2 +
web/frontend/src/i18n/locales/pt-br.json | 2 +
web/frontend/src/i18n/locales/zh.json | 2 +
web/frontend/src/store/code-block.ts | 11 +
web/frontend/src/store/index.ts | 1 +
10 files changed, 402 insertions(+), 33 deletions(-)
create mode 100644 web/frontend/src/store/code-block.ts
diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx
index 4579926d8..9ec0c5a0c 100644
--- a/web/frontend/src/components/agent/skills/detail-sheet.tsx
+++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx
@@ -13,6 +13,10 @@ import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
+import {
+ MarkdownCodeBlock,
+ MessageCodeBlock,
+} from "@/components/chat/message-code-block"
import {
Sheet,
SheetContent,
@@ -176,6 +180,9 @@ export function DetailSheet({
{selectedSkillDetail.content}
@@ -183,11 +190,12 @@ export function DetailSheet({
) : null}
{detailView === "raw" ? (
-
-
- {selectedSkillDetail.content}
-
-
+
) : null}
{detailView === "meta" ? (
diff --git a/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx b/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx
index c52ad601c..0be02649f 100644
--- a/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx
+++ b/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx
@@ -1,4 +1,5 @@
import type { ChannelConfig } from "@/api/channels"
+import { MessageCodeBlock } from "@/components/chat/message-code-block"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput } from "@/components/shared-form"
import {
@@ -180,9 +181,12 @@ export function MqttForm({
{t("channels.mqtt.uplink")}
{`${topicBase}/request`}
-
- {`{\n "text": "your message"\n}`}
-
+
@@ -199,9 +203,12 @@ export function MqttForm({
{t("channels.mqtt.downlink")}
{`${topicBase}/response`}
-
- {`{\n "text": "agent response"\n}`}
-
+
diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx
index 6527562fe..0a197b25e 100644
--- a/web/frontend/src/components/chat/assistant-message.tsx
+++ b/web/frontend/src/components/chat/assistant-message.tsx
@@ -197,7 +197,6 @@ export function AssistantMessage({
label={toolName || t("chat.toolCallArgumentsLabel")}
className="my-0 shadow-none"
bodyClassName="px-3 py-2 text-[12px] leading-relaxed"
- wrapLongLines
/>
)}
diff --git a/web/frontend/src/components/chat/message-code-block.tsx b/web/frontend/src/components/chat/message-code-block.tsx
index 0da081194..79a3706a2 100644
--- a/web/frontend/src/components/chat/message-code-block.tsx
+++ b/web/frontend/src/components/chat/message-code-block.tsx
@@ -3,17 +3,30 @@ import {
IconChevronDown,
IconCopy,
} from "@tabler/icons-react"
+import { useAtom } from "jotai"
import hljs from "highlight.js/lib/core"
import json from "highlight.js/lib/languages/json"
-import { type ComponentProps, type ReactNode, useState } from "react"
+import {
+ type ComponentProps,
+ type CSSProperties,
+ type ReactNode,
+ useState,
+} from "react"
import { useTranslation } from "react-i18next"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { cn } from "@/lib/utils"
+import { codeBlockWrapAtom } from "@/store/code-block"
import {
extractCodeBlockFromPreNode,
+ extractCodeBlockRenderState,
type MarkdownNode,
+ splitCodeIntoLines,
+ splitHighlightedHtmlIntoLines,
+ splitRenderedCodeContentIntoLines,
+ trimTrailingEmptyRenderedCodeLine,
+ trimTrailingEmptyStringLine,
} from "./message-code-block.utils"
import { Button } from "@/components/ui/button"
@@ -27,10 +40,10 @@ interface MessageCodeBlockProps {
code: string
language?: string | null
label?: string
- children?: ReactNode
className?: string
bodyClassName?: string
- wrapLongLines?: boolean
+ children?: ReactNode
+ trimTrailingEmptyLine?: boolean
}
interface MarkdownCodeBlockProps extends ComponentProps<"pre"> {
@@ -53,13 +66,14 @@ export function MessageCodeBlock({
code,
language = null,
label,
- children,
className,
bodyClassName,
- wrapLongLines = false,
+ children,
+ trimTrailingEmptyLine = false,
}: MessageCodeBlockProps) {
const { t } = useTranslation()
const { copy, isCopied } = useCopyToClipboard()
+ const [wrapLongLines, setWrapLongLines] = useAtom(codeBlockWrapAtom)
const [isExpanded, setIsExpanded] = useState(true)
const blockLabel =
label ??
@@ -68,7 +82,31 @@ export function MessageCodeBlock({
: t("chat.codeLabel").toLocaleLowerCase())
const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode")
const expandLabel = isExpanded ? t("chat.collapseCode") : t("chat.expandCode")
+ const wrapLabel = wrapLongLines
+ ? t("chat.disableCodeWrap")
+ : t("chat.enableCodeWrap")
+ const renderedCodeState = children
+ ? extractCodeBlockRenderState(children)
+ : {
+ renderedContent: null,
+ className: undefined,
+ }
const highlightedHtml = !children ? getHighlightedHtml(code, language) : null
+ const highlightedLines = highlightedHtml
+ ? splitHighlightedHtmlIntoLines(highlightedHtml)
+ : null
+ const codeLines = children
+ ? (trimTrailingEmptyLine
+ ? trimTrailingEmptyRenderedCodeLine(
+ splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent),
+ )
+ : splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent))
+ : (trimTrailingEmptyLine
+ ? trimTrailingEmptyStringLine(
+ highlightedLines ?? splitCodeIntoLines(code),
+ )
+ : (highlightedLines ?? splitCodeIntoLines(code)))
+ const lineNumberWidth = `${String(codeLines.length).length + 1}ch`
return (
{copyLabel}
+
@@ -158,6 +241,7 @@ export function MarkdownCodeBlock({
code={code}
language={language}
bodyClassName={className}
+ trimTrailingEmptyLine
>
{children}
diff --git a/web/frontend/src/components/chat/message-code-block.utils.ts b/web/frontend/src/components/chat/message-code-block.utils.ts
index 2133ec638..40e76a2a8 100644
--- a/web/frontend/src/components/chat/message-code-block.utils.ts
+++ b/web/frontend/src/components/chat/message-code-block.utils.ts
@@ -1,3 +1,11 @@
+import {
+ Children,
+ cloneElement,
+ Fragment,
+ isValidElement,
+ type ReactNode,
+} from "react"
+
export interface MarkdownNode {
type?: string
value?: string
@@ -6,7 +14,7 @@ export interface MarkdownNode {
children?: MarkdownNode[]
}
-function toClassNameTokens(className: unknown): string[] {
+export function toClassNameTokens(className: unknown): string[] {
if (typeof className === "string") {
return className.split(/\s+/).filter(Boolean)
}
@@ -72,6 +80,10 @@ export function extractCodeBlockLanguage(className: unknown): string | null {
return languageToken ? languageToken.slice("language-".length) : null
}
+export function stripSingleTrailingLineBreak(value: string): string {
+ return value.replace(/\r?\n$/, "")
+}
+
export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
code: string
language: string | null
@@ -79,7 +91,248 @@ export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
const codeNode = findFirstDescendantByTagName(node, "code")
return {
- code: extractTextFromMarkdownNode(codeNode ?? node),
+ code: stripSingleTrailingLineBreak(extractTextFromMarkdownNode(codeNode ?? node)),
language: extractCodeBlockLanguage(codeNode?.properties?.className),
}
}
+
+export function extractCodeBlockRenderState(children: ReactNode): {
+ renderedContent: ReactNode
+ className: string | undefined
+} {
+ const childNodes = Children.toArray(children)
+ const codeChild = childNodes.find(
+ (child) =>
+ isValidElement<{ children?: ReactNode; className?: unknown }>(child) &&
+ typeof child.type === "string" &&
+ child.type === "code",
+ )
+
+ if (
+ isValidElement<{ children?: ReactNode; className?: unknown }>(codeChild)
+ ) {
+ const classNameTokens = toClassNameTokens(codeChild.props.className)
+ return {
+ renderedContent: codeChild.props.children,
+ className:
+ classNameTokens.length > 0 ? classNameTokens.join(" ") : undefined,
+ }
+ }
+
+ return {
+ renderedContent: children,
+ className: undefined,
+ }
+}
+
+function mergeNodeLineGroups(
+ currentLines: Node[][],
+ nextLines: Node[][],
+): Node[][] {
+ if (nextLines.length === 0) {
+ return currentLines
+ }
+
+ const mergedLines = currentLines.map((line) => [...line])
+ mergedLines[mergedLines.length - 1].push(...nextLines[0])
+
+ for (const line of nextLines.slice(1)) {
+ mergedLines.push([...line])
+ }
+
+ return mergedLines
+}
+
+function splitDomNodeIntoLines(node: Node, ownerDocument: Document): Node[][] {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return (node.textContent ?? "").split("\n").map((line) =>
+ line.length > 0 ? [ownerDocument.createTextNode(line)] : [],
+ )
+ }
+
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ return [[]]
+ }
+
+ const element = node as Element
+ if (element.tagName.toLowerCase() === "br") {
+ return [
+ [],
+ [],
+ ]
+ }
+
+ const childLines = splitHighlightedHtmlIntoNodeLines(
+ Array.from(element.childNodes),
+ ownerDocument,
+ )
+
+ return childLines.map((lineChildren) => {
+ const clonedElement = element.cloneNode(false)
+ for (const child of lineChildren) {
+ clonedElement.appendChild(child)
+ }
+
+ return [clonedElement]
+ })
+}
+
+function splitHighlightedHtmlIntoNodeLines(
+ nodes: Node[],
+ ownerDocument: Document,
+): Node[][] {
+ let lines: Node[][] = [[]]
+
+ for (const node of nodes) {
+ lines = mergeNodeLineGroups(
+ lines,
+ splitDomNodeIntoLines(node, ownerDocument),
+ )
+ }
+
+ return lines
+}
+
+export function splitCodeIntoLines(code: string): string[] {
+ return code.split("\n")
+}
+
+export function splitHighlightedHtmlIntoLines(highlightedHtml: string): string[] {
+ if (typeof document === "undefined") {
+ return splitCodeIntoLines(highlightedHtml)
+ }
+
+ const container = document.createElement("div")
+ container.innerHTML = highlightedHtml
+
+ return splitHighlightedHtmlIntoNodeLines(
+ Array.from(container.childNodes),
+ document,
+ ).map((lineNodes) => {
+ const lineContainer = document.createElement("div")
+ for (const node of lineNodes) {
+ lineContainer.appendChild(node)
+ }
+
+ return lineContainer.innerHTML
+ })
+}
+
+export function trimTrailingEmptyStringLine(lines: string[]): string[] {
+ if (lines.length > 1 && lines[lines.length - 1] === "") {
+ return lines.slice(0, -1)
+ }
+
+ return lines
+}
+
+function isEmptyRenderedCodeNode(node: ReactNode): boolean {
+ if (node === null || node === undefined || typeof node === "boolean") {
+ return true
+ }
+
+ if (typeof node === "string" || typeof node === "number") {
+ return String(node).length === 0
+ }
+
+ if (Array.isArray(node)) {
+ return node.every(isEmptyRenderedCodeNode)
+ }
+
+ if (!isValidElement<{ children?: ReactNode }>(node)) {
+ return false
+ }
+
+ return Children.toArray(node.props.children).every(isEmptyRenderedCodeNode)
+}
+
+export function trimTrailingEmptyRenderedCodeLine(
+ lines: ReactNode[][],
+): ReactNode[][] {
+ if (
+ lines.length > 1 &&
+ lines[lines.length - 1].every(isEmptyRenderedCodeNode)
+ ) {
+ return lines.slice(0, -1)
+ }
+
+ return lines
+}
+
+function mergeReactLineGroups(
+ currentLines: ReactNode[][],
+ nextLines: ReactNode[][],
+): ReactNode[][] {
+ if (nextLines.length === 0) {
+ return currentLines
+ }
+
+ const mergedLines = currentLines.map((line) => [...line])
+ mergedLines[mergedLines.length - 1].push(...nextLines[0])
+
+ for (const line of nextLines.slice(1)) {
+ mergedLines.push([...line])
+ }
+
+ return mergedLines
+}
+
+function splitTextNodeIntoLines(value: string | number): ReactNode[][] {
+ return String(value).split("\n").map((line) => (line.length > 0 ? [line] : []))
+}
+
+function splitReactNodeIntoLines(node: ReactNode): ReactNode[][] {
+ if (node === null || node === undefined || typeof node === "boolean") {
+ return [[]]
+ }
+
+ if (typeof node === "string" || typeof node === "number") {
+ return splitTextNodeIntoLines(node)
+ }
+
+ if (Array.isArray(node)) {
+ return splitRenderedCodeContentIntoLines(node)
+ }
+
+ if (!isValidElement<{ children?: ReactNode }>(node)) {
+ return [[node]]
+ }
+
+ if (node.type === Fragment) {
+ return splitRenderedCodeContentIntoLines(Children.toArray(node.props.children))
+ }
+
+ if (typeof node.type === "string" && node.type === "br") {
+ return [
+ [],
+ [],
+ ]
+ }
+
+ const childLines = splitRenderedCodeContentIntoLines(
+ Children.toArray(node.props.children),
+ )
+
+ return childLines.map((lineChildren, lineIndex) => [
+ cloneElement(
+ node,
+ {
+ key: `${node.key ?? "code-line"}-${lineIndex}`,
+ },
+ ...lineChildren,
+ ),
+ ])
+}
+
+export function splitRenderedCodeContentIntoLines(
+ content: ReactNode,
+): ReactNode[][] {
+ const contentNodes = Array.isArray(content) ? content : [content]
+ let lines: ReactNode[][] = [[]]
+
+ for (const node of contentNodes) {
+ lines = mergeReactLineGroups(lines, splitReactNodeIntoLines(node))
+ }
+
+ return lines
+}
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index 1f15cee11..cb97f0a5e 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -76,6 +76,8 @@
"copyMessage": "Copy message",
"copyCode": "Copy code",
"copiedLabel": "Copied",
+ "enableCodeWrap": "Wrap lines",
+ "disableCodeWrap": "Disable wrap",
"expandCode": "Expand code",
"collapseCode": "Collapse code",
"history": "History",
diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json
index 8e27078fc..ca1f9ed32 100644
--- a/web/frontend/src/i18n/locales/pt-br.json
+++ b/web/frontend/src/i18n/locales/pt-br.json
@@ -76,6 +76,8 @@
"copyMessage": "Copiar mensagem",
"copyCode": "Copiar código",
"copiedLabel": "Copiado",
+ "enableCodeWrap": "Quebrar linhas",
+ "disableCodeWrap": "Desativar quebra",
"expandCode": "Expandir código",
"collapseCode": "Recolher código",
"history": "Histórico",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index bd027fee4..5590adab2 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -76,6 +76,8 @@
"copyMessage": "复制消息",
"copyCode": "复制代码",
"copiedLabel": "已复制",
+ "enableCodeWrap": "开启换行",
+ "disableCodeWrap": "关闭换行",
"expandCode": "展开代码",
"collapseCode": "折叠代码",
"history": "历史记录",
diff --git a/web/frontend/src/store/code-block.ts b/web/frontend/src/store/code-block.ts
new file mode 100644
index 000000000..612e45fca
--- /dev/null
+++ b/web/frontend/src/store/code-block.ts
@@ -0,0 +1,11 @@
+import { atomWithStorage } from "jotai/utils"
+
+export const CODE_BLOCK_WRAP_STORAGE_KEY = "picoclaw:code-block-wrap"
+export const DEFAULT_CODE_BLOCK_WRAP = false
+
+export const codeBlockWrapAtom = atomWithStorage
(
+ CODE_BLOCK_WRAP_STORAGE_KEY,
+ DEFAULT_CODE_BLOCK_WRAP,
+ undefined,
+ { getOnInit: true },
+)
diff --git a/web/frontend/src/store/index.ts b/web/frontend/src/store/index.ts
index a13b7b161..2ef7631fe 100644
--- a/web/frontend/src/store/index.ts
+++ b/web/frontend/src/store/index.ts
@@ -1,3 +1,4 @@
export * from "./gateway"
export * from "./chat"
+export * from "./code-block"
export * from "./tour"