feat(webui): show forked history boundary
This commit is contained in:
@@ -76,8 +76,8 @@ describe("MessageBubble", () => {
|
||||
|
||||
expect(row).toHaveClass("ml-auto", "flex");
|
||||
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
|
||||
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render fork control for user messages", () => {
|
||||
@@ -91,8 +91,8 @@ describe("MessageBubble", () => {
|
||||
|
||||
render(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Fork from here" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fork control in completed assistant action rows", () => {
|
||||
@@ -107,7 +107,7 @@ describe("MessageBubble", () => {
|
||||
|
||||
render(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fork from here" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fork" }));
|
||||
expect(onForkFromHere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -207,11 +207,11 @@ describe("MessageBubble", () => {
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("I can help with the next step.");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -235,11 +235,11 @@ describe("MessageBubble", () => {
|
||||
try {
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
|
||||
|
||||
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
|
||||
);
|
||||
} finally {
|
||||
Reflect.deleteProperty(navigator, "clipboard");
|
||||
@@ -268,12 +268,12 @@ describe("MessageBubble", () => {
|
||||
try {
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("Rejected clipboard copy.");
|
||||
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
|
||||
);
|
||||
} finally {
|
||||
Reflect.deleteProperty(navigator, "clipboard");
|
||||
@@ -292,7 +292,7 @@ describe("MessageBubble", () => {
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show copy when showAssistantCopyAction is false", () => {
|
||||
@@ -305,7 +305,7 @@ describe("MessageBubble", () => {
|
||||
|
||||
render(<MessageBubble message={message} showAssistantCopyAction={false} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders trace messages as collapsible tool groups", () => {
|
||||
|
||||
@@ -55,6 +55,23 @@ describe("ThreadMessages", () => {
|
||||
expect(rows[1]).toHaveClass("mt-4");
|
||||
});
|
||||
|
||||
it("renders a fork boundary divider after the copied history", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "original", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "answer", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "branch prompt", createdAt: 3 },
|
||||
];
|
||||
|
||||
render(
|
||||
<ThreadMessages
|
||||
messages={messages}
|
||||
forkBoundaryMessageCount={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Forked from history")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps file edits as their own activity row inside a turn", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
@@ -639,7 +656,7 @@ describe("ThreadMessages", () => {
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
expect(screen.getByText("final reply")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -649,7 +666,7 @@ describe("ThreadMessages", () => {
|
||||
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 },
|
||||
];
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses turn ids as activity grouping boundaries when available", () => {
|
||||
|
||||
@@ -758,7 +758,7 @@ describe("ThreadShell", () => {
|
||||
|
||||
const targetText = await screen.findByText("answer 100");
|
||||
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
|
||||
name: "Fork from here",
|
||||
name: "Fork",
|
||||
}));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -804,7 +804,7 @@ describe("ThreadShell", () => {
|
||||
target: { value: "keep my current draft" },
|
||||
});
|
||||
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
|
||||
name: "Fork from here",
|
||||
name: "Fork",
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1));
|
||||
@@ -864,7 +864,7 @@ describe("ThreadShell", () => {
|
||||
|
||||
const targetText = await screen.findByText("answer2");
|
||||
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
|
||||
name: "Fork from here",
|
||||
name: "Fork",
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 2));
|
||||
@@ -962,7 +962,7 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
await screen.findByText("answer1");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Fork from here" }).at(-1)!);
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Fork" }).at(-1)!);
|
||||
|
||||
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1));
|
||||
await act(async () => {
|
||||
|
||||
@@ -230,6 +230,24 @@ describe("useSessions", () => {
|
||||
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
|
||||
});
|
||||
|
||||
it("keeps a fork title visible while the server session list catches up", async () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([]);
|
||||
const client = fakeClient();
|
||||
client.forkChat.mockResolvedValue("chat-fork");
|
||||
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(client),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => {
|
||||
await result.current.forkChat("source", 2, "Fork: Original title");
|
||||
});
|
||||
|
||||
expect(client.forkChat).toHaveBeenCalledWith("source", 2, "Fork: Original title");
|
||||
expect(result.current.sessions[0]?.title).toBe("Fork: Original title");
|
||||
});
|
||||
|
||||
it("passes through WebUI transcript user media as images and media", async () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
|
||||
Reference in New Issue
Block a user