You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/10/09 14:33:46 UTC
(skywalking-horizon-ui) 01/01: fix(conversation-view): copy over plain HTTP, with each copy button in its title
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/conversation-view-copy-insecure-context
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 4bb4d3bb6702dd06f217bacdeff3ce4059668425
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Oct 9 22:33:32 2026 +0800
fix(conversation-view): copy over plain HTTP, with each copy button in its title
A browser gives a page the Clipboard API only in a secure context, HTTPS
or localhost. On a page served over plain HTTP from any other address,
every copy button of the conversation page did nothing and said nothing,
because the copy returned early when the API was missing. The copy now
falls back to execCommand on a hidden textarea, inside the click, as the
share-link copy in the layer shell already does. It also falls back when
the browser refuses the API. The button says "copied" only when one of
the two worked.
The Prompt tab drew each copy button after the text it copies, so for a
long body the button sat after the preview, or at the end of the whole
JSON once opened. Each button is now in the title of what it copies: a
message that is one text has it on the message head, a labelled block on
its label, a tool on its name, and the whole body and the settings on
the section head. A section's button copies while the section is closed.
---
docs/changelog/1.1.0.md | 2 +-
packages/conversation-view/src/styles.css | 5 +
packages/conversation-view/src/view/prompt.ts | 132 ++++++++++++++-------
packages/conversation-view/src/view/structured.ts | 36 +++++-
.../test/prompt-langchain.test.ts | 29 ++++-
.../conversation-view/test/prompt-panel.test.ts | 76 +++++++++++-
packages/conversation-view/test/structured.test.ts | 68 ++++++++++-
7 files changed, 288 insertions(+), 60 deletions(-)
diff --git a/docs/changelog/1.1.0.md b/docs/changelog/1.1.0.md
index cf85ed02..3856c032 100644
--- a/docs/changelog/1.1.0.md
+++ b/docs/changelog/1.1.0.md
@@ -30,7 +30,7 @@
**The Conversations tab says what each conversation did.** Its columns are now Talks, **Model calls**, **Subagents**, **Bash runs** and **Changes**: the change records the Sessionizer captured as of the newest round, one per observation (a watched tool call per workspace root, the agent's own patch of an edit, a scan that found nothing, a change seen between calls that no call made), with the plus-over-minus mark and the lines those records added and removed, summed over the records, bes [...]
-**The conversation page shows the prompt each model call sent, and the answer it got.** When the AI Sessionizer captured the bodies a call exchanged with its model provider, the call's step gains a **Prompt** tab: the exact request as it was sent and the exact response as it came back, neither of which a transcript holds. The bodies are not part of the conversation document — they are megabytes per call — so the tab opens on an offer stating how many stored files of the session it needs [...]
+**The conversation page shows the prompt each model call sent, and the answer it got.** When the AI Sessionizer captured the bodies a call exchanged with its model provider, the call's step gains a **Prompt** tab: the exact request as it was sent and the exact response as it came back, neither of which a transcript holds. The bodies are not part of the conversation document — they are megabytes per call — so the tab opens on an offer stating how many stored files of the session it needs [...]
**The Prompt tab reads a LangChain or LangGraph agent's calls.** For such an agent the Sessionizer stores what LangChain handed its model, a list of LangChain's own serialized messages, and the tab drew each of those requests as one empty message. It now shows every message in LangChain's words, `system`, `human`, `ai` and `tool`, with the tool calls an `ai` message made, each drawn once with the arguments LangChain parsed even where the provider repeats it in the message content, and ea [...]
diff --git a/packages/conversation-view/src/styles.css b/packages/conversation-view/src/styles.css
index 781d6742..711f664b 100644
--- a/packages/conversation-view/src/styles.css
+++ b/packages/conversation-view/src/styles.css
@@ -507,6 +507,11 @@ select.acv-stream-btn { border: 1px solid var(--acv-line); background: var(--acv
.acv-prompt-head-btn { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.acv-prompt-head-btn:hover { background: color-mix(in srgb, var(--sw-info) 4%, transparent); }
.acv-prompt-caret { margin-left: auto; color: var(--acv-faint); }
+/* The copy button sits beside the toggle, not inside it: HTML allows no button inside a button. */
+.acv-prompt-section-head { display: flex; align-items: center; }
+.acv-prompt-section-head > .acv-prompt-head-btn { flex: 1; min-width: 0; }
+.acv-prompt-section-head > .acv-copy { margin-right: 11px; }
+.acv-prompt-block > .acv-kicker > .acv-copy:first-child { margin-left: 0; }
.acv-prompt-body { padding: 4px 11px 11px; }
.acv-prompt-message { margin: 10px 0; border: 1px solid var(--acv-line); border-radius: 7px; overflow: hidden; }
.acv-prompt-message-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; padding: 5px 9px; border-bottom: 1px solid var(--acv-line); background: var(--acv-surface-2); }
diff --git a/packages/conversation-view/src/view/prompt.ts b/packages/conversation-view/src/view/prompt.ts
index a2d8c95c..77b6d84c 100644
--- a/packages/conversation-view/src/view/prompt.ts
+++ b/packages/conversation-view/src/view/prompt.ts
@@ -194,9 +194,11 @@ function drawSide(
} else if (read.text != null) {
// the body rebuilt and matched its digest but is not JSON — a provider's plain error, say. It is
// shown as the text it is; nothing about it is known beyond that.
- panel = section(ctx, e, 'raw', s.promptWholeBody, '', block(ctx, { kind: 'text', text: read.text }, `${e.id}|${state.promptSide}|raw`), true);
+ const text = `<div class="acv-prompt-block">${textBlock(ctx, read.text, `${e.id}|${state.promptSide}|raw`)}</div>`;
+ panel = section(ctx, e, 'raw', s.promptWholeBody, '', text, { open: true, copy: read.text });
} else {
- panel = section(ctx, e, 'raw', s.promptWholeBody, '', json(ctx, read.raw, `${e.id}|${state.promptSide}|raw`), true);
+ const raw = pretty(read.raw);
+ panel = section(ctx, e, 'raw', s.promptWholeBody, '', json(ctx, raw, `${e.id}|${state.promptSide}|raw`), { open: true, copy: raw });
}
return panel;
}
@@ -278,13 +280,13 @@ function drawRequest(ctx: ViewContext, e: Step, read: ReadRequest, sides: Sides,
fill(s.promptMessages, { count: String(read.messages.length) }),
fill(s.promptMessagesNote, { bytes: f.number(new TextEncoder().encode(JSON.stringify(read.raw['messages'])).length) }),
read.messages.map((m, i) => message(ctx, m, i + 1, read.messages.length, `${e.id}|req|msg|${i}`)).join(''),
- true,
+ { open: true },
);
// A LangChain request carries nothing but its messages, and an empty section says nothing.
const settings = Object.keys(read.rest).length
- ? section(ctx, e, 'settings', s.promptSettings, Object.keys(read.rest).join(', '), json(ctx, read.rest, `${e.id}|req|set`))
+ ? documentSection(ctx, e, 'settings', s.promptSettings, Object.keys(read.rest).join(', '), read.rest, `${e.id}|req|set`)
: '';
- const whole = section(ctx, e, 'request-raw', s.promptWholeBody, '', json(ctx, read.raw, `${e.id}|req|raw`));
+ const whole = documentSection(ctx, e, 'request-raw', s.promptWholeBody, '', read.raw, `${e.id}|req|raw`);
return `${modes}${system}${tools}${messages}${settings}${whole}`;
}
@@ -374,27 +376,49 @@ function drawResponse(ctx: ViewContext, e: Step, read: ReadResponse): string {
// What the sections above do not show: the model, the type, a stop sequence, and whatever else the
// provider sent. A body that verified is shown whole or not at all.
const rest = Object.keys(read.rest).length
- ? section(ctx, e, 'response-settings', s.promptSettings, Object.keys(read.rest).join(', '), json(ctx, read.rest, `${e.id}|res|rest`))
+ ? documentSection(ctx, e, 'response-settings', s.promptSettings, Object.keys(read.rest).join(', '), read.rest, `${e.id}|res|rest`)
: '';
- const whole = section(ctx, e, 'response-raw', s.promptWholeBody, '', json(ctx, read.raw, `${e.id}|res|raw`));
+ const whole = documentSection(ctx, e, 'response-raw', s.promptWholeBody, '', read.raw, `${e.id}|res|raw`);
return `<div class="acv-prompt-facts">${facts}</div>${usage}${blocks}${rest}${whole}`;
}
-/** One collapsible section, remembered per step so a reader's choice survives redraws. */
-function section(ctx: ViewContext, e: Step, key: string, title: string, note: string, inner: string, openByDefault = false): string {
+/**
+ * One collapsible section, remembered per step so a reader's choice survives redraws.
+ *
+ * A section that holds one document carries its copy button in its title row, outside the toggle,
+ * so the whole document copies while the section is still closed.
+ */
+function section(
+ ctx: ViewContext,
+ e: Step,
+ key: string,
+ title: string,
+ note: string,
+ inner: string,
+ opts: { open?: boolean; copy?: string } = {},
+): string {
const id = `${e.id}|${key}`;
- const open = ctx.state.openPromptSections.has(id) || (openByDefault && !ctx.state.openPromptSections.has(`-${id}`));
+ const open = ctx.state.openPromptSections.has(id) || (!!opts.open && !ctx.state.openPromptSections.has(`-${id}`));
return `
- <section class="acv-prompt-section">
- <button type="button" class="acv-prompt-head-btn" data-prompt-section="${esc(id)}" aria-expanded="${open}">
- <span class="acv-kicker">${esc(title)}</span>
- ${note ? `<span class="acv-faint">${esc(note)}</span>` : ''}
- <span class="acv-prompt-caret">${open ? '▾' : '▸'}</span>
- </button>
+ <section class="acv-prompt-section"${opts.copy != null ? ' data-copy-scope' : ''}>
+ <div class="acv-prompt-section-head">
+ <button type="button" class="acv-prompt-head-btn" data-prompt-section="${esc(id)}" aria-expanded="${open}">
+ <span class="acv-kicker">${esc(title)}</span>
+ ${note ? `<span class="acv-faint">${esc(note)}</span>` : ''}
+ <span class="acv-prompt-caret">${open ? '▾' : '▸'}</span>
+ </button>
+ ${opts.copy != null ? copyOf(ctx, opts.copy) : ''}
+ </div>
${open ? `<div class="acv-prompt-body">${inner}</div>` : ''}
</section>`;
}
+/** A section that is one JSON value, such as the whole body or its settings. */
+function documentSection(ctx: ViewContext, e: Step, key: string, title: string, note: string, value: unknown, textKey: string): string {
+ const text = pretty(value);
+ return section(ctx, e, key, title, note, json(ctx, text, textKey), { copy: text });
+}
+
/**
* One message, in a box of its own with its place in the request on it.
*
@@ -404,13 +428,16 @@ function section(ctx: ViewContext, e: Step, key: string, title: string, note: st
*/
function message(ctx: ViewContext, m: PromptMessage, n: number, total: number, key: string): string {
const { s } = ctx;
+ // A message that is one text with no label of its own has no title but the message's, so its copy
+ // button goes there rather than on a title row of its own.
+ const only = m.blocks.length === 1 && m.blocks[0]!.kind === 'text' && !m.blocks[0]!.reminder ? m.blocks[0]! : null;
return `
- <div class="acv-prompt-message">
+ <div class="acv-prompt-message"${only ? ' data-copy-scope' : ''}>
<div class="acv-prompt-message-head">
- <span class="acv-prompt-role">${esc(m.role || s.promptUnknownRole)}</span>
+ <span><span class="acv-prompt-role">${esc(m.role || s.promptUnknownRole)}</span>${only ? copyOf(ctx, only.text ?? '') : ''}</span>
<span class="acv-faint">${esc(fill(s.promptMessageOf, { n: String(n), total: String(total) }))}</span>
</div>
- <div class="acv-prompt-message-body">${m.blocks.map((b, i) => block(ctx, b, `${key}|${i}`)).join('')}</div>
+ <div class="acv-prompt-message-body">${m.blocks.map((b, i) => block(ctx, b, `${key}|${i}`, b === only)).join('')}</div>
</div>`;
}
@@ -420,9 +447,11 @@ function message(ctx: ViewContext, m: PromptMessage, n: number, total: number, k
* Every block is drawn the same way, whatever it holds: a label saying what it is, then the content
* in a card of its own with a coloured edge naming its kind, the page's own colour for that kind.
* A message holds several of these, and a reader has to be able to see where one ends — which text
- * the runtime injected, and which of them the person wrote.
+ * the runtime injected, and which of them the person wrote. The label row is the block's title and
+ * carries its copy button; a text with no label gets a row with the button alone, unless the
+ * message's own head is its title.
*/
-function block(ctx: ViewContext, b: PromptBlock, key: string): string {
+function block(ctx: ViewContext, b: PromptBlock, key: string, headed = false): string {
const { s } = ctx;
if (b.kind === 'text' || b.kind === 'thinking') {
const text = b.text ?? '';
@@ -430,29 +459,33 @@ function block(ctx: ViewContext, b: PromptBlock, key: string): string {
// The label is the block's own, never the message's: a message whose first block the runtime
// injected usually carries the person's text as its second, and the two must not read alike.
const mark = b.kind === 'thinking' ? ' thinking' : b.reminder ? ' injected' : '';
+ if (headed) return `<div class="acv-prompt-block${mark}">${textBlock(ctx, text, key)}</div>`;
return `
- <div class="acv-prompt-block${mark}">
- ${label ? `<div class="acv-kicker">${esc(label)}</div>` : ''}
+ <div class="acv-prompt-block${mark}" data-copy-scope>
+ <div class="acv-kicker">${esc(label)}${copyOf(ctx, text)}</div>
${textBlock(ctx, text, key)}
</div>`;
}
if (b.kind === 'tool_use') {
+ const input = pretty(b.json);
return `
- <div class="acv-prompt-block tool">
- <div class="acv-kicker">${esc(fill(s.promptToolUse, { name: b.name ?? '' }))}${blockId(ctx, b)}</div>
- ${json(ctx, b.json, key)}
+ <div class="acv-prompt-block tool" data-copy-scope>
+ <div class="acv-kicker">${esc(fill(s.promptToolUse, { name: b.name ?? '' }))}${blockId(ctx, b)}${copyOf(ctx, input)}</div>
+ ${json(ctx, input, key)}
</div>`;
}
if (b.kind === 'tool_result') {
+ const result = b.text ?? pretty(b.json);
// a result that failed and one that did not can carry the same text, and the panel must not make
// them look alike
return `
- <div class="acv-prompt-block tool${b.failed ? ' failed' : ''}">
- <div class="acv-kicker">${esc(b.failed ? s.promptToolFailed : s.promptToolResult)}${blockId(ctx, b)}</div>
- ${b.text != null ? textBlock(ctx, b.text, key) : json(ctx, b.json, key)}
+ <div class="acv-prompt-block tool${b.failed ? ' failed' : ''}" data-copy-scope>
+ <div class="acv-kicker">${esc(b.failed ? s.promptToolFailed : s.promptToolResult)}${blockId(ctx, b)}${copyOf(ctx, result)}</div>
+ ${b.text != null ? textBlock(ctx, result, key) : json(ctx, result, key)}
</div>`;
}
- return `<div class="acv-prompt-block"><div class="acv-kicker">${esc(b.kind)}</div>${json(ctx, b.json, key)}</div>`;
+ const value = pretty(b.json);
+ return `<div class="acv-prompt-block" data-copy-scope><div class="acv-kicker">${esc(b.kind)}${copyOf(ctx, value)}</div>${json(ctx, value, key)}</div>`;
}
/** The tool call a block is: its own id, or the one a result answers. */
@@ -462,11 +495,12 @@ function blockId(ctx: ViewContext, b: PromptBlock): string {
function tool(ctx: ViewContext, t: { name: string; description: string; raw: unknown }, key: string): string {
const first = t.description.split('\n')[0] ?? '';
+ const definition = pretty(t.raw);
return `
- <div class="acv-prompt-tool">
- <div class="acv-prompt-tool-name mono">${esc(t.name)}</div>
+ <div class="acv-prompt-tool" data-copy-scope>
+ <div class="acv-prompt-tool-name mono">${esc(t.name)}${copyOf(ctx, definition)}</div>
<div class="acv-faint">${esc(first.length > 200 ? `${first.slice(0, 200)}…` : first)}</div>
- ${json(ctx, t.raw, key)}
+ ${json(ctx, definition, key)}
</div>`;
}
@@ -476,31 +510,37 @@ function blockSummary(ctx: ViewContext, blocks: PromptBlock[]): string {
}
/**
- * A text, cut short until the reader asks for the rest.
- *
- * The whole text is always in the page, hidden beside the button that copies it, so the copy is of
- * everything whatever is shown. What the reader opened is remembered per text, the way the
- * transcript remembers it, so it survives moving between the request and the response.
+ * The copy button for a title, with the whole text it copies hidden beside it. The text is never
+ * the preview the panel shows, so a long text copies whole while it is still cut short.
+ */
+function copyOf(ctx: ViewContext, text: string): string {
+ return `${copyButton(ctx.s)}<span class="acv-copy-src" hidden>${esc(text)}</span>`;
+}
+
+function pretty(value: unknown): string {
+ return JSON.stringify(value, null, 2) ?? '';
+}
+
+/**
+ * A text, cut short until the reader asks for the rest. What the reader opened is remembered per
+ * text, the way the transcript remembers it, so it survives moving between the request and the
+ * response.
*/
function textBlock(ctx: ViewContext, text: string, key: string): string {
const { s, state } = ctx;
const long = text.length > PREVIEW;
const open = long && state.openTexts.has(key);
const more = long && !open ? `<button type="button" class="acv-linkish acv-text-more" data-text-toggle="${esc(key)}">${esc(s.showAllLines)}</button>` : '';
- return `<div class="acv-block" data-copy-scope>${esc(open || !long ? text : `${text.slice(0, PREVIEW)}…`)}${copyButton(
- s,
- )}<span class="acv-copy-src" hidden>${esc(text)}</span></div>${more}`;
+ return `<div class="acv-block">${esc(open || !long ? text : `${text.slice(0, PREVIEW)}…`)}</div>${more}`;
}
-function json(ctx: ViewContext, value: unknown, key: string): string {
+/** A JSON value as `pretty` writes it, cut short the same way. */
+function json(ctx: ViewContext, text: string, key: string): string {
const { s, state } = ctx;
- const text = JSON.stringify(value, null, 2) ?? '';
const long = text.length > JSON_PREVIEW;
const open = long && state.openTexts.has(key);
const more = long && !open ? `<button type="button" class="acv-linkish acv-text-more" data-text-toggle="${esc(key)}">${esc(s.showAllLines)}</button>` : '';
- return `<pre class="acv-raw" data-copy-scope>${esc(open || !long ? text : `${text.slice(0, JSON_PREVIEW)}…`)}${copyButton(
- s,
- )}<span class="acv-copy-src" hidden>${esc(text)}</span></pre>${more}`;
+ return `<pre class="acv-raw">${esc(open || !long ? text : `${text.slice(0, JSON_PREVIEW)}…`)}</pre>${more}`;
}
function wire(ctx: ViewContext, body: HTMLElement): void {
diff --git a/packages/conversation-view/src/view/structured.ts b/packages/conversation-view/src/view/structured.ts
index 6f1b8865..7cada1f8 100644
--- a/packages/conversation-view/src/view/structured.ts
+++ b/packages/conversation-view/src/view/structured.ts
@@ -282,14 +282,15 @@ export function copyButton(s: Pick<ViewStrings, 'copy'>, title = s.copy): string
* the block it is scoped to. Says so on the button for a moment. */
export function copyField(btn: HTMLElement, copied: string): void {
const text = btn.closest('.acv-field, [data-copy-scope]')?.querySelector('.acv-copy-src, .acv-field-text, .acv-field-value, .acv-copy-text');
- if (!text || typeof navigator === 'undefined' || !navigator.clipboard) return;
+ if (!text) return;
const value = text.querySelector('.acv-faint')
? ''
: Array.from(text.childNodes)
.filter((node) => !(node instanceof HTMLElement && node.classList.contains('acv-field-cut')))
.map((node) => node.textContent ?? '')
.join('');
- void navigator.clipboard.writeText(value).then(() => {
+ void writeClipboard(btn.ownerDocument, value).then((done) => {
+ if (!done) return;
const was = btn.textContent;
btn.textContent = copied;
btn.classList.add('done');
@@ -300,6 +301,37 @@ export function copyField(btn: HTMLElement, copied: string): void {
});
}
+/** A browser has the Clipboard API only in a secure context, HTTPS or
+ * localhost, and a self-hosted Horizon or Sessionizer viewer is often plain
+ * HTTP. Without it the copy goes through a selection instead, still inside
+ * the click, which is the user activation `execCommand` needs. */
+function writeClipboard(doc: Document, value: string): Promise<boolean> {
+ if (typeof navigator === 'undefined' || !navigator.clipboard) return Promise.resolve(copySelection(doc, value));
+ return navigator.clipboard.writeText(value).then(
+ () => true,
+ () => copySelection(doc, value),
+ );
+}
+
+function copySelection(doc: Document, value: string): boolean {
+ const focused = doc.activeElement;
+ const area = doc.createElement('textarea');
+ area.value = value;
+ area.setAttribute('readonly', '');
+ area.style.position = 'fixed';
+ area.style.opacity = '0';
+ doc.body.appendChild(area);
+ area.select();
+ try {
+ return doc.execCommand('copy');
+ } catch {
+ return false;
+ } finally {
+ area.remove();
+ if (focused instanceof HTMLElement && doc.activeElement !== focused) focused.focus({ preventScroll: true });
+ }
+}
+
export interface TextBody {
html: string;
/** Lines the body takes when drawn, for a card's clamp to judge. */
diff --git a/packages/conversation-view/test/prompt-langchain.test.ts b/packages/conversation-view/test/prompt-langchain.test.ts
index 3b073da7..af026602 100644
--- a/packages/conversation-view/test/prompt-langchain.test.ts
+++ b/packages/conversation-view/test/prompt-langchain.test.ts
@@ -192,7 +192,8 @@ function callWithSummary(): string {
}
describe('the prompt panel on a LangChain call', () => {
- it('draws the request message by message, in LangChain words', async () => {
+ /** Mounts the call, loads its bodies, and gives the page's root. */
+ async function openPrompt(): Promise<HTMLElement> {
const step = callWithSummary();
expect(step).not.toBe('');
host = document.createElement('div');
@@ -207,7 +208,12 @@ describe('the prompt panel on a LangChain call', () => {
host.querySelector<HTMLButtonElement>('[data-tab="prompt"]')!.click();
host.querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
await new Promise((r) => setTimeout(r, 0));
- const panel = host.querySelector<HTMLElement>('.acv-inspector-body')!;
+ return host;
+ }
+
+ it('draws the request message by message, in LangChain words', async () => {
+ const root = await openPrompt();
+ const panel = root.querySelector<HTMLElement>('.acv-inspector-body')!;
const roles = [...panel.querySelectorAll('.acv-prompt-role')].map((e) => e.textContent);
expect(roles).toContain('system');
expect(roles).toContain('human');
@@ -220,9 +226,24 @@ describe('the prompt panel on a LangChain call', () => {
// nothing but messages in the body, so no empty settings section
expect(panel.textContent).not.toContain('Settings');
// a LangChain request names no request before it, and nothing is missing
- host.querySelector<HTMLButtonElement>('[data-prompt-whole="0"]')!.click();
- const changes = host.querySelector<HTMLElement>('.acv-inspector-body')!.textContent;
+ root.querySelector<HTMLButtonElement>('[data-prompt-whole="0"]')!.click();
+ const changes = root.querySelector<HTMLElement>('.acv-inspector-body')!.textContent;
expect(changes).toContain('names no request before it');
expect(changes).not.toContain('not among the loaded bodies');
});
+
+ it("puts the copy button of a message that is one text on the message's head", async () => {
+ const written: string[] = [];
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: (t: string) => (written.push(t), Promise.resolve()) },
+ });
+ const panel = (await openPrompt()).querySelector<HTMLElement>('.acv-inspector-body')!;
+ const system = [...panel.querySelectorAll<HTMLElement>('.acv-prompt-message')].find((m) => m.querySelector('.acv-prompt-role')!.textContent === 'system')!;
+ expect(system.querySelector('.acv-prompt-block .acv-kicker')).toBeNull();
+ system.querySelector<HTMLButtonElement>('.acv-prompt-message-head .acv-copy')!.click();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(written).toEqual([system.querySelector('.acv-block')!.textContent]);
+ expect(written[0]).not.toBe('');
+ });
});
diff --git a/packages/conversation-view/test/prompt-panel.test.ts b/packages/conversation-view/test/prompt-panel.test.ts
index e4b561d8..36ebc870 100644
--- a/packages/conversation-view/test/prompt-panel.test.ts
+++ b/packages/conversation-view/test/prompt-panel.test.ts
@@ -209,12 +209,78 @@ describe('the prompt panel', () => {
tab(root, 'prompt')!.click();
panel(root).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
await new Promise((r) => setTimeout(r, 0));
- const copy = panel(root).querySelector<HTMLButtonElement>('.acv-block .acv-copy')!;
- const whole = copy.parentElement!.querySelector('.acv-copy-src')!.textContent!;
- copy.click();
+ // the injected reminder of the first message is long, so the panel shows it cut short
+ const reminder = panel(root).querySelector<HTMLElement>('.acv-prompt-block.injected')!;
+ const preview = reminder.querySelector('.acv-block')!.textContent!;
+ expect(preview).toContain('…');
+ reminder.querySelector<HTMLButtonElement>('.acv-kicker .acv-copy')!.click();
await new Promise((r) => setTimeout(r, 0));
- expect(written).toEqual([whole]);
- expect(written[0]).not.toBe('copy');
+ expect(written).toHaveLength(1);
+ expect(written[0]).toBe(reminder.querySelector('.acv-copy-src')!.textContent);
+ expect(written[0]!.length).toBeGreaterThan(preview.length);
+ expect(written[0]).toContain(preview.slice(0, -1));
+ expect(written[0]).not.toContain('copy');
+ });
+
+ it('puts each copy button in the title of what it copies, never at the end of the text', async () => {
+ const root = mount();
+ const written: string[] = [];
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: (t: string) => (written.push(t), Promise.resolve()) },
+ });
+ tab(root, 'prompt')!.click();
+ panel(root).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(panel(root).querySelectorAll('.acv-block .acv-copy, .acv-raw .acv-copy')).toHaveLength(0);
+ // the assistant's text has no label, and it is one of two blocks, so its title row is the button alone
+ const answer = panel(root).querySelectorAll<HTMLElement>('.acv-prompt-message')[1]!;
+ expect(answer.querySelector('.acv-prompt-message-head .acv-copy')).toBeNull();
+ const text = answer.querySelector<HTMLElement>('.acv-prompt-block:not(.tool) > .acv-kicker')!;
+ expect(text.firstElementChild!.classList.contains('acv-copy')).toBe(true);
+ text.querySelector<HTMLButtonElement>('.acv-copy')!.click();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(written).toEqual(['Reading it.']);
+ written.length = 0;
+ // the whole body copies from its title while its section is still closed
+ const head = panel(root).querySelector<HTMLElement>('[data-prompt-section$="|request-raw"]')!.parentElement!;
+ expect(head.parentElement!.querySelector('.acv-prompt-body')).toBeNull();
+ head.querySelector<HTMLButtonElement>('.acv-copy')!.click();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(Object.keys(JSON.parse(written[0]!) as object)).toContain('messages');
+ head.querySelector<HTMLButtonElement>('[data-prompt-section]')!.click();
+ panel(root).querySelector<HTMLButtonElement>('[data-prompt-section$="|request-raw"]')!.closest('section')!.querySelector<HTMLButtonElement>('[data-text-toggle]')?.click();
+ const shown = panel(root).querySelector('[data-prompt-section$="|request-raw"]')!.closest('section')!.querySelector('.acv-raw')!.textContent;
+ expect(written[0]).toBe(shown);
+ });
+
+ it('copies the whole text on a page served over plain HTTP, which has no Clipboard API', async () => {
+ const root = mount();
+ const written: string[] = [];
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
+ Object.defineProperty(document, 'execCommand', {
+ configurable: true,
+ value: (command: string) => {
+ const area = document.body.querySelector('textarea')!;
+ written.push(`${command}:${area.value.slice(area.selectionStart, area.selectionEnd)}`);
+ return true;
+ },
+ });
+ try {
+ tab(root, 'prompt')!.click();
+ panel(root).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+ await new Promise((r) => setTimeout(r, 0));
+ const reminder = panel(root).querySelector<HTMLElement>('.acv-prompt-block.injected')!;
+ const copy = reminder.querySelector<HTMLButtonElement>('.acv-kicker .acv-copy')!;
+ const whole = reminder.querySelector('.acv-copy-src')!.textContent!;
+ copy.click();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(written).toEqual([`copy:${whole}`]);
+ expect(copy.textContent).toBe('copied');
+ } finally {
+ Reflect.deleteProperty(navigator, 'clipboard');
+ Reflect.deleteProperty(document, 'execCommand');
+ }
});
it('draws what it could rebuild when the server left a file out', async () => {
diff --git a/packages/conversation-view/test/structured.test.ts b/packages/conversation-view/test/structured.test.ts
index f75c5f39..e097dc93 100644
--- a/packages/conversation-view/test/structured.test.ts
+++ b/packages/conversation-view/test/structured.test.ts
@@ -17,10 +17,10 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it } from 'vitest';
import type { AszViewDocument } from '../src/index.js';
import { ConversationModel } from '../src/model.js';
-import { drawStructured, structure, textBody } from '../src/view/structured.js';
+import { copyField, drawStructured, structure, textBody } from '../src/view/structured.js';
const S = { copy: 'copy' };
const changes = JSON.parse(readFileSync(resolve('test/fixtures/workspace-changes.json'), 'utf8')) as AszViewDocument;
@@ -161,3 +161,67 @@ describe('copy buttons and the edit diff', () => {
]);
});
});
+
+describe('copying on a page that has no Clipboard API', () => {
+ /** What a browser copies: the selected part of the element that holds
+ * the selection. jsdom has no execCommand, so the test stands in for it. */
+ function selectionCopy(answer: boolean): string[] {
+ const written: string[] = [];
+ Object.defineProperty(document, 'execCommand', {
+ configurable: true,
+ value: (command: string) => {
+ const area = document.body.querySelector('textarea')!;
+ if (command === 'copy' && answer) written.push(area.value.slice(area.selectionStart, area.selectionEnd));
+ return answer;
+ },
+ });
+ return written;
+ }
+ function field(): HTMLButtonElement {
+ const host = document.createElement('div');
+ host.innerHTML = drawStructured(structure('{"command":"ls -la","description":"list"}')!, S);
+ document.body.appendChild(host);
+ const btn = host.querySelector<HTMLButtonElement>('[data-copy]')!;
+ btn.focus();
+ return btn;
+ }
+ const settle = () => new Promise((r) => setTimeout(r, 0));
+
+ afterEach(() => {
+ Reflect.deleteProperty(navigator, 'clipboard');
+ Reflect.deleteProperty(document, 'execCommand');
+ document.body.innerHTML = '';
+ });
+
+ it('copies through a selection when the page is plain HTTP', async () => {
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
+ const written = selectionCopy(true);
+ const btn = field();
+ copyField(btn, 'copied');
+ await settle();
+ expect(written).toEqual(['ls -la']);
+ expect(btn.textContent).toBe('copied');
+ expect(document.body.querySelector('textarea')).toBeNull();
+ expect(document.activeElement).toBe(btn);
+ });
+
+ it('copies through a selection when the browser refuses the Clipboard API', async () => {
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: () => Promise.reject(new Error('NotAllowedError')) } });
+ const written = selectionCopy(true);
+ const btn = field();
+ copyField(btn, 'copied');
+ await settle();
+ expect(written).toEqual(['ls -la']);
+ expect(btn.textContent).toBe('copied');
+ });
+
+ it('does not say copied when the browser refuses both ways', async () => {
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
+ selectionCopy(false);
+ const btn = field();
+ copyField(btn, 'copied');
+ await settle();
+ expect(btn.textContent).toBe('copy');
+ expect(btn.classList.contains('done')).toBe(false);
+ });
+});