增加会话标题重命名功能,优化历史面板交互
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("更新后的标题");
});
});
+146 -4
View File
@@ -8,34 +8,69 @@ import {
Box, Box,
IconButton, IconButton,
Stack, Stack,
TextField,
Tooltip, Tooltip,
Typography, Typography,
alpha, alpha,
useTheme, useTheme,
} from "@mui/material"; } 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 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"; import HistoryRounded from "@mui/icons-material/HistoryRounded";
type AgentHeaderProps = { type AgentHeaderProps = {
sessionTitle?: string; sessionTitle?: string;
canRenameSessionTitle?: boolean;
isHydrating?: boolean;
isStreaming: boolean; isStreaming: boolean;
isHistoryOpen: boolean; isHistoryOpen: boolean;
onHistoryToggle: () => void; onHistoryToggle: () => void;
onRenameSessionTitle?: (title: string) => void;
onNewConversation: () => void; onNewConversation: () => void;
onClose: () => void; onClose: () => void;
}; };
export const AgentHeader = ({ export const AgentHeader = ({
sessionTitle, sessionTitle,
canRenameSessionTitle = false,
isHydrating = false,
isStreaming, isStreaming,
isHistoryOpen, isHistoryOpen,
onHistoryToggle, onHistoryToggle,
onRenameSessionTitle,
onNewConversation, onNewConversation,
onClose, onClose,
}: AgentHeaderProps) => { }: AgentHeaderProps) => {
const theme = useTheme(); const theme = useTheme();
const displayTitle = sessionTitle?.trim() || "TJWater Agent"; 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 ( return (
<Box <Box
@@ -89,12 +124,92 @@ export const AgentHeader = ({
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` }, "0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` }, "70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` }, "100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
} },
}} }}
/> />
</Box> </Box>
</motion.div> </motion.div>
<Box sx={{ minWidth: 0 }}> <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 <Typography
variant="h6" variant="h6"
fontWeight={800} fontWeight={800}
@@ -106,11 +221,36 @@ export const AgentHeader = ({
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
maxWidth: { xs: "calc(100vw - 220px)", sm: 320 }, maxWidth: { xs: "calc(100vw - 256px)", sm: 284 },
}} }}
> >
{displayTitle} {displayTitle}
</Typography> </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}> <Typography variant="caption" color="text.secondary" fontWeight={500}>
{isStreaming {isStreaming
? "正在思考分析任务..." ? "正在思考分析任务..."
@@ -118,6 +258,8 @@ export const AgentHeader = ({
? "基于大模型的水力分析引擎" ? "基于大模型的水力分析引擎"
: "当前会话标题"} : "当前会话标题"}
</Typography> </Typography>
</>
)}
</Box> </Box>
</Stack> </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", "新的会话标题");
});
});
+145 -1
View File
@@ -18,7 +18,11 @@ import {
Tooltip, Tooltip,
Typography, Typography,
alpha, alpha,
useTheme,
} from "@mui/material"; } 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 EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded"; import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded";
import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded"; import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded";
@@ -31,6 +35,7 @@ type AgentHistoryPanelProps = {
activeSessionId?: string; activeSessionId?: string;
isHydrating?: boolean; isHydrating?: boolean;
onNewSession: () => void; onNewSession: () => void;
onRenameSession: (sessionId: string, title: string) => void;
onSelectSession: (sessionId: string) => void; onSelectSession: (sessionId: string) => void;
onDeleteSession: (sessionId: string) => void; onDeleteSession: (sessionId: string) => void;
}; };
@@ -68,14 +73,19 @@ const getSessionGroupLabel = (timestamp: number) => {
}; };
export const AgentHistoryPanel = ({ export const AgentHistoryPanel = ({
sessions, sessions,
activeSessionId, activeSessionId,
isHydrating = false, isHydrating = false,
onNewSession, onNewSession,
onRenameSession,
onSelectSession, onSelectSession,
onDeleteSession, onDeleteSession,
}: AgentHistoryPanelProps) => { }: AgentHistoryPanelProps) => {
const theme = useTheme();
const [keyword, setKeyword] = React.useState(""); 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 [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null); const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
@@ -105,6 +115,23 @@ export const AgentHistoryPanel = ({
(session) => session.id === pendingDeleteSessionId, (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 ( return (
<> <>
<Paper <Paper
@@ -240,7 +267,10 @@ export const AgentHistoryPanel = ({
<Paper <Paper
key={session.id} key={session.id}
elevation={0} elevation={0}
onClick={() => onSelectSession(session.id)} onClick={() => {
if (editingSessionId === session.id) return;
onSelectSession(session.id);
}}
sx={{ sx={{
px: 1.25, px: 1.25,
py: 1, py: 1,
@@ -259,6 +289,92 @@ export const AgentHistoryPanel = ({
> >
<Stack direction="row" spacing={1} alignItems="flex-start"> <Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
{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 <Typography
variant="body2" variant="body2"
fontWeight={isActive ? 800 : 700} fontWeight={isActive ? 800 : 700}
@@ -277,7 +393,34 @@ export const AgentHistoryPanel = ({
{formatRelativeDate(session.updatedAt)} {formatRelativeDate(session.updatedAt)}
</Typography> </Typography>
</Box> </Box>
)}
</Box>
<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="删除会话"> <Tooltip title="删除会话">
<span> <span>
<IconButton <IconButton
@@ -303,6 +446,7 @@ export const AgentHistoryPanel = ({
</span> </span>
</Tooltip> </Tooltip>
</Stack> </Stack>
</Stack>
</Paper> </Paper>
); );
})} })}
+20
View File
@@ -70,6 +70,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
cycleBranch, cycleBranch,
abort, abort,
createSession, createSession,
renameSession,
removeSession, removeSession,
switchSession, switchSession,
} = useAgentChatSession({ } = useAgentChatSession({
@@ -134,6 +135,21 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
[removeSession], [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) => { const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
setIsResizing(true); setIsResizing(true);
@@ -231,9 +247,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentHeader <AgentHeader
sessionTitle={sessionTitle} sessionTitle={sessionTitle}
canRenameSessionTitle={Boolean(activeStorageSessionId)}
isHydrating={isHydrating}
isStreaming={isStreaming} isStreaming={isStreaming}
isHistoryOpen={isHistoryOpen} isHistoryOpen={isHistoryOpen}
onHistoryToggle={handleHistoryToggle} onHistoryToggle={handleHistoryToggle}
onRenameSessionTitle={handleRenameActiveSession}
onNewConversation={handleNewConversation} onNewConversation={handleNewConversation}
onClose={onClose} onClose={onClose}
/> />
@@ -277,6 +296,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
handleSelectSession(id); handleSelectSession(id);
setIsHistoryOpen(false); setIsHistoryOpen(false);
}} }}
onRenameSession={handleRenameSession}
onDeleteSession={handleDeleteSession} onDeleteSession={handleDeleteSession}
/> />
</Box> </Box>
@@ -75,6 +75,7 @@ export type LegacyPersistedChatState = {
export type ChatSessionRecord = { export type ChatSessionRecord = {
id: string; id: string;
title: string; title: string;
isTitleManuallyEdited?: boolean;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
sessionId?: string; sessionId?: string;
@@ -98,6 +99,7 @@ export type ChatStorageMeta = {
export type LoadedChatState = { export type LoadedChatState = {
storageSessionId?: string; storageSessionId?: string;
title?: string; title?: string;
isTitleManuallyEdited?: boolean;
messages: Message[]; messages: Message[];
sessionId?: string; sessionId?: string;
branchGroups: BranchGroup[]; branchGroups: BranchGroup[];
+10
View File
@@ -39,6 +39,7 @@ type ChatDB = DBSchema & {
const emptyLoadedChatState = (): LoadedChatState => ({ const emptyLoadedChatState = (): LoadedChatState => ({
storageSessionId: undefined, storageSessionId: undefined,
title: undefined, title: undefined,
isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [], branchGroups: [],
@@ -55,6 +56,7 @@ const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatSt
return { return {
storageSessionId: session.id, storageSessionId: session.id,
title: session.title, title: session.title,
isTitleManuallyEdited: session.isTitleManuallyEdited ?? false,
messages: sanitizeMessages(session.messages), messages: sanitizeMessages(session.messages),
sessionId: session.sessionId, sessionId: session.sessionId,
branchGroups: sanitizeBranchGroups(session.branchGroups), branchGroups: sanitizeBranchGroups(session.branchGroups),
@@ -163,6 +165,7 @@ const migrateLegacyLocalStorage = async () => {
const sessionRecord: ChatSessionRecord = { const sessionRecord: ChatSessionRecord = {
id: createId(), id: createId(),
title: "新对话", title: "新对话",
isTitleManuallyEdited: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
sessionId: legacyState.sessionId, sessionId: legacyState.sessionId,
@@ -241,6 +244,7 @@ export const saveActiveChatState = async (
const nextRecord: ChatSessionRecord = { const nextRecord: ChatSessionRecord = {
id: storageSessionId, id: storageSessionId,
title: finalTitle, title: finalTitle,
isTitleManuallyEdited: state.isTitleManuallyEdited ?? existingSession?.isTitleManuallyEdited ?? false,
createdAt: existingSession?.createdAt ?? now, createdAt: existingSession?.createdAt ?? now,
updatedAt: now, updatedAt: now,
sessionId: state.sessionId, sessionId: state.sessionId,
@@ -272,6 +276,9 @@ export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
export const updateChatSessionTitle = async ( export const updateChatSessionTitle = async (
storageSessionId: string, storageSessionId: string,
title: string, title: string,
options?: {
isTitleManuallyEdited?: boolean;
},
): Promise<void> => { ): Promise<void> => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -285,6 +292,8 @@ export const updateChatSessionTitle = async (
await db.put(SESSION_STORE, { await db.put(SESSION_STORE, {
...session, ...session,
title: normalizedTitle, title: normalizedTitle,
isTitleManuallyEdited:
options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
}; };
@@ -298,6 +307,7 @@ export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
const session: ChatSessionRecord = { const session: ChatSessionRecord = {
id: createId(), id: createId(),
title: "新对话", title: "新对话",
isTitleManuallyEdited: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
sessionId: undefined, sessionId: undefined,
@@ -146,6 +146,7 @@ export const useAgentChatSession = ({
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined); const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined); const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]); const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]); const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
@@ -154,6 +155,7 @@ export const useAgentChatSession = ({
const [isHydrating, setIsHydrating] = useState(true); const [isHydrating, setIsHydrating] = useState(true);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | undefined>(undefined); const sessionIdRef = useRef<string | undefined>(undefined);
const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null); const cancelPromiseRef = useRef<Promise<void> | null>(null);
const titleUpdateNonceRef = useRef(0); const titleUpdateNonceRef = useRef(0);
@@ -161,6 +163,10 @@ export const useAgentChatSession = ({
sessionIdRef.current = sessionId; sessionIdRef.current = sessionId;
}, [sessionId]); }, [sessionId]);
useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
}, [isSessionTitleManuallyEdited]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -180,6 +186,7 @@ export const useAgentChatSession = ({
setMessages(loadedState.messages); setMessages(loadedState.messages);
setSessionTitle(loadedState.title); setSessionTitle(loadedState.title);
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
setSessionId(loadedState.sessionId); setSessionId(loadedState.sessionId);
setBranchGroups(loadedState.branchGroups); setBranchGroups(loadedState.branchGroups);
setChatSessions(sessions); setChatSessions(sessions);
@@ -207,6 +214,7 @@ export const useAgentChatSession = ({
const state: LoadedChatState = { const state: LoadedChatState = {
storageSessionId: storageSessionIdRef.current, storageSessionId: storageSessionIdRef.current,
title: sessionTitle, title: sessionTitle,
isTitleManuallyEdited: isSessionTitleManuallyEdited,
messages, messages,
sessionId, sessionId,
branchGroups, branchGroups,
@@ -230,7 +238,7 @@ export const useAgentChatSession = ({
return () => { return () => {
window.clearTimeout(persistTimer); window.clearTimeout(persistTimer);
}; };
}, [branchGroups, isHydrating, messages, sessionId, sessionTitle]); }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, sessionId, sessionTitle]);
useEffect(() => { useEffect(() => {
setBranchGroups((prev) => { setBranchGroups((prev) => {
@@ -354,12 +362,14 @@ export const useAgentChatSession = ({
}); });
} else if (event.type === "session_title") { } else if (event.type === "session_title") {
const nextTitle = event.title.trim(); const nextTitle = event.title.trim();
if (nextTitle) { if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
setSessionTitle(nextTitle); setSessionTitle(nextTitle);
const currentStorageSessionId = storageSessionIdRef.current; const currentStorageSessionId = storageSessionIdRef.current;
if (currentStorageSessionId) { if (currentStorageSessionId) {
const currentNonce = ++titleUpdateNonceRef.current; const currentNonce = ++titleUpdateNonceRef.current;
void updateChatSessionTitle(currentStorageSessionId, nextTitle) void updateChatSessionTitle(currentStorageSessionId, nextTitle, {
isTitleManuallyEdited: false,
})
.then(() => listChatSessions()) .then(() => listChatSessions())
.then((sessions) => { .then((sessions) => {
if (titleUpdateNonceRef.current !== currentNonce) return; if (titleUpdateNonceRef.current !== currentNonce) return;
@@ -487,6 +497,7 @@ export const useAgentChatSession = ({
} }
setMessages([]); setMessages([]);
setSessionTitle(undefined); setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false);
setBranchGroups([]); setBranchGroups([]);
setBranchTransition(null); setBranchTransition(null);
setSessionId(undefined); setSessionId(undefined);
@@ -512,6 +523,7 @@ export const useAgentChatSession = ({
sessionIdRef.current = newState.sessionId; sessionIdRef.current = newState.sessionId;
setMessages(newState.messages); setMessages(newState.messages);
setSessionTitle(newState.title); setSessionTitle(newState.title);
setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false);
setSessionId(newState.sessionId); setSessionId(newState.sessionId);
setBranchGroups(newState.branchGroups); setBranchGroups(newState.branchGroups);
setChatSessions(sessions); setChatSessions(sessions);
@@ -538,6 +550,7 @@ export const useAgentChatSession = ({
setBranchTransition(null); setBranchTransition(null);
setMessages(nextState.messages); setMessages(nextState.messages);
setSessionTitle(nextState.title); setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId); setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups); setBranchGroups(nextState.branchGroups);
setChatSessions(sessions); setChatSessions(sessions);
@@ -571,6 +584,7 @@ export const useAgentChatSession = ({
setBranchTransition(null); setBranchTransition(null);
setMessages([]); setMessages([]);
setSessionTitle(undefined); setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setBranchGroups([]); setBranchGroups([]);
return; return;
@@ -588,6 +602,7 @@ export const useAgentChatSession = ({
setBranchTransition(null); setBranchTransition(null);
setMessages(nextState.messages); setMessages(nextState.messages);
setSessionTitle(nextState.title); setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId); setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups); setBranchGroups(nextState.branchGroups);
setChatSessions(sessionsAfterDelete); setChatSessions(sessionsAfterDelete);
@@ -607,6 +622,29 @@ export const useAgentChatSession = ({
[runPrompt], [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 () => { const regenerate = useCallback(async () => {
if (isHydrating || isStreaming || messages.length === 0) return; if (isHydrating || isStreaming || messages.length === 0) return;
@@ -776,6 +814,7 @@ export const useAgentChatSession = ({
abort, abort,
createSession, createSession,
reset, reset,
renameSession,
removeSession, removeSession,
switchSession, switchSession,
}; };