feat: add pt-br translation and improve session history UI

- Add complete pt-br locale (536 lines)
- Improve session history menu with better UX
- Add session API endpoints for history management
- Register pt-br in i18n index

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Fornalha 2026-04-04 04:08:55 -03:00
parent 84e42d6904
commit a70264efa2
9 changed files with 481 additions and 60 deletions

View file

@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
@ -21,6 +22,7 @@ func (h *Handler) registerSessionRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/sessions", h.handleListSessions)
mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession)
mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession)
mux.HandleFunc("PATCH /api/sessions/{id}", h.handleRenameSession)
}
// sessionFile mirrors the on-disk session JSON structure from pkg/session.
@ -37,6 +39,8 @@ type sessionListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Preview string `json:"preview"`
Channel string `json:"channel"`
PeerName string `json:"peer_name,omitempty"`
MessageCount int `json:"message_count"`
Created string `json:"created"`
Updated string `json:"updated"`
@ -76,6 +80,19 @@ const (
handledToolResponseSummaryText = "Requested output delivered via tool attachment."
)
// knownSanitizedPrefixes maps channel prefixes to human-readable channel names.
var knownSanitizedPrefixes = []struct {
prefix string
channel string
}{
{sanitizedPicoSessionPrefix, "pico"},
{"agent_main_whatsapp_native_direct_", "whatsapp"},
{"agent_main_telegram_direct_", "telegram"},
{"agent_main_discord_direct_", "discord"},
{"agent_main_slack_direct_", "slack"},
{"agent_main_matrix_direct_", "matrix"},
}
// extractPicoSessionID extracts the session UUID from a full session key.
// Returns the UUID and true if the key matches the Pico session pattern.
func extractPicoSessionID(key string) (string, bool) {
@ -92,6 +109,28 @@ func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
return "", false
}
// extractAnySessionID extracts session ID and channel name from any known
// sanitized key prefix. Returns sessionID, channel, ok.
func extractAnySessionID(key string) (string, string, bool) {
for _, p := range knownSanitizedPrefixes {
if strings.HasPrefix(key, p.prefix) {
return strings.TrimPrefix(key, p.prefix), p.channel, true
}
}
return "", "", false
}
// extractAnySessionIDFromKey extracts session ID and channel from unsanitized key.
func extractAnySessionIDFromKey(key string) (string, string, bool) {
for _, p := range knownSanitizedPrefixes {
unsanitized := strings.ReplaceAll(p.prefix, "_", ":")
if strings.HasPrefix(key, unsanitized) {
return strings.TrimPrefix(key, unsanitized), p.channel, true
}
}
return "", "", false
}
func sanitizeSessionKey(key string) string {
return strings.ReplaceAll(key, ":", "_")
}
@ -165,11 +204,16 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag
}
func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
sessionKey := picoSessionPrefix + sessionID
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
return h.readJSONLSessionByBase(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
}
// readJSONLSessionByBase reads a JSONL session by its sanitized base name (without extension).
func (h *Handler) readJSONLSessionByBase(dir, baseName string) (sessionFile, error) {
base := filepath.Join(dir, baseName)
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
sessionKey := strings.ReplaceAll(baseName, "_", ":")
meta, err := h.readSessionMeta(metaPath, sessionKey)
if err != nil {
return sessionFile{}, err
@ -202,7 +246,7 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
}, nil
}
func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
func buildSessionListItem(sessionID, channel string, sess sessionFile) sessionListItem {
preview := ""
for _, msg := range sess.Messages {
if msg.Role == "user" {
@ -225,6 +269,7 @@ func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
ID: sessionID,
Title: title,
Preview: preview,
Channel: channel,
MessageCount: validMessageCount,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
@ -235,6 +280,108 @@ func isEmptySession(sess sessionFile) bool {
return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == ""
}
// whatsappPeerInfo holds resolved WhatsApp peer data.
type whatsappPeerInfo struct {
Phone string // "+5521..."
PushName string // "Willian Santos"
}
// resolveWhatsAppLIDs resolves WhatsApp LID session IDs to phone numbers
// and push names using the whatsmeow SQLite store.
func (h *Handler) resolveWhatsAppLIDs(lids []string) map[string]whatsappPeerInfo {
if len(lids) == 0 {
return nil
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return nil
}
workspace := cfg.Agents.Defaults.Workspace
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
}
if len(workspace) > 0 && workspace[0] == '~' {
home, _ := os.UserHomeDir()
if len(workspace) > 1 && workspace[1] == '/' {
workspace = home + workspace[1:]
} else {
workspace = home
}
}
dbPath := filepath.Join(workspace, "whatsapp", "store.db")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
result := make(map[string]whatsappPeerInfo)
for _, lid := range lids {
cleanLID := strings.TrimSuffix(lid, "@lid")
info := whatsappPeerInfo{}
// Resolve LID → phone
query := "SELECT pn FROM whatsmeow_lid_map WHERE lid='" + cleanLID + "';"
if out, err := exec.Command("sqlite3", dbPath, query).Output(); err == nil {
phone := strings.TrimSpace(string(out))
if phone != "" {
info.Phone = "+" + phone
// Resolve phone → name via contacts table (prefer push_name, fallback to full_name)
contactQuery := "SELECT COALESCE(NULLIF(push_name,''), NULLIF(full_name,'')) FROM whatsmeow_contacts WHERE their_jid LIKE '" + phone + "%' LIMIT 1;"
if nameOut, err := exec.Command("sqlite3", dbPath, contactQuery).Output(); err == nil {
name := strings.TrimSpace(string(nameOut))
if name != "" {
info.PushName = name
}
}
}
}
if info.Phone != "" {
result[lid] = info
}
}
return result
}
// findAndReadSession tries all known channel prefixes to find and read a session.
func (h *Handler) findAndReadSession(dir, sessionID string) (sessionFile, error) {
for _, p := range knownSanitizedPrefixes {
baseName := p.prefix + sessionID
sess, err := h.readJSONLSessionByBase(dir, baseName)
if err == nil && !isEmptySession(sess) {
return sess, nil
}
// Try legacy JSON
legacyPath := filepath.Join(dir, baseName+".json")
data, err := os.ReadFile(legacyPath)
if err == nil {
var legacySess sessionFile
if json.Unmarshal(data, &legacySess) == nil && !isEmptySession(legacySess) {
return legacySess, nil
}
}
}
return sessionFile{}, os.ErrNotExist
}
// findSessionFiles returns all file paths for a session across all channel prefixes.
func findSessionFiles(dir, sessionID string) []string {
var paths []string
for _, p := range knownSanitizedPrefixes {
base := filepath.Join(dir, p.prefix+sessionID)
for _, ext := range []string{".jsonl", ".meta.json", ".json"} {
path := base + ext
if _, err := os.Stat(path); err == nil {
paths = append(paths, path)
}
}
}
return paths
}
func truncateRunes(s string, maxLen int) string {
if maxLen <= 0 {
return ""
@ -400,13 +547,15 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
ok bool
)
var channel string
switch {
case strings.HasSuffix(name, ".jsonl"):
sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
baseName := strings.TrimSuffix(name, ".jsonl")
sessionID, channel, ok = extractAnySessionID(baseName)
if !ok {
continue
}
sess, loadErr = h.readJSONLSession(dir, sessionID)
sess, loadErr = h.readJSONLSessionByBase(dir, baseName)
if loadErr == nil && isEmptySession(sess) {
continue
}
@ -415,10 +564,10 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
case filepath.Ext(name) == ".json":
base := strings.TrimSuffix(name, ".json")
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
if jsonlSess, jsonlErr := h.readJSONLSession(
if _, _, found := extractAnySessionID(base); found {
if jsonlSess, jsonlErr := h.readJSONLSessionByBase(
dir,
jsonlSessionID,
base,
); jsonlErr == nil &&
!isEmptySession(jsonlSess) {
continue
@ -435,7 +584,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
if isEmptySession(sess) {
continue
}
sessionID, ok = extractPicoSessionID(sess.Key)
sessionID, channel, ok = extractAnySessionIDFromKey(sess.Key)
if !ok {
continue
}
@ -454,7 +603,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
}
seen[sessionID] = struct{}{}
items = append(items, buildSessionListItem(sessionID, sess))
items = append(items, buildSessionListItem(sessionID, channel, sess))
}
// Sort by updated descending (most recent first)
@ -462,6 +611,34 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
return items[i].Updated > items[j].Updated
})
// Apply custom titles
if customTitles, err := h.loadCustomTitles(); err == nil {
for i := range items {
if t, ok := customTitles[items[i].ID]; ok {
items[i].Title = t
}
}
}
// Resolve WhatsApp LIDs to phone numbers
var whatsappLIDs []string
for _, item := range items {
if item.Channel == "whatsapp" {
whatsappLIDs = append(whatsappLIDs, item.ID)
}
}
if lidMap := h.resolveWhatsAppLIDs(whatsappLIDs); len(lidMap) > 0 {
for i := range items {
if info, ok := lidMap[items[i].ID]; ok {
items[i].PeerName = info.Phone
// Use push_name as default title if no custom title was set
if info.PushName != "" && items[i].Title == items[i].Preview {
items[i].Title = info.PushName
}
}
}
}
// Pagination parameters
offsetStr := r.URL.Query().Get("offset")
limitStr := r.URL.Query().Get("limit")
@ -508,25 +685,14 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
return
}
sess, err := h.readJSONLSession(dir, sessionID)
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
sess, err := h.findAndReadSession(dir, sessionID)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
sess, err = h.readLegacySession(dir, sessionID)
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "session not found", http.StatusNotFound)
} else {
http.Error(w, "failed to parse session", http.StatusInternalServerError)
}
return
http.Error(w, "session not found", http.StatusNotFound)
} else {
http.Error(w, "failed to parse session", http.StatusInternalServerError)
}
return
}
messages := visibleSessionMessages(sess.Messages)
@ -557,13 +723,10 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
return
}
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
legacyPath := base + ".json"
paths := findSessionFiles(dir, sessionID)
removed := false
for _, path := range []string{jsonlPath, metaPath, legacyPath} {
for _, path := range paths {
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
continue
@ -581,3 +744,89 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// customTitlesPath returns the path to the custom session titles file.
func (h *Handler) customTitlesPath() (string, error) {
dir, err := h.sessionsDir()
if err != nil {
return "", err
}
return filepath.Join(dir, ".session-titles.json"), nil
}
// loadCustomTitles reads the custom session titles map from disk.
func (h *Handler) loadCustomTitles() (map[string]string, error) {
path, err := h.customTitlesPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]string), nil
}
return nil, err
}
var titles map[string]string
if err := json.Unmarshal(data, &titles); err != nil {
return make(map[string]string), nil
}
return titles, nil
}
// saveCustomTitles writes the custom session titles map to disk.
func (h *Handler) saveCustomTitles(titles map[string]string) error {
path, err := h.customTitlesPath()
if err != nil {
return err
}
data, err := json.Marshal(titles)
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// handleRenameSession updates the custom title for a session.
//
// PATCH /api/sessions/{id}
func (h *Handler) handleRenameSession(w http.ResponseWriter, r *http.Request) {
sessionID := r.PathValue("id")
if sessionID == "" {
http.Error(w, "missing session id", http.StatusBadRequest)
return
}
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
title := strings.TrimSpace(body.Title)
if title == "" {
http.Error(w, "title must not be empty", http.StatusBadRequest)
return
}
if len([]rune(title)) > maxSessionTitleRunes {
runes := []rune(title)
title = string(runes[:maxSessionTitleRunes])
}
titles, err := h.loadCustomTitles()
if err != nil {
http.Error(w, "failed to load titles", http.StatusInternalServerError)
return
}
titles[sessionID] = title
if err := h.saveCustomTitles(titles); err != nil {
http.Error(w, "failed to save title", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"title": title})
}

View file

@ -4,6 +4,8 @@ export interface SessionSummary {
id: string
title: string
preview: string
channel: string
peer_name?: string
message_count: number
created: string
updated: string
@ -53,3 +55,18 @@ export async function deleteSession(id: string): Promise<void> {
throw new Error(`Failed to delete session ${id}: ${res.status}`)
}
}
export async function renameSession(
id: string,
title: string,
): Promise<{ title: string }> {
const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
})
if (!res.ok) {
throw new Error(`Failed to rename session ${id}: ${res.status}`)
}
return res.json()
}

