diff --git a/.changeset/response-copy-markdown.md b/.changeset/response-copy-markdown.md new file mode 100644 index 00000000..e04891cc --- /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 d494864b..8149bb4f 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 79fd2da2..f4302284 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 5b7c86c0..a07c5bab 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 fe0fc76b..6d476b92 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 e8278777..f30e4074 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -733,6 +733,7 @@ Last Response
+
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 9a8c3a38..3b3cec05 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 eaedb2d5..ebcb25a4 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 00000000..f29c3d13 --- /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(); + }); +});