Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { copyResult } from '../../utils/export-visualization';
import { CopyResultButton } from './copy-result-button';

vi.mock('../../utils/export-visualization', () => ({
copyResult: vi.fn(),
}));

declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

async function click(button: HTMLButtonElement) {
await act(async () => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}

describe('CopyResultButton', () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
const target = document.createElement('div');

beforeEach(() => {
vi.useFakeTimers();
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
vi.mocked(copyResult).mockReset();
});

function render(getTarget: () => HTMLElement | null = () => target) {
act(() => {
root.render(<CopyResultButton className="action" getTarget={getTarget} text="raw result" />);
});
return container.querySelector('button')!;
}

it('shows Copied feedback and reverts after the timeout', async () => {
vi.mocked(copyResult).mockResolvedValue(true);
const button = render();
expect(button.title).toBe('Copy result');

await click(button);
expect(copyResult).toHaveBeenCalledWith(target, 'raw result');
expect(button.title).toBe('Copied');

act(() => {
vi.advanceTimersByTime(1500);
});
expect(button.title).toBe('Copy result');
});

it('shows no feedback when the copy fell back to a download', async () => {
vi.mocked(copyResult).mockResolvedValue(false);
const button = render();

await click(button);
expect(button.title).toBe('Copy result');
});

it('does nothing without a target', async () => {
const button = render(() => null);

await click(button);
expect(copyResult).not.toHaveBeenCalled();
});
});
40 changes: 40 additions & 0 deletions apps/ai-studio/src/components/visualize/copy-result-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Check, Copy } from '@phosphor-icons/react';
import { useEffect, useRef, useState } from 'react';

import { copyResult } from '../../utils/export-visualization';

type Props = {
className: string;
getTarget: () => HTMLElement | null;
text: string;
disabled?: boolean;
};

export function CopyResultButton({ className, getTarget, text, disabled }: Props) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);

useEffect(() => () => globalThis.clearTimeout(timerRef.current), []);

async function handleClick() {
const target = getTarget();
if (!target) return;
if (await copyResult(target, text)) {
setCopied(true);
globalThis.clearTimeout(timerRef.current);
timerRef.current = globalThis.setTimeout(() => setCopied(false), 1500);
}
}

return (
<button
type="button"
className={className}
title={copied ? 'Copied' : 'Copy result'}
disabled={disabled}
onClick={() => void handleClick()}
>
{copied ? <Check /> : <Copy />}
</button>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@
color: var(--ax-txt-primary-default, #151516);
}

.action:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}

.body {
max-height: 20rem;
overflow-y: auto;
Expand Down
26 changes: 9 additions & 17 deletions apps/ai-studio/src/components/visualize/visualize-card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ArrowsOut, ClipboardText, Copy, DownloadSimple, Eye } from '@phosphor-icons/react';
import { ArrowsOut, DownloadSimple, Eye } from '@phosphor-icons/react';
import { getStoreEdges, getStoreNodes } from '@workflowbuilder/sdk';
import { Suspense, useEffect, useRef, useState } from 'react';

Expand All @@ -8,8 +8,9 @@ import { VISUALIZE_MODES } from '../../nodes/visualize/schema';
import { useExecutionStore } from '../../stores/use-execution-store';
import { adaptVisualization } from '../../utils/adapt-visualization';
import { type VisualizeRenderer, detectFormat } from '../../utils/detect-format';
import { copyImage, copySource, downloadPng } from '../../utils/export-visualization';
import { downloadPng } from '../../utils/export-visualization';
import { extractOutputText } from '../../utils/extract-output-text';
import { CopyResultButton } from './copy-result-button';
import { RENDERER_LABELS, getRenderer } from './renderers';
import { VisualizeModal } from './visualize-modal';

Expand Down Expand Up @@ -111,30 +112,21 @@ export function VisualizeCard({ props }: Props) {
<button type="button" className={styles['action']} title="Expand" onClick={() => setExpanded(true)}>
<ArrowsOut />
</button>
<button
type="button"
<CopyResultButton
className={styles['action']}
title="Copy image"
onClick={() => contentRef.current && void copyImage(contentRef.current)}
>
<Copy />
</button>
getTarget={() => contentRef.current}
text={renderText}
disabled={adapting}
/>
<button
type="button"
className={styles['action']}
title="Download PNG"
disabled={adapting}
onClick={() => contentRef.current && void downloadPng(contentRef.current)}
>
<DownloadSimple />
</button>
<button
type="button"
className={styles['action']}
title="Copy source text"
onClick={() => void copySource(renderText)}
>
<ClipboardText />
</button>
</div>
</div>
<div className={styles['body']}>
Expand Down
22 changes: 4 additions & 18 deletions apps/ai-studio/src/components/visualize/visualize-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ClipboardText, Copy, DownloadSimple, FileSvg, X } from '@phosphor-icons/react';
import { DownloadSimple, FileSvg, X } from '@phosphor-icons/react';
import { Suspense, useRef } from 'react';
import { createPortal } from 'react-dom';

import styles from './visualize-modal.module.css';

import type { VisualizeRenderer } from '../../utils/detect-format';
import { copyImage, copySource, downloadPng, downloadSvg } from '../../utils/export-visualization';
import { downloadPng, downloadSvg } from '../../utils/export-visualization';
import { CopyResultButton } from './copy-result-button';
import { getRenderer } from './renderers';

