Skip to content

Add a copy-as-Markdown action to assistant/agent responses#71

Open
DragonnZhang wants to merge 1 commit into
mainfrom
loop/copy-message
Open

Add a copy-as-Markdown action to assistant/agent responses#71
DragonnZhang wants to merge 1 commit into
mainfrom
loop/copy-message

Conversation

@DragonnZhang

Copy link
Copy Markdown
Collaborator

Closes #70

What & why

OpenWork already lets you copy user messages (hover → copy) and code blocks (each CodeBlock has a copy button), but a full assistant/agent response had no copy affordance — the only hover action on an assistant bubble was "open in new window" (pop-out). Grabbing a response meant manually selecting the rendered text, losing the Markdown.

Every comparable AI desktop surface offers a per-message copy: Codex desktop ("Copy as Markdown" — openai/codex #2880, #17241, one of its most-requested features) and Claude / ChatGPT desktop. This closes that frontend-only parity gap.

This adds a Copy button to the assistant bubble's hover actions (next to the pop-out control) that copies the response's raw Markdown (message.content) to the clipboard, with a 2-second "copied" check state and a toast.copyFailed on error — mirroring the existing user-message copy and CodeBlock copy patterns.

Frontend-only. No qwen-code backend change: it reads text already in the rendered message and calls the Web Clipboard API.

Changes

  • components/app-shell/ChatDisplay.tsx — extract an AssistantMessage component so it can own the copy button's transient copied state (a hook can't live in MessageBubble's role switch — the same reason ErrorMessage was already extracted). Hover actions are now a small top-right cluster: Copy (new) + the existing pop-out, shown only on a settled (non-streaming) response. The copy button carries data-testid="assistant-copy" and a data-copied attribute for e2e; the aria-label/tooltip flips between common.copy and common.copied.
  • No i18n changes — reuses the existing common.copy, common.copied, and toast.copyFailed keys (1544 keys/locale unchanged).
  • e2e/app.ts + e2e/runner.ts — add a backend-independent seed(profileDirs) hook that runs after the isolated profile is created and before Electron launches, so an assertion can pre-seed on-disk session state that the embedded SessionManager loads on boot (no qwen-code needed to render a historical transcript).
  • e2e/assertions/copy-message.assert.ts — new CDP assertion (below).

Verification (DoD)

  • bun run typecheck:allzero new errors. Delta vs main is zero: the only errors are the 11 pre-existing ones in apps/electron (auto-update.ts owner/repo, a settings-default-thinking test tuple, two test files importing vitest) — none in the files this PR touches.
  • bun test — the failing set is byte-for-byte identical to main, verified by diffing the sorted unique failure lists from a clean-main run and this branch in the same environment: 56 pre-existing failures on both, diff reports IDENTICAL. This change adds zero new failures.
  • bun run lint:i18n:parity — OK (6 locales, 1544 keys each; no keys added).
  • eslint on ChatDisplay.tsx — 0 errors (6 pre-existing exhaustive-deps warnings, none from the new component).
  • Renderer build (bun run electron:build:renderer, where this change lives) — ✅ builds cleanly.
  • CDP assertion transpiles + loads (bun build --target=bun + dynamic import) — ✅.

CDP e2e assertion (copy-message.assert.ts)

Drives the real built app over CDP through the full path (no backend required — the session is pre-seeded as a plain session.jsonl under the isolated profile's default-workspace root, which the embedded SessionManager loads on boot):

  1. Seed a 1-user + 1-assistant session on disk, then open it from the sidebar (the row selects on mousedown).
  2. The assistant bubble's copy button renders and starts in the data-copied="false" state (proof the seeded response loaded, not the empty/draft state).
  3. Stub navigator.clipboard.writeText in-page (so the check is deterministic on a headless host), click the copy button, and assert the exact response Markdown was written to the clipboard — proving it copies the real response text, not merely toggles an icon.
  4. Assert the button enters its data-copied="true" state — proving it's a real, wired action.

⚠️ Local CDP run was blocked by this environment's egress policy, not by the feature. bun install's Electron postinstall (and thus the app build/launch) fails with 403 Forbidden from the egress proxy when fetching the Electron binary — the org policy allowlists npm/PyPI/crates but not the Electron/GitHub release host. xvfb is present; only the binary fetch is denied (do-not-retry policy denial). The assertion and harness changes transpile and load, and are included so CI / a reviewer can run bun run e2e in an environment with normal network access. Everything that doesn't require launching Electron (typecheck, unit tests, i18n parity, eslint, renderer build) passes with zero delta vs main.

Part of the autonomous desktop-feature loop (loop-bot).

🤖 Generated with Claude Code


Generated by Claude Code

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a "Copy" (copy-as-Markdown) action to assistant/agent responses

2 participants