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();
+  });
+});