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;