Skip to content

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

Merged
DragonnZhang merged 1 commit into
mainfrom
loop/copy-message
Jul 10, 2026
Merged

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

Conversation

@DragonnZhang

Copy link
Copy Markdown
Member

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.tsowner/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
@DragonnZhang
DragonnZhang merged commit 6a7e71b into mainJul 10, 2026
1 check passed
Sign up for freeto 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

@DragonnZhang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add a copy-as-Markdown action to assistant/agent responses by DragonnZhang · Pull Request #71 · modelstudioai/openwork · GitHub
Skip to content

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

Merged
DragonnZhang merged 1 commit into
mainfrom
loop/copy-message
Jul 10, 2026
Merged

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

Conversation

@DragonnZhang

Copy link
Copy Markdown
Member

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.tsowner/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
@DragonnZhang
DragonnZhang merged commit 6a7e71b into mainJul 10, 2026
1 check passed
Sign up for freeto 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

@DragonnZhang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add a copy-as-Markdown action to assistant/agent responses by DragonnZhang · Pull Request #71 · modelstudioai/openwork · GitHub
Skip to content

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

Merged
DragonnZhang merged 1 commit into
mainfrom
loop/copy-message
Jul 10, 2026
Merged

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

Conversation

@DragonnZhang

Copy link
Copy Markdown
Member

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.tsowner/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
@DragonnZhang
DragonnZhang merged commit 6a7e71b into mainJul 10, 2026
1 check passed
Sign up for freeto 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

@DragonnZhang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add a copy-as-Markdown action to assistant/agent responses by DragonnZhang · Pull Request #71 · modelstudioai/openwork · GitHub
Skip to content

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

Merged
DragonnZhang merged 1 commit into
mainfrom
loop/copy-message
Jul 10, 2026
Merged

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

Conversation

@DragonnZhang

Copy link
Copy Markdown
Member

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.tsowner/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
@DragonnZhang
DragonnZhang merged commit 6a7e71b into mainJul 10, 2026
1 check passed
Sign up for freeto 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

@DragonnZhang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add a copy-as-Markdown action to assistant/agent responses by DragonnZhang · Pull Request #71 · modelstudioai/openwork · GitHub
Skip to content

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

Merged
DragonnZhang merged 1 commit into
mainfrom
loop/copy-message
Jul 10, 2026
Merged

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

Conversation

@DragonnZhang

Copy link
Copy Markdown
Member

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.tsowner/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
@DragonnZhang
DragonnZhang merged commit 6a7e71b into mainJul 10, 2026
1 check passed
Sign up for freeto 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

@DragonnZhang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add a copy-as-Markdown action to assistant/agent responses by DragonnZhang · Pull Request #71 · modelstudioai/openwork · GitHub
Skip to content

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

Merged
DragonnZhang merged 1 commit into
mainfrom
loop/copy-message
Jul 10, 2026
Merged

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

Conversation

@DragonnZhang

Copy link
Copy Markdown
Member

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.tsowner/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
@DragonnZhang
DragonnZhang merged commit 6a7e71b into mainJul 10, 2026
1 check passed
Sign up for freeto 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

@DragonnZhang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add a copy-as-Markdown action to assistant/agent responses by DragonnZhang · Pull Request #71 · modelstudioai/openwork · GitHub
Skip to content

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

Merged
DragonnZhang merged 1 commit into
mainfrom
loop/copy-message
Jul 10, 2026
Merged

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

Conversation

@DragonnZhang

Copy link
Copy Markdown
Member

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.tsowner/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
@DragonnZhang
DragonnZhang merged commit 6a7e71b into mainJul 10, 2026
1 check passed
Sign up for freeto 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

@DragonnZhang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Add a copy-as-Markdown action to assistant/agent responses by DragonnZhang · Pull Request #71 · modelstudioai/openwork · GitHub
Skip to content

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

Merged
DragonnZhang merged 1 commit into
mainfrom
loop/copy-message
Jul 10, 2026
Merged

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

Conversation

@DragonnZhang

Copy link
Copy Markdown
Member

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.tsowner/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
@DragonnZhang
DragonnZhang merged commit 6a7e71b into mainJul 10, 2026
1 check passed
Sign up for freeto 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

@DragonnZhang@claude