feat(web): allow renaming quick-chat tabs locally (#830)

* 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 <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-05-05 21:47:35 +01:00
committed by GitHub
parent bce9fd266a
commit 5f4b3ec0c0
9 changed files with 374 additions and 30 deletions
@@ -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({});
});
});
@@ -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({
<div className="flex items-center gap-1 px-2 py-1 border-b bg-muted/20">
<div className="flex items-center gap-1 overflow-x-auto flex-1 scrollbar-hide">
{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 (
<div
<QuickChatTabItem
key={s.sessionId || `new-${index}`}
className={`flex items-center gap-1 rounded transition-colors whitespace-nowrap ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted"
}`}
>
<button
type="button"
onClick={() => onTabChange(s.sessionId)}
className="flex items-center px-2.5 py-1 text-xs cursor-pointer"
>
<span className="truncate max-w-[100px]">{tabName}</span>
</button>
<button
type="button"
aria-label={`Close ${tabName}`}
className="p-1 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => onTabClose(s.sessionId)}
>
<IconX className="h-3 w-3" />
</button>
</div>
name={tabName}
isActive={s.sessionId === activeSessionId}
isRenameable={s.sessionId !== ""}
onActivate={() => onTabChange(s.sessionId)}
onClose={() => onTabClose(s.sessionId)}
onRename={(name) => onRename(s.sessionId, name)}
/>
);
})}
</div>
@@ -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 && (
<QuickChatSessionView sessionId={activeSessionId} />
@@ -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<Parameters<typeof QuickChatTabItem>[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(<QuickChatTabItem {...makeProps({ onRename })} />);
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(<QuickChatTabItem {...makeProps({ onRename })} />);
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(<QuickChatTabItem {...makeProps({ onRename })} />);
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(<QuickChatTabItem {...makeProps({ onRename })} />);
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(
<QuickChatTabItem {...makeProps({ isRenameable: false })} />,
);
startEditing(getByText("Original"));
expect(queryByLabelText(RENAME_LABEL)).toBeNull();
});
});
@@ -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<HTMLInputElement>(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 (
<div
className={`flex items-center gap-1 rounded transition-colors whitespace-nowrap ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted"
}`}
>
{isEditing ? (
<input
ref={inputRef}
aria-label="Rename chat"
value={draft}
onChange={(e) => 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]"
/>
) : (
<button
type="button"
onClick={onActivate}
onDoubleClick={handleStartEdit}
title={isRenameable ? "Double-click to rename" : undefined}
className="flex items-center px-2.5 py-1 text-xs cursor-pointer"
>
<span className="truncate max-w-[160px]">{name}</span>
</button>
)}
<button
type="button"
aria-label={`Close ${name}`}
className="p-1 cursor-pointer opacity-60 hover:opacity-100"
onClick={onClose}
>
<IconX className="h-3 w-3" />
</button>
</div>
);
});
@@ -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,
};
}
+31
View File
@@ -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<string, string> {
const raw = getLocalStorage<Record<string, string>>(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";
@@ -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<AppState>) => {
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<AppState>) => {
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<AppState>) => {
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"]);
});
});
+10 -2
View File
@@ -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<AppState>, state: Partial<AppState>): void {
export function hydrateUI(draft: Draft<AppState>, state: Partial<AppState>): 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 &&
+7 -2
View File
@@ -8,6 +8,7 @@ import {
removeStoredSidebarDraft,
setLocalStorage,
setStoredCollapsedSubtaskParents,
setStoredQuickChatName,
setStoredSidebarActiveViewId,
setStoredSidebarDraft,
setStoredSidebarUserViews,
@@ -584,13 +585,17 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
set((draft) => {
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;