mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge #810: a Copy Markdown button in the response viewer
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
commit
b4e44c4f6a
9 files changed
+405
-11
No files matched your search
@@ -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.
|
||||
@@ -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. Its header button
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+51
-6
@@ -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');
|
||||
@@ -3241,6 +3281,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
|
||||
@@ -3320,6 +3362,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 = '';
|
||||
|
||||
@@ -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: '监视中',
|
||||
|
||||
@@ -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()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1804,6 +1804,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;
|
||||
|
||||
@@ -14946,6 +14946,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;
|
||||
|
||||
@@ -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('<!DOCTYPE html><html><body></body></html>');
|
||||
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<void>;
|
||||
loadFullContext(): Promise<void>;
|
||||
copyResponseViewerMarkdown(): Promise<void>;
|
||||
_bindResponseViewerInteractions(body: HTMLElement): void;
|
||||
_copyText: ReturnType<typeof vi.fn>;
|
||||
showToast: ReturnType<typeof vi.fn>;
|
||||
activeSessionId?: string;
|
||||
sessions?: Map<string, { mode: string }>;
|
||||
}
|
||||
|
||||
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<ViewerApp>)._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 =
|
||||
'<div class="rv-code-wrap"><button class="rv-copy-btn"></button><pre><code>npm test</code></pre></div>';
|
||||
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(/<div class="response-viewer-actions">[\s\S]*?<\/div>/)?.[0] ?? '';
|
||||
const tag = actions.match(/<button[^>]*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</button>');
|
||||
});
|
||||
|
||||
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('<!doctype html><html><body></body></html>', {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user