重构会话标题编辑和删除确认逻辑;重构历史会话时间记录

This commit is contained in:
2026-05-19 17:54:09 +08:00
parent 2fbfba118f
commit 4f54da64d0
6 changed files with 329 additions and 136 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ describe("AgentHeader", () => {
fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
target: { value: "更新后的标题" },
});
fireEvent.click(screen.getByLabelText("确认修改对话标题"));
fireEvent.click(screen.getByLabelText("确认"));
expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
});
+31 -32
View File
@@ -129,10 +129,9 @@ export const AgentHeader = ({
/>
</Box>
</motion.div>
<Box sx={{ minWidth: 0, minHeight: 52, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<Box sx={{ minWidth: 0 }}>
{isEditingTitle ? (
<Box>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 }, transform: "translateY(2px)" }}>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 } }}>
<TextField
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
@@ -152,27 +151,35 @@ export const AgentHeader = ({
flex: 1,
minWidth: 0,
"& .MuiOutlinedInput-root": {
height: 34,
bgcolor: alpha("#fff", 0.7),
padding: "6px 8px",
bgcolor: "transparent",
borderRadius: 1.5,
transition: "all 0.2s ease-in-out",
"&.Mui-focused": {
bgcolor: alpha("#fff", 0.6),
boxShadow: `0 2px 10px ${alpha("#000", 0.05)}`,
},
"& fieldset": {
borderColor: alpha("#000", 0.08),
borderColor: "transparent",
},
"&:hover fieldset": {
borderColor: alpha(theme.palette.primary.main, 0.4),
borderColor: alpha(theme.palette.primary.main, 0.2),
},
"&.Mui-focused fieldset": {
borderColor: theme.palette.primary.main,
borderWidth: "1.5px",
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
borderColor: alpha(theme.palette.primary.main, 0.5),
borderWidth: "1px",
},
},
"& .MuiInputBase-input": {
padding: "4px 12px",
fontSize: "1.05rem",
fontWeight: 700,
color: theme.palette.text.primary,
padding: 0,
height: "auto",
fontSize: "1.25rem",
fontWeight: 800,
letterSpacing: -0.3,
lineHeight: "1.2",
background: `linear-gradient(90deg, #01579b, #00838f)`,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
}}
/>
@@ -206,22 +213,22 @@ export const AgentHeader = ({
<CloseRounded sx={{ fontSize: 18 }} />
</IconButton>
</Stack>
</Box>
) : (
<>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
<Typography
<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",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: -0.3,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: { xs: "calc(100vw - 256px)", sm: 284 },
px: "8px",
lineHeight: 1.2,
}}
>
{displayTitle}
@@ -235,8 +242,8 @@ export const AgentHeader = ({
onClick={handleStartEditing}
disabled={isHydrating || isStreaming}
sx={{
width: 24,
height: 24,
width: 30,
height: 30,
color: "text.secondary",
bgcolor: alpha("#fff", 0.45),
"&:hover": {
@@ -245,20 +252,12 @@ export const AgentHeader = ({
},
}}
>
<EditRounded sx={{ fontSize: 16 }} />
<EditRounded sx={{ fontSize: 18 }} />
</IconButton>
</span>
</Tooltip>
) : null}
</Stack>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
{isStreaming
? "正在思考分析任务..."
: displayTitle === "TJWater Agent"
? "基于大模型的水力分析引擎"
: "当前会话标题"}
</Typography>
</>
</Stack>
)}
</Box>
</Stack>
+83 -100
View File
@@ -85,7 +85,6 @@ export const AgentHistoryPanel = ({
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);
const filteredSessions = React.useMemo(() => {
@@ -300,7 +299,7 @@ export const AgentHistoryPanel = ({
},
}}
>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Stack direction="row" spacing={1} alignItems="center">
<Box sx={{ flex: 1, minWidth: 0 }}>
{editingSessionId === session.id ? (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
@@ -386,6 +385,38 @@ export const AgentHistoryPanel = ({
<CloseRounded sx={{ fontSize: 16 }} />
</IconButton>
</Stack>
) : pendingDeleteSessionId === session.id ? (
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 20,
height: 20,
borderRadius: "50%",
bgcolor: alpha("#ef5350", 0.15),
color: "#ef5350",
flexShrink: 0
}}
>
<WarningRounded sx={{ fontSize: 13 }} />
</Box>
<Typography
variant="body2"
fontWeight={800}
color="error.main"
sx={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
</Typography>
</Stack>
) : (
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<Typography
@@ -409,8 +440,9 @@ export const AgentHistoryPanel = ({
)}
</Box>
<Stack direction="row" spacing={0.25} sx={{ display: editingSessionId === session.id ? 'none' : 'flex' }}>
<Tooltip title="修改会话标题">
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && (
<Stack direction="row" spacing={0.25}>
<Tooltip title="修改会话标题">
<span>
<IconButton
size="small"
@@ -421,8 +453,8 @@ export const AgentHistoryPanel = ({
}}
disabled={isHydrating || editingSessionId === session.id}
sx={{
width: 24,
height: 24,
width: 28,
height: 28,
color: "text.secondary",
"&:hover": {
color: "primary.main",
@@ -442,11 +474,11 @@ export const AgentHistoryPanel = ({
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(session.id);
setIsDeleteDialogOpen(true);
}}
disabled={isHydrating}
sx={{
width: 24,
height: 24,
width: 28,
height: 28,
color: "text.secondary",
"&:hover": {
color: "error.main",
@@ -459,6 +491,48 @@ export const AgentHistoryPanel = ({
</span>
</Tooltip>
</Stack>
)}
{pendingDeleteSessionId === session.id && (
<Stack direction="row" spacing={0.5} alignItems="center">
<IconButton
size="small"
aria-label="确认删除"
onClick={(event) => {
event.stopPropagation();
onDeleteSession(session.id);
setPendingDeleteSessionId(null);
}}
sx={{
width: 28,
height: 28,
color: "error.main",
bgcolor: alpha("#ef5350", 0.1),
"&:hover": { bgcolor: alpha("#ef5350", 0.2) },
}}
>
<CheckRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
aria-label="取消删除"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(null);
}}
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>
)}
</Stack>
</Paper>
);
@@ -470,97 +544,6 @@ export const AgentHistoryPanel = ({
)}
</Box>
</Paper>
<Dialog
open={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }}
TransitionProps={{
onExited: () => setPendingDeleteSessionId(null)
}}
PaperProps={{
sx: {
borderRadius: 4,
bgcolor: alpha("#fff", 0.85),
backdropFilter: "blur(24px)",
boxShadow: `0 16px 40px ${alpha("#000", 0.12)}`,
border: `1px solid ${alpha("#fff", 0.6)}`,
minWidth: 320,
},
}}
>
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1.5, pb: 1, pt: 3, px: 3 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: "50%",
bgcolor: alpha("#ef5350", 0.12),
color: "#ef5350",
}}
>
<WarningRounded sx={{ fontSize: 22 }} />
</Box>
<Typography component="span" variant="h6" fontWeight={800} color="text.primary">
</Typography>
</DialogTitle>
<DialogContent sx={{ px: 3, pb: 2 }}>
<DialogContentText color="text.secondary" sx={{ fontSize: "0.95rem" }}>
{pendingDeleteSession ? (
<Typography component="span" fontWeight={700} color="text.primary">
{pendingDeleteSession.title}
</Typography>
) : (
"该会话"
)}
<br />
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3, pt: 1 }}>
<Button
onClick={() => setIsDeleteDialogOpen(false)}
sx={{
color: "text.secondary",
fontWeight: 600,
borderRadius: 2.5,
px: 2.5,
"&:hover": { bgcolor: alpha("#000", 0.04) },
}}
>
</Button>
<Button
variant="contained"
onClick={() => {
if (pendingDeleteSessionId) {
onDeleteSession(pendingDeleteSessionId);
}
setIsDeleteDialogOpen(false);
}}
sx={{
bgcolor: "#ef5350",
color: "#fff",
fontWeight: 700,
borderRadius: 2.5,
px: 3,
boxShadow: `0 4px 12px ${alpha("#ef5350", 0.3)}`,
"&:hover": {
bgcolor: "#e53935",
boxShadow: `0 6px 16px ${alpha("#ef5350", 0.4)}`,
},
}}
>
</Button>
</DialogActions>
</Dialog>
</>
);
};
+142
View File
@@ -0,0 +1,142 @@
import type { ChatSessionRecord } from "./GlobalChatbox.types";
import {
createEmptyChatSession,
loadChatSessionById,
saveActiveChatState,
updateChatSessionTitle,
} from "./chatStorage";
type StoreName = "sessions" | "meta";
const stores: Record<StoreName, Map<string, any>> = {
sessions: new Map(),
meta: new Map(),
};
const mockDb = {
get: jest.fn(async (storeName: StoreName, key: string) => stores[storeName].get(key)),
getAll: jest.fn(async (storeName: StoreName) => Array.from(stores[storeName].values())),
put: jest.fn(async (storeName: StoreName, value: { id?: string; key?: string }) => {
const key = storeName === "sessions" ? value.id : value.key;
if (!key) {
throw new Error(`Missing key for store ${storeName}`);
}
stores[storeName].set(key, value);
return key;
}),
delete: jest.fn(async (storeName: StoreName, key: string) => {
stores[storeName].delete(key);
}),
};
jest.mock("idb", () => ({
openDB: jest.fn(async () => mockDb),
}));
describe("chatStorage timestamp semantics", () => {
let now = new Date("2026-05-19T09:00:00+08:00").getTime();
let dateNowSpy: jest.SpyInstance<number, []>;
beforeEach(() => {
stores.sessions.clear();
stores.meta.clear();
mockDb.get.mockClear();
mockDb.getAll.mockClear();
mockDb.put.mockClear();
mockDb.delete.mockClear();
window.localStorage.clear();
now = new Date("2026-05-19T09:00:00+08:00").getTime();
dateNowSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
dateNowSpy.mockRestore();
});
it("keeps anchor and content timestamps when reopening an old session", async () => {
const record: ChatSessionRecord = {
id: "old-session",
title: "很久之前的会话",
isTitleManuallyEdited: false,
createdAt: new Date("2026-04-01T10:00:00+08:00").getTime(),
updatedAt: new Date("2026-04-01T10:30:00+08:00").getTime(),
sessionId: "remote-1",
messages: [
{
id: "message-1",
role: "user",
content: "老问题",
branchRootId: "message-1",
},
],
branchGroups: [],
};
stores.sessions.set(record.id, record);
const loadedState = await loadChatSessionById(record.id);
now = new Date("2026-05-19T09:30:00+08:00").getTime();
await saveActiveChatState(loadedState);
expect(stores.sessions.get(record.id)).toMatchObject({
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
});
it("does not change timestamps when renaming a session", async () => {
const record: ChatSessionRecord = {
id: "rename-session",
title: "旧标题",
isTitleManuallyEdited: false,
createdAt: new Date("2026-04-10T08:00:00+08:00").getTime(),
updatedAt: new Date("2026-04-10T08:05:00+08:00").getTime(),
sessionId: "remote-2",
messages: [
{
id: "message-2",
role: "user",
content: "保留时间",
branchRootId: "message-2",
},
],
branchGroups: [],
};
stores.sessions.set(record.id, record);
now = new Date("2026-05-19T11:00:00+08:00").getTime();
await updateChatSessionTitle(record.id, "新标题", {
isTitleManuallyEdited: true,
});
expect(stores.sessions.get(record.id)).toMatchObject({
title: "新标题",
isTitleManuallyEdited: true,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
});
it("anchors createdAt to the first real message time for a new empty session", async () => {
const emptyState = await createEmptyChatSession();
const storageSessionId = emptyState.storageSessionId;
now = new Date("2026-05-19T09:05:00+08:00").getTime();
await saveActiveChatState({
...emptyState,
messages: [
{
id: "message-3",
role: "user",
content: "第一条消息",
branchRootId: "message-3",
},
],
sessionId: "remote-3",
});
expect(stores.sessions.get(storageSessionId!)).toMatchObject({
createdAt: now,
updatedAt: now,
});
});
});
+15 -2
View File
@@ -51,6 +51,17 @@ const sanitizeMessages = (messages: Message[] | undefined) =>
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
const serializeConversationState = (state: {
messages: Message[];
branchGroups: BranchGroup[];
sessionId?: string;
}) =>
JSON.stringify({
messages: sanitizeMessages(state.messages),
branchGroups: sanitizeBranchGroups(state.branchGroups),
sessionId: state.sessionId ?? null,
});
const hasChatContent = (state: {
messages: Message[];
branchGroups: BranchGroup[];
@@ -260,6 +271,9 @@ export const saveActiveChatState = async (
const storageSessionId = state.storageSessionId ?? createId();
const preferredTitle = state.title?.trim();
const finalTitle = preferredTitle || existingSession?.title || "新对话";
const hasContentChanged =
!existingSession ||
serializeConversationState(existingSession) !== serializeConversationState(state);
const shouldAnchorCreatedAtToFirstMessage =
Boolean(existingSession) && !hasChatContent(existingSession) && hasContent;
const nextRecord: ChatSessionRecord = {
@@ -269,7 +283,7 @@ export const saveActiveChatState = async (
createdAt: shouldAnchorCreatedAtToFirstMessage
? now
: existingSession?.createdAt ?? now,
updatedAt: now,
updatedAt: hasContentChanged ? now : existingSession?.updatedAt ?? now,
sessionId: state.sessionId,
messages: sanitizeMessages(state.messages),
branchGroups: sanitizeBranchGroups(state.branchGroups),
@@ -315,7 +329,6 @@ export const updateChatSessionTitle = async (
title: normalizedTitle,
isTitleManuallyEdited:
options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
updatedAt: Date.now(),
});
};
@@ -48,6 +48,16 @@ type PromptRunOptions = {
assistantMessage?: Message;
};
const createPersistedStateKey = (state: LoadedChatState) =>
JSON.stringify({
storageSessionId: state.storageSessionId ?? null,
title: state.title ?? null,
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
sessionId: state.sessionId ?? null,
messages: state.messages,
branchGroups: state.branchGroups,
});
const upsertProgress = (
progress: ChatProgress[] | undefined,
event: StreamEvent & { type: "progress" },
@@ -158,6 +168,16 @@ export const useAgentChatSession = ({
const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null);
const titleUpdateNonceRef = useRef(0);
const lastPersistedStateKeyRef = useRef(
createPersistedStateKey({
storageSessionId: undefined,
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
branchGroups: [],
}),
);
useEffect(() => {
sessionIdRef.current = sessionId;
@@ -180,6 +200,7 @@ export const useAgentChatSession = ({
storageSessionIdRef.current = loadedState.storageSessionId;
sessionIdRef.current = loadedState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState);
hydrationCompletedRef.current = true;
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
@@ -219,11 +240,19 @@ export const useAgentChatSession = ({
sessionId,
branchGroups,
};
const currentStateKey = createPersistedStateKey(state);
if (currentStateKey === lastPersistedStateKeyRef.current) {
return;
}
void saveActiveChatState(state)
.then((storageSessionId) => {
if (hydrationNonceRef.current !== currentHydrationNonce) return;
storageSessionIdRef.current = storageSessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey({
...state,
storageSessionId,
});
return listChatSessions();
})
.then((sessions) => {
@@ -503,6 +532,14 @@ export const useAgentChatSession = ({
setSessionId(undefined);
sessionIdRef.current = undefined;
storageSessionIdRef.current = undefined;
lastPersistedStateKeyRef.current = createPersistedStateKey({
storageSessionId: undefined,
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
branchGroups: [],
});
titleUpdateNonceRef.current += 1;
setIsStreaming(false);
}, []);
@@ -521,6 +558,7 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1;
storageSessionIdRef.current = newState.storageSessionId;
sessionIdRef.current = newState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(newState);
setMessages(newState.messages);
setSessionTitle(newState.title);
setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false);
@@ -547,6 +585,7 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1;
storageSessionIdRef.current = nextState.storageSessionId;
sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setBranchTransition(null);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
@@ -581,6 +620,14 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1;
storageSessionIdRef.current = undefined;
sessionIdRef.current = undefined;
lastPersistedStateKeyRef.current = createPersistedStateKey({
storageSessionId: undefined,
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
branchGroups: [],
});
setBranchTransition(null);
setMessages([]);
setSessionTitle(undefined);
@@ -599,6 +646,7 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1;
storageSessionIdRef.current = nextState.storageSessionId;
sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setBranchTransition(null);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
@@ -637,12 +685,20 @@ export const useAgentChatSession = ({
if (storageSessionIdRef.current === targetStorageSessionId) {
setSessionTitle(normalizedTitle);
setIsSessionTitleManuallyEdited(true);
lastPersistedStateKeyRef.current = createPersistedStateKey({
storageSessionId: targetStorageSessionId,
title: normalizedTitle,
isTitleManuallyEdited: true,
messages,
sessionId: sessionIdRef.current,
branchGroups,
});
}
} catch (error) {
console.error("[GlobalChatbox] Failed to rename chat session:", error);
}
},
[isHydrating],
[branchGroups, isHydrating, messages],
);
const regenerate = useCallback(async () => {