Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
79 changes: 79 additions & 0 deletions apps/ai-studio/src/components/visualize/copy-image-button.spec.tsx
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 { copyImage } from '../../utils/export-visualization';
import { CopyImageButton } from './copy-image-button';

vi.mock('../../utils/export-visualization', () => ({
copyImage: 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('CopyImageButton', () => {
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(copyImage).mockReset();
});

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

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

await click(button);
expect(copyImage).toHaveBeenCalledWith(target);
expect(button.title).toBe('Copied');

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

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

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

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

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

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

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

export function CopyImageButton({ className, getTarget, 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 copyImage(target)) {
setCopied(true);
globalThis.clearTimeout(timerRef.current);
timerRef.current = globalThis.setTimeout(() => setCopied(false), 1500);
}
}

return (
<button
type="button"
className={className}
title={copied ? 'Copied' : 'Copy image'}
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
23 changes: 5 additions & 18 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 { CopyImageButton } from './copy-image-button';
import { RENDERER_LABELS, getRenderer } from './renderers';
import { VisualizeModal } from './visualize-modal';

Expand Down Expand Up @@ -111,30 +112,16 @@ export function VisualizeCard({ props }: Props) {
<button type="button" className={styles['action']} title="Expand" onClick={() => setExpanded(true)}>
<ArrowsOut />
</button>
<button
type="button"
className={styles['action']}
title="Copy image"
onClick={() => contentRef.current && void copyImage(contentRef.current)}
>
<Copy />
</button>
<CopyImageButton className={styles['action']} getTarget={() => contentRef.current} 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 { CopyImageButton } from './copy-image-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>
<CopyImageButton className={styles['action']} getTarget={() => contentRef.current} />
<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
9 changes: 0 additions & 9 deletions apps/ai-studio/src/utils/export-visualization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,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