fix(ui): explain empty chat exports (#129881)

This commit is contained in:
Peter Steinberger
2026-08-25 22:22:08 -07:00
committed by GitHub
parent 647414e3e3
commit 3d37bb4cba
7 changed files with 161 additions and 8 deletions
@@ -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(
+1
View File
@@ -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",
+7 -2
View File
@@ -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";
}
+14 -3
View File
@@ -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);
+2 -1
View File
@@ -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>;
+43
View File
@@ -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.");
});
});
+5 -2
View File
@@ -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 {