View file

@ -234,6 +234,9 @@ export function AppHeader() {
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("pt-BR")}>
Português (Brasil)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>

View file

@ -63,6 +63,17 @@ export function ChatPage() {
newChat,
} = usePicoChat()
// Load session from URL query param ?session=<id>
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const sessionParam = params.get("session")
if (sessionParam && sessionParam !== activeSessionId) {
switchSession(sessionParam)
// Clean up URL without reload
window.history.replaceState({}, "", window.location.pathname)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const { state: gwState } = useGateway()
const isGatewayRunning = gwState === "running"
const isChatConnected = connectionState === "connected"
@ -85,6 +96,7 @@ export function ChatPage() {
observerRef,
loadSessions,
handleDeleteSession,
handleRenameSession,
} = useSessionHistory({
activeSessionId,
onDeletedActiveSession: newChat,
@ -225,6 +237,7 @@ export function ChatPage() {
}}
onSwitchSession={switchSession}
onDeleteSession={handleDeleteSession}
onRenameSession={handleRenameSession}
/>
</PageHeader>

View file

@ -1,6 +1,6 @@
import { IconHistory, IconTrash } from "@tabler/icons-react"
import { IconBrandWhatsapp, IconCheck, IconCopy, IconHistory, IconLink, IconPencil, IconTrash, IconX } from "@tabler/icons-react"
import dayjs from "dayjs"
import type { RefObject } from "react"
import { type RefObject, useState } from "react"
import { useTranslation } from "react-i18next"
import type { SessionSummary } from "@/api/sessions"
@ -23,6 +23,7 @@ interface SessionHistoryMenuProps {
onOpenChange: (open: boolean) => void
onSwitchSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onRenameSession: (sessionId: string, title: string) => void
}
export function SessionHistoryMenu({
@ -35,18 +36,63 @@ export function SessionHistoryMenu({
onOpenChange,
onSwitchSession,
onDeleteSession,
onRenameSession,
}: SessionHistoryMenuProps) {
const { t } = useTranslation()
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState("")
const [copiedId, setCopiedId] = useState<string | null>(null)
const [copiedWaId, setCopiedWaId] = useState<string | null>(null)
const startRename = (e: React.MouseEvent, session: SessionSummary) => {
e.preventDefault()
e.stopPropagation()
setEditingId(session.id)
setEditValue(session.title || session.preview || "")
}
const confirmRename = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (editingId && editValue.trim()) {
onRenameSession(editingId, editValue.trim())
}
setEditingId(null)
}
const cancelRename = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setEditingId(null)
}
const copyLink = (e: React.MouseEvent, sessionId: string) => {
e.preventDefault()
e.stopPropagation()
const url = `${window.location.origin}/?session=${sessionId}`
navigator.clipboard.writeText(url)
setCopiedId(sessionId)
setTimeout(() => setCopiedId(null), 2000)
}
const copyWhatsApp = (e: React.MouseEvent, peerName: string, sessionId: string) => {
e.preventDefault()
e.stopPropagation()
const phone = peerName.replace(/\+/g, "")
navigator.clipboard.writeText(`wa.me/${phone}`)
setCopiedWaId(sessionId)
setTimeout(() => setCopiedWaId(null), 2000)
}
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenu onOpenChange={(open) => { if (!open) setEditingId(null); onOpenChange(open) }}>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="sm" className="h-9 gap-2">
<IconHistory className="size-4" />
<span className="hidden sm:inline">{t("chat.history")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuContent align="end" className="w-80">
<ScrollArea className="max-h-[300px]">
{loadError && (
<DropdownMenuItem disabled>
@ -65,33 +111,95 @@ export function SessionHistoryMenu({
sessions.map((session) => (
<DropdownMenuItem
key={session.id}
className={`group relative my-0.5 flex flex-col items-start gap-0.5 pr-8 ${
className={`group relative my-0.5 flex flex-col items-start gap-0.5 pr-24 ${
session.id === activeSessionId ? "bg-accent" : ""
}`}
onClick={() => onSwitchSession(session.id)}
onClick={() => { if (!editingId) onSwitchSession(session.id) }}
>
<span className="line-clamp-1 text-sm font-medium">
{session.title}
</span>
<span className="text-muted-foreground text-xs">
{t("chat.messagesCount", {
count: session.message_count,
})}{" "}
· {dayjs(session.updated).fromNow()}
</span>
<Button
variant="ghost"
size="icon"
aria-label={t("chat.deleteSession")}
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDeleteSession(session.id)
}}
>
<IconTrash className="h-4 w-4" />
</Button>
{editingId === session.id ? (
<div className="flex w-full items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
className="bg-background border-input h-6 flex-1 rounded border px-1 text-sm"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") confirmRename(e as unknown as React.MouseEvent)
if (e.key === "Escape") setEditingId(null)
}}
autoFocus
/>
<Button variant="ghost" size="icon" className="h-5 w-5 text-green-500" onClick={confirmRename}>
<IconCheck className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={cancelRename}>
<IconX className="h-3 w-3" />
</Button>
</div>
) : (
<>
<span className="line-clamp-1 text-sm font-medium">
{session.title || session.preview}
</span>
<span className="text-muted-foreground text-xs">
{t("chat.messagesCount", { count: session.message_count })}{" "}
· {dayjs(session.updated).fromNow()}
</span>
</>
)}
{editingId !== session.id && (
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
aria-label={t("chat.renameSession")}
className="text-muted-foreground hover:text-foreground h-6 w-6"
onClick={(e) => startRename(e, session)}
>
<IconPencil className="h-3.5 w-3.5" />
</Button>
{session.peer_name && (
<Button
variant="ghost"
size="icon"
aria-label="wa.me"
className="text-muted-foreground hover:text-green-600 h-6 w-6"
onClick={(e) => copyWhatsApp(e, session.peer_name!, session.id)}
>
{copiedWaId === session.id ? (
<IconCheck className="h-3.5 w-3.5 text-green-500" />
) : (
<IconBrandWhatsapp className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
aria-label={t("chat.copyLink")}
className="text-muted-foreground hover:text-foreground h-6 w-6"
onClick={(e) => copyLink(e, session.id)}
>
{copiedId === session.id ? (
<IconCheck className="h-3.5 w-3.5 text-green-500" />
) : (
<IconLink className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("chat.deleteSession")}
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-6 w-6"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDeleteSession(session.id)
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
)}
</DropdownMenuItem>
))
)}

View file

@ -1,7 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { type SessionSummary, deleteSession, getSessions } from "@/api/sessions"
import {
type SessionSummary,
deleteSession,
getSessions,
renameSession,
} from "@/api/sessions"
const LIMIT = 20
@ -106,6 +111,20 @@ export function useSessionHistory({
[activeSessionId, onDeletedActiveSession, sessions],
)
const handleRenameSession = useCallback(
async (id: string, title: string) => {
try {
const result = await renameSession(id, title)
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title: result.title } : s)),
)
} catch (err) {
console.error("Failed to rename session:", err)
}
},
[],
)
return {
sessions,
hasMore,
@ -114,5 +133,6 @@ export function useSessionHistory({
observerRef,
loadSessions,
handleDeleteSession,
handleRenameSession,
}
}

View file

@ -1,5 +1,6 @@
import dayjs from "dayjs"
import "dayjs/locale/en"
import "dayjs/locale/pt-br"
import "dayjs/locale/zh-cn"
import localizedFormat from "dayjs/plugin/localizedFormat"
import relativeTime from "dayjs/plugin/relativeTime"
@ -8,6 +9,7 @@ import LanguageDetector from "i18next-browser-languagedetector"
import { initReactI18next } from "react-i18next"
import en from "./locales/en.json"
import ptBr from "./locales/pt-br.json"
import zh from "./locales/zh.json"
dayjs.extend(relativeTime)
@ -26,6 +28,9 @@ i18n
en: {
translation: en,
},
"pt-BR": {
translation: ptBr,
},
zh: {
translation: zh,
},
@ -41,6 +46,8 @@ i18n
i18n.on("languageChanged", (lng) => {
if (lng.startsWith("zh")) {
dayjs.locale("zh-cn")
} else if (lng.startsWith("pt")) {
dayjs.locale("pt-br")
} else {
dayjs.locale("en")
}

View file

@ -48,6 +48,8 @@
"historyOpenFailed": "Failed to open this chat history",
"loadingMore": "Loading more...",
"deleteSession": "Delete session",
"renameSession": "Rename session",
"copyLink": "Copy link",
"messagesCount": "{{count}} messages",
"noModel": "Select model",
"attachImage": "Add images",

View file

@ -48,6 +48,8 @@
"historyOpenFailed": "打开该历史会话失败",
"loadingMore": "加载更多...",
"deleteSession": "删除会话",
"renameSession": "重命名会话",
"copyLink": "复制链接",
"messagesCount": "{{count}} 条消息",
"noModel": "选择模型",
"attachImage": "添加图片",