diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx
index 9b53bc1..9043a8d 100644
--- a/src/components/chat/AgentTurn.tsx
+++ b/src/components/chat/AgentTurn.tsx
@@ -23,17 +23,14 @@ import {
import type { Theme } from "@mui/material/styles";
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import RefreshRounded from "@mui/icons-material/RefreshRounded";
-import EditRounded from "@mui/icons-material/EditRounded";
-import CloseRounded from "@mui/icons-material/CloseRounded";
-import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded";
-import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
+import { TbArrowsSplit2 } from "react-icons/tb";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
-import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types";
+import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChatInlineChart } from "./ChatInlineChart";
@@ -45,7 +42,6 @@ import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded";
-import SendRounded from "@mui/icons-material/SendRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import TerminalRounded from "@mui/icons-material/TerminalRounded";
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
@@ -58,24 +54,34 @@ import type { PermissionReply } from "@/lib/chatStream";
type AgentTurnProps = {
message: Message;
- branchState?: BranchState;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
- onRegenerate: () => void;
- onEditResubmit: (messageId: string, newContent: string) => void;
- onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
+ onRegenerate: (messageId: string) => void;
+ onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
};
-const MarkdownBlock = ({ children }: { children: string }) => (
-
- {children}
-
-);
+const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
+
+const MarkdownBlock = ({ children }: { children: string }) => {
+ const handleCopy = React.useCallback((event: React.ClipboardEvent) => {
+ const selectedText = window.getSelection()?.toString();
+ if (!selectedText) return;
+
+ event.preventDefault();
+ event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
const formatMetadataValue = (value: unknown) => {
if (typeof value === "string") {
@@ -667,7 +673,6 @@ const PermissionRequestGroup = ({
export const AgentTurn = React.memo(
({
message,
- branchState,
messageSpeechState,
onSpeak,
onPause,
@@ -675,17 +680,13 @@ export const AgentTurn = React.memo(
onStopSpeech,
isTtsSupported,
onRegenerate,
- onEditResubmit,
- onCycleBranch,
+ onCreateBranch,
onReplyPermission,
}: AgentTurnProps) => {
const theme = useTheme();
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const [isHovered, setIsHovered] = React.useState(false);
- const [isEditing, setIsEditing] = React.useState(false);
- const [editDraft, setEditDraft] = React.useState(message.content);
- const rootMessageId = message.branchRootId ?? message.id;
const isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed",
) ?? false;
@@ -720,185 +721,33 @@ export const AgentTurn = React.memo(
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
- {isEditing ? (
-
- setEditDraft(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- if (editDraft.trim() !== message.content) {
- onEditResubmit(message.id, editDraft);
- }
- setIsEditing(false);
- } else if (e.key === "Escape") {
- setEditDraft(message.content);
- setIsEditing(false);
- }
- }}
- sx={{
- width: "100%",
- minHeight: 60,
- bgcolor: "transparent",
- border: "none",
- outline: "none",
- resize: "none",
- fontFamily: "inherit",
- fontSize: "1rem",
- color: "text.primary",
- lineHeight: 1.6,
- }}
- />
-
- { setEditDraft(message.content); setIsEditing(false); }}
- sx={{
- bgcolor: alpha("#000", 0.05),
- color: "text.secondary",
- width: 34, height: 34,
- "&:hover": { bgcolor: alpha("#000", 0.1) }
- }}
- >
-
-
- {
- onEditResubmit(message.id, editDraft);
- setIsEditing(false);
- }}
- sx={{
- bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1),
- color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled",
- width: 34, height: 34,
- boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none",
- "&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) }
- }}
- >
-
-
-
-
- ) : (
- <>
-
- {message.content}
-
-
- {isHovered && !isEditing && (
-
- { setIsEditing(true); setEditDraft(message.content); }}
- aria-label="编辑提问"
- sx={{
- width: 26,
- height: 26,
- bgcolor: alpha("#fff", 0.9),
- color: "#00acc1",
- boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`,
- "&:hover": { bgcolor: "#fff", color: "#00838f" }
- }}
- >
-
-
-
- )}
-
-
-
- {branchState && branchState.total > 1 ? (
-
-
- onCycleBranch(rootMessageId, -1)}
- sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
- >
-
-
-
- {branchState.activeIndex + 1} / {branchState.total}
-
- onCycleBranch(rootMessageId, 1)}
- sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
- >
-
-
-
-
- ) : null}
- >
- )}
+
+ {message.content}
+
);
}
@@ -1060,7 +909,9 @@ export const AgentTurn = React.memo(
size="small"
aria-label="复制"
onClick={() => {
- navigator.clipboard.writeText(message.content);
+ navigator.clipboard.writeText(
+ normalizeClipboardText(message.content),
+ );
// Could add a toast here
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
@@ -1073,13 +924,25 @@ export const AgentTurn = React.memo(
size="small"
aria-label="重新生成"
onClick={() => {
- onRegenerate();
+ onRegenerate(message.id);
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
+
+ {
+ onCreateBranch(message.id);
+ }}
+ sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
+ >
+
+
+
)}
@@ -1088,87 +951,40 @@ export const AgentTurn = React.memo(
- {(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? (
+ {!isErrorMessage && isTtsSupported ? (
- {!isErrorMessage && isTtsSupported ? (
+ {messageSpeechState === "idle" ? (
+ onSpeak(message.id, stripMarkdown(answerContent))}
+ aria-label="朗读消息"
+ sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
+ >
+
+
+ ) : null}
+ {messageSpeechState === "playing" ? (
<>
- {messageSpeechState === "idle" ? (
- onSpeak(message.id, stripMarkdown(answerContent))}
- aria-label="朗读消息"
- sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
- >
-
-
- ) : null}
- {messageSpeechState === "playing" ? (
- <>
-
-
-
-
-
-
- >
- ) : null}
- {messageSpeechState === "paused" ? (
- <>
-
-
-
-
-
-
- >
- ) : null}
+
+
+
+
+
+
+ >
+ ) : null}
+ {messageSpeechState === "paused" ? (
+ <>
+
+
+
+
+
+
>
) : null}
-
- {branchState && branchState.total > 1 ? (
-
-
- onCycleBranch(rootMessageId, -1)}
- sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
- >
-
-
-
- {branchState.activeIndex + 1} / {branchState.total}
-
- onCycleBranch(rootMessageId, 1)}
- sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
- >
-
-
-
-
- ) : null}
) : null}
diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx
index 9efa895..20bc225 100644
--- a/src/components/chat/AgentWorkspace.test.tsx
+++ b/src/components/chat/AgentWorkspace.test.tsx
@@ -33,8 +33,6 @@ jest.mock("./AgentTurn", () => ({
describe("AgentWorkspace", () => {
const defaultProps = {
- branchGroups: [],
- branchTransition: null,
bottomRef: { current: null },
speakingMessageId: null,
speechState: "idle" as const,
@@ -44,8 +42,7 @@ describe("AgentWorkspace", () => {
onStopSpeech: jest.fn(),
isTtsSupported: false,
onRegenerate: jest.fn(),
- onEditResubmit: jest.fn(),
- onCycleBranch: jest.fn(),
+ onCreateBranch: jest.fn(),
onReplyPermission: jest.fn(),
};
diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx
index ba15fa8..37e415f 100644
--- a/src/components/chat/AgentWorkspace.tsx
+++ b/src/components/chat/AgentWorkspace.tsx
@@ -13,17 +13,12 @@ import { AgentTurn } from "./AgentTurn";
import { TypingIndicator } from "./GlobalChatbox.parts";
import type { PermissionReply } from "@/lib/chatStream";
import type {
- BranchGroup,
- BranchState,
- BranchTransition,
Message,
SpeechState,
} from "./GlobalChatbox.types";
type AgentWorkspaceProps = {
messages: Message[];
- branchGroups: BranchGroup[];
- branchTransition: BranchTransition | null;
isStreaming: boolean;
bottomRef: React.RefObject;
speakingMessageId: string | null;
@@ -33,15 +28,13 @@ type AgentWorkspaceProps = {
onResumeSpeech: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
- onRegenerate: () => void;
- onEditResubmit: (messageId: string, newContent: string) => void;
- onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
+ onRegenerate: (messageId: string) => void;
+ onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
};
type TurnListProps = {
messages: Message[];
- branchGroups: BranchGroup[];
speakingMessageId: string | null;
speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
@@ -49,9 +42,8 @@ type TurnListProps = {
onResumeSpeech: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
- onRegenerate: () => void;
- onEditResubmit: (messageId: string, newContent: string) => void;
- onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
+ onRegenerate: (messageId: string) => void;
+ onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
};
@@ -61,7 +53,6 @@ const sameMessages = (left: Message[], right: Message[]) =>
const TurnListInner = ({
messages,
- branchGroups,
speakingMessageId,
speechState,
onSpeak,
@@ -70,45 +61,26 @@ const TurnListInner = ({
onStopSpeech,
isTtsSupported,
onRegenerate,
- onEditResubmit,
- onCycleBranch,
+ onCreateBranch,
onReplyPermission,
}: TurnListProps) => {
- const branchStateByRootId = React.useMemo(() => {
- const next = new Map();
- branchGroups.forEach((group) => {
- if (group.branches.length > 1) {
- next.set(group.rootMessageId, {
- activeIndex: group.activeIndex,
- total: group.branches.length,
- });
- }
- });
- return next;
- }, [branchGroups]);
-
return (
<>
- {messages.map((message) => {
- const rootMessageId = message.branchRootId ?? message.id;
- return (
-
- );
- })}
+ {messages.map((message) => (
+
+ ))}
>
);
};
@@ -117,7 +89,6 @@ const TurnList = React.memo(
TurnListInner,
(prevProps, nextProps) =>
sameMessages(prevProps.messages, nextProps.messages) &&
- prevProps.branchGroups === nextProps.branchGroups &&
prevProps.speakingMessageId === nextProps.speakingMessageId &&
prevProps.speechState === nextProps.speechState &&
prevProps.onSpeak === nextProps.onSpeak &&
@@ -126,8 +97,7 @@ const TurnList = React.memo(
prevProps.onStopSpeech === nextProps.onStopSpeech &&
prevProps.isTtsSupported === nextProps.isTtsSupported &&
prevProps.onRegenerate === nextProps.onRegenerate &&
- prevProps.onEditResubmit === nextProps.onEditResubmit &&
- prevProps.onCycleBranch === nextProps.onCycleBranch &&
+ prevProps.onCreateBranch === nextProps.onCreateBranch &&
prevProps.onReplyPermission === nextProps.onReplyPermission,
);
@@ -249,8 +219,6 @@ const EmptyState = () => {
export const AgentWorkspace = ({
messages,
- branchGroups,
- branchTransition,
isStreaming,
bottomRef,
speakingMessageId,
@@ -261,8 +229,7 @@ export const AgentWorkspace = ({
onStopSpeech,
isTtsSupported,
onRegenerate,
- onEditResubmit,
- onCycleBranch,
+ onCreateBranch,
onReplyPermission,
}: AgentWorkspaceProps) => {
const theme = useTheme();
@@ -274,18 +241,12 @@ export const AgentWorkspace = ({
(!latestAssistant ||
(latestAssistant.content.trim().length === 0 &&
!(latestAssistant.artifacts?.length)));
- const stableMessages = branchTransition
- ? messages.slice(0, branchTransition.parentCount)
- : messages;
- const transitionMessages = branchTransition
- ? messages.slice(branchTransition.parentCount)
- : [];
const streamingMessage =
- !branchTransition && isStreaming && messages.at(-1)?.role === "assistant"
+ isStreaming && messages.at(-1)?.role === "assistant"
? messages.at(-1)
: undefined;
const historyMessages =
- streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages;
+ streamingMessage !== undefined ? messages.slice(0, -1) : messages;
return (
{streamingMessage ? (
) : null}
-
- {branchTransition ? (
-
-
-
-
-
- ) : null}
) : null}
diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx
index a9d674c..d1d1192 100644
--- a/src/components/chat/GlobalChatbox.tsx
+++ b/src/components/chat/GlobalChatbox.tsx
@@ -67,15 +67,12 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
messages,
chatSessions,
activeSessionId,
- branchGroups,
- branchTransition,
isHydrating,
isStreaming,
sessionTitle,
sendPrompt,
regenerate,
- editAndResubmit,
- cycleBranch,
+ createBranch,
abort,
replyPermission,
createSession,
@@ -344,8 +341,6 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
= ({ open, onClose }) => {
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={regenerate}
- onEditResubmit={editAndResubmit}
- onCycleBranch={cycleBranch}
+ onCreateBranch={createBranch}
onReplyPermission={replyPermission}
/>
diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts
index 8e29200..2ff19ff 100644
--- a/src/components/chat/GlobalChatbox.types.ts
+++ b/src/components/chat/GlobalChatbox.types.ts
@@ -55,34 +55,6 @@ export type Message = {
progress?: ChatProgress[];
artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[];
- branchRootId?: string;
-};
-
-export type BranchState = {
- activeIndex: number;
- total: number;
-};
-
-export type MessageBranch = {
- id: string;
- label: string;
- sessionId?: string;
- messages: Message[];
-};
-
-export type BranchGroup = {
- id: string;
- rootMessageId: string;
- parentCount: number;
- activeIndex: number;
- branches: MessageBranch[];
-};
-
-export type BranchTransition = {
- rootMessageId: string;
- parentCount: number;
- activeBranchId: string;
- nonce: number;
};
export type Props = {
@@ -106,7 +78,6 @@ export type LoadedChatState = {
title?: string;
isTitleManuallyEdited?: boolean;
messages: Message[];
- branchGroups: BranchGroup[];
isStreaming?: boolean;
runStatus?: string;
};
diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts
index eb6ccf2..4564a52 100644
--- a/src/components/chat/GlobalChatbox.utils.ts
+++ b/src/components/chat/GlobalChatbox.utils.ts
@@ -1,4 +1,4 @@
-import type { BranchGroup, Message } from "./GlobalChatbox.types";
+import type { Message } from "./GlobalChatbox.types";
export const createId = () =>
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -36,12 +36,3 @@ export const cloneMessage = (message: Message): Message => ({
});
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
-
-export const cloneBranchGroups = (branchGroups: BranchGroup[]) =>
- branchGroups.map((group) => ({
- ...group,
- branches: group.branches.map((branch) => ({
- ...branch,
- messages: cloneMessages(branch.messages),
- })),
- }));
diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts
index f1117c9..1575f86 100644
--- a/src/components/chat/chatStorage.test.ts
+++ b/src/components/chat/chatStorage.test.ts
@@ -21,7 +21,6 @@ describe("chatStorage backend-only persistence", () => {
title: undefined,
messages: [],
sessionId: undefined,
- branchGroups: [],
});
expect(apiFetch).not.toHaveBeenCalled();
});
@@ -60,11 +59,9 @@ describe("chatStorage backend-only persistence", () => {
id: "message-2",
role: "user",
content: "第一条消息",
- branchRootId: "message-2",
},
],
sessionId: undefined,
- branchGroups: [],
},
);
diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts
index 81d5506..30ee6ff 100644
--- a/src/components/chat/chatStorage.ts
+++ b/src/components/chat/chatStorage.ts
@@ -2,12 +2,11 @@ import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
import type {
- BranchGroup,
ChatSessionSummary,
LoadedChatState,
Message,
} from "./GlobalChatbox.types";
-import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils";
+import { cloneMessages } from "./GlobalChatbox.utils";
type BackendSessionPayload = {
id?: string;
@@ -23,22 +22,16 @@ export const createEmptyChatState = (): LoadedChatState => ({
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
- branchGroups: [],
});
const sanitizeMessages = (messages: Message[] | undefined) =>
Array.isArray(messages) ? cloneMessages(messages) : [];
-const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
- Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
-
const hasChatContent = (state: {
messages: Message[];
- branchGroups: BranchGroup[];
sessionId?: string;
}) =>
state.messages.length > 0 ||
- state.branchGroups.length > 0 ||
Boolean(state.sessionId);
const compareSessionsByAnchorTime = (
@@ -107,7 +100,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise ({
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
- branchGroups: [],
})),
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
@@ -39,7 +39,6 @@ jest.mock("../chatStorage", () => ({
isTitleManuallyEdited: false,
messages: [],
sessionId: "session-loaded",
- branchGroups: [],
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
@@ -52,10 +51,12 @@ describe("useAgentChatSession", () => {
saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset();
+ jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentPermission).mockReset();
jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
+ jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
@@ -596,9 +597,10 @@ describe("useAgentChatSession", () => {
await act(async () => {
await result.current.sendPrompt("重新分析压力异常");
});
+ const assistantMessageId = result.current.messages[1]?.id ?? "";
await act(async () => {
- await result.current.regenerate();
+ await result.current.regenerate(assistantMessageId);
});
expect(streamAgentChat).toHaveBeenNthCalledWith(
@@ -609,4 +611,91 @@ describe("useAgentChatSession", () => {
}),
);
});
+
+ it("replaces the current chain when regenerating a middle assistant message", async () => {
+ listChatSessions.mockResolvedValue([]);
+
+ const { result } = renderHook(() =>
+ useAgentChatSession({
+ projectId: "project-1",
+ onToolCall: jest.fn(),
+ }),
+ );
+
+ await waitFor(() => expect(result.current.isHydrating).toBe(false));
+
+ await act(async () => {
+ await result.current.sendPrompt("第一轮");
+ });
+
+ await act(async () => {
+ await result.current.sendPrompt("第二轮");
+ });
+
+ const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
+
+ await act(async () => {
+ await result.current.regenerate(firstAssistantMessageId);
+ });
+
+ expect(result.current.messages).toHaveLength(2);
+ expect(result.current.messages[0]).toEqual(
+ expect.objectContaining({
+ role: "user",
+ content: "第一轮",
+ }),
+ );
+ expect(result.current.messages[1]).toEqual(
+ expect.objectContaining({
+ role: "assistant",
+ content: "",
+ }),
+ );
+ expect(streamAgentChat).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({
+ message: "第一轮",
+ regenerateFromMessageIndex: 0,
+ }),
+ );
+ });
+
+ it("forks a copied conversation from an assistant message", async () => {
+ listChatSessions.mockResolvedValue([]);
+
+ const { result } = renderHook(() =>
+ useAgentChatSession({
+ projectId: "project-1",
+ onToolCall: jest.fn(),
+ }),
+ );
+
+ await waitFor(() => expect(result.current.isHydrating).toBe(false));
+
+ await act(async () => {
+ await result.current.sendPrompt("第一轮");
+ });
+
+ const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
+
+ await act(async () => {
+ await result.current.createBranch(firstAssistantMessageId);
+ });
+
+ expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2);
+ expect(result.current.activeSessionId).toBe("forked-session");
+ expect(result.current.messages).toHaveLength(2);
+ expect(result.current.messages[0]).toEqual(
+ expect.objectContaining({
+ role: "user",
+ content: "第一轮",
+ }),
+ );
+ expect(result.current.messages[1]).toEqual(
+ expect.objectContaining({
+ role: "assistant",
+ }),
+ );
+ expect(streamAgentChat).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts
index f046270..99dab87 100644
--- a/src/components/chat/hooks/useAgentChatSession.ts
+++ b/src/components/chat/hooks/useAgentChatSession.ts
@@ -18,15 +18,12 @@ import type {
import type {
AgentArtifact,
AgentPermissionRequest,
- BranchGroup,
- BranchTransition,
ChatProgress,
ChatSessionSummary,
LoadedChatState,
Message,
} from "../GlobalChatbox.types";
import {
- cloneBranchGroups,
cloneMessages,
createId,
} from "../GlobalChatbox.utils";
@@ -68,7 +65,6 @@ const createPersistedStateKey = (state: LoadedChatState) =>
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
sessionId: state.sessionId ?? null,
messages: state.messages,
- branchGroups: state.branchGroups,
});
const upsertProgress = (
@@ -193,13 +189,12 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
};
};
-const createUserMessage = (content: string, branchRootId?: string): Message => {
+const createUserMessage = (content: string): Message => {
const id = createId();
return {
id,
role: "user",
content,
- branchRootId: branchRootId ?? id,
};
};
@@ -209,9 +204,6 @@ const createAssistantMessage = (): Message => ({
content: "",
});
-const messagesEqual = (left: Message[], right: Message[]) =>
- JSON.stringify(left) === JSON.stringify(right);
-
export const useAgentChatSession = ({
projectId,
onToolCall,
@@ -226,15 +218,12 @@ export const useAgentChatSession = ({
const [sessionTitle, setSessionTitle] = useState(undefined);
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
const [sessionId, setSessionId] = useState(undefined);
- const [branchGroups, setBranchGroups] = useState([]);
const [chatSessions, setChatSessions] = useState([]);
- const [branchTransition, setBranchTransition] = useState(null);
const [isStreaming, setIsStreaming] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
const abortRef = useRef(null);
const sessionIdRef = useRef(undefined);
const messagesRef = useRef([]);
- const branchGroupsRef = useRef([]);
const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null);
const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef | null>(null);
@@ -245,7 +234,6 @@ export const useAgentChatSession = ({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
- branchGroups: [],
}),
);
@@ -257,9 +245,6 @@ export const useAgentChatSession = ({
messagesRef.current = messages;
}, [messages]);
- useEffect(() => {
- branchGroupsRef.current = branchGroups;
- }, [branchGroups]);
useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
@@ -279,17 +264,14 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
- branchGroups: [],
});
hydrationCompletedRef.current = true;
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
- setBranchTransition(null);
setMessages([]);
setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
- setBranchGroups([]);
setChatSessions([]);
setIsHydrating(false);
return;
@@ -313,7 +295,6 @@ export const useAgentChatSession = ({
setSessionTitle(loadedState.title);
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
setSessionId(loadedState.sessionId);
- setBranchGroups(loadedState.branchGroups);
setChatSessions(sessions);
if (
loadedState.sessionId &&
@@ -351,7 +332,6 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: isSessionTitleManuallyEdited,
messages,
sessionId,
- branchGroups,
};
const currentStateKey = createPersistedStateKey(state);
@@ -381,46 +361,7 @@ export const useAgentChatSession = ({
return () => {
window.clearTimeout(persistTimer);
};
- }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
-
- useEffect(() => {
- setBranchGroups((prev) => {
- let changed = false;
- const next = prev.map((group) => {
- const rootMessage = messages[group.parentCount];
- if (
- !rootMessage ||
- rootMessage.role !== "user" ||
- (rootMessage.branchRootId ?? rootMessage.id) !== group.rootMessageId
- ) {
- return group;
- }
-
- const activeBranch = group.branches[group.activeIndex];
- if (!activeBranch) {
- return group;
- }
-
- const nextSuffix = cloneMessages(messages.slice(group.parentCount));
- if (
- activeBranch.sessionId === sessionId &&
- messagesEqual(activeBranch.messages, nextSuffix)
- ) {
- return group;
- }
-
- changed = true;
- const branches = group.branches.map((branch, index) =>
- index === group.activeIndex
- ? { ...branch, sessionId, messages: nextSuffix }
- : branch,
- );
- return { ...group, branches };
- });
-
- return changed ? next : prev;
- });
- }, [messages, sessionId]);
+ }, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
setMessages((prev) =>
@@ -479,7 +420,6 @@ export const useAgentChatSession = ({
title: nextTitle,
isTitleManuallyEdited: false,
messages: messagesRef.current,
- branchGroups: branchGroupsRef.current,
});
}
if (targetSessionId) {
@@ -643,7 +583,6 @@ export const useAgentChatSession = ({
await cancelPromiseRef.current?.catch(() => undefined);
onBeforeSend?.();
- setBranchTransition(null);
const nextUserMessage = userMessage ?? createUserMessage(prompt);
const nextAssistantMessage = assistantMessage ?? createAssistantMessage();
@@ -832,7 +771,6 @@ export const useAgentChatSession = ({
const controller = abortRef.current;
controller?.abort();
- setBranchTransition(null);
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
sessionIdRef.current = undefined;
@@ -841,13 +779,11 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
- branchGroups: [],
});
setMessages([]);
setSessionTitle("新对话");
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
- setBranchGroups([]);
setIsStreaming(false);
}, [isHydrating, isStreaming]);
@@ -868,12 +804,10 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
- setBranchTransition(null);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId);
- setBranchGroups(nextState.branchGroups);
setChatSessions(sessions);
if (nextState.sessionId && nextState.isStreaming) {
resumeStreamingSession(nextState.sessionId);
@@ -917,14 +851,11 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
- branchGroups: [],
});
- setBranchTransition(null);
setMessages([]);
setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
- setBranchGroups([]);
return;
}
@@ -937,12 +868,10 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
- setBranchTransition(null);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId);
- setBranchGroups(nextState.branchGroups);
setChatSessions(sessionsAfterDelete);
} catch (error) {
console.error("[GlobalChatbox] Failed to delete chat session:", error);
@@ -985,183 +914,99 @@ export const useAgentChatSession = ({
title: normalizedTitle,
isTitleManuallyEdited: true,
messages,
- branchGroups,
});
}
} catch (error) {
console.error("[GlobalChatbox] Failed to rename chat session:", error);
}
},
- [branchGroups, isHydrating, messages],
+ [isHydrating, messages],
);
- const regenerate = useCallback(async () => {
+ const regenerate = useCallback(async (messageId: string) => {
if (isHydrating || isStreaming || messages.length === 0) return;
- let lastUserIndex = messages.length - 1;
- while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") {
- lastUserIndex--;
+ const targetAssistantIndex = messages.findIndex(
+ (message) => message.id === messageId && message.role === "assistant",
+ );
+ if (targetAssistantIndex < 0) {
+ return;
}
- if (lastUserIndex < 0) return;
+ let targetUserIndex = targetAssistantIndex - 1;
+ while (targetUserIndex >= 0 && messages[targetUserIndex].role !== "user") {
+ targetUserIndex--;
+ }
- const lastUser = messages[lastUserIndex];
- const lastUserContent = lastUser.content;
- const nextMessages = cloneMessages(messages.slice(0, lastUserIndex));
- const nextUserMessage = createUserMessage(
- lastUserContent,
- lastUser.branchRootId ?? lastUser.id,
- );
- const nextAssistantMessage = createAssistantMessage();
+ if (targetUserIndex < 0) return;
- setMessages(nextMessages);
- await runPrompt({
- prompt: lastUserContent,
- regenerateFromMessageIndex: lastUserIndex,
- preparedMessages: [
- ...nextMessages,
- nextUserMessage,
- nextAssistantMessage,
- ],
- userMessage: nextUserMessage,
- assistantMessage: nextAssistantMessage,
- });
- }, [isHydrating, isStreaming, messages, runPrompt]);
+ const targetUser = messages[targetUserIndex];
+ const targetUserContent = targetUser.content;
+ const nextMessages = cloneMessages(messages.slice(0, targetUserIndex));
+ const nextUserMessage = createUserMessage(targetUserContent);
+ const nextAssistantMessage = createAssistantMessage();
- const editAndResubmit = useCallback(
- async (messageId: string, newContent: string) => {
+ setMessages(nextMessages);
+ await runPrompt({
+ prompt: targetUserContent,
+ regenerateFromMessageIndex: targetUserIndex,
+ preparedMessages: [
+ ...nextMessages,
+ nextUserMessage,
+ nextAssistantMessage,
+ ],
+ userMessage: nextUserMessage,
+ assistantMessage: nextAssistantMessage,
+ });
+ }, [isHydrating, isStreaming, messages, runPrompt]);
+
+ const createBranch = useCallback(
+ async (messageId: string) => {
if (isHydrating || isStreaming) return;
- const trimmedContent = newContent.trim();
- if (!trimmedContent) return;
+ const assistantIndex = messages.findIndex(
+ (message) => message.id === messageId && message.role === "assistant",
+ );
+ if (assistantIndex < 0) return;
- const messageIndex = messages.findIndex((m) => m.id === messageId);
- if (messageIndex < 0 || messages[messageIndex].role !== "user") return;
-
- const originalMessage = messages[messageIndex];
- if (trimmedContent === originalMessage.content.trim()) return;
-
- const rootMessageId = originalMessage.branchRootId ?? originalMessage.id;
const currentSessionId = sessionIdRef.current;
- const keepMessageCount = messageIndex;
- const prefix = cloneMessages(messages.slice(0, messageIndex));
- const originalSuffix = cloneMessages(messages.slice(messageIndex));
+ const keepMessageCount = assistantIndex + 1;
+ const copiedMessages = cloneMessages(messages.slice(0, keepMessageCount));
const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount);
- const nextUserMessage = createUserMessage(trimmedContent, rootMessageId);
- const nextAssistantMessage = createAssistantMessage();
- const nextSuffix = [nextUserMessage, nextAssistantMessage];
-
- setBranchGroups((prev) => {
- const next = cloneBranchGroups(prev);
- const groupIndex = next.findIndex(
- (group) =>
- group.rootMessageId === rootMessageId && group.parentCount === messageIndex,
- );
-
- if (groupIndex >= 0) {
- const group = next[groupIndex];
- group.branches[group.activeIndex] = {
- ...group.branches[group.activeIndex],
- sessionId: currentSessionId,
- messages: originalSuffix,
- };
- group.branches.push({
- id: createId(),
- label: `分支 ${group.branches.length + 1}`,
- sessionId: forkedSessionId,
- messages: cloneMessages(nextSuffix),
- });
- group.activeIndex = group.branches.length - 1;
- } else {
- next.push({
- id: rootMessageId,
- rootMessageId,
- parentCount: messageIndex,
- activeIndex: 1,
- branches: [
- {
- id: createId(),
- label: "分支 1",
- sessionId: currentSessionId,
- messages: originalSuffix,
- },
- {
- id: createId(),
- label: "分支 2",
- sessionId: forkedSessionId,
- messages: cloneMessages(nextSuffix),
- },
- ],
- });
- }
-
- return next;
- });
-
sessionIdRef.current = forkedSessionId;
setSessionId(forkedSessionId);
- await runPrompt({
- prompt: trimmedContent,
- sessionIdOverride: forkedSessionId,
- preparedMessages: [...prefix, ...nextSuffix],
- userMessage: nextUserMessage,
- assistantMessage: nextAssistantMessage,
- });
- },
- [isHydrating, isStreaming, messages, runPrompt],
- );
-
- const cycleBranch = useCallback(
- (rootMessageId: string, direction: -1 | 1) => {
- if (isHydrating || isStreaming) return;
-
- setBranchGroups((prev) => {
- const next = cloneBranchGroups(prev);
- const group = next.find((item) => item.rootMessageId === rootMessageId);
- if (!group || group.branches.length < 2) {
- return prev;
- }
-
- const nextIndex =
- (group.activeIndex + direction + group.branches.length) % group.branches.length;
- const selectedBranch = group.branches[nextIndex];
- group.activeIndex = nextIndex;
-
- const nextMessages = [
- ...cloneMessages(messages.slice(0, group.parentCount)),
- ...cloneMessages(selectedBranch.messages),
- ];
- setBranchTransition({
- rootMessageId,
- parentCount: group.parentCount,
- activeBranchId: selectedBranch.id,
- nonce: Date.now(),
+ messagesRef.current = copiedMessages;
+ setMessages(copiedMessages);
+ setIsSessionTitleManuallyEdited(false);
+ const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
+ setSessionTitle(forkTitle);
+ try {
+ await saveActiveChatState({
+ title: forkTitle,
+ isTitleManuallyEdited: false,
+ messages: copiedMessages,
+ sessionId: forkedSessionId,
});
- sessionIdRef.current = selectedBranch.sessionId;
- setSessionId(selectedBranch.sessionId);
- setMessages(nextMessages);
-
- return next;
- });
+ setChatSessions(await listChatSessions());
+ } catch (error) {
+ console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
+ }
},
- [isHydrating, isStreaming, messages],
+ [isHydrating, isStreaming, messages, sessionTitle],
);
return {
messages,
chatSessions,
activeSessionId: sessionIdRef.current,
- branchGroups,
- branchTransition,
isHydrating,
isStreaming,
sessionTitle,
sessionId,
sendPrompt,
regenerate,
- editAndResubmit,
- cycleBranch,
+ createBranch,
abort,
replyPermission,
createSession,