增加会话标题重命名功能,优化历史面板交互
Build Push and Deploy / docker-image (push) Successful in 2m0s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped

This commit is contained in:
2026-05-19 16:42:28 +08:00
parent 3800d73e85
commit 9106b8d4a9
8 changed files with 508 additions and 71 deletions
+40
View File
@@ -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">) => <img {...props} alt={props.alt ?? ""} />,
}));
const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
describe("AgentHeader", () => {
it("submits a renamed active session title", () => {
const onRenameSessionTitle = jest.fn();
renderWithTheme(
<AgentHeader
sessionTitle="原始标题"
canRenameSessionTitle
isStreaming={false}
isHistoryOpen={false}
onHistoryToggle={jest.fn()}
onRenameSessionTitle={onRenameSessionTitle}
onNewConversation={jest.fn()}
onClose={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "修改对话标题" }));
fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
target: { value: "更新后的标题" },
});
fireEvent.click(screen.getByLabelText("确认修改对话标题"));
expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
});
});
+168 -26
View File
@@ -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 (
<Box
@@ -89,35 +124,142 @@ export const AgentHeader = ({
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
}
},
}}
/>
</Box>
</motion.div>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="h6"
fontWeight={800}
sx={{
background: `linear-gradient(90deg, #01579b, #00838f)`,
backgroundClip: "text",
color: "transparent",
letterSpacing: -0.3,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: { xs: "calc(100vw - 220px)", sm: 320 },
}}
>
{displayTitle}
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
{isStreaming
? "正在思考分析任务..."
: displayTitle === "TJWater Agent"
? "基于大模型的水力分析引擎"
: "当前会话标题"}
</Typography>
<Box sx={{ minWidth: 0, minHeight: 52, display: "flex", flexDirection: "column", justifyContent: "center" }}>
{isEditingTitle ? (
<Box>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 }, transform: "translateY(2px)" }}>
<TextField
value={draftTitle}
onChange={(event) => 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,
}
}}
/>
<IconButton
size="small"
aria-label="确认"
onClick={handleConfirmEditing}
disabled={!draftTitle.trim()}
sx={{
width: 30,
height: 30,
color: "success.main",
bgcolor: alpha(theme.palette.success.main, 0.1),
"&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
}}
>
<CheckRounded sx={{ fontSize: 18 }} />
</IconButton>
<IconButton
size="small"
aria-label="取消"
onClick={handleCancelEditing}
sx={{
width: 30,
height: 30,
color: "text.secondary",
bgcolor: alpha("#000", 0.05),
"&:hover": { bgcolor: alpha("#000", 0.1) },
}}
>
<CloseRounded sx={{ fontSize: 18 }} />
</IconButton>
</Stack>
</Box>
) : (
<>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
<Typography
variant="h6"
fontWeight={800}
sx={{
background: `linear-gradient(90deg, #01579b, #00838f)`,
backgroundClip: "text",
color: "transparent",
letterSpacing: -0.3,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: { xs: "calc(100vw - 256px)", sm: 284 },
}}
>
{displayTitle}
</Typography>
{canRenameSessionTitle ? (
<Tooltip title="修改对话标题">
<span>
<IconButton
size="small"
aria-label="修改对话标题"
onClick={handleStartEditing}
disabled={isHydrating || isStreaming}
sx={{
width: 24,
height: 24,
color: "text.secondary",
bgcolor: alpha("#fff", 0.45),
"&:hover": {
color: "primary.main",
bgcolor: alpha(theme.palette.primary.main, 0.08),
},
}}
>
<EditRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
) : null}
</Stack>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
{isStreaming
? "正在思考分析任务..."
: displayTitle === "TJWater Agent"
? "基于大模型的水力分析引擎"
: "当前会话标题"}
</Typography>
</>
)}
</Box>
</Stack>
@@ -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(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
describe("AgentHistoryPanel", () => {
it("renames a history session from the list", () => {
const onRenameSession = jest.fn();
renderWithTheme(
<AgentHistoryPanel
sessions={[
{
id: "session-1",
title: "旧会话标题",
createdAt: Date.now(),
updatedAt: Date.now(),
},
]}
activeSessionId="session-1"
onNewSession={jest.fn()}
onRenameSession={onRenameSession}
onSelectSession={jest.fn()}
onDeleteSession={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "修改会话标题" }));
fireEvent.change(screen.getByPlaceholderText("请输入会话标题"), {
target: { value: "新的会话标题" },
});
fireEvent.click(screen.getByLabelText("确认修改历史会话标题"));
expect(onRenameSession).toHaveBeenCalledWith("session-1", "新的会话标题");
});
});
+186 -42
View File
@@ -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<string | null>(null);
const [draftTitle, setDraftTitle] = React.useState("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(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 (
<>
<Paper
@@ -240,7 +267,10 @@ export const AgentHistoryPanel = ({
<Paper
key={session.id}
elevation={0}
onClick={() => 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 = ({
>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
fontWeight={isActive ? 800 : 700}
color="text.primary"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{session.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
{formatRelativeDate(session.updatedAt)}
</Typography>
{editingSessionId === session.id ? (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
<TextField
value={draftTitle}
onChange={(event) => 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,
}
}}
/>
<IconButton
size="small"
aria-label="确认"
onClick={(event) => {
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) },
}}
>
<CheckRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
aria-label="取消"
onClick={(event) => {
event.stopPropagation();
handleCancelRename();
}}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.05),
"&:hover": { bgcolor: alpha("#000", 0.1) },
}}
>
<CloseRounded sx={{ fontSize: 16 }} />
</IconButton>
</Stack>
) : (
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<Typography
variant="body2"
fontWeight={isActive ? 800 : 700}
color="text.primary"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{session.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
{formatRelativeDate(session.updatedAt)}
</Typography>
</Box>
)}
</Box>
<Tooltip title="删除会话">
<span>
<IconButton
size="small"
aria-label="删除会话"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(session.id);
setIsDeleteDialogOpen(true);
}}
sx={{
width: 24,
height: 24,
color: "text.secondary",
"&:hover": {
color: "error.main",
bgcolor: alpha("#ef5350", 0.08),
},
}}
>
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Stack direction="row" spacing={0.25} sx={{ display: editingSessionId === session.id ? 'none' : 'flex' }}>
<Tooltip title="修改会话标题">
<span>
<IconButton
size="small"
aria-label="修改会话标题"
onClick={(event) => {
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),
},
}}
>
<EditRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="删除会话">
<span>
<IconButton
size="small"
aria-label="删除会话"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(session.id);
setIsDeleteDialogOpen(true);
}}
sx={{
width: 24,
height: 24,
color: "text.secondary",
"&:hover": {
color: "error.main",
bgcolor: alpha("#ef5350", 0.08),
},
}}
>
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
</Stack>
</Stack>
</Paper>
);
+20
View File
@@ -70,6 +70,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
cycleBranch,
abort,
createSession,
renameSession,
removeSession,
switchSession,
} = useAgentChatSession({
@@ -134,6 +135,21 @@ export const GlobalChatbox: React.FC<Props> = ({ 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<Props> = ({ open, onClose }) => {
<AgentHeader
sessionTitle={sessionTitle}
canRenameSessionTitle={Boolean(activeStorageSessionId)}
isHydrating={isHydrating}
isStreaming={isStreaming}
isHistoryOpen={isHistoryOpen}
onHistoryToggle={handleHistoryToggle}
onRenameSessionTitle={handleRenameActiveSession}
onNewConversation={handleNewConversation}
onClose={onClose}
/>
@@ -277,6 +296,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
handleSelectSession(id);
setIsHistoryOpen(false);
}}
onRenameSession={handleRenameSession}
onDeleteSession={handleDeleteSession}
/>
</Box>
@@ -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[];
+10
View File
@@ -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<ChatSessionSummary[]> => {
export const updateChatSessionTitle = async (
storageSessionId: string,
title: string,
options?: {
isTitleManuallyEdited?: boolean;
},
): Promise<void> => {
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<LoadedChatState> => {
const session: ChatSessionRecord = {
id: createId(),
title: "新对话",
isTitleManuallyEdited: false,
createdAt: now,
updatedAt: now,
sessionId: undefined,
@@ -146,6 +146,7 @@ export const useAgentChatSession = ({
const [messages, setMessages] = useState<Message[]>([]);
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
@@ -154,6 +155,7 @@ export const useAgentChatSession = ({
const [isHydrating, setIsHydrating] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | undefined>(undefined);
const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | 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,
};