This commit is contained in:
OpenClaw-User 2026-03-14 17:06:10 +08:00
commit 7661d035c9
7 changed files with 197 additions and 9 deletions

View file

@ -21,6 +21,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.
@ -504,3 +505,83 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleRenameSession updates the title/summary of a specific 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 req struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
req.Title = strings.TrimSpace(req.Title)
dir, err := h.sessionsDir()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
legacyPath := base + ".json"
// Try JSONL session first (new format)
if _, statErr := os.Stat(jsonlPath); statErr == nil {
meta, readErr := h.readSessionMeta(metaPath, picoSessionPrefix+sessionID)
if readErr != nil {
http.Error(w, "failed to read session metadata", http.StatusInternalServerError)
return
}
meta.Summary = req.Title
data, marshalErr := json.Marshal(meta)
if marshalErr != nil {
http.Error(w, "failed to marshal metadata", http.StatusInternalServerError)
return
}
if writeErr := os.WriteFile(metaPath, data, 0o644); writeErr != nil {
http.Error(w, "failed to write metadata", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
// Try legacy JSON session
if _, statErr := os.Stat(legacyPath); statErr == nil {
data, readErr := os.ReadFile(legacyPath)
if readErr != nil {
http.Error(w, "failed to read session", http.StatusInternalServerError)
return
}
var sess sessionFile
if parseErr := json.Unmarshal(data, &sess); parseErr != nil {
http.Error(w, "failed to parse session", http.StatusInternalServerError)
return
}
sess.Summary = req.Title
data, marshalErr := json.Marshal(sess)
if marshalErr != nil {
http.Error(w, "failed to marshal session", http.StatusInternalServerError)
return
}
if writeErr := os.WriteFile(legacyPath, data, 0o644); writeErr != nil {
http.Error(w, "failed to write session", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
http.Error(w, "session not found", http.StatusNotFound)
}

View file

@ -49,3 +49,14 @@ 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<void> {
const res = await fetch(`/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}`)
}
}

View file

@ -53,6 +53,7 @@ export function ChatPage() {
observerRef,
loadSessions,
handleDeleteSession,
handleRenameSession,
} = useSessionHistory({
activeSessionId,
onDeletedActiveSession: newChat,
@ -130,6 +131,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 { IconHistory, IconPencil, IconTrash } from "@tabler/icons-react"
import dayjs from "dayjs"
import type { RefObject } from "react"
import { type KeyboardEvent, 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,8 +36,41 @@ export function SessionHistoryMenu({
onOpenChange,
onSwitchSession,
onDeleteSession,
onRenameSession,
}: SessionHistoryMenuProps) {
const { t } = useTranslation()
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState("")
const startRename = (session: SessionSummary, e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setRenamingId(session.id)
setRenameValue(session.title || session.preview)
}
const commitRename = (sessionId: string) => {
const trimmed = renameValue.trim()
if (trimmed) {
onRenameSession(sessionId, trimmed)
}
setRenamingId(null)
}
const handleRenameKeyDown = (
e: KeyboardEvent<HTMLInputElement>,
sessionId: string,
) => {
if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
commitRename(sessionId)
} else if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
setRenamingId(null)
}
}
return (
<DropdownMenu onOpenChange={onOpenChange}>
@ -46,7 +80,20 @@ export function SessionHistoryMenu({
<span className="hidden sm:inline">{t("chat.history")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuContent
align="end"
className="w-72"
onInteractOutside={(e) => {
// Prevent dropdown from closing while user is typing in the rename input
if (renamingId) e.preventDefault()
}}
onEscapeKeyDown={(e) => {
if (renamingId) {
e.preventDefault()
setRenamingId(null)
}
}}
>
<ScrollArea className="max-h-[300px]">
{loadError && (
<DropdownMenuItem disabled>
@ -65,20 +112,48 @@ 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-16 ${
session.id === activeSessionId ? "bg-accent" : ""
}`}
onClick={() => onSwitchSession(session.id)}
onClick={() => {
if (renamingId === session.id) return
onSwitchSession(session.id)
}}
>
<span className="line-clamp-1 text-sm font-medium">
{session.title || session.preview}
</span>
{renamingId === session.id ? (
<input
autoFocus
className="bg-background border-input focus:ring-ring w-full rounded border px-1 py-0.5 text-sm font-medium outline-none focus:ring-1"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => handleRenameKeyDown(e, session.id)}
onBlur={() => commitRename(session.id)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<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>
{/* Rename button */}
<Button
variant="ghost"
size="icon"
aria-label={t("chat.renameSession")}
className="text-muted-foreground hover:bg-accent absolute top-1/2 right-8 h-6 w-6 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => startRename(session, e)}
>
<IconPencil className="h-4 w-4" />
</Button>
{/* Delete button */}
<Button
variant="ghost"
size="icon"

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
@ -100,6 +105,17 @@ export function useSessionHistory({
[activeSessionId, onDeletedActiveSession],
)
const handleRenameSession = useCallback(async (id: string, title: string) => {
try {
await renameSession(id, title)
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s)),
)
} catch (err) {
console.error("Failed to rename session:", err)
}
}, [])
return {
sessions,
hasMore,
@ -108,5 +124,6 @@ export function useSessionHistory({
observerRef,
loadSessions,
handleDeleteSession,
handleRenameSession,
}
}

View file

@ -32,6 +32,7 @@
"historyOpenFailed": "Failed to open this chat history",
"loadingMore": "Loading more...",
"deleteSession": "Delete session",
"renameSession": "Rename session",
"messagesCount": "{{count}} messages",
"noModel": "Select model",
"empty": {

View file

@ -32,6 +32,7 @@
"historyOpenFailed": "打开该历史会话失败",
"loadingMore": "加载更多...",
"deleteSession": "删除会话",
"renameSession": "重命名会话",
"messagesCount": "{{count}} 条消息",
"noModel": "选择模型",
"empty": {