diff --git a/src/components/chat/AgentHeader.test.tsx b/src/components/chat/AgentHeader.test.tsx
new file mode 100644
index 0000000..53929c6
--- /dev/null
+++ b/src/components/chat/AgentHeader.test.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { ThemeProvider, createTheme } from "@mui/material/styles";
+
+import { AgentHeader } from "./AgentHeader";
+
+jest.mock("next/image", () => ({
+ __esModule: true,
+ default: (props: React.ComponentProps<"img">) =>
,
+}));
+
+const renderWithTheme = (ui: React.ReactElement) =>
+ render({ui});
+
+describe("AgentHeader", () => {
+ it("submits a renamed active session title", () => {
+ const onRenameSessionTitle = jest.fn();
+
+ renderWithTheme(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "修改对话标题" }));
+ fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
+ target: { value: "更新后的标题" },
+ });
+ fireEvent.click(screen.getByLabelText("确认修改对话标题"));
+
+ expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
+ });
+});
diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx
index be57972..2573158 100644
--- a/src/components/chat/AgentHeader.tsx
+++ b/src/components/chat/AgentHeader.tsx
@@ -8,34 +8,69 @@ import {
Box,
IconButton,
Stack,
+ TextField,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
-import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
+import CheckRounded from "@mui/icons-material/CheckRounded";
import CloseRounded from "@mui/icons-material/CloseRounded";
+import EditRounded from "@mui/icons-material/EditRounded";
+import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import HistoryRounded from "@mui/icons-material/HistoryRounded";
type AgentHeaderProps = {
sessionTitle?: string;
+ canRenameSessionTitle?: boolean;
+ isHydrating?: boolean;
isStreaming: boolean;
isHistoryOpen: boolean;
onHistoryToggle: () => void;
+ onRenameSessionTitle?: (title: string) => void;
onNewConversation: () => void;
onClose: () => void;
};
export const AgentHeader = ({
sessionTitle,
+ canRenameSessionTitle = false,
+ isHydrating = false,
isStreaming,
isHistoryOpen,
onHistoryToggle,
+ onRenameSessionTitle,
onNewConversation,
onClose,
}: AgentHeaderProps) => {
const theme = useTheme();
const displayTitle = sessionTitle?.trim() || "TJWater Agent";
+ const [isEditingTitle, setIsEditingTitle] = React.useState(false);
+ const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
+
+ React.useEffect(() => {
+ if (!isEditingTitle) {
+ setDraftTitle(sessionTitle?.trim() || "");
+ }
+ }, [isEditingTitle, sessionTitle]);
+
+ const handleStartEditing = () => {
+ if (!canRenameSessionTitle || isHydrating || isStreaming) return;
+ setDraftTitle(sessionTitle?.trim() || "");
+ setIsEditingTitle(true);
+ };
+
+ const handleCancelEditing = () => {
+ setDraftTitle(sessionTitle?.trim() || "");
+ setIsEditingTitle(false);
+ };
+
+ const handleConfirmEditing = () => {
+ const normalizedTitle = draftTitle.trim();
+ if (!normalizedTitle) return;
+ onRenameSessionTitle?.(normalizedTitle);
+ setIsEditingTitle(false);
+ };
return (
-
-
- {displayTitle}
-
-
- {isStreaming
- ? "正在思考分析任务..."
- : displayTitle === "TJWater Agent"
- ? "基于大模型的水力分析引擎"
- : "当前会话标题"}
-
+
+ {isEditingTitle ? (
+
+
+ setDraftTitle(event.target.value)}
+ size="small"
+ autoFocus
+ placeholder="请输入对话标题"
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ handleConfirmEditing();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ handleCancelEditing();
+ }
+ }}
+ sx={{
+ flex: 1,
+ minWidth: 0,
+ "& .MuiOutlinedInput-root": {
+ height: 34,
+ bgcolor: alpha("#fff", 0.7),
+ borderRadius: 1.5,
+ transition: "all 0.2s ease-in-out",
+ "& fieldset": {
+ borderColor: alpha("#000", 0.08),
+ },
+ "&:hover fieldset": {
+ borderColor: alpha(theme.palette.primary.main, 0.4),
+ },
+ "&.Mui-focused fieldset": {
+ borderColor: theme.palette.primary.main,
+ borderWidth: "1.5px",
+ boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
+ },
+ },
+ "& .MuiInputBase-input": {
+ padding: "4px 12px",
+ fontSize: "1.05rem",
+ fontWeight: 700,
+ color: theme.palette.text.primary,
+ }
+ }}
+ />
+
+
+
+
+
+
+
+
+ ) : (
+ <>
+
+
+ {displayTitle}
+
+ {canRenameSessionTitle ? (
+
+
+
+
+
+
+
+ ) : null}
+
+
+ {isStreaming
+ ? "正在思考分析任务..."
+ : displayTitle === "TJWater Agent"
+ ? "基于大模型的水力分析引擎"
+ : "当前会话标题"}
+
+ >
+ )}
diff --git a/src/components/chat/AgentHistoryPanel.test.tsx b/src/components/chat/AgentHistoryPanel.test.tsx
new file mode 100644
index 0000000..8dc3d6d
--- /dev/null
+++ b/src/components/chat/AgentHistoryPanel.test.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { ThemeProvider, createTheme } from "@mui/material/styles";
+
+import { AgentHistoryPanel } from "./AgentHistoryPanel";
+
+const renderWithTheme = (ui: React.ReactElement) =>
+ render({ui});
+
+describe("AgentHistoryPanel", () => {
+ it("renames a history session from the list", () => {
+ const onRenameSession = jest.fn();
+
+ renderWithTheme(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "修改会话标题" }));
+ fireEvent.change(screen.getByPlaceholderText("请输入会话标题"), {
+ target: { value: "新的会话标题" },
+ });
+ fireEvent.click(screen.getByLabelText("确认修改历史会话标题"));
+
+ expect(onRenameSession).toHaveBeenCalledWith("session-1", "新的会话标题");
+ });
+});
diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx
index 21b8dde..ca606c4 100644
--- a/src/components/chat/AgentHistoryPanel.tsx
+++ b/src/components/chat/AgentHistoryPanel.tsx
@@ -18,7 +18,11 @@ import {
Tooltip,
Typography,
alpha,
+ useTheme,
} from "@mui/material";
+import CheckRounded from "@mui/icons-material/CheckRounded";
+import CloseRounded from "@mui/icons-material/CloseRounded";
+import EditRounded from "@mui/icons-material/EditRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded";
import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded";
@@ -31,6 +35,7 @@ type AgentHistoryPanelProps = {
activeSessionId?: string;
isHydrating?: boolean;
onNewSession: () => void;
+ onRenameSession: (sessionId: string, title: string) => void;
onSelectSession: (sessionId: string) => void;
onDeleteSession: (sessionId: string) => void;
};
@@ -68,14 +73,19 @@ const getSessionGroupLabel = (timestamp: number) => {
};
export const AgentHistoryPanel = ({
+
sessions,
activeSessionId,
isHydrating = false,
onNewSession,
+ onRenameSession,
onSelectSession,
onDeleteSession,
}: AgentHistoryPanelProps) => {
+ const theme = useTheme();
const [keyword, setKeyword] = React.useState("");
+ const [editingSessionId, setEditingSessionId] = React.useState(null);
+ const [draftTitle, setDraftTitle] = React.useState("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState(null);
@@ -105,6 +115,23 @@ export const AgentHistoryPanel = ({
(session) => session.id === pendingDeleteSessionId,
);
+ const handleStartRename = (sessionId: string, title: string) => {
+ setEditingSessionId(sessionId);
+ setDraftTitle(title);
+ };
+
+ const handleCancelRename = () => {
+ setEditingSessionId(null);
+ setDraftTitle("");
+ };
+
+ const handleConfirmRename = (sessionId: string) => {
+ const normalizedTitle = draftTitle.trim();
+ if (!normalizedTitle) return;
+ onRenameSession(sessionId, normalizedTitle);
+ handleCancelRename();
+ };
+
return (
<>
onSelectSession(session.id)}
+ onClick={() => {
+ if (editingSessionId === session.id) return;
+ onSelectSession(session.id);
+ }}
sx={{
px: 1.25,
py: 1,
@@ -259,49 +289,163 @@ export const AgentHistoryPanel = ({
>
-
- {session.title}
-
-
- {formatRelativeDate(session.updatedAt)}
-
+ {editingSessionId === session.id ? (
+
+ setDraftTitle(event.target.value)}
+ size="small"
+ autoFocus
+ placeholder="请输入会话标题"
+ onClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ event.stopPropagation();
+ handleConfirmRename(session.id);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ handleCancelRename();
+ }
+ }}
+ sx={{
+ flex: 1,
+ minWidth: 0,
+ "& .MuiOutlinedInput-root": {
+ height: 32,
+ bgcolor: alpha("#fff", 0.75),
+ borderRadius: 1.5,
+ transition: "all 0.2s ease-in-out",
+ "& fieldset": {
+ borderColor: alpha("#000", 0.08),
+ },
+ "&:hover fieldset": {
+ borderColor: alpha(theme.palette.primary.main, 0.4),
+ },
+ "&.Mui-focused fieldset": {
+ borderColor: theme.palette.primary.main,
+ borderWidth: "1.5px",
+ boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
+ },
+ },
+ "& .MuiInputBase-input": {
+ padding: "4px 10px",
+ fontSize: "0.85rem",
+ fontWeight: 700,
+ color: theme.palette.text.primary,
+ }
+ }}
+ />
+ {
+ event.stopPropagation();
+ handleConfirmRename(session.id);
+ }}
+ disabled={!draftTitle.trim()}
+ sx={{
+ width: 28,
+ height: 28,
+ color: "success.main",
+ bgcolor: alpha(theme.palette.success.main, 0.1),
+ "&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
+ }}
+ >
+
+
+ {
+ event.stopPropagation();
+ handleCancelRename();
+ }}
+ sx={{
+ width: 28,
+ height: 28,
+ color: "text.secondary",
+ bgcolor: alpha("#000", 0.05),
+ "&:hover": { bgcolor: alpha("#000", 0.1) },
+ }}
+ >
+
+
+
+ ) : (
+
+
+ {session.title}
+
+
+ {formatRelativeDate(session.updatedAt)}
+
+
+ )}
-
-
- {
- event.stopPropagation();
- setPendingDeleteSessionId(session.id);
- setIsDeleteDialogOpen(true);
- }}
- sx={{
- width: 24,
- height: 24,
- color: "text.secondary",
- "&:hover": {
- color: "error.main",
- bgcolor: alpha("#ef5350", 0.08),
- },
- }}
- >
-
-
-
-
+
+
+
+ {
+ event.stopPropagation();
+ handleStartRename(session.id, session.title);
+ }}
+ disabled={isHydrating || editingSessionId === session.id}
+ sx={{
+ width: 24,
+ height: 24,
+ color: "text.secondary",
+ "&:hover": {
+ color: "primary.main",
+ bgcolor: alpha("#00acc1", 0.08),
+ },
+ }}
+ >
+
+
+
+
+
+
+ {
+ event.stopPropagation();
+ setPendingDeleteSessionId(session.id);
+ setIsDeleteDialogOpen(true);
+ }}
+ sx={{
+ width: 24,
+ height: 24,
+ color: "text.secondary",
+ "&:hover": {
+ color: "error.main",
+ bgcolor: alpha("#ef5350", 0.08),
+ },
+ }}
+ >
+
+
+
+
+
);
diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx
index 921ab5b..8f4db76 100644
--- a/src/components/chat/GlobalChatbox.tsx
+++ b/src/components/chat/GlobalChatbox.tsx
@@ -70,6 +70,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
cycleBranch,
abort,
createSession,
+ renameSession,
removeSession,
switchSession,
} = useAgentChatSession({
@@ -134,6 +135,21 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
[removeSession],
);
+ const handleRenameSession = useCallback(
+ (storageSessionId: string, title: string) => {
+ void renameSession(storageSessionId, title);
+ },
+ [renameSession],
+ );
+
+ const handleRenameActiveSession = useCallback(
+ (title: string) => {
+ if (!activeStorageSessionId) return;
+ void renameSession(activeStorageSessionId, title);
+ },
+ [activeStorageSessionId, renameSession],
+ );
+
const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault();
setIsResizing(true);
@@ -231,9 +247,12 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
@@ -277,6 +296,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
handleSelectSession(id);
setIsHistoryOpen(false);
}}
+ onRenameSession={handleRenameSession}
onDeleteSession={handleDeleteSession}
/>
diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts
index 4fae4b3..acba4e7 100644
--- a/src/components/chat/GlobalChatbox.types.ts
+++ b/src/components/chat/GlobalChatbox.types.ts
@@ -75,6 +75,7 @@ export type LegacyPersistedChatState = {
export type ChatSessionRecord = {
id: string;
title: string;
+ isTitleManuallyEdited?: boolean;
createdAt: number;
updatedAt: number;
sessionId?: string;
@@ -98,6 +99,7 @@ export type ChatStorageMeta = {
export type LoadedChatState = {
storageSessionId?: string;
title?: string;
+ isTitleManuallyEdited?: boolean;
messages: Message[];
sessionId?: string;
branchGroups: BranchGroup[];
diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts
index 3574362..3c2d794 100644
--- a/src/components/chat/chatStorage.ts
+++ b/src/components/chat/chatStorage.ts
@@ -39,6 +39,7 @@ type ChatDB = DBSchema & {
const emptyLoadedChatState = (): LoadedChatState => ({
storageSessionId: undefined,
title: undefined,
+ isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
branchGroups: [],
@@ -55,6 +56,7 @@ const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatSt
return {
storageSessionId: session.id,
title: session.title,
+ isTitleManuallyEdited: session.isTitleManuallyEdited ?? false,
messages: sanitizeMessages(session.messages),
sessionId: session.sessionId,
branchGroups: sanitizeBranchGroups(session.branchGroups),
@@ -163,6 +165,7 @@ const migrateLegacyLocalStorage = async () => {
const sessionRecord: ChatSessionRecord = {
id: createId(),
title: "新对话",
+ isTitleManuallyEdited: false,
createdAt: now,
updatedAt: now,
sessionId: legacyState.sessionId,
@@ -241,6 +244,7 @@ export const saveActiveChatState = async (
const nextRecord: ChatSessionRecord = {
id: storageSessionId,
title: finalTitle,
+ isTitleManuallyEdited: state.isTitleManuallyEdited ?? existingSession?.isTitleManuallyEdited ?? false,
createdAt: existingSession?.createdAt ?? now,
updatedAt: now,
sessionId: state.sessionId,
@@ -272,6 +276,9 @@ export const listChatSessions = async (): Promise => {
export const updateChatSessionTitle = async (
storageSessionId: string,
title: string,
+ options?: {
+ isTitleManuallyEdited?: boolean;
+ },
): Promise => {
if (typeof window === "undefined") return;
@@ -285,6 +292,8 @@ export const updateChatSessionTitle = async (
await db.put(SESSION_STORE, {
...session,
title: normalizedTitle,
+ isTitleManuallyEdited:
+ options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
updatedAt: Date.now(),
});
};
@@ -298,6 +307,7 @@ export const createEmptyChatSession = async (): Promise => {
const session: ChatSessionRecord = {
id: createId(),
title: "新对话",
+ isTitleManuallyEdited: false,
createdAt: now,
updatedAt: now,
sessionId: undefined,
diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts
index 08e6f97..b517898 100644
--- a/src/components/chat/hooks/useAgentChatSession.ts
+++ b/src/components/chat/hooks/useAgentChatSession.ts
@@ -146,6 +146,7 @@ export const useAgentChatSession = ({
const [messages, setMessages] = useState([]);
const [sessionTitle, setSessionTitle] = useState(undefined);
+ const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
const [sessionId, setSessionId] = useState(undefined);
const [branchGroups, setBranchGroups] = useState([]);
const [chatSessions, setChatSessions] = useState([]);
@@ -154,6 +155,7 @@ export const useAgentChatSession = ({
const [isHydrating, setIsHydrating] = useState(true);
const abortRef = useRef(null);
const sessionIdRef = useRef(undefined);
+ const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef | null>(null);
const titleUpdateNonceRef = useRef(0);
@@ -161,6 +163,10 @@ export const useAgentChatSession = ({
sessionIdRef.current = sessionId;
}, [sessionId]);
+ useEffect(() => {
+ isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
+ }, [isSessionTitleManuallyEdited]);
+
useEffect(() => {
let cancelled = false;
@@ -180,6 +186,7 @@ export const useAgentChatSession = ({
setMessages(loadedState.messages);
setSessionTitle(loadedState.title);
+ setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
setSessionId(loadedState.sessionId);
setBranchGroups(loadedState.branchGroups);
setChatSessions(sessions);
@@ -207,6 +214,7 @@ export const useAgentChatSession = ({
const state: LoadedChatState = {
storageSessionId: storageSessionIdRef.current,
title: sessionTitle,
+ isTitleManuallyEdited: isSessionTitleManuallyEdited,
messages,
sessionId,
branchGroups,
@@ -230,7 +238,7 @@ export const useAgentChatSession = ({
return () => {
window.clearTimeout(persistTimer);
};
- }, [branchGroups, isHydrating, messages, sessionId, sessionTitle]);
+ }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, sessionId, sessionTitle]);
useEffect(() => {
setBranchGroups((prev) => {
@@ -354,12 +362,14 @@ export const useAgentChatSession = ({
});
} else if (event.type === "session_title") {
const nextTitle = event.title.trim();
- if (nextTitle) {
+ if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
setSessionTitle(nextTitle);
const currentStorageSessionId = storageSessionIdRef.current;
if (currentStorageSessionId) {
const currentNonce = ++titleUpdateNonceRef.current;
- void updateChatSessionTitle(currentStorageSessionId, nextTitle)
+ void updateChatSessionTitle(currentStorageSessionId, nextTitle, {
+ isTitleManuallyEdited: false,
+ })
.then(() => listChatSessions())
.then((sessions) => {
if (titleUpdateNonceRef.current !== currentNonce) return;
@@ -487,6 +497,7 @@ export const useAgentChatSession = ({
}
setMessages([]);
setSessionTitle(undefined);
+ setIsSessionTitleManuallyEdited(false);
setBranchGroups([]);
setBranchTransition(null);
setSessionId(undefined);
@@ -512,6 +523,7 @@ export const useAgentChatSession = ({
sessionIdRef.current = newState.sessionId;
setMessages(newState.messages);
setSessionTitle(newState.title);
+ setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false);
setSessionId(newState.sessionId);
setBranchGroups(newState.branchGroups);
setChatSessions(sessions);
@@ -538,6 +550,7 @@ export const useAgentChatSession = ({
setBranchTransition(null);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
+ setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups);
setChatSessions(sessions);
@@ -571,6 +584,7 @@ export const useAgentChatSession = ({
setBranchTransition(null);
setMessages([]);
setSessionTitle(undefined);
+ setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
setBranchGroups([]);
return;
@@ -588,6 +602,7 @@ export const useAgentChatSession = ({
setBranchTransition(null);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
+ setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups);
setChatSessions(sessionsAfterDelete);
@@ -607,6 +622,29 @@ export const useAgentChatSession = ({
[runPrompt],
);
+ const renameSession = useCallback(
+ async (targetStorageSessionId: string, nextTitle: string) => {
+ const normalizedTitle = nextTitle.trim();
+ if (!normalizedTitle || isHydrating) return;
+
+ try {
+ await updateChatSessionTitle(targetStorageSessionId, normalizedTitle, {
+ isTitleManuallyEdited: true,
+ });
+ const sessions = await listChatSessions();
+ setChatSessions(sessions);
+
+ if (storageSessionIdRef.current === targetStorageSessionId) {
+ setSessionTitle(normalizedTitle);
+ setIsSessionTitleManuallyEdited(true);
+ }
+ } catch (error) {
+ console.error("[GlobalChatbox] Failed to rename chat session:", error);
+ }
+ },
+ [isHydrating],
+ );
+
const regenerate = useCallback(async () => {
if (isHydrating || isStreaming || messages.length === 0) return;
@@ -776,6 +814,7 @@ export const useAgentChatSession = ({
abort,
createSession,
reset,
+ renameSession,
removeSession,
switchSession,
};