Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 52 additions & 15 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pre><code>.
const copyBtn = ev.target.closest('.rv-copy-btn');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand Down
21 changes: 21 additions & 0 deletions src/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
119 changes: 119 additions & 0 deletions test/response-viewer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string, Record<string, unknown>>) {
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<typeof fetch>) => 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<string, { mode: string }>;
toggleResponseViewer: () => Promise<void>;
};
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<typeof vi.fn>;
showToast: ReturnType<typeof vi.fn>;
_copyResponseViewerChunk: (body: object, event: object) => Promise<boolean>;
};
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();
});
});