From ffccdb1dd94420485045aa1630955a23a58b0eb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 13:44:16 +0000 Subject: [PATCH] Add a copy-as-Markdown action to assistant messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assistant/agent responses had only a pop-out hover action and no way to copy them, even though user messages and code blocks were already copyable. Comparable desktop chat surfaces (Codex's much-requested Copy as Markdown, Claude/ChatGPT desktop) all offer a per-message copy. Extract an AssistantMessage component in ChatDisplay so it can own the copy button's transient copied state (mirroring the existing ErrorMessage extraction). The button sits next to the pop-out control, copies the response's raw Markdown via the Clipboard API, shows a 2s check state, and toasts on failure. Only shown on a settled (non-streaming) response. Reuses existing i18n keys (common.copy / common.copied / toast.copyFailed) — no new keys. Also add a backend-independent seed hook to the e2e harness so an assertion can pre-seed an on-disk session that the embedded SessionManager loads on boot, and a CDP assertion that seeds a user+assistant session, clicks the copy button, and asserts the exact response markdown reaches the clipboard and the button enters its copied state. Closes #70 --- .../components/app-shell/ChatDisplay.tsx | 153 +++++++++++++---- docs/loop/feature-ledger.md | 3 +- e2e/app.ts | 21 +++ e2e/assertions/copy-message.assert.ts | 161 ++++++++++++++++++ e2e/runner.ts | 10 +- 5 files changed, 308 insertions(+), 40 deletions(-) create mode 100644 e2e/assertions/copy-message.assert.ts diff --git a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx index 1ee9b7973..f0c1a54a2 100644 --- a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx +++ b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx @@ -3,11 +3,13 @@ import { useTranslation } from "react-i18next" import { useEffect, useState, useMemo, useCallback } from "react" import { AlertTriangle, + Check, CheckCircle2, ChevronDown, ChevronRight, ChevronUp, CircleAlert, + Copy, ExternalLink, Info, X, @@ -2329,6 +2331,113 @@ function ErrorMessage({ message, onOpenUrl, sessionId, onRetry }: { message: Mes ) } +/** + * AssistantMessage - Separate component for assistant/agent responses so it can + * own the copy-button's `copied` state (a hook can't live in the role-switch of + * MessageBubble). Renders the markdown bubble plus hover actions: copy the + * response as Markdown (parity with Claude/ChatGPT/Codex desktop, which all + * offer a per-message copy) and the existing pop-out control. + */ +function AssistantMessage({ + message, + renderMode, + onOpenUrl, + onOpenFile, + onPopOut, +}: { + message: Message + renderMode: RenderMode + onOpenUrl?: (url: string) => void + onOpenFile?: (path: string) => void + onPopOut?: (message: Message) => void +}) { + const { t } = useTranslation() + const [copied, setCopied] = React.useState(false) + const copyResetRef = React.useRef | null>(null) + + React.useEffect(() => { + return () => { + if (copyResetRef.current) clearTimeout(copyResetRef.current) + } + }, []) + + const handleCopy = React.useCallback(async () => { + try { + await navigator.clipboard.writeText(message.content) + setCopied(true) + if (copyResetRef.current) clearTimeout(copyResetRef.current) + copyResetRef.current = setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error('[ChatDisplay] Failed to copy assistant message:', error) + toast.error(t('toast.copyFailed')) + } + }, [message.content, t]) + + // Copy/pop-out only make sense on a settled (non-streaming) response. + const showActions = !message.isStreaming + + return ( +
+
+ {showActions && ( +
+ {/* Copy response as Markdown */} + + {/* Pop-out button */} + {onPopOut && ( + + )} +
+ )} + {/* Use StreamingMarkdown for block-level memoization during streaming */} + {message.isStreaming ? ( + + ) : ( + + + {message.content} + + + )} +
+
+ ) +} + function MessageBubble({ message, onOpenFile, @@ -2367,43 +2476,13 @@ function MessageBubble({ // === ASSISTANT MESSAGE: Left-aligned gray bubble with markdown rendering === if (message.role === 'assistant') { return ( -
-
- {/* Pop-out button - visible on hover */} - {onPopOut && !message.isStreaming && ( - - )} - {/* Use StreamingMarkdown for block-level memoization during streaming */} - {message.isStreaming ? ( - - ) : ( - - - {message.content} - - - )} -
-
+ ) } diff --git a/docs/loop/feature-ledger.md b/docs/loop/feature-ledger.md index 51235562a..98e4a3edb 100644 --- a/docs/loop/feature-ledger.md +++ b/docs/loop/feature-ledger.md @@ -32,7 +32,8 @@ log, not the system of record. | slug | title | source | feasibility | status | issue | pr | branch | updated | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-03 | Renderer-only pref (localStorage) applied app-wide via `` + `data-reduce-motion` on `` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion included; **could not run locally** (egress 403s Electron binary download). | +| copy-message | "Copy" (copy-as-Markdown) action on assistant/agent responses | Codex desktop "Copy as Markdown" (openai/codex #2880, #17241) + Claude/ChatGPT desktop per-message copy | frontend-only | pr-open | [#70](https://github.com/modelstudioai/openwork/issues/70) | [#71](https://github.com/modelstudioai/openwork/pull/71) | loop/copy-message | 2026-07-08 | User messages + code blocks were already copyable, but full assistant responses had only a pop-out button — no copy. Extracted `AssistantMessage` component in `ChatDisplay.tsx` (owns `copied` state, mirrors the existing `ErrorMessage` extraction) with a hover copy button next to pop-out; copies raw `message.content` via `navigator.clipboard.writeText`, 2s "copied" check state, `toast.copyFailed` on error. **Zero new i18n keys** (reuses `common.copy`/`common.copied`/`toast.copyFailed`). Added a general `seed` hook to the e2e harness (`app.ts`/`runner.ts`) so assertions can pre-seed an on-disk session the embedded `SessionManager` loads on boot (no backend). typecheck:all zero-delta; `bun test` 56-failure set byte-identical to main; renderer build ✅; i18n parity ✅ (1544 keys); eslint 0 errors. CDP assertion (`copy-message.assert.ts`) seeds a user+assistant session, opens it, clicks the copy button, and asserts the exact response markdown reaches the (stubbed) clipboard + the button enters its copied state; **could not run locally** (org egress policy 403s the Electron binary download — same block as prior rounds). | +| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | merged | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-08 | **Merged** into `main` (2026-07-06). Renderer-only pref (localStorage) applied app-wide via `` + `data-reduce-motion` on `` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. | | composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude/ChatGPT/Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. | | scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code / ChatGPT / Codex desktop | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Opened by a prior run. Floating jump button in `ChatDisplay` + `seed()` harness hook. Awaiting review. | | thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-03 | **Merged** into `main` (2026-07-02). `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). | diff --git a/e2e/app.ts b/e2e/app.ts index 98c93a451..c349502c6 100644 --- a/e2e/app.ts +++ b/e2e/app.ts @@ -22,6 +22,16 @@ const E2E_DIR = join(ROOT_DIR, '.e2e'); const MAIN_BUNDLE = join(ELECTRON_DIR, 'dist/main.cjs'); const RENDERER_HTML = join(ELECTRON_DIR, 'dist/renderer/index.html'); +/** The isolated per-launch profile directories, handed to a {@link LaunchOptions.seed} hook. */ +export interface ProfileDirs { + /** Electron userData (cache, cookies, local storage, single-instance lock). */ + userDataDir: string; + /** App config + workspace registry (CRAFT_CONFIG_DIR / ~/.craft-agent). */ + configDir: string; + /** Root of the default conversation workspace (QWEN_DEFAULT_WORKSPACE_DIR). */ + workspaceDir: string; +} + export interface LaunchOptions { /** Override the remote-debugging port (default: an ephemeral free port). */ port?: number; @@ -29,6 +39,12 @@ export interface LaunchOptions { rebuild?: boolean; /** Milliseconds to wait for the main renderer target to appear. */ startupTimeoutMs?: number; + /** + * Runs after the isolated profile dirs are created but BEFORE Electron starts, + * so an assertion can pre-seed on-disk state (e.g. a workspace + a session + * with messages) that the app will load on launch. Backend-independent. + */ + seed?: (dirs: ProfileDirs) => void | Promise; } export interface LaunchedApp { @@ -134,6 +150,11 @@ export async function launchApp(options: LaunchOptions = {}): Promise 0', + { timeoutMs: 30000, message: 'React UI did not mount' }, + ); + await session.waitForSelector('[aria-label="Craft menu"]', { + timeoutMs: 30000, + message: 'app did not reach the ready AppShell state', + }); + + // 1. The seeded session appears in the sidebar list. + await session.waitForSelector(ROW, { + timeoutMs: 20000, + message: 'seeded session row did not appear in the session list', + }); + + // 2. Open it. The row selects on `mousedown` (not click), so dispatch a real + // left-button press + release on the row's button. + const opened = await session.evaluate(`(() => { + const btn = document.querySelector(${JSON.stringify(ROW)} + ' button.entity-row-btn') + || document.querySelector(${JSON.stringify(ROW)} + ' button') + || document.querySelector(${JSON.stringify(ROW)}); + if (!btn) return false; + btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })); + btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })); + return true; + })()`); + if (!opened) throw new Error('could not find/press the seeded session row'); + + // 3. The assistant bubble's copy button renders (proof the seeded response + // loaded, not the empty/draft state). + await session.waitForSelector(COPY_BTN, { + timeoutMs: 20000, + message: 'assistant copy button did not render for the seeded response', + }); + + // 4. It starts in the un-copied state. + const initialCopied = await session.evaluate( + `document.querySelector(${JSON.stringify(COPY_BTN)})?.getAttribute('data-copied') ?? null`, + ); + if (initialCopied !== 'false') { + throw new Error(`copy button should start un-copied, saw data-copied=${JSON.stringify(initialCopied)}`); + } + + // 5. Stub clipboard.writeText so we can capture the copied text deterministically + // on a headless host, then click the button. + await session.evaluate(`(() => { + window.__copiedText = undefined; + try { + navigator.clipboard.writeText = (text) => { window.__copiedText = String(text); return Promise.resolve(); }; + } catch { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: (text) => { window.__copiedText = String(text); return Promise.resolve(); } }, + }); + } + return true; + })()`); + + const clicked = await session.evaluate(`(() => { + const el = document.querySelector(${JSON.stringify(COPY_BTN)}); + if (!el) return false; + el.click(); + return true; + })()`); + if (!clicked) throw new Error('could not click the assistant copy button'); + + // 6. The exact response markdown was placed on the clipboard. + await session.waitForFunction('window.__copiedText !== undefined', { + timeoutMs: 5000, + message: 'clipboard.writeText was never called by the copy button', + }); + const copiedText = await session.evaluate('window.__copiedText ?? null'); + if (copiedText !== ASSISTANT_CONTENT) { + throw new Error( + `copy button wrote the wrong text to the clipboard.\n expected: ${JSON.stringify(ASSISTANT_CONTENT)}\n actual: ${JSON.stringify(copiedText)}`, + ); + } + + // 7. The button reflects the copied state (proves it's a real, wired action). + await session.waitForFunction( + `document.querySelector(${JSON.stringify(COPY_BTN)})?.getAttribute('data-copied') === 'true'`, + { timeoutMs: 5000, message: 'copy button did not enter its "copied" state after clicking' }, + ); + }, +}; + +export default assertion; diff --git a/e2e/runner.ts b/e2e/runner.ts index be4b38b8f..b65db6059 100644 --- a/e2e/runner.ts +++ b/e2e/runner.ts @@ -12,7 +12,7 @@ import { Glob } from 'bun'; import { join } from 'node:path'; import { mkdirSync } from 'node:fs'; -import { launchApp, type LaunchedApp } from './app'; +import { launchApp, type LaunchedApp, type ProfileDirs } from './app'; const ROOT_DIR = join(import.meta.dir, '..'); const ASSERTIONS_DIR = join(import.meta.dir, 'assertions'); @@ -22,6 +22,12 @@ const SCREENSHOT_DIR = join(ROOT_DIR, '.e2e/screenshots'); export interface Assertion { /** Human-readable description, shown in the report. */ name: string; + /** + * Optional: pre-seed on-disk state before the app launches (e.g. a workspace + * and a session with messages). Runs after the isolated profile is created, + * before Electron starts. Backend-independent. + */ + seed?: (dirs: ProfileDirs) => void | Promise; /** Drive the running app and throw if the expected behavior is missing. */ run(app: LaunchedApp): Promise; } @@ -60,7 +66,7 @@ async function runOne(file: string, assertion: Assertion): Promise