mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(ui): explain empty chat exports (#129881)
This commit is contained in:
committed by
GitHub
parent
647414e3e3
commit
3d37bb4cba
@@ -1,5 +1,6 @@
|
||||
// Control UI E2E tests cover slash command relevance and keyboard ordering.
|
||||
import path from "node:path";
|
||||
import { text } from "node:stream/consumers";
|
||||
import { expect, it } from "vitest";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
@@ -9,6 +10,94 @@ const suite = createControlUiE2eSuite({
|
||||
});
|
||||
|
||||
suite.define(() => {
|
||||
it.each(["/export-session", "/export"])(
|
||||
"shows an empty export result and retains staged attachments for %s",
|
||||
async (command) => {
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, { historyMessages: [] });
|
||||
const downloads: string[] = [];
|
||||
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
||||
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
||||
await composer.waitFor({ state: "visible" });
|
||||
await page.locator(".agent-chat__file-input").setInputFiles({
|
||||
name: "export-proof.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("keep this staged"),
|
||||
});
|
||||
const attachment = page.locator(".chat-attachment-file__name", {
|
||||
hasText: "export-proof.txt",
|
||||
});
|
||||
await attachment.waitFor({ state: "visible" });
|
||||
|
||||
await composer.fill(command);
|
||||
if (artifactDir && command === "/export") {
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDir, "empty-export-before.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
await page
|
||||
.locator(".chat-thread-inner")
|
||||
.getByText("There are no messages to export yet.", { exact: true })
|
||||
.waitFor({ state: "visible" });
|
||||
if (artifactDir && command === "/export") {
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDir, "empty-export-after.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await expect.poll(() => composer.inputValue()).toBe("");
|
||||
expect(await attachment.isVisible()).toBe(true);
|
||||
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("downloads a populated conversation as Markdown without sending a chat request", async () => {
|
||||
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
|
||||
const question = "What can you export?";
|
||||
const answer = "A readable conversation.";
|
||||
const gateway = await installMockGateway(page, {
|
||||
historyMessages: [
|
||||
{ role: "user", content: question },
|
||||
{ role: "assistant", content: answer },
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
await page.locator(".chat-thread-inner").getByText(answer, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
|
||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
||||
await composer.fill("/export");
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/^chat-OpenClaw-.+\.md$/);
|
||||
const stream = await download.createReadStream();
|
||||
if (!stream) {
|
||||
throw new Error("chat export did not provide a readable download");
|
||||
}
|
||||
const markdown = await text(stream);
|
||||
expect(markdown).toContain("# Chat with OpenClaw");
|
||||
expect(markdown).toContain("## You");
|
||||
expect(markdown).toContain(question);
|
||||
expect(markdown).toContain("## OpenClaw");
|
||||
expect(markdown).toContain(answer);
|
||||
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps visible search results and keyboard selection in relevance order", async () => {
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
await suite.withPage(
|
||||
|
||||
@@ -5197,6 +5197,7 @@ export const en: TranslationMap = {
|
||||
stoppingCurrentRun: "Stopping current run...",
|
||||
chatHistoryCleared: "Chat history cleared.",
|
||||
exportingThread: "Exporting session...",
|
||||
emptyExport: "There are no messages to export yet.",
|
||||
unknownCommand: "Unknown command: `{command}`",
|
||||
options: "Options: {options}.",
|
||||
sessionUnavailable: "Session capability is unavailable",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CommandsListResult } from "../../../../packages/gateway-protocol/s
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ModelCatalogEntry, SessionsListResult } from "../../api/types.ts";
|
||||
import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { peekChatMetadata } from "../../lib/chat/chat-metadata-store.ts";
|
||||
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import { executeSlashCommand } from "./chat-command-executor.ts";
|
||||
import { clearChatHistory } from "./chat-history.ts";
|
||||
import { enqueuePendingRunMessage } from "./chat-queue.ts";
|
||||
import { readChatSessionActionAccess } from "./chat-session-action-access.ts";
|
||||
import type { ChatExportResult } from "./export.ts";
|
||||
import { handleAbortChat } from "./run-lifecycle.ts";
|
||||
import { scheduleChatScroll, type ChatScrollHost } from "./scroll.ts";
|
||||
|
||||
@@ -76,7 +78,7 @@ export type ChatCommandHost = Parameters<typeof handleAbortChat>[0] &
|
||||
sessionsResultAgentId?: string | null;
|
||||
createChatSession?: () => Promise<boolean>;
|
||||
confirmConversationReset?: () => Promise<boolean>;
|
||||
exportCurrentChat?: () => Promise<void> | void;
|
||||
exportCurrentChat?: () => Promise<ChatExportResult> | ChatExportResult;
|
||||
refreshCurrentSessionTools?: () => Promise<void>;
|
||||
refreshCurrentChat?: () => Promise<void>;
|
||||
} & UiSessionDefaultsHost &
|
||||
@@ -389,7 +391,10 @@ export async function dispatchChatSlashCommand(
|
||||
}
|
||||
break;
|
||||
case "export-session":
|
||||
await host.exportCurrentChat?.();
|
||||
if ((await host.exportCurrentChat?.()) === "empty") {
|
||||
injectCommandResult(host, t("chat.commandResults.emptyExport"));
|
||||
scheduleChatScroll(host, false, false, { contentChanged: true });
|
||||
}
|
||||
return "completed";
|
||||
}
|
||||
|
||||
|
||||
@@ -442,15 +442,26 @@ describe("handleSendChat browser annotation context", () => {
|
||||
|
||||
describe("handleSendChat immediate local commands", () => {
|
||||
it.each(["/export-session", "/export"])(
|
||||
"preserves staged attachments while %s exports the chat",
|
||||
"shows an empty export outcome and preserves staged attachments for %s",
|
||||
async (command) => {
|
||||
const attachment = createStagedAttachment("export-att");
|
||||
const exportCurrentChat = vi.fn();
|
||||
const host = createImmediateCommandHost(command, attachment, { exportCurrentChat });
|
||||
const exportCurrentChat = vi.fn(() => "empty" as const);
|
||||
const afterCommit = vi.fn(() => () => undefined);
|
||||
const host = createImmediateCommandHost(command, attachment, {
|
||||
exportCurrentChat,
|
||||
renderLifecycle: { invalidate: vi.fn(), afterCommit },
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
expect(exportCurrentChat).toHaveBeenCalledOnce();
|
||||
expect(host.chatMessages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "system",
|
||||
content: "There are no messages to export yet.",
|
||||
}),
|
||||
]);
|
||||
expect(afterCommit).toHaveBeenCalledOnce();
|
||||
expect(host.chatMessage).toBe("");
|
||||
expect(host.chatAttachments).toEqual([attachment]);
|
||||
expect(getChatAttachmentDataUrl(attachment)).toBe(attachmentDataUrl);
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { ChatProps } from "./chat-view.ts";
|
||||
import type { BackgroundTasksHost } from "./components/chat-background-tasks.ts";
|
||||
import type { SessionWorkspaceHost } from "./components/chat-session-workspace.ts";
|
||||
import type { SidebarContent } from "./components/chat-sidebar.ts";
|
||||
import type { ChatExportResult } from "./export.ts";
|
||||
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./input-history.ts";
|
||||
import type { RenderLifecycle } from "./render-lifecycle.ts";
|
||||
import type { PendingChatAbort } from "./run-lifecycle.ts";
|
||||
@@ -157,7 +158,7 @@ export type ChatPageHost = ChatHost &
|
||||
announceSessionSwitch?: (sessionKey: string, label: string) => void;
|
||||
createChatSession?: () => Promise<boolean>;
|
||||
confirmConversationReset?: () => Promise<boolean>;
|
||||
exportCurrentChat?: () => Promise<void> | void;
|
||||
exportCurrentChat?: () => Promise<ChatExportResult> | ChatExportResult;
|
||||
refreshCurrentSessionTools?: () => Promise<void>;
|
||||
refreshCurrentChat?: () => Promise<void>;
|
||||
refreshSessionPullRequests?: (options?: { refresh?: boolean }) => Promise<void>;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { exportChatMarkdown } from "./export.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("exportChatMarkdown", () => {
|
||||
it("reports an empty transcript without creating a download", () => {
|
||||
const createObjectURL = vi.spyOn(URL, "createObjectURL");
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
|
||||
|
||||
expect(exportChatMarkdown([], "OpenClaw")).toBe("empty");
|
||||
expect(createObjectURL).not.toHaveBeenCalled();
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads one readable Markdown file for a populated transcript", async () => {
|
||||
const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:chat-export");
|
||||
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
expect(
|
||||
exportChatMarkdown(
|
||||
[
|
||||
{ role: "user", content: "What can you export?", timestamp: 1_000 },
|
||||
{ role: "assistant", content: "A readable conversation.", timestamp: 2_000 },
|
||||
],
|
||||
"OpenClaw",
|
||||
),
|
||||
).toBe("downloaded");
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledOnce();
|
||||
expect(click).toHaveBeenCalledOnce();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:chat-export");
|
||||
const markdown = await (createObjectURL.mock.calls[0]![0] as Blob).text();
|
||||
expect(markdown).toContain("# Chat with OpenClaw");
|
||||
expect(markdown).toContain("## You");
|
||||
expect(markdown).toContain("What can you export?");
|
||||
expect(markdown).toContain("## OpenClaw");
|
||||
expect(markdown).toContain("A readable conversation.");
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,15 @@
|
||||
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
|
||||
import { extractTextCached } from "../../lib/chat/message-extract.ts";
|
||||
|
||||
export type ChatExportResult = "downloaded" | "empty";
|
||||
|
||||
/**
|
||||
* Export chat history as markdown file.
|
||||
*/
|
||||
export function exportChatMarkdown(messages: unknown[], assistantName: string): void {
|
||||
export function exportChatMarkdown(messages: unknown[], assistantName: string): ChatExportResult {
|
||||
const markdown = buildChatMarkdown(messages, assistantName);
|
||||
if (!markdown) {
|
||||
return;
|
||||
return "empty";
|
||||
}
|
||||
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -17,6 +19,7 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string):
|
||||
link.download = `chat-${assistantName}-${Date.now()}.md`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return "downloaded";
|
||||
}
|
||||
|
||||
function buildChatMarkdown(messages: unknown[], assistantName: string): string | null {
|
||||
|
||||
Reference in New Issue
Block a user