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.
This commit is contained in:
Aamer Akhter committed 2026-11-11 18:21:19 -05:00
1 parent 8bedbbe793
commit 992e43bc5c
9 files changed
+405 -11

No files matched your search

+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');
@@ -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 = '';
+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
@@ -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;
+14
View File
@@ -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;