diff --git a/src/web/public/app.js b/src/web/public/app.js index 6f953be8..d5462b0b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1840,6 +1840,9 @@ class CodemanApp { _bindResponseViewerInteractions(body) { if (!body || body.dataset.rvBound === '1') return; body.dataset.rvBound = '1'; + body.addEventListener('dblclick', (ev) => { + void this._copyResponseViewerChunk(body, ev); + }); body.addEventListener('click', async (ev) => { // One-click copy: lift the raw source from the sibling
.
const copyBtn = ev.target.closest('.rv-copy-btn');
@@ -1869,6 +1872,51 @@ class CodemanApp {
});
}
+ /**
+ * Copy one semantic response chunk. Transcript source is stored directly on
+ * the owning DOM node so Markdown rendering cannot change clipboard content.
+ */
+ async _copyResponseViewerChunk(body, ev) {
+ const target = ev?.target?.closest ? ev.target : ev?.target?.parentElement;
+ if (!body || !target?.closest) return false;
+ if (
+ target.closest(
+ 'button, a, input, textarea, select, [contenteditable="true"], [data-no-reply-copy]'
+ )
+ ) {
+ return false;
+ }
+
+ const message = target.closest('.rv-message');
+ const sourceElement = message && body.contains(message) ? message : body;
+ const text = sourceElement._codemanCopyText;
+ if (typeof text !== 'string' || !text) return false;
+
+ ev.preventDefault?.();
+ ev.stopPropagation?.();
+ const ok = await this._copyText(text);
+ if (ok) {
+ sourceElement.classList.remove('rv-copy-feedback');
+ // Restart the confirmation animation on intentional repeated copies.
+ void sourceElement.offsetWidth;
+ sourceElement.classList.add('rv-copy-feedback');
+ clearTimeout(sourceElement._rvCopyFeedbackTimer);
+ sourceElement._rvCopyFeedbackTimer = setTimeout(() => {
+ sourceElement.classList.remove('rv-copy-feedback');
+ }, 700);
+ if (typeof MobileTerminalControls !== 'undefined') {
+ MobileTerminalControls.feedback?.('copy');
+ }
+ }
+ this.showToast(
+ ok
+ ? window.codemanT?.('Copied to clipboard') || 'Copied to clipboard'
+ : 'Copy failed',
+ ok ? 'success' : 'error'
+ );
+ return ok;
+ }
+
/**
* Copy text to the clipboard. Prefers the async Clipboard API (secure
* contexts); falls back to a hidden-textarea + execCommand path so copy
@@ -1914,23 +1962,10 @@ class CodemanApp {
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
const data = (await res.json())?.data ?? {};
- let lastResponse = data.text || '';
-
- // Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome.
- // Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and
- // shell sessions have no transcript source at all; for TUI modes
- // (codex/opencode/gemini) it yields repaint garbage, so a clear
- // placeholder beats a messy screen dump there.
- const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude';
- if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) {
- const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
- const termData = (await termRes.json())?.data ?? {};
- if (termData.terminalBuffer) {
- lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
- }
- }
+ const lastResponse = data.text || '';
const body = document.getElementById('responseViewerBody');
+ body._codemanCopyText = lastResponse;
if (lastResponse) {
body.innerHTML = this._renderMarkdown(lastResponse);
this._bindResponseViewerInteractions(body);
@@ -1966,6 +2001,7 @@ class CodemanApp {
const title = document.getElementById('responseViewerTitle');
if (!body) return;
+ body._codemanCopyText = '';
if (messages.length === 0) {
body.textContent = 'No conversation history available';
return;
@@ -1980,6 +2016,7 @@ class CodemanApp {
const div = document.createElement('div');
const isUser = msg.role === 'user';
div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
+ div._codemanCopyText = typeof msg.text === 'string' ? msg.text : '';
const role = document.createElement('div');
role.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 67e2a847..370f0b62 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -10411,6 +10411,27 @@ kbd {
line-height: 1.7;
}
+/* Double-click/double-tap reply copy feedback. `manipulation` keeps panning and
+ pinch zoom while preventing browser double-tap zoom from stealing the gesture. */
+.response-viewer-body,
+.rv-message {
+ touch-action: manipulation;
+}
+
+.response-viewer-body.rv-copy-feedback,
+.rv-message.rv-copy-feedback {
+ animation: rv-copy-confirm 700ms ease-out;
+}
+
+@keyframes rv-copy-confirm {
+ from {
+ box-shadow: inset 0 0 0 2px rgba(109, 219, 127, 0.8);
+ }
+ to {
+ box-shadow: inset 0 0 0 1px rgba(109, 219, 127, 0);
+ }
+}
+
.rv-text p,
.response-viewer-body > p {
margin: 0 0 0.85em;
diff --git a/test/response-viewer.test.ts b/test/response-viewer.test.ts
new file mode 100644
index 00000000..f4631a25
--- /dev/null
+++ b/test/response-viewer.test.ts
@@ -0,0 +1,119 @@
+/**
+ * @fileoverview Response viewer source-selection and copy regressions.
+ */
+import { readFileSync } from 'node:fs';
+import { performance } from 'node:perf_hooks';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+function fakeClassList() {
+ const values = new Set();
+ return {
+ add: (...names: string[]) => names.forEach((name) => values.add(name)),
+ remove: (...names: string[]) => names.forEach((name) => values.delete(name)),
+ contains: (name: string) => values.has(name),
+ };
+}
+
+function loadCodemanAppClass(elements: Record>) {
+ const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
+ const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
+ const context = vm.createContext({
+ console,
+ performance,
+ setInterval: vi.fn(),
+ clearInterval: vi.fn(),
+ setTimeout,
+ clearTimeout,
+ requestAnimationFrame: vi.fn(),
+ HTMLCanvasElement: class HTMLCanvasElement {},
+ WebSocket: { OPEN: 1 },
+ fetch: (...args: Parameters) => global.fetch(...args),
+ document: {
+ addEventListener: vi.fn(),
+ getElementById: (id: string) => elements[id] ?? null,
+ },
+ localStorage: {
+ length: 0,
+ key: vi.fn(),
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ },
+ window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
+ MobileDetection: {},
+ });
+ vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
+ return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
+}
+
+describe('Last Response viewer', () => {
+ it('does not replace an empty transcript response with the full terminal history', async () => {
+ const elements = {
+ responseViewer: { classList: fakeClassList() },
+ responseViewerBackdrop: { classList: fakeClassList() },
+ responseViewerBody: { textContent: '', innerHTML: '', scrollTop: 0 },
+ responseViewerTitle: { textContent: '' },
+ responseViewerMore: { style: { display: '' }, textContent: '' },
+ };
+ const CodemanApp = loadCodemanAppClass(elements);
+ const app = Object.create((CodemanApp as { prototype: object }).prototype) as {
+ activeSessionId: string;
+ sessions: Map;
+ toggleResponseViewer: () => Promise;
+ };
+ app.activeSessionId = 'claude-session';
+ app.sessions = new Map([['claude-session', { mode: 'claude' }]]);
+
+ const fetchMock = vi.fn(async () => ({
+ json: async () => ({ success: true, data: { text: '', timestamp: '' } }),
+ }));
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ await app.toggleResponseViewer();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith('/api/sessions/claude-session/last-response');
+ expect(elements.responseViewerBody.textContent).toContain('No response yet');
+ expect(elements.responseViewerTitle.textContent).toBe('Last Response');
+ });
+
+ it('copies the raw source owned by the selected response message', async () => {
+ const CodemanApp = loadCodemanAppClass({});
+ const app = Object.create((CodemanApp as { prototype: object }).prototype) as {
+ _copyText: ReturnType;
+ showToast: ReturnType;
+ _copyResponseViewerChunk: (body: object, event: object) => Promise;
+ };
+ app._copyText = vi.fn(async () => true);
+ app.showToast = vi.fn();
+
+ const classList = fakeClassList();
+ const message = {
+ _codemanCopyText: 'raw **Markdown**\\n\\n```ts\\nconst value = 1;\\n```',
+ classList,
+ offsetWidth: 10,
+ };
+ const body = {
+ contains: (candidate: unknown) => candidate === message,
+ };
+ const target = {
+ closest: (selector: string) => {
+ if (selector.startsWith('button,')) return null;
+ if (selector === '.rv-message') return message;
+ return null;
+ },
+ };
+ const event = {
+ target,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ };
+
+ await expect(app._copyResponseViewerChunk(body, event)).resolves.toBe(true);
+ expect(app._copyText).toHaveBeenCalledWith(message._codemanCopyText);
+ expect(classList.contains('rv-copy-feedback')).toBe(true);
+ expect(event.preventDefault).toHaveBeenCalled();
+ });
+});