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" "errors"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
@ -21,6 +22,7 @@ func (h *Handler) registerSessionRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/sessions", h.handleListSessions) mux.HandleFunc("GET /api/sessions", h.handleListSessions)
mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession) mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession)
mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession) 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. // sessionFile mirrors the on-disk session JSON structure from pkg/session.
@ -37,6 +39,8 @@ type sessionListItem struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Preview string `json:"preview"` Preview string `json:"preview"`
Channel string `json:"channel"`
PeerName string `json:"peer_name,omitempty"`
MessageCount int `json:"message_count"` MessageCount int `json:"message_count"`
Created string `json:"created"` Created string `json:"created"`
Updated string `json:"updated"` Updated string `json:"updated"`
@ -76,6 +80,19 @@ const (
handledToolResponseSummaryText = "Requested output delivered via tool attachment." 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. // extractPicoSessionID extracts the session UUID from a full session key.
// Returns the UUID and true if the key matches the Pico session pattern. // Returns the UUID and true if the key matches the Pico session pattern.
func extractPicoSessionID(key string) (string, bool) { func extractPicoSessionID(key string) (string, bool) {
@ -92,6 +109,28 @@ func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
return "", false 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 { func sanitizeSessionKey(key string) string {
return strings.ReplaceAll(key, ":", "_") 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) { func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
sessionKey := picoSessionPrefix + sessionID return h.readJSONLSessionByBase(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) }
// 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" jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json" metaPath := base + ".meta.json"
sessionKey := strings.ReplaceAll(baseName, "_", ":")
meta, err := h.readSessionMeta(metaPath, sessionKey) meta, err := h.readSessionMeta(metaPath, sessionKey)
if err != nil { if err != nil {
return sessionFile{}, err return sessionFile{}, err
@ -202,7 +246,7 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
}, nil }, nil
} }
func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem { func buildSessionListItem(sessionID, channel string, sess sessionFile) sessionListItem {
preview := "" preview := ""
for _, msg := range sess.Messages { for _, msg := range sess.Messages {
if msg.Role == "user" { if msg.Role == "user" {
@ -225,6 +269,7 @@ func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
ID: sessionID, ID: sessionID,
Title: title, Title: title,
Preview: preview, Preview: preview,
Channel: channel,
MessageCount: validMessageCount, MessageCount: validMessageCount,
Created: sess.Created.Format(time.RFC3339), Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.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) == "" 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 { func truncateRunes(s string, maxLen int) string {
if maxLen <= 0 { if maxLen <= 0 {
return "" return ""
@ -400,13 +547,15 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
ok bool ok bool
) )
var channel string
switch { switch {
case strings.HasSuffix(name, ".jsonl"): case strings.HasSuffix(name, ".jsonl"):
sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl")) baseName := strings.TrimSuffix(name, ".jsonl")
sessionID, channel, ok = extractAnySessionID(baseName)
if !ok { if !ok {
continue continue
} }
sess, loadErr = h.readJSONLSession(dir, sessionID) sess, loadErr = h.readJSONLSessionByBase(dir, baseName)
if loadErr == nil && isEmptySession(sess) { if loadErr == nil && isEmptySession(sess) {
continue continue
} }
@ -415,10 +564,10 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
case filepath.Ext(name) == ".json": case filepath.Ext(name) == ".json":
base := strings.TrimSuffix(name, ".json") base := strings.TrimSuffix(name, ".json")
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil { if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found { if _, _, found := extractAnySessionID(base); found {
if jsonlSess, jsonlErr := h.readJSONLSession( if jsonlSess, jsonlErr := h.readJSONLSessionByBase(
dir, dir,
jsonlSessionID, base,
); jsonlErr == nil && ); jsonlErr == nil &&
!isEmptySession(jsonlSess) { !isEmptySession(jsonlSess) {
continue continue
@ -435,7 +584,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
if isEmptySession(sess) { if isEmptySession(sess) {
continue continue
} }
sessionID, ok = extractPicoSessionID(sess.Key) sessionID, channel, ok = extractAnySessionIDFromKey(sess.Key)
if !ok { if !ok {
continue continue
} }
@ -454,7 +603,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
} }
seen[sessionID] = struct{}{} seen[sessionID] = struct{}{}
items = append(items, buildSessionListItem(sessionID, sess)) items = append(items, buildSessionListItem(sessionID, channel, sess))
} }
// Sort by updated descending (most recent first) // 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 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 // Pagination parameters
offsetStr := r.URL.Query().Get("offset") offsetStr := r.URL.Query().Get("offset")
limitStr := r.URL.Query().Get("limit") limitStr := r.URL.Query().Get("limit")
@ -508,17 +685,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
return return
} }
sess, err := h.readJSONLSession(dir, sessionID) sess, err := h.findAndReadSession(dir, sessionID)
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
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 err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
http.Error(w, "session not found", http.StatusNotFound) http.Error(w, "session not found", http.StatusNotFound)
@ -527,7 +694,6 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
}
messages := visibleSessionMessages(sess.Messages) messages := visibleSessionMessages(sess.Messages)
@ -557,13 +723,10 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
return return
} }
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)) paths := findSessionFiles(dir, sessionID)
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
legacyPath := base + ".json"
removed := false removed := false
for _, path := range []string{jsonlPath, metaPath, legacyPath} { for _, path := range paths {
if err := os.Remove(path); err != nil { if err := os.Remove(path); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
continue continue
@ -581,3 +744,89 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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 id: string
title: string title: string
preview: string preview: string
channel: string
peer_name?: string
message_count: number message_count: number
created: string created: string
updated: 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}`) 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")}> <DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English English
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("pt-BR")}>
Português (Brasil)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}> <DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -63,6 +63,17 @@ export function ChatPage() {
newChat, newChat,
} = usePicoChat() } = 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 { state: gwState } = useGateway()
const isGatewayRunning = gwState === "running" const isGatewayRunning = gwState === "running"
const isChatConnected = connectionState === "connected" const isChatConnected = connectionState === "connected"
@ -85,6 +96,7 @@ export function ChatPage() {
observerRef, observerRef,
loadSessions, loadSessions,
handleDeleteSession, handleDeleteSession,
handleRenameSession,
} = useSessionHistory({ } = useSessionHistory({
activeSessionId, activeSessionId,
onDeletedActiveSession: newChat, onDeletedActiveSession: newChat,
@ -225,6 +237,7 @@ export function ChatPage() {
}} }}
onSwitchSession={switchSession} onSwitchSession={switchSession}
onDeleteSession={handleDeleteSession} onDeleteSession={handleDeleteSession}
onRenameSession={handleRenameSession}
/> />
</PageHeader> </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 dayjs from "dayjs"
import type { RefObject } from "react" import { type RefObject, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { SessionSummary } from "@/api/sessions" import type { SessionSummary } from "@/api/sessions"
@ -23,6 +23,7 @@ interface SessionHistoryMenuProps {
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onSwitchSession: (sessionId: string) => void onSwitchSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void
onRenameSession: (sessionId: string, title: string) => void
} }
export function SessionHistoryMenu({ export function SessionHistoryMenu({
@ -35,18 +36,63 @@ export function SessionHistoryMenu({
onOpenChange, onOpenChange,
onSwitchSession, onSwitchSession,
onDeleteSession, onDeleteSession,
onRenameSession,
}: SessionHistoryMenuProps) { }: SessionHistoryMenuProps) {
const { t } = useTranslation() 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 ( return (
<DropdownMenu onOpenChange={onOpenChange}> <DropdownMenu onOpenChange={(open) => { if (!open) setEditingId(null); onOpenChange(open) }}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="secondary" size="sm" className="h-9 gap-2"> <Button variant="secondary" size="sm" className="h-9 gap-2">
<IconHistory className="size-4" /> <IconHistory className="size-4" />
<span className="hidden sm:inline">{t("chat.history")}</span> <span className="hidden sm:inline">{t("chat.history")}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72"> <DropdownMenuContent align="end" className="w-80">
<ScrollArea className="max-h-[300px]"> <ScrollArea className="max-h-[300px]">
{loadError && ( {loadError && (
<DropdownMenuItem disabled> <DropdownMenuItem disabled>
@ -65,33 +111,95 @@ export function SessionHistoryMenu({
sessions.map((session) => ( sessions.map((session) => (
<DropdownMenuItem <DropdownMenuItem
key={session.id} 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" : "" session.id === activeSessionId ? "bg-accent" : ""
}`} }`}
onClick={() => onSwitchSession(session.id)} onClick={() => { if (!editingId) onSwitchSession(session.id) }}
> >
{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"> <span className="line-clamp-1 text-sm font-medium">
{session.title} {session.title || session.preview}
</span> </span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{t("chat.messagesCount", { {t("chat.messagesCount", { count: session.message_count })}{" "}
count: session.message_count,
})}{" "}
· {dayjs(session.updated).fromNow()} · {dayjs(session.updated).fromNow()}
</span> </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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={t("chat.deleteSession")} 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" className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-6 w-6"
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
onDeleteSession(session.id) onDeleteSession(session.id)
}} }}
> >
<IconTrash className="h-4 w-4" /> <IconTrash className="h-3.5 w-3.5" />
</Button> </Button>
</div>
)}
</DropdownMenuItem> </DropdownMenuItem>
)) ))
)} )}

View file

@ -1,7 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" 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 const LIMIT = 20
@ -106,6 +111,20 @@ export function useSessionHistory({
[activeSessionId, onDeletedActiveSession, sessions], [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 { return {
sessions, sessions,
hasMore, hasMore,
@ -114,5 +133,6 @@ export function useSessionHistory({
observerRef, observerRef,
loadSessions, loadSessions,
handleDeleteSession, handleDeleteSession,
handleRenameSession,
} }
} }

View file

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

View file

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

View file

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