diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 42d451a05..b8c8128dc 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -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) +} diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index 10b0d28fd..eb64950c8 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -49,3 +49,14 @@ export async function deleteSession(id: string): Promise { throw new Error(`Failed to delete session ${id}: ${res.status}`) } } + +export async function renameSession(id: string, title: string): Promise { + 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}`) + } +} diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 1906a0367..e3fb48381 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -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} /> diff --git a/web/frontend/src/components/chat/session-history-menu.tsx b/web/frontend/src/components/chat/session-history-menu.tsx index 009e8fbb9..b6b28913e 100644 --- a/web/frontend/src/components/chat/session-history-menu.tsx +++ b/web/frontend/src/components/chat/session-history-menu.tsx @@ -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(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, + sessionId: string, + ) => { + if (e.key === "Enter") { + e.preventDefault() + e.stopPropagation() + commitRename(sessionId) + } else if (e.key === "Escape") { + e.preventDefault() + e.stopPropagation() + setRenamingId(null) + } + } return ( @@ -46,7 +80,20 @@ export function SessionHistoryMenu({ {t("chat.history")} - + { + // Prevent dropdown from closing while user is typing in the rename input + if (renamingId) e.preventDefault() + }} + onEscapeKeyDown={(e) => { + if (renamingId) { + e.preventDefault() + setRenamingId(null) + } + }} + > {loadError && ( @@ -65,20 +112,48 @@ export function SessionHistoryMenu({ sessions.map((session) => ( onSwitchSession(session.id)} + onClick={() => { + if (renamingId === session.id) return + onSwitchSession(session.id) + }} > - - {session.title || session.preview} - + {renamingId === session.id ? ( + setRenameValue(e.target.value)} + onKeyDown={(e) => handleRenameKeyDown(e, session.id)} + onBlur={() => commitRename(session.id)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {session.title || session.preview} + + )} {t("chat.messagesCount", { count: session.message_count, })}{" "} · {dayjs(session.updated).fromNow()} + + {/* Rename button */} + + + {/* Delete button */}