diff --git a/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx b/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx new file mode 100644 index 000000000..e3837defe --- /dev/null +++ b/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx @@ -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; + 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(); + }); + 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(); + }); +}); diff --git a/apps/ai-studio/src/components/visualize/copy-result-button.tsx b/apps/ai-studio/src/components/visualize/copy-result-button.tsx new file mode 100644 index 000000000..7b3100de0 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/copy-result-button.tsx @@ -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>(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 ( + + ); +} diff --git a/apps/ai-studio/src/components/visualize/visualize-card.module.css b/apps/ai-studio/src/components/visualize/visualize-card.module.css index a89c57d1c..90a45315a 100644 --- a/apps/ai-studio/src/components/visualize/visualize-card.module.css +++ b/apps/ai-studio/src/components/visualize/visualize-card.module.css @@ -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; diff --git a/apps/ai-studio/src/components/visualize/visualize-card.tsx b/apps/ai-studio/src/components/visualize/visualize-card.tsx index 13944b2f0..ce2bb4b27 100644 --- a/apps/ai-studio/src/components/visualize/visualize-card.tsx +++ b/apps/ai-studio/src/components/visualize/visualize-card.tsx @@ -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'; @@ -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'; @@ -111,30 +112,21 @@ export function VisualizeCard({ props }: Props) { - + getTarget={() => contentRef.current} + text={renderText} + disabled={adapting} + /> -
diff --git a/apps/ai-studio/src/components/visualize/visualize-modal.tsx b/apps/ai-studio/src/components/visualize/visualize-modal.tsx index 775dc8717..43abe9c7e 100644 --- a/apps/ai-studio/src/components/visualize/visualize-modal.tsx +++ b/apps/ai-studio/src/components/visualize/visualize-modal.tsx @@ -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 = { @@ -29,14 +30,7 @@ export function VisualizeModal({ renderer, text, data, badge, isVector, onClose Visualize {badge}
- + contentRef.current} text={text} /> )} - diff --git a/apps/ai-studio/src/data/ai-debate-flow.ts b/apps/ai-studio/src/data/ai-debate-flow.ts index d469b66e3..2a120bfec 100644 --- a/apps/ai-studio/src/data/ai-debate-flow.ts +++ b/apps/ai-studio/src/data/ai-debate-flow.ts @@ -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', diff --git a/apps/ai-studio/src/data/content-repurposer-flow.ts b/apps/ai-studio/src/data/content-repurposer-flow.ts index df469fc47..0f65b9f6d 100644 --- a/apps/ai-studio/src/data/content-repurposer-flow.ts +++ b/apps/ai-studio/src/data/content-repurposer-flow.ts @@ -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', diff --git a/apps/ai-studio/src/data/meeting-notes-flow.ts b/apps/ai-studio/src/data/meeting-notes-flow.ts index 7189a5498..d12ea647f 100644 --- a/apps/ai-studio/src/data/meeting-notes-flow.ts +++ b/apps/ai-studio/src/data/meeting-notes-flow.ts @@ -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', diff --git a/apps/ai-studio/src/data/research-flow.ts b/apps/ai-studio/src/data/research-flow.ts index 444006d97..64f39ab69 100644 --- a/apps/ai-studio/src/data/research-flow.ts +++ b/apps/ai-studio/src/data/research-flow.ts @@ -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', diff --git a/apps/ai-studio/src/data/support-triage-flow.ts b/apps/ai-studio/src/data/support-triage-flow.ts index 7fa6b06a6..e98f5454e 100644 --- a/apps/ai-studio/src/data/support-triage-flow.ts +++ b/apps/ai-studio/src/data/support-triage-flow.ts @@ -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', diff --git a/apps/ai-studio/src/utils/export-visualization.ts b/apps/ai-studio/src/utils/export-visualization.ts index cdfc8e139..29005a7de 100644 --- a/apps/ai-studio/src/utils/export-visualization.ts +++ b/apps/ai-studio/src/utils/export-visualization.ts @@ -14,8 +14,10 @@ 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 { +export async function copyResult(element: HTMLElement, text: string): Promise { const blob = await toBlob(element, PNG_OPTIONS); if (!blob) { return false; @@ -23,7 +25,12 @@ export async function copyImage(element: HTMLElement): Promise { 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 @@ -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 { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } -} diff --git a/apps/backend/src/routes/executions.ts b/apps/backend/src/routes/executions.ts index 0934f645b..57580ba27 100644 --- a/apps/backend/src/routes/executions.ts +++ b/apps/backend/src/routes/executions.ts @@ -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. diff --git a/deploy/ai-studio/nginx/default.conf b/deploy/ai-studio/nginx/default.conf index 501c28d4f..c3677d506 100644 --- a/deploy/ai-studio/nginx/default.conf +++ b/deploy/ai-studio/nginx/default.conf @@ -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/ {