diff --git a/ui/src/e2e/chat-slash-command-ranking.e2e.test.ts b/ui/src/e2e/chat-slash-command-ranking.e2e.test.ts index a7e2a43670a7..1b7c93a854aa 100644 --- a/ui/src/e2e/chat-slash-command-ranking.e2e.test.ts +++ b/ui/src/e2e/chat-slash-command-ranking.e2e.test.ts @@ -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( diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 68fc0fe1428c..1339e5f883ff 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/chat/chat-commands.ts b/ui/src/pages/chat/chat-commands.ts index aa6a46a25b25..2bde553f7778 100644 --- a/ui/src/pages/chat/chat-commands.ts +++ b/ui/src/pages/chat/chat-commands.ts @@ -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[0] & sessionsResultAgentId?: string | null; createChatSession?: () => Promise; confirmConversationReset?: () => Promise; - exportCurrentChat?: () => Promise | void; + exportCurrentChat?: () => Promise | ChatExportResult; refreshCurrentSessionTools?: () => Promise; refreshCurrentChat?: () => Promise; } & 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"; } diff --git a/ui/src/pages/chat/chat-send-submit.test.ts b/ui/src/pages/chat/chat-send-submit.test.ts index 9d6d40d8694e..28aa63fefbe9 100644 --- a/ui/src/pages/chat/chat-send-submit.test.ts +++ b/ui/src/pages/chat/chat-send-submit.test.ts @@ -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); diff --git a/ui/src/pages/chat/chat-state-host.ts b/ui/src/pages/chat/chat-state-host.ts index 5667faeb7267..ce7cf97eaade 100644 --- a/ui/src/pages/chat/chat-state-host.ts +++ b/ui/src/pages/chat/chat-state-host.ts @@ -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; confirmConversationReset?: () => Promise; - exportCurrentChat?: () => Promise | void; + exportCurrentChat?: () => Promise | ChatExportResult; refreshCurrentSessionTools?: () => Promise; refreshCurrentChat?: () => Promise; refreshSessionPullRequests?: (options?: { refresh?: boolean }) => Promise; diff --git a/ui/src/pages/chat/export.test.ts b/ui/src/pages/chat/export.test.ts new file mode 100644 index 000000000000..678ab73521e0 --- /dev/null +++ b/ui/src/pages/chat/export.test.ts @@ -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."); + }); +}); diff --git a/ui/src/pages/chat/export.ts b/ui/src/pages/chat/export.ts index 7733954075f8..404c319eedfa 100644 --- a/ui/src/pages/chat/export.ts +++ b/ui/src/pages/chat/export.ts @@ -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 {