type Props = {
Expand All @@ -29,14 +30,7 @@ export function VisualizeModal({ renderer, text, data, badge, isVector, onClose
<span className={styles['title']}>Visualize</span>
<span className={styles['badge']}>{badge}</span>
<div className={styles['actions']}>
<button
type="button"
className={styles['action']}
title="Copy image"
onClick={() => contentRef.current && void copyImage(contentRef.current)}
>
<Copy />
</button>
<CopyResultButton className={styles['action']} getTarget={() => contentRef.current} text={text} />
<button
type="button"
className={styles['action']}
Expand All @@ -55,14 +49,6 @@ export function VisualizeModal({ renderer, text, data, badge, isVector, onClose
<FileSvg />
</button>
)}
<button
type="button"
className={styles['action']}
title="Copy source text"
onClick={() => void copySource(text)}
>
<ClipboardText />
</button>
<button type="button" className={styles['action']} title="Close" onClick={onClose}>
<X weight="bold" />
</button>
Expand Down
1 change: 1 addition & 0 deletions apps/ai-studio/src/data/ai-debate-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Be decisive.`,
properties: {
label: 'Visualize',
description: 'Renders the verdict (auto-detects the format).',
mode: 'auto',
},
type: 'ai-studio/visualize',
icon: 'Eye',
Expand Down
1 change: 1 addition & 0 deletions apps/ai-studio/src/data/content-repurposer-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ Keep each draft's wording as-is - do not rewrite it. Just organize and label.`,
properties: {
label: 'Visualize',
description: 'Renders the content pack (auto-detects the format).',
mode: 'auto',
},
type: 'ai-studio/visualize',
icon: 'Eye',
Expand Down
1 change: 1 addition & 0 deletions apps/ai-studio/src/data/meeting-notes-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ line "_Meeting Bot_".`,
properties: {
label: 'Visualize',
description: 'Renders the recap (auto-detects the format).',
mode: 'auto',
},
type: 'ai-studio/visualize',
icon: 'Eye',
Expand Down
1 change: 1 addition & 0 deletions apps/ai-studio/src/data/research-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Only state things you found via search. If a claim isn't supported by a result,
properties: {
label: 'Visualize',
description: 'Renders the research brief (auto-detects the format).',
mode: 'auto',
},
type: 'ai-studio/visualize',
icon: 'Eye',
Expand Down
1 change: 1 addition & 0 deletions apps/ai-studio/src/data/support-triage-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ If not, output "⚠️ NEEDS REVISION" followed by specific, actionable fixes.`,
properties: {
label: 'Visualize',
description: 'Visualizes the approved reply (auto-detects the format).',
mode: 'auto',
},
type: 'ai-studio/visualize',
icon: 'Eye',
Expand Down
20 changes: 9 additions & 11 deletions apps/ai-studio/src/utils/export-visualization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,23 @@ export async function downloadPng(element: HTMLElement, filename = 'visualizatio
triggerDownload(dataUrl, filename);
}

// Writes both formats in one clipboard entry: pasting yields the image where
// images are accepted and the plain text of the result in text-only fields.
// Returns false when it falls back to a download (e.g. Firefox can't write image blobs to the clipboard).
export async function copyImage(element: HTMLElement): Promise<boolean> {
export async function copyResult(element: HTMLElement, text: string): Promise<boolean> {
const blob = await toBlob(element, PNG_OPTIONS);
if (!blob) {
return false;
}
const canCopyImage = typeof ClipboardItem !== 'undefined' && Boolean(navigator.clipboard?.write);
if (canCopyImage) {
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
'text/plain': new Blob([text], { type: 'text/plain' }),
}),
]);
return true;
} catch {
// fall back to download
Expand All @@ -44,12 +51,3 @@ export async function downloadSvg(element: HTMLElement, filename = 'visualizatio
const dataUrl = await toSvg(element);
triggerDownload(dataUrl, filename);
}

export async function copySource(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
7 changes: 7 additions & 0 deletions apps/backend/src/routes/executions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ export function createExecutionsRoutes(
return c.json({ code: 'execution_not_found', message: 'Execution not found' }, 404);
}

// Any nginx hop between us and the browser buffers this stream into its
// proxy buffers by default and flushes only at stream close, turning live
// progress into one end-of-run burst. nginx honors this header from the
// upstream response and disables buffering per-response, covering hops
// whose config we don't own (e.g. a TLS-terminating host nginx).
c.header('X-Accel-Buffering', 'no');

return streamSSE(c, async (stream) => {
// Catch-up snapshot. Reuses the same incremental query (afterSequence=0)
// that powers live drains — one query shape across the route, not two.
Expand Down
6 changes: 6 additions & 0 deletions deploy/ai-studio/nginx/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ server {
proxy_cache off;
proxy_read_timeout 1h;
gzip off;
# nginx hides upstream X-Accel-* headers, so the backend's own
# X-Accel-Buffering dies at this hop. Re-emit it for whatever
# TLS-terminating proxy sits in front (README "TLS / going public");
# a default-config host nginx would otherwise buffer the whole
# stream and deliver it only at stream close.
add_header X-Accel-Buffering no;
}

location /api/ {
Expand Down
Loading