Merge #810: a Copy Markdown button in the response viewer

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-12 02:20:20 +01:00
commit b4e44c4f6a
9 files changed
+405 -11

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": patch
---
Response viewer: add a Copy Markdown button that copies the raw source of the newest answer (every part of its turn), so headings, lists and code fences paste intact. An empty viewer and a refused clipboard both say so.
+3 -2
View File
@@ -187,8 +187,9 @@ Setup in [Notifications And Approvals](Notifications-And-Approvals).
## Reading long answers
The terminal viewport is small. **Last Response** (opt-in header button) renders the agent's
last answer as scrollable text instead, with a **More** button for additional context.
Persian, Arabic and Hebrew answers read right to left there.
last answer as scrollable text instead, with a **More** button for additional context and a
**Copy Markdown** button for its raw source. Persian, Arabic and Hebrew answers read right to
left there.
The [File Viewer](Working-With-Files) works on phones too, including edit mode, which is
enough to fix a typo an agent introduced while you are away from your desk. Its header button
+5 -3
View File
@@ -77,9 +77,11 @@ Turning on either starts the watcher.
**Last Response** (header button, opt-in) renders the agent's last answer as scrollable text
rather than terminal output. It exists mostly for phones, where reading a long answer in a
terminal viewport is painful. **More** loads additional context. Persian, Arabic and Hebrew
answers read right to left here, which the terminal cannot do, while code and the file paths
it links stay left to right.
terminal viewport is painful. **More** loads additional context. **Copy Markdown** copies the
newest answer's raw Markdown, every part of its turn, so headings, lists and code fences paste
intact (the same answer in both views; tool and status blocks are left out). Persian, Arabic
and Hebrew answers read right to left here, which the terminal cannot do, while code and the
file paths it links stay left to right.
## After the fact
+51 -6
View File
@@ -774,6 +774,7 @@ class CodemanApp {
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null;
this._responseViewerMarkdown = '';
// ── Session detach / undock (beta) ───────────────────────────────────
// A "solo window" is a popped-out browser window showing exactly one
@@ -2948,12 +2949,7 @@ class CodemanApp {
ev.stopPropagation();
const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
const ok = code ? await this._copyText(code.textContent || '') : false;
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
clearTimeout(copyBtn._resetTimer);
copyBtn._resetTimer = setTimeout(() => {
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
}, 1500);
this._setCopyControlState(copyBtn, ok);
return;
}
@@ -2997,6 +2993,50 @@ class CodemanApp {
}
}
_setCopyControlState(btn, success) {
if (!btn) return;
btn.classList.remove('rv-copied', 'rv-copy-failed');
btn.classList.add(success ? 'rv-copied' : 'rv-copy-failed');
clearTimeout(btn._resetTimer);
btn._resetTimer = setTimeout(() => {
btn.classList.remove('rv-copied', 'rv-copy-failed');
}, 1500);
}
/**
* The raw Markdown of the newest answer in a message list: every row of its
* turn, or the last row when the reader emits no numeric turns (Codex, the
* pane parser). Tool and status blocks share the assistant role but are not
* the answer.
*/
_responseViewerMarkdownFrom(messages) {
const answers = messages.filter(
(msg) => msg && msg.role === 'assistant' && (!msg.kind || msg.kind === 'response') && msg.text?.trim()
);
const last = answers[answers.length - 1];
if (!last) return '';
if (typeof last.turn !== 'number') return last.text;
return answers
.filter((msg) => msg.turn === last.turn)
.map((msg) => msg.text)
.join('\n\n');
}
/** Copy the raw Markdown source of the answer the response viewer is showing. */
async copyResponseViewerMarkdown() {
const button = document.getElementById('responseViewerCopyMarkdown');
const markdown = this._responseViewerMarkdown || '';
if (!markdown) {
this._setCopyControlState(button, false);
this.showToast('No response content to copy', 'warning');
return;
}
const copied = await this._copyText(markdown);
this._setCopyControlState(button, copied);
if (copied) this.showToast('Copied response as Markdown', 'success');
else this.showToast('Failed to copy response as Markdown', 'error');
}
/** Build one response-viewer message so the brief and full views share markup and CSS. */
_buildResponseViewerMessage(text, role, agentLabel, meta) {
const div = document.createElement('div');
@@ -3241,6 +3281,8 @@ class CodemanApp {
}
}
this._responseViewerMarkdown = this._responseViewerMarkdownFrom(turnMessages) || lastResponse;
const body = document.getElementById('responseViewerBody');
if (turnMessages.length > 0) {
// The whole last turn, rendered exactly as the full view renders that
@@ -3320,6 +3362,9 @@ class CodemanApp {
return;
}
// Keep the brief view's answer when this history holds none of its own.
this._responseViewerMarkdown = this._responseViewerMarkdownFrom(messages) || this._responseViewerMarkdown;
// Render conversation thread
const agentLabel = this._getResponseViewerAgentLabel();
body.innerHTML = '';
+5
View File
@@ -245,6 +245,11 @@
'No full conversation history available for this session': '此会话没有可显示的完整对话历史',
'Last Response': '最近一次回复',
More: '更多',
'Copy Markdown': '复制 Markdown',
'Copy response as Markdown': '将回复复制为 Markdown',
'Copied response as Markdown': '已将回复复制为 Markdown',
'Failed to copy response as Markdown': '无法将回复复制为 Markdown',
'No response content to copy': '没有可复制的回复内容',
'Codeman version': '{name}版本',
Stop: '停止',
Watching: '监视中',
+1
View File
@@ -733,6 +733,7 @@
<span id="responseViewerTitle">Last Response</span>
<div class="response-viewer-actions">
<button class="response-viewer-more" id="responseViewerMore" onclick="app.loadFullContext()">More</button>
<button class="response-viewer-more response-viewer-copy-markdown" id="responseViewerCopyMarkdown" type="button" onclick="app.copyResponseViewerMarkdown()" title="Copy response as Markdown" aria-label="Copy response as Markdown">Copy Markdown</button>
<button class="response-viewer-close" onclick="app.toggleResponseViewer()">&times;</button>
</div>
</div>
+21
View File
@@ -1804,6 +1804,27 @@ html.mobile-init .file-browser-panel {
max-height: 92dvh;
}
.response-viewer-header {
gap: 8px;
padding: 12px;
}
.response-viewer-header > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.response-viewer-actions {
flex-shrink: 0;
gap: 4px;
}
.response-viewer-more {
padding: 3px 7px;
}
.response-viewer-body {
font-size: 14.5px;
line-height: 1.65;
+14
View File
@@ -14946,6 +14946,20 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
background: var(--control-bg-hover);
}
.response-viewer-copy-markdown {
white-space: nowrap;
}
.response-viewer-copy-markdown.rv-copied {
color: var(--green);
border-color: var(--green);
}
.response-viewer-copy-markdown.rv-copy-failed {
color: var(--red);
border-color: var(--red);
}
.response-viewer-close {
background: none;
border: none;
+300
View File
@@ -0,0 +1,300 @@
/**
* @fileoverview The response viewer's Copy Markdown button.
*
* The button copies the raw Markdown of the answer the viewer is showing, so a
* heading stays `##` and a code fence stays a fence, which rendered text selected
* with the mouse cannot do. Pinned here:
*
* 1. The copied source follows what the viewer rendered: the whole last answered
* turn in the brief view, the same selection in the full view, and nothing
* left over from an earlier open.
* 2. Tool and status blocks (DeepSeek, the external-CLI pane parser) are not the
* answer and are never copied, and neither is a prompt.
* 3. An empty viewer and a refused clipboard both say so; neither reports success.
* 4. The markup, phone layout and zh-CN strings the button depends on.
*
* app.js is loaded via `vm` with a jsdom document, as in
* response-viewer-last-turn.test.ts.
* Port: N/A
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const read = (file: string) => readFileSync(resolve(PUBLIC, file), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const { document, NodeFilter } = dom.window;
const appContext = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
fetch: vi.fn(),
navigator: {},
document,
NodeFilter,
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
}) as { fetch: unknown; navigator: unknown; __CodemanApp: { prototype: object } };
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, appContext);
interface ViewerApp {
toggleResponseViewer(): Promise<void>;
loadFullContext(): Promise<void>;
copyResponseViewerMarkdown(): Promise<void>;
_bindResponseViewerInteractions(body: HTMLElement): void;
_copyText: ReturnType<typeof vi.fn>;
showToast: ReturnType<typeof vi.fn>;
activeSessionId?: string;
sessions?: Map<string, { mode: string }>;
}
function mountViewer() {
const ids = ['responseViewer', 'responseViewerBackdrop', 'responseViewerBody', 'responseViewerTitle'];
const [, , body] = ids.map((id) => {
const el = document.createElement('div');
el.id = id;
document.body.appendChild(el);
return el;
});
const more = document.createElement('button');
more.id = 'responseViewerMore';
const button = document.createElement('button');
button.id = 'responseViewerCopyMarkdown';
document.body.append(more, button);
return { body, button };
}
/** `responses` are served in order, one per fetch, inside the real route envelope. */
function makeApp(...responses: unknown[]): ViewerApp {
const app = Object.create(appContext.__CodemanApp.prototype) as ViewerApp;
app.activeSessionId = 's1';
app.sessions = new Map([['s1', { mode: 'codex' }]]);
app._copyText = vi.fn(async () => true);
app.showToast = vi.fn();
appContext.fetch = vi.fn(async () => ({ json: async () => ({ success: true, data: responses.shift() }) }));
return app;
}
const MARKDOWN = '## Update\n\n- **Keep** the syntax\n- [docs](https://example.com)\n\n```ts\nconst a = 1;\n```';
afterEach(() => {
document.body.innerHTML = '';
appContext.navigator = {};
});
afterAll(() => dom.window.close());
describe('what Copy Markdown copies', () => {
it('copies the raw source of a single-text response', async () => {
mountViewer();
const app = makeApp({ text: MARKDOWN });
await app.toggleResponseViewer();
await app.copyResponseViewerMarkdown();
expect(app._copyText).toHaveBeenCalledWith(MARKDOWN);
});
it('copies every row of the turn the brief view rendered, not only the final one', async () => {
mountViewer();
const app = makeApp({
text: 'Done.',
messages: [
{ role: 'assistant', text: '## Finding', turn: 2 },
{ role: 'assistant', text: 'The bug is on line 3.', turn: 2 },
{ role: 'assistant', text: 'Done.', turn: 2 },
],
});
await app.toggleResponseViewer();
await app.copyResponseViewerMarkdown();
expect(app._copyText).toHaveBeenCalledWith('## Finding\n\nThe bug is on line 3.\n\nDone.');
});
it('follows a fresh open instead of keeping the previous answer', async () => {
mountViewer();
const app = makeApp({ text: 'old answer' }, { text: 'new answer' });
await app.toggleResponseViewer();
await app.toggleResponseViewer(); // close
await app.toggleResponseViewer();
await app.copyResponseViewerMarkdown();
expect(app._copyText).toHaveBeenCalledWith('new answer');
});
it('copies the last answered turn from the full view, ignoring earlier turns and a queued prompt', async () => {
mountViewer();
const app = makeApp(
{ text: 'stale brief answer' },
{
messages: [
{ role: 'user', text: 'first', turn: 1 },
{ role: 'assistant', text: 'first answer', turn: 1 },
{ role: 'user', text: 'second', turn: 2 },
{ role: 'assistant', text: 'part one', turn: 2 },
{ role: 'assistant', text: 'part two', turn: 2 },
{ role: 'user', text: 'queued', turn: 3, queued: true },
],
}
);
await app.toggleResponseViewer();
await app.loadFullContext();
await app.copyResponseViewerMarkdown();
expect(app._copyText).toHaveBeenCalledWith('part one\n\npart two');
});
it('copies only the last response block when blocks have no turns, never a tool or status block', async () => {
mountViewer();
const app = makeApp(
{ text: 'brief' },
{
messages: [
{ kind: 'prompt', role: 'user', text: 'ask' },
{ kind: 'response', role: 'assistant', text: 'earlier response' },
{ kind: 'response', role: 'assistant', text: 'latest response' },
{ kind: 'tool', role: 'assistant', text: 'ran a tool' },
{ kind: 'status', role: 'assistant', text: 'max-tokens' },
],
}
);
await app.toggleResponseViewer();
await app.loadFullContext();
await app.copyResponseViewerMarkdown();
expect(app._copyText).toHaveBeenCalledWith('latest response');
});
it('keeps the brief answer when the full view finds no history and leaves it on screen', async () => {
mountViewer();
const app = makeApp({ text: 'the answer on screen' }, { messages: [] });
await app.toggleResponseViewer();
await app.loadFullContext();
await app.copyResponseViewerMarkdown();
expect(app._copyText).toHaveBeenCalledWith('the answer on screen');
});
});
describe('what Copy Markdown reports', () => {
it('confirms a successful copy on the button and in a toast', async () => {
const { button } = mountViewer();
const app = makeApp({ text: MARKDOWN });
await app.toggleResponseViewer();
await app.copyResponseViewerMarkdown();
expect(button.classList.contains('rv-copied')).toBe(true);
expect(app.showToast).toHaveBeenCalledWith('Copied response as Markdown', 'success');
});
it('warns and copies nothing when the viewer has no response, even after an earlier one', async () => {
const { button } = mountViewer();
const app = makeApp({ text: 'old answer' }, { text: '' });
await app.toggleResponseViewer();
await app.toggleResponseViewer(); // close
await app.toggleResponseViewer();
await app.copyResponseViewerMarkdown();
expect(app._copyText).not.toHaveBeenCalled();
expect(button.classList.contains('rv-copy-failed')).toBe(true);
expect(app.showToast).toHaveBeenCalledWith('No response content to copy', 'warning');
});
it('reports failure when the Clipboard API rejects and the execCommand fallback also fails', async () => {
const { button } = mountViewer();
const app = makeApp({ text: MARKDOWN });
delete (app as Partial<ViewerApp>)._copyText; // own stub off: the real prototype method runs
const writeText = vi.fn(async () => {
throw new Error('NotAllowedError');
});
appContext.navigator = { clipboard: { writeText } };
const execCommand = vi.fn(() => false);
(document as unknown as { execCommand: unknown }).execCommand = execCommand;
await app.toggleResponseViewer();
await app.copyResponseViewerMarkdown();
expect(writeText).toHaveBeenCalledWith(MARKDOWN);
expect(execCommand).toHaveBeenCalledWith('copy');
expect(button.classList.contains('rv-copied')).toBe(false);
expect(button.classList.contains('rv-copy-failed')).toBe(true);
expect(app.showToast).toHaveBeenCalledWith('Failed to copy response as Markdown', 'error');
});
it('a later success clears the failure state', async () => {
const { button } = mountViewer();
const app = makeApp({ text: MARKDOWN });
app._copyText.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
await app.toggleResponseViewer();
await app.copyResponseViewerMarkdown();
await app.copyResponseViewerMarkdown();
expect(button.classList.contains('rv-copy-failed')).toBe(false);
expect(button.classList.contains('rv-copied')).toBe(true);
});
});
describe('the per-code-block copy button still works through the shared state helper', () => {
it('copies the block and marks the button', async () => {
const { body } = mountViewer();
body.innerHTML =
'<div class="rv-code-wrap"><button class="rv-copy-btn"></button><pre><code>npm test</code></pre></div>';
const app = makeApp();
app._bindResponseViewerInteractions(body);
const button = body.querySelector('.rv-copy-btn') as HTMLElement;
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(button.classList.contains('rv-copied')).toBe(true));
expect(app._copyText).toHaveBeenCalledWith('npm test');
});
});
describe('Copy Markdown markup, layout and strings', () => {
it('sits in the viewer actions as a real button wired to the copy method', () => {
const actions = read('index.html').match(/<div class="response-viewer-actions">[\s\S]*?<\/div>/)?.[0] ?? '';
const tag = actions.match(/<button[^>]*id="responseViewerCopyMarkdown"[^>]*>/)?.[0] ?? '';
expect(tag).toContain('type="button"');
expect(tag).toContain('onclick="app.copyResponseViewerMarkdown()"');
expect(tag).toContain('aria-label="Copy response as Markdown"');
expect(actions).toContain('>Copy Markdown</button>');
});
it('colors copy feedback from skin tokens, like the code-block button', () => {
const css = read('styles.css');
const rule = (selector: string) => css.split(`${selector} {`)[1]?.split('}')[0] ?? '';
expect(rule('.response-viewer-copy-markdown.rv-copied')).toContain('var(--green)');
expect(rule('.response-viewer-copy-markdown.rv-copy-failed')).toContain('var(--red)');
});
it('lets a long title shrink on phones so the actions stay on screen', () => {
const css = read('mobile.css');
const rule = (selector: string) => css.split(`${selector} {`)[1]?.split('}')[0] ?? '';
expect(rule('.response-viewer-header > span')).toContain('min-width: 0');
expect(rule('.response-viewer-header > span')).toContain('text-overflow: ellipsis');
expect(rule('.response-viewer-actions')).toContain('flex-shrink: 0');
});
it('translates the button and every toast into zh-CN', () => {
const i18nDom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(read('i18n.js'), i18nDom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (i18nDom.window as unknown as { CodemanI18n: { t(s: string): string; configure(o: object): void } })
.CodemanI18n;
api.configure({ language: 'zh-CN' });
const method = read('app.js').match(/\n {2}async copyResponseViewerMarkdown\(\) \{[\s\S]*?\n {2}\}\n/)?.[0] ?? '';
const toasts = [...method.matchAll(/'((?:No|Copied|Failed)[^']+)'/g)].map((m) => m[1]);
expect(new Set(toasts)).toEqual(
new Set(['No response content to copy', 'Copied response as Markdown', 'Failed to copy response as Markdown'])
);
// Exact strings: a generic rule turns any "Failed to ..." into half-translated text.
expect(api.t('No response content to copy')).toBe('没有可复制的回复内容');
expect(api.t('Copied response as Markdown')).toBe('已将回复复制为 Markdown');
expect(api.t('Failed to copy response as Markdown')).toBe('无法将回复复制为 Markdown');
expect(api.t('Copy Markdown')).toBe('复制 Markdown');
expect(api.t('Copy response as Markdown')).toBe('将回复复制为 Markdown');
i18nDom.window.close();
});
});