From 992e43bc5c59431e86191ed47559d10860682a27 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 31 Oct 2026 15:39:36 -0400 Subject: [PATCH] feat(response-viewer): add a Copy Markdown button The viewer renders the agent's answer, so selecting it loses the source: headings, list markers and code fences do not survive. The only source copy was the per-code-block button. Copy Markdown copies the raw source of the newest answer the viewer holds: every row of its turn, or the last response block when the reader emits no turns. Tool and status blocks share the assistant role and are left out. The copied text is recorded where the views render, so a reopened viewer never copies an earlier answer, and an empty viewer warns instead of copying. The feedback helper replaces the inline copy of the same logic in the code-block button. On phones the header title now truncates so the longer action row stays on screen. --- .changeset/response-copy-markdown.md | 5 + docs/wiki/Mobile-Guide.md | 5 +- docs/wiki/Watching-Agents-Work.md | 8 +- src/web/public/app.js | 57 +++- src/web/public/i18n.js | 5 + src/web/public/index.html | 1 + src/web/public/mobile.css | 21 ++ src/web/public/styles.css | 14 + test/response-viewer-copy-markdown.test.ts | 300 +++++++++++++++++++++ 9 files changed, 405 insertions(+), 11 deletions(-) create mode 100644 .changeset/response-copy-markdown.md create mode 100644 test/response-viewer-copy-markdown.test.ts diff --git a/.changeset/response-copy-markdown.md b/.changeset/response-copy-markdown.md new file mode 100644 index 000000000..e04891cc4 --- /dev/null +++ b/.changeset/response-copy-markdown.md @@ -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. diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index d494864b1..8149bb4ff 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -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. diff --git a/docs/wiki/Watching-Agents-Work.md b/docs/wiki/Watching-Agents-Work.md index 79fd2da21..f43022841 100644 --- a/docs/wiki/Watching-Agents-Work.md +++ b/docs/wiki/Watching-Agents-Work.md @@ -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 diff --git a/src/web/public/app.js b/src/web/public/app.js index 5b7c86c0e..a07c5babf 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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'); @@ -3239,6 +3279,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 @@ -3318,6 +3360,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 = ''; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index fe0fc76b4..6d476b920 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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: '监视中', diff --git a/src/web/public/index.html b/src/web/public/index.html index e82787779..f30e40741 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -733,6 +733,7 @@

Resume Conversation

Last Response
+
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 9a8c3a384..3b3cec05e 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1776,6 +1776,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; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index eaedb2d5c..ebcb25a48 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -14924,6 +14924,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; diff --git a/test/response-viewer-copy-markdown.test.ts b/test/response-viewer-copy-markdown.test.ts new file mode 100644 index 000000000..f29c3d135 --- /dev/null +++ b/test/response-viewer-copy-markdown.test.ts @@ -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(''); +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; + loadFullContext(): Promise; + copyResponseViewerMarkdown(): Promise; + _bindResponseViewerInteractions(body: HTMLElement): void; + _copyText: ReturnType; + showToast: ReturnType; + activeSessionId?: string; + sessions?: Map; +} + +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)._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 = + '
npm test
'; + 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(/
[\s\S]*?<\/div>/)?.[0] ?? ''; + const tag = actions.match(/]*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'); + }); + + 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('', { + 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(); + }); +});