From 5f4b3ec0c008550bebca0197689e331823469ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 5 May 2026 21:47:35 +0100 Subject: [PATCH] feat(web): allow renaming quick-chat tabs locally (#830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): allow renaming quick-chat tabs locally Double-click a quick-chat tab to rename it. The name is stored in localStorage and overlays the SSR-derived task title on hydration, so renames survive reload without touching the backend. * fix(web): address quick-chat rename review feedback - Drop unused sessionId prop from QuickChatTabItem - Enter handler now blurs the input instead of calling commit(), preventing a duplicate commit when onBlur fires on unmount - Add hydrator tests covering the localStorage name overlay * fix(web): make Escape actually cancel quick-chat rename Escape was setting isEditing(false), which unmounted the focused input, causing onBlur (commit) to fire synchronously with the typed draft still in the closure — committing the rename instead of discarding it. Cancel now flips a cancelledRef and blurs the input; commit short-circuits when the ref is set, so onBlur is the single edit-exit path for both Enter and Escape. Adds a render test covering both flows. * fix(web): guard quick-chat rename against IME and orphan storage writes - Skip Enter/Escape handling while IME composition is active so candidate confirmation does not commit/cancel the rename - renameQuickChatSession only writes to localStorage when the session was actually found in state, preventing orphan entries for stale session IDs --------- Co-authored-by: Kandev Agent --- .../quick-chat/quick-chat-modal.test.ts | 31 ++++- .../quick-chat/quick-chat-modal.tsx | 39 +++--- .../quick-chat/quick-chat-tab-item.test.tsx | 84 +++++++++++++ .../quick-chat/quick-chat-tab-item.tsx | 116 ++++++++++++++++++ .../quick-chat/use-quick-chat-modal.ts | 9 ++ apps/web/lib/local-storage.ts | 31 +++++ apps/web/lib/state/hydration/hydrator.test.ts | 73 +++++++++++ apps/web/lib/state/hydration/hydrator.ts | 12 +- apps/web/lib/state/slices/ui/ui-slice.ts | 9 +- 9 files changed, 374 insertions(+), 30 deletions(-) create mode 100644 apps/web/components/quick-chat/quick-chat-tab-item.test.tsx create mode 100644 apps/web/components/quick-chat/quick-chat-tab-item.tsx create mode 100644 apps/web/lib/state/hydration/hydrator.test.ts diff --git a/apps/web/components/quick-chat/quick-chat-modal.test.ts b/apps/web/components/quick-chat/quick-chat-modal.test.ts index 770880d1fe..54b0e8ed18 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.test.ts +++ b/apps/web/components/quick-chat/quick-chat-modal.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { createUISlice } from "@/lib/state/slices/ui/ui-slice"; +import { getStoredQuickChatNames } from "@/lib/local-storage"; import type { UISlice } from "@/lib/state/slices/ui/types"; const SESSION_ID = "sess-1"; @@ -48,3 +49,31 @@ describe("openQuickChat agentProfileId persistence", () => { expect(findSession(store)?.agentProfileId).toBe(PROFILE_ID); }); }); + +describe("renameQuickChatSession local persistence", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("updates the session name in the store", () => { + const store = makeStore(); + store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID); + store.getState().renameQuickChatSession(SESSION_ID, "My renamed chat"); + expect(findSession(store)?.name).toBe("My renamed chat"); + }); + + it("persists the rename to localStorage so it survives reload", () => { + const store = makeStore(); + store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID); + store.getState().renameQuickChatSession(SESSION_ID, "Persisted name"); + expect(getStoredQuickChatNames()).toEqual({ [SESSION_ID]: "Persisted name" }); + }); + + it("does not write to storage when the session does not exist in state", () => { + const store = makeStore(); + expect(() => + store.getState().renameQuickChatSession("ghost-session", "Whatever"), + ).not.toThrow(); + expect(getStoredQuickChatNames()).toEqual({}); + }); +}); diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx index 23c25d60fc..22cecbe515 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.tsx +++ b/apps/web/components/quick-chat/quick-chat-modal.tsx @@ -4,11 +4,12 @@ import { memo } from "react"; import { useShallow } from "zustand/react/shallow"; import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; import { Button } from "@kandev/ui/button"; -import { IconLoader2, IconMessageCircle, IconPlus, IconX } from "@tabler/icons-react"; +import { IconLoader2, IconMessageCircle, IconPlus } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { PassthroughTerminal } from "@/components/task/passthrough-terminal"; import { QuickChatContent } from "./quick-chat-content"; import { QuickChatDeleteDialog } from "./quick-chat-delete-dialog"; +import { QuickChatTabItem } from "./quick-chat-tab-item"; import { useQuickChatModal } from "./use-quick-chat-modal"; type QuickChatModalProps = { @@ -21,12 +22,14 @@ function QuickChatTabs({ onTabChange, onTabClose, onNewChat, + onRename, }: { sessions: Array<{ sessionId: string; workspaceId: string; name?: string }>; activeSessionId: string; onTabChange: (sessionId: string) => void; onTabClose: (sessionId: string) => void; onNewChat: () => void; + onRename: (sessionId: string, name: string) => void; }) { if (sessions.length === 0) return null; @@ -34,34 +37,18 @@ function QuickChatTabs({
{sessions.map((s, index) => { - const isActive = s.sessionId === activeSessionId; // Show "New Chat" for empty session IDs (agent picker tabs) const tabName = s.sessionId === "" ? "New Chat" : s.name || `Chat ${index + 1}`; return ( -
- - -
+ name={tabName} + isActive={s.sessionId === activeSessionId} + isRenameable={s.sessionId !== ""} + onActivate={() => onTabChange(s.sessionId)} + onClose={() => onTabClose(s.sessionId)} + onRename={(name) => onRename(s.sessionId, name)} + /> ); })}
@@ -172,6 +159,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic handleSelectAgent, handleCloseTab, handleConfirmClose, + handleRename, } = useQuickChatModal(workspaceId); return ( @@ -189,6 +177,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic onTabChange={setActiveQuickChatSession} onTabClose={handleCloseTab} onNewChat={handleNewChat} + onRename={handleRename} /> {activeSessionId && !activeSessionNeedsAgent && ( diff --git a/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx b/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx new file mode 100644 index 0000000000..9f438668d6 --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx @@ -0,0 +1,84 @@ +import { afterEach, describe, it, expect, vi } from "vitest"; +import { cleanup, render, fireEvent } from "@testing-library/react"; +import { QuickChatTabItem } from "./quick-chat-tab-item"; + +afterEach(cleanup); + +function makeProps(overrides: Partial[0]> = {}) { + return { + name: "Original", + isActive: true, + isRenameable: true, + onActivate: vi.fn(), + onClose: vi.fn(), + onRename: vi.fn(), + ...overrides, + }; +} + +const RENAME_LABEL = "Rename chat"; + +function startEditing(label: HTMLElement) { + fireEvent.doubleClick(label); +} + +describe("QuickChatTabItem rename", () => { + it("commits the rename on Enter, calling onRename exactly once", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "New name" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onRename).toHaveBeenCalledTimes(1); + expect(onRename).toHaveBeenCalledWith("New name"); + }); + + it("discards the draft on Escape — onRename is NOT called even after blur fires on unmount", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "Should be discarded" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onRename).not.toHaveBeenCalled(); + }); + + it("does not call onRename when the trimmed draft equals the original name", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: " Original " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onRename).not.toHaveBeenCalled(); + }); + + it("ignores Enter while IME composition is active", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "Composing candidate" } }); + // Enter pressed while IME is composing — should be a no-op (IME confirms candidate). + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + + expect(onRename).not.toHaveBeenCalled(); + }); + + it("does not enter edit mode when isRenameable is false", () => { + const { getByText, queryByLabelText } = render( + , + ); + startEditing(getByText("Original")); + + expect(queryByLabelText(RENAME_LABEL)).toBeNull(); + }); +}); diff --git a/apps/web/components/quick-chat/quick-chat-tab-item.tsx b/apps/web/components/quick-chat/quick-chat-tab-item.tsx new file mode 100644 index 0000000000..377a627c8d --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-tab-item.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { IconX } from "@tabler/icons-react"; + +type QuickChatTabItemProps = { + name: string; + isActive: boolean; + isRenameable: boolean; + onActivate: () => void; + onClose: () => void; + onRename: (name: string) => void; +}; + +/** Tab in the quick-chat modal. Double-click the label to rename (local-only). */ +export const QuickChatTabItem = memo(function QuickChatTabItem({ + name, + isActive, + isRenameable, + onActivate, + onClose, + onRename, +}: QuickChatTabItemProps) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(name); + const inputRef = useRef(null); + // Both Enter and Escape close edit mode by blurring the input so onBlur is + // the single commit path. Escape additionally sets this ref so commit knows + // to skip the rename — the blur fires synchronously with the typed draft + // still in the closure, and we'd otherwise rename to whatever the user typed. + const cancelledRef = useRef(false); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const commit = useCallback(() => { + if (cancelledRef.current) { + cancelledRef.current = false; + setIsEditing(false); + return; + } + const trimmed = draft.trim(); + if (trimmed && trimmed !== name) onRename(trimmed); + setIsEditing(false); + }, [draft, name, onRename]); + + const cancel = useCallback(() => { + cancelledRef.current = true; + setDraft(name); + inputRef.current?.blur(); + }, [name]); + + const handleStartEdit = useCallback(() => { + if (!isRenameable) return; + setDraft(name); + setIsEditing(true); + }, [isRenameable, name]); + + return ( +
+ {isEditing ? ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + // IME composition uses Enter to confirm a candidate; let the IME + // handle it instead of committing the rename. + if (e.nativeEvent.isComposing) return; + if (e.key === "Enter") { + e.preventDefault(); + // Trigger blur instead of calling commit() directly: the input + // unmount on setIsEditing(false) would otherwise fire onBlur and + // call commit() a second time. + inputRef.current?.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + }} + className="px-2.5 py-1 text-xs bg-background border border-input rounded outline-none focus:ring-1 focus:ring-ring max-w-[160px]" + /> + ) : ( + + )} + +
+ ); +}); diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts index b59ccff41a..a2da0e70f5 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -155,6 +155,14 @@ export function useQuickChatModal(workspaceId: string) { [reset, store], ); + const handleRename = useCallback( + (sessionId: string, name: string) => { + if (!sessionId) return; + store.renameQuickChatSession(sessionId, name); + }, + [store], + ); + const handleConfirmClose = useCallback(async () => { if (!sessionToClose) return; const sessionId = sessionToClose; @@ -188,5 +196,6 @@ export function useQuickChatModal(workspaceId: string) { handleSelectAgent, handleCloseTab, handleConfirmClose, + handleRename, }; } diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index ba7277ea1d..e24771bb47 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -642,6 +642,7 @@ export function cleanupTaskStorage( // Session-keyed storage — drafts, files panel state, scroll, etc. for (const sessionId of sessionIds) { + removeStoredQuickChatName(sessionId); removeSessionStorage(`${PR_PANEL_OFFERED_PREFIX}${sessionId}`); removeSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`); removeSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`); @@ -658,6 +659,36 @@ export function cleanupTaskStorage( } } +// --- Quick chat custom names (localStorage, global) --- +// +// Local-only: not persisted to the backend. Keyed by sessionId so the rename +// follows the underlying chat across reloads but vanishes if the user clears +// browser storage. The backend task title remains the auto-generated name. + +const QUICK_CHAT_NAMES_KEY = "kandev.quickChat.names"; + +export function getStoredQuickChatNames(): Record { + const raw = getLocalStorage>(QUICK_CHAT_NAMES_KEY, {}); + if (!raw || typeof raw !== "object") return {}; + return raw; +} + +export function setStoredQuickChatName(sessionId: string, name: string): void { + if (!sessionId) return; + const all = getStoredQuickChatNames(); + if (name) all[sessionId] = name; + else delete all[sessionId]; + setLocalStorage(QUICK_CHAT_NAMES_KEY, all); +} + +export function removeStoredQuickChatName(sessionId: string): void { + if (!sessionId) return; + const all = getStoredQuickChatNames(); + if (!(sessionId in all)) return; + delete all[sessionId]; + setLocalStorage(QUICK_CHAT_NAMES_KEY, all); +} + // --- Sidebar filter views (localStorage, global) --- const SIDEBAR_VIEWS_KEY = "kandev.sidebar.views"; diff --git a/apps/web/lib/state/hydration/hydrator.test.ts b/apps/web/lib/state/hydration/hydrator.test.ts new file mode 100644 index 0000000000..170f64aa34 --- /dev/null +++ b/apps/web/lib/state/hydration/hydrator.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { produce } from "immer"; +import type { Draft } from "immer"; +import { hydrateUI } from "./hydrator"; +import { defaultUIState } from "@/lib/state/slices/ui/ui-slice"; +import type { AppState } from "@/lib/state/store"; + +function makeDraft(): AppState { + // hydrateUI only touches UI-slice fields; an empty object cast satisfies + // the rest without dragging the full AppState shape into this test. + return { ...defaultUIState } as unknown as AppState; +} + +describe("hydrateUI — quick chat name overlay", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("overlays a locally-renamed name onto the SSR-provided session name", () => { + window.localStorage.setItem( + "kandev.quickChat.names", + JSON.stringify({ "sess-1": "My custom name" }), + ); + + const result = produce(makeDraft(), (draft: Draft) => { + hydrateUI(draft, { + quickChat: { + isOpen: false, + activeSessionId: null, + sessions: [{ sessionId: "sess-1", workspaceId: "ws-1", name: "Agent A - Chat 1" }], + }, + }); + }); + + expect(result.quickChat.sessions[0].name).toBe("My custom name"); + }); + + it("keeps the SSR-provided name when no local rename exists", () => { + const result = produce(makeDraft(), (draft: Draft) => { + hydrateUI(draft, { + quickChat: { + isOpen: false, + activeSessionId: null, + sessions: [{ sessionId: "sess-2", workspaceId: "ws-1", name: "Agent A - Chat 1" }], + }, + }); + }); + + expect(result.quickChat.sessions[0].name).toBe("Agent A - Chat 1"); + }); + + it("only overlays sessions that have a stored rename, leaving siblings untouched", () => { + window.localStorage.setItem( + "kandev.quickChat.names", + JSON.stringify({ "sess-a": "Renamed A" }), + ); + + const result = produce(makeDraft(), (draft: Draft) => { + hydrateUI(draft, { + quickChat: { + isOpen: false, + activeSessionId: null, + sessions: [ + { sessionId: "sess-a", workspaceId: "ws-1", name: "Original A" }, + { sessionId: "sess-b", workspaceId: "ws-1", name: "Original B" }, + ], + }, + }); + }); + + expect(result.quickChat.sessions.map((s) => s.name)).toEqual(["Renamed A", "Original B"]); + }); +}); diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index 6df1d4f0ba..05b8e3354a 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -1,6 +1,7 @@ import type { Draft } from "immer"; import type { AppState } from "../store"; import { migrateView } from "../slices/ui/ui-slice"; +import { getStoredQuickChatNames } from "@/lib/local-storage"; import { deepMerge, mergeSessionMap, mergeLoadingState } from "./merge-strategies"; /** @@ -205,14 +206,21 @@ function hydrateSessionRuntime( } /** Hydrate UI slices without overwriting active connection state. */ -function hydrateUI(draft: Draft, state: Partial): void { +export function hydrateUI(draft: Draft, state: Partial): void { if (state.previewPanel) deepMerge(draft.previewPanel, state.previewPanel); if (state.rightPanel) deepMerge(draft.rightPanel, state.rightPanel); if (state.diffs) deepMerge(draft.diffs, state.diffs); if (state.quickChat) { // Merge quick chat sessions, preserving isOpen from client if (state.quickChat.sessions) { - draft.quickChat.sessions = state.quickChat.sessions; + // Local renames live in localStorage and override the SSR-provided name + // (which derives from the backend task title). Apply on every hydration + // so a renamed chat keeps its local name across reloads and tab switches. + const storedNames = getStoredQuickChatNames(); + draft.quickChat.sessions = state.quickChat.sessions.map((s) => { + const local = storedNames[s.sessionId]; + return local ? { ...s, name: local } : s; + }); // Validate activeSessionId exists in sessions after merge if ( draft.quickChat.activeSessionId && diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 10005d25e8..c441a4d5c9 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -8,6 +8,7 @@ import { removeStoredSidebarDraft, setLocalStorage, setStoredCollapsedSubtaskParents, + setStoredQuickChatName, setStoredSidebarActiveViewId, setStoredSidebarDraft, setStoredSidebarUserViews, @@ -584,13 +585,17 @@ export const createUISlice: StateCreator { draft.quickChat.activeSessionId = sessionId; }), - renameQuickChatSession: (sessionId, name) => + renameQuickChatSession: (sessionId, name) => { + let renamed = false; set((draft) => { const session = draft.quickChat.sessions.find((s) => s.sessionId === sessionId); if (session) { session.name = name; + renamed = true; } - }), + }); + if (renamed) setStoredQuickChatName(sessionId, name); + }, setSessionFailureNotification: (n) => set((draft) => { draft.sessionFailureNotification = n;