From 2cb94e8e3a2769417d89eeca90f3cb43f9be72ba Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 13 May 2026 16:45:33 +0800 Subject: [PATCH] feat(chat): support renaming chat sessions inline Adds a pencil icon next to the trash icon on each session row in the chat dropdown. Clicking it turns the title into an inline editable input: Enter / blur saves, Escape cancels. Server: new PATCH /api/chat/sessions/{id} handler that updates the title via the existing `UpdateChatSessionTitle` sqlc query, broadcasts a new `chat:session_updated` WS event so other tabs / devices stay in sync, and rejects blank titles. Frontend mutation is optimistic with rollback, matching the existing delete-session pattern. MUL-2110 Co-authored-by: multica-agent --- packages/core/api/client.ts | 7 + packages/core/chat/mutations.ts | 39 ++++ packages/core/realtime/use-realtime-sync.ts | 29 +++ packages/core/types/events.ts | 1 + .../views/chat/components/chat-window.tsx | 168 +++++++++++++++--- packages/views/locales/en/chat.json | 1 + packages/views/locales/zh-Hans/chat.json | 1 + server/cmd/server/router.go | 1 + server/internal/handler/chat.go | 65 +++++++ server/internal/handler/chat_test.go | 56 ++++++ server/pkg/protocol/events.go | 1 + server/pkg/protocol/messages.go | 10 ++ 12 files changed, 355 insertions(+), 24 deletions(-) diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index a85a93ce1..11f218191 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -1138,6 +1138,13 @@ export class ApiClient { await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" }); } + async updateChatSession(id: string, data: { title: string }): Promise { + return this.fetch(`/api/chat/sessions/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); + } + async listChatMessages(sessionId: string): Promise { return this.fetch(`/api/chat/sessions/${sessionId}/messages`); } diff --git a/packages/core/chat/mutations.ts b/packages/core/chat/mutations.ts index 58a366e07..c43663b86 100644 --- a/packages/core/chat/mutations.ts +++ b/packages/core/chat/mutations.ts @@ -64,6 +64,45 @@ export function useMarkChatSessionRead() { }); } +/** + * Renames a chat session. Optimistically swaps the title in the cached + * list so the dropdown reflects the new label immediately; rolls back on + * error. The matching `chat:session_updated` WS event keeps other + * tabs/devices in sync — see use-realtime-sync.ts. + */ +export function useUpdateChatSession() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + + return useMutation({ + mutationFn: (data: { sessionId: string; title: string }) => { + logger.info("updateChatSession.start", { + sessionId: data.sessionId, + titleLength: data.title.length, + }); + return api.updateChatSession(data.sessionId, { title: data.title }); + }, + onMutate: async ({ sessionId, title }) => { + await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) }); + + const prevSessions = qc.getQueryData(chatKeys.sessions(wsId)); + + const patch = (old?: ChatSession[]) => + old?.map((s) => (s.id === sessionId ? { ...s, title } : s)); + qc.setQueryData(chatKeys.sessions(wsId), patch); + + return { prevSessions }; + }, + onError: (err, vars, ctx) => { + logger.error("updateChatSession.error.rollback", { sessionId: vars.sessionId, err }); + if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) }); + }, + }); +} + /** * Hard-deletes a chat session. Optimistically removes the row from the * sessions list so the dropdown updates instantly; rolls back on error. diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 9893be440..0e8f7e38d 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -259,6 +259,7 @@ export function useRealtimeSync( "daemon:heartbeat", // Chat events are handled explicitly below; do not double-invalidate. "chat:message", "chat:done", "chat:session_read", "chat:session_deleted", + "chat:session_updated", // task:message stays out of the prefix path because it fires per // streamed message during a long run — invalidating the snapshot on // every message would flood the network. Specific chat handlers below @@ -724,6 +725,33 @@ export function useRealtimeSync( invalidateSessionLists(); }); + // chat:session_updated fires after the creator renames a session in + // any tab/device. Patch the cached row inline so the dropdown reflects + // the new title without a full sessions-list refetch. + const unsubChatSessionUpdated = ws.on("chat:session_updated", (p) => { + const payload = p as { + chat_session_id: string; + title?: string; + updated_at?: string; + }; + chatWsLogger.info("chat:session_updated (global)", payload); + const id = getCurrentWsId(); + if (!id) return; + const patch = ( + old?: { id: string; title: string; updated_at: string }[], + ) => + old?.map((s) => + s.id === payload.chat_session_id + ? { + ...s, + title: payload.title ?? s.title, + updated_at: payload.updated_at ?? s.updated_at, + } + : s, + ); + qc.setQueryData(chatKeys.sessions(id), patch); + }); + // chat:session_deleted fires after a hard delete. The originating tab has // already optimistically dropped the row via useDeleteChatSession; this // handler keeps OTHER tabs/devices in sync and also clears the active @@ -784,6 +812,7 @@ export function useRealtimeSync( unsubTaskFailed(); unsubChatSessionRead(); unsubChatSessionDeleted(); + unsubChatSessionUpdated(); timers.forEach(clearTimeout); timers.clear(); }; diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index 7f09e3b4f..334c2950b 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -54,6 +54,7 @@ export type WSEventType = | "chat:done" | "chat:session_read" | "chat:session_deleted" + | "chat:session_updated" | "project:created" | "project:updated" | "project:deleted" diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index 945dab231..04fc27e8b 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { motion } from "motion/react"; -import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2 } from "lucide-react"; +import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2, Pencil } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; import { @@ -46,6 +46,7 @@ import { useCreateChatSession, useDeleteChatSession, useMarkChatSessionRead, + useUpdateChatSession, } from "@multica/core/chat/mutations"; import { useChatStore } from "@multica/core/chat"; import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list"; @@ -734,7 +735,12 @@ function SessionDropdown({ const [showArchived, setShowArchived] = useState(false); const [pendingDelete, setPendingDelete] = useState(null); + // Inline rename: only one row can be in edit mode at a time. We track the + // session id (not the full session) so a stale closure can't overwrite a + // newer rename pulled in via WS. + const [renamingId, setRenamingId] = useState(null); const deleteSession = useDeleteChatSession(); + const updateSession = useUpdateChatSession(); const setActiveSession = useChatStore((s) => s.setActiveSession); const formatTimeAgo = useFormatTimeAgo(); @@ -773,14 +779,35 @@ function SessionDropdown({ }); }; + const handleSubmitRename = (sessionId: string, raw: string) => { + const trimmed = raw.trim(); + const current = sessions.find((s) => s.id === sessionId); + setRenamingId(null); + // No-op submits (unchanged or blank) skip the network round-trip — the + // server would reject a blank title anyway, and an unchanged title would + // just bump updated_at for no user-visible reason. + if (!trimmed || trimmed === current?.title) return; + updateSession.mutate({ sessionId, title: trimmed }); + }; + const renderRow = (session: ChatSession) => { const isCurrent = session.id === activeSessionId; const agent = agentById.get(session.agent_id) ?? null; const isRunning = inFlightSessionIds.has(session.id); + const isRenaming = renamingId === session.id; return ( onSelectSession(session)} + // While renaming we don't want a row click to select the session + // OR close the menu — the user is editing text, not navigating. + // closeOnClick=false keeps the dropdown open across input clicks + // / button clicks inside the row; the normal "click row → switch + // session → close menu" flow is unchanged when isRenaming=false. + closeOnClick={!isRenaming} + onClick={() => { + if (isRenaming) return; + onSelectSession(session); + }} className="group flex min-w-0 items-center gap-2" > {agent ? ( @@ -795,45 +822,84 @@ function SessionDropdown({ )}
-
- {session.title?.trim() || t(($) => $.window.untitled)} -
-
- {formatTimeAgo(session.updated_at)} -
+ {isRenaming ? ( + handleSubmitRename(session.id, value)} + onCancel={() => setRenamingId(null)} + /> + ) : ( + <> +
+ {session.title?.trim() || t(($) => $.window.untitled)} +
+
+ {formatTimeAgo(session.updated_at)} +
+ + )}
{/* Right-edge status pip: in-flight wins over unread because * "still working" is more actionable than "has reply" — and * the two rarely coexist in practice (the unread flag fires * on chat_message write, by which point the task has just * finished). Same pip shape as unread for visual rhythm, - * amber + pulse to read as activity. */} - {isRunning ? ( + * amber + pulse to read as activity. + * + * Hidden while renaming so the inline input has room to + * breathe and trailing pips don't visually trail off-screen + * next to the editor caret. */} + {!isRenaming && isRunning ? ( $.window.running)} title={t(($) => $.window.running)} className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse" /> - ) : session.has_unread ? ( + ) : !isRenaming && session.has_unread ? ( $.window.unread)} title={t(($) => $.window.unread)} className="size-1.5 shrink-0 rounded-full bg-brand" /> ) : null} - {isCurrent && } - + {!isRenaming && isCurrent && ( + + )} + {!isRenaming && ( + <> + + + + )}
); }; @@ -950,6 +1016,60 @@ function SessionDropdown({ ); } +/** + * Inline editor for a session title. Mounts focused with the existing + * title pre-selected so the user can either replace it outright or arrow + * into the existing text. Enter commits, Escape / blur cancels. + * + * Lives inside a DropdownMenuItem; we stop propagation on keys and clicks + * so Base UI's menu doesn't intercept arrow / space / enter for navigation + * while the user is typing. + */ +function SessionRenameInput({ + initialValue, + onSubmit, + onCancel, +}: { + initialValue: string; + onSubmit: (value: string) => void; + onCancel: () => void; +}) { + const { t } = useT("chat"); + const [value, setValue] = useState(initialValue); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + return ( + $.session_history.row_rename_aria)} + onChange={(e) => setValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onKeyDown={(e) => { + // Stop the menu from stealing arrow / typeahead / space input. + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + onSubmit(value); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + onBlur={() => onSubmit(value)} + className="w-full rounded-sm bg-background px-1 py-0.5 text-sm outline-none ring-1 ring-border focus-visible:ring-brand" + /> + ); +} + function useFormatTimeAgo(): (dateStr: string) => string { const { t } = useT("chat"); return (dateStr: string) => { diff --git a/packages/views/locales/en/chat.json b/packages/views/locales/en/chat.json index 0ba37e379..75e687cc2 100644 --- a/packages/views/locales/en/chat.json +++ b/packages/views/locales/en/chat.json @@ -37,6 +37,7 @@ "days": "{{count}}d ago" }, "row_delete_aria": "Delete chat session", + "row_rename_aria": "Rename chat session", "delete_dialog": { "title": "Delete chat session", "description_with_title": "\"{{title}}\" and its messages will be permanently removed. This action cannot be undone.", diff --git a/packages/views/locales/zh-Hans/chat.json b/packages/views/locales/zh-Hans/chat.json index 1025c9c36..fc90233ff 100644 --- a/packages/views/locales/zh-Hans/chat.json +++ b/packages/views/locales/zh-Hans/chat.json @@ -34,6 +34,7 @@ "days": "{{count}} 天前" }, "row_delete_aria": "删除对话", + "row_rename_aria": "重命名对话", "delete_dialog": { "title": "删除对话", "description_with_title": "\"{{title}}\" 及其消息会被永久删除,无法撤销。", diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 85db90eb8..5255e64aa 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -519,6 +519,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Get("/", h.ListChatSessions) r.Route("/{sessionId}", func(r chi.Router) { r.Get("/", h.GetChatSession) + r.Patch("/", h.UpdateChatSession) r.Delete("/", h.DeleteChatSession) r.Post("/messages", h.SendChatMessage) r.Get("/messages", h.ListChatMessages) diff --git a/server/internal/handler/chat.go b/server/internal/handler/chat.go index 3c0be810d..f28a29823 100644 --- a/server/internal/handler/chat.go +++ b/server/internal/handler/chat.go @@ -5,6 +5,7 @@ import ( "errors" "log/slog" "net/http" + "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" @@ -14,6 +15,10 @@ import ( "github.com/multica-ai/multica/server/pkg/protocol" ) +// chatSessionTitleMaxLen caps the rename input. Long enough to fit a +// meaningful summary, short enough to keep the dropdown row scannable. +const chatSessionTitleMaxLen = 200 + // --------------------------------------------------------------------------- // Chat Sessions // --------------------------------------------------------------------------- @@ -232,6 +237,66 @@ func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, chatSessionToResponse(session)) } +type UpdateChatSessionRequest struct { + Title *string `json:"title"` +} + +// UpdateChatSession updates user-editable fields on a chat session — today +// just `title`, surfaced by the inline rename affordance in the session +// dropdown. Title is the only field accepted: `status` is legacy + read-only, +// agent/creator/workspace are immutable, the resume pointers +// (session_id / work_dir / runtime_id) are daemon-owned. +func (h *Handler) UpdateChatSession(w http.ResponseWriter, r *http.Request) { + userID, ok := requireUserID(w, r) + if !ok { + return + } + workspaceID := ctxWorkspaceID(r.Context()) + sessionID := chi.URLParam(r, "sessionId") + + var req UpdateChatSessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Title == nil { + writeError(w, http.StatusBadRequest, "title is required") + return + } + title := strings.TrimSpace(*req.Title) + if title == "" { + writeError(w, http.StatusBadRequest, "title is required") + return + } + if len([]rune(title)) > chatSessionTitleMaxLen { + writeError(w, http.StatusBadRequest, "title is too long") + return + } + + session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID) + if !ok { + return + } + + updated, err := h.Queries.UpdateChatSessionTitle(r.Context(), db.UpdateChatSessionTitleParams{ + ID: session.ID, + Title: title, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update chat session") + return + } + + resolvedSessionID := uuidToString(updated.ID) + h.publishChat(protocol.EventChatSessionUpdated, workspaceID, "member", userID, resolvedSessionID, protocol.ChatSessionUpdatedPayload{ + ChatSessionID: resolvedSessionID, + Title: updated.Title, + UpdatedAt: timestampToString(updated.UpdatedAt), + }) + + writeJSON(w, http.StatusOK, chatSessionToResponse(updated)) +} + // DeleteChatSession hard-deletes a chat session owned by the caller. The // row lock + cancel + delete run inside a single tx so a concurrent // SendChatMessage cannot enqueue a task that would later be orphaned by diff --git a/server/internal/handler/chat_test.go b/server/internal/handler/chat_test.go index 499382926..f5986e4bc 100644 --- a/server/internal/handler/chat_test.go +++ b/server/internal/handler/chat_test.go @@ -107,6 +107,62 @@ func TestSendChatMessage_LinksAttachments(t *testing.T) { } } +// TestUpdateChatSession_RenamesTitle confirms PATCH writes the new title, +// returns the updated row, and the server-side row reflects it. +func TestUpdateChatSession_RenamesTitle(t *testing.T) { + agentID := createHandlerTestAgent(t, "ChatRenameAgent", []byte("[]")) + sessionID := createHandlerTestChatSession(t, agentID) + + req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{ + "title": " Renamed Session ", + }) + req = withURLParam(req, "sessionId", sessionID) + req = withChatTestWorkspaceCtx(t, req) + w := httptest.NewRecorder() + testHandler.UpdateChatSession(w, req) + if w.Code != http.StatusOK { + t.Fatalf("UpdateChatSession: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp ChatSessionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode update: %v", err) + } + if resp.Title != "Renamed Session" { + t.Fatalf("response title: want %q, got %q", "Renamed Session", resp.Title) + } + + var dbTitle string + if err := testPool.QueryRow( + context.Background(), + `SELECT title FROM chat_session WHERE id = $1`, + sessionID, + ).Scan(&dbTitle); err != nil { + t.Fatalf("query chat_session: %v", err) + } + if dbTitle != "Renamed Session" { + t.Fatalf("db title: want %q, got %q", "Renamed Session", dbTitle) + } +} + +// TestUpdateChatSession_RejectsBlank refuses an empty/whitespace title with 400. +// (Untitled is a render-side fallback, not a stored value.) +func TestUpdateChatSession_RejectsBlank(t *testing.T) { + agentID := createHandlerTestAgent(t, "ChatRenameBlankAgent", []byte("[]")) + sessionID := createHandlerTestChatSession(t, agentID) + + req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{ + "title": " ", + }) + req = withURLParam(req, "sessionId", sessionID) + req = withChatTestWorkspaceCtx(t, req) + w := httptest.NewRecorder() + testHandler.UpdateChatSession(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("UpdateChatSession blank: expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + // TestSendChatMessage_InvalidAttachmentIDs rejects malformed UUIDs in // attachment_ids with 400 before any side effects (no message row created). func TestSendChatMessage_InvalidAttachmentIDs(t *testing.T) { diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index 3c64a9a01..49c606b24 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -70,6 +70,7 @@ const ( EventChatDone = "chat:done" EventChatSessionRead = "chat:session_read" EventChatSessionDeleted = "chat:session_deleted" + EventChatSessionUpdated = "chat:session_updated" // Project events EventProjectCreated = "project:created" diff --git a/server/pkg/protocol/messages.go b/server/pkg/protocol/messages.go index 616f6edec..f10197e71 100644 --- a/server/pkg/protocol/messages.go +++ b/server/pkg/protocol/messages.go @@ -101,6 +101,16 @@ type ChatSessionDeletedPayload struct { ChatSessionID string `json:"chat_session_id"` } +// ChatSessionUpdatedPayload is broadcast when a user-editable field on a +// chat session changes (today: title via inline rename). Other tabs/devices +// patch the session row in their cached list so the dropdown stays in sync +// without a full refetch. +type ChatSessionUpdatedPayload struct { + ChatSessionID string `json:"chat_session_id"` + Title string `json:"title"` + UpdatedAt string `json:"updated_at"` +} + // DaemonHeartbeatRequestPayload is sent from daemon to server over WebSocket // to update last_seen_at and pull pending actions for a single runtime. // Mirrors the body of POST /api/daemon/heartbeat so both transports share