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
Expand Up @@ -3,7 +3,7 @@ export const generativeWidgetToolCardAppearanceDescriptor: AppearanceSurfaceDesc
id: 'generative-widget-tool-card',
parts: [
{ id: 'root' }, { id: 'title' }, { id: 'extra' }, { id: 'status' },
{ id: 'exportAction' }, { id: 'placeholder' }, { id: 'preview' },
{ id: 'exportAction' }, { id: 'htmlExportAction' }, { id: 'placeholder' }, { id: 'preview' },
{ id: 'captureRoot' }, { id: 'exportStage' },
],
states: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
gap: var(--openbitfun-control-flow-chat-control-gap);
}

.generative-widget-card__export-action {
display: inline-flex;
align-items: center;
}

.generative-widget-card__status {
font-size: var(--openbitfun-type-flow-control-font-size);
color: var(--openbitfun-color-content-secondary);
Expand Down
130 changes: 128 additions & 2 deletions src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,147 @@ import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { GenerativeWidgetToolCard } from './GenerativeWidgetToolCard';
import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat';
import type { GenerativeWidgetFrameProps } from '@/tools/generative-widget/GenerativeWidgetFrame';
import { exportGenerativeWidgetHtml } from '@/tools/generative-widget/widgetHtmlExport';
import { notificationService } from '@/shared/notification-system';
import { createTab } from '@/shared/utils/tabUtils';

const frameState = vi.hoisted(() => ({ props: null as GenerativeWidgetFrameProps | null }));

vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>();
const { createTestI18nT } = await import('@/test/i18nTestUtils');
return { ...actual, useTranslation: () => ({ t: createTestI18nT('flow-chat') }) };
});
vi.mock('@/tools/generative-widget/GenerativeWidgetFrame', () => ({ default: () => null }));
vi.mock('@/infrastructure/i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/infrastructure/i18n')>();
const { createTestI18nT } = await import('@/test/i18nTestUtils');
return { ...actual, useI18n: () => ({ t: createTestI18nT('flow-chat'), currentLanguage: 'en-US' }) };
});
vi.mock('@/tools/generative-widget/widgetHtmlExport', () => ({ exportGenerativeWidgetHtml: vi.fn() }));
vi.mock('@/tools/generative-widget/GenerativeWidgetFrame', () => ({
default: (props: GenerativeWidgetFrameProps) => { frameState.props = props; return null; },
}));
vi.mock('@/tools/generative-widget/GenerativeWidgetStaticRenderer', () => ({ default: () => null }));
vi.mock('@/tools/generative-widget/useGenerativeWidgetPromptMenu', () => ({ useGenerativeWidgetPromptMenu: () => vi.fn() }));
vi.mock('@/tools/generative-widget/widgetInteraction', () => ({ handleWidgetBridgeEvent: vi.fn() }));
vi.mock('@/shared/notification-system', () => ({ notificationService: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/shared/utils/tabUtils', () => ({ createTab: vi.fn() }));
vi.mock('../utils/captureElementToDownloadsPng', () => ({ captureElementToDownloadsPng: vi.fn() }));

afterEach(() => vi.unstubAllGlobals());
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); frameState.props = null; });

it('shows image export after the final streamed content is applied without remounting', () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const partialCode = '<p>Partial</p>';
const finalCode = '<p>Complete</p>';
const item: FlowToolItem = {
id: 'streamed-widget', type: 'tool', toolName: 'GenerativeUI', timestamp: 1,
status: 'streaming', isParamsStreaming: true, partialParams: { widget_code: partialCode },
toolCall: { id: 'streamed-widget', input: {} },
};
const render = (value: FlowToolItem) => act(() => root.render(
<GenerativeWidgetToolCard toolItem={value} config={{} as ToolCardConfig} />));
const rendered = (widgetCode: string) => act(() => frameState.props?.onWidgetEvent?.({
source: 'openbitfun-widget', type: 'openbitfun-widget:rendered',
widgetId: 'streamed-widget', widgetCode,
}));
const exportAction = () => container.querySelector('[data-openbitfun-part="exportAction"]');
try {
render(item);
const card = container.querySelector('[data-openbitfun-part="root"]');
act(() => frameState.props?.onWidgetEvent?.({ source: 'openbitfun-widget', type: 'openbitfun-widget:ready' }));
rendered(partialCode);
expect(exportAction()).toBeNull();
expect(container.querySelector('[data-openbitfun-part="htmlExportAction"]')).toBeNull();
render({ ...item, status: 'completed', isParamsStreaming: false,
toolCall: { id: 'streamed-widget', input: { widget_code: finalCode } },
toolResult: { success: true, result: { widget_code: finalCode } },
});
expect(exportAction()).toBeNull();
expect(container.querySelector('[data-openbitfun-part="htmlExportAction"]')).not.toBeNull();
// A shell-ready event must not mark unapplied final content as ready.
act(() => frameState.props?.onWidgetEvent?.({ source: 'openbitfun-widget', type: 'openbitfun-widget:ready' }));
expect(exportAction()).toBeNull();
rendered(partialCode);
expect(exportAction()).toBeNull();
rendered(finalCode);
expect(exportAction()).not.toBeNull();
expect(container.querySelector('[data-openbitfun-part="root"]')).toBe(card);
} finally {
act(() => root.unmount());
container.remove();
}
});

it('opens source details only from the dedicated button, not the header or its title', () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const code = '<p>Widget content</p>';
const item: FlowToolItem = { id: 'details-widget', type: 'tool', toolName: 'GenerativeUI', timestamp: 1,
status: 'completed', toolCall: { id: 'details-widget', input: { widget_code: code, title: 'Chart' } },
toolResult: { success: true },
};
try {
act(() => root.render(<GenerativeWidgetToolCard toolItem={item} sessionId="session-1" config={{} as ToolCardConfig} />));
const header = container.querySelector<HTMLElement>('[data-openbitfun-component="flow-chat-tool-card"][data-openbitfun-part="surface"]')!;
expect(header.getAttribute('data-openbitfun-interactive')).toBe('false');
expect(container.querySelector('.generative-widget-card')?.getAttribute('title')).toBeNull();
act(() => header.click());
act(() => container.querySelector<HTMLElement>('[data-openbitfun-part="title"]')!.click());
expect(createTab).not.toHaveBeenCalled();
act(() => container.querySelector<HTMLButtonElement>('button[data-openbitfun-affordance="open-panel-right"]')!.click());
expect(createTab).toHaveBeenCalledTimes(1);
expect(createTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'generative-widget', title: 'Chart',
data: expect.objectContaining({ widgetCode: code,
_source: expect.objectContaining({ sessionId: 'session-1', toolCallId: 'details-widget' }),
}),
}));
} finally {
act(() => root.unmount());
container.remove();
}
});

it('exports completed source without preview readiness and handles cancellation, success and failure', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const code = '<p>Export me</p>';
const item: FlowToolItem = { id: 'html-widget', type: 'tool', toolName: 'GenerativeUI', timestamp: 1,
status: 'completed', toolCall: { id: 'html-widget', input: { widget_code: code, title: 'Chart' } },
toolResult: { success: true },
};
const exportHtml = vi.mocked(exportGenerativeWidgetHtml);
exportHtml.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('Write failed'));
try {
act(() => root.render(<GenerativeWidgetToolCard toolItem={item} config={{} as ToolCardConfig} />));
const button = () => container.querySelector<HTMLButtonElement>('[data-openbitfun-part="htmlExportAction"] button')!;
await act(async () => button().click());
expect(exportHtml).toHaveBeenCalledWith({
widgetCode: code, title: 'Chart', language: 'en-US', dialogTitle: 'Export HTML',
hostUnavailableMessage: 'This action requires OpenBitFun and is unavailable in the exported HTML.',
});
expect(notificationService.success).not.toHaveBeenCalled();
expect(notificationService.error).not.toHaveBeenCalled();
await act(async () => button().click());
expect(notificationService.success).toHaveBeenCalledWith('HTML exported');
await act(async () => button().click());
expect(notificationService.error).toHaveBeenCalledWith('Failed to export HTML');
expect(button().disabled).toBe(false);
expect(createTab).not.toHaveBeenCalled();
} finally {
act(() => root.unmount());
container.remove();
}
});

it('preserves the live preview disclosure on failure and keeps historical failures compact', () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
Expand Down
121 changes: 85 additions & 36 deletions src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { useToolCardDisclosure } from '../timeline/readerState';
import { subscribeOverlayInteraction, Tooltip, Icon, IconButton } from '@openbitfun/ui';
import type { ToolCardProps } from '../types/flow-chat';
import { ProminentToolCard, ProminentToolCardSummary, ToolProcessingDots } from '@openbitfun/ui/flow-chat';
import { useTranslation } from 'react-i18next';
import { useI18n } from '@/infrastructure/i18n';
import GenerativeWidgetFrame, {
type WidgetContextMenuMessage,
type WidgetMessage,
} from '@/tools/generative-widget/GenerativeWidgetFrame';
import GenerativeWidgetStaticRenderer from '@/tools/generative-widget/GenerativeWidgetStaticRenderer';
import { handleWidgetBridgeEvent } from '@/tools/generative-widget/widgetInteraction';
import { exportGenerativeWidgetHtml } from '@/tools/generative-widget/widgetHtmlExport';
import { useGenerativeWidgetPromptMenu } from '@/tools/generative-widget/useGenerativeWidgetPromptMenu';
import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore';
import { captureElementToDownloadsPng } from '../utils/captureElementToDownloadsPng';
Expand Down Expand Up @@ -47,7 +48,7 @@ function parseWidgetResult(raw: unknown): WidgetResult | null {
}

export const GenerativeWidgetToolCard: React.FC<ToolCardProps> = ({ toolItem, sessionId }) => {
const { t } = useTranslation('flow-chat');
const { t, currentLanguage } = useI18n('flow-chat');
const { status, toolCall, toolResult, partialParams, isParamsStreaming } = toolItem;
const previewRef = useRef<HTMLDivElement | null>(null);
const captureRootRef = useRef<HTMLDivElement | null>(null);
Expand All @@ -59,6 +60,7 @@ export const GenerativeWidgetToolCard: React.FC<ToolCardProps> = ({ toolItem, se
const [selectionRevision, setSelectionRevision] = useState(0);
const [menuSelectionActive, setMenuSelectionActive] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isExportingHtml, setIsExportingHtml] = useState(false);
const [shouldRenderExportClone, setShouldRenderExportClone] = useState(false);
const [exportWidth, setExportWidth] = useState<number | null>(null);
const { cardRootRef, applyExpandedState } = useToolCardHeightContract({
Expand Down Expand Up @@ -154,16 +156,12 @@ export const GenerativeWidgetToolCard: React.FC<ToolCardProps> = ({ toolItem, se
});
}, [isClickable, sessionId, title, toolCall?.id, toolItem.id, widgetCode, widgetId]);

const handleCardClick = useCallback(
const handleToggle = useCallback(
(e: React.MouseEvent) => {
if (isFailed) {
e.preventDefault();
applyExpandedState(isCardExpanded, !isCardExpanded, setIsCardExpanded);
return;
}
handleOpenPanel();
e.preventDefault();
applyExpandedState(isCardExpanded, !isCardExpanded, setIsCardExpanded);
},
[applyExpandedState, handleOpenPanel, isCardExpanded, isFailed, setIsCardExpanded],
[applyExpandedState, isCardExpanded, setIsCardExpanded],
);

const handleWidgetEvent = useCallback((event: WidgetMessage) => {
Expand All @@ -178,7 +176,10 @@ export const GenerativeWidgetToolCard: React.FC<ToolCardProps> = ({ toolItem, se
return;
}
if (event.type === 'openbitfun-widget:ready') {
setReadyCode(widgetCode);
return;
}
if (event.type === 'openbitfun-widget:rendered') {
setReadyCode(event.widgetCode);
return;
}
if (
Expand All @@ -188,7 +189,7 @@ export const GenerativeWidgetToolCard: React.FC<ToolCardProps> = ({ toolItem, se
return;
}
handleWidgetBridgeEvent(event, 'tool-card');
}, [hideMenu, openPromptMenu, widgetCode]);
}, [hideMenu, openPromptMenu]);

useEffect(() => {
if (!menuSelectionActive) {
Expand Down Expand Up @@ -241,6 +242,28 @@ export const GenerativeWidgetToolCard: React.FC<ToolCardProps> = ({ toolItem, se
[isClickable, readyCode, widgetCode, isExporting, t],
);

const handleExportHtml = useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!isClickable || isExportingHtml) return;
setIsExportingHtml(true);
try {
const saved = await exportGenerativeWidgetHtml({
widgetCode,
title,
language: currentLanguage,
dialogTitle: t('toolCards.generativeWidget.exportHtml'),
hostUnavailableMessage: t('toolCards.generativeWidget.exportHtmlHostUnavailable'),
});
if (saved) notificationService.success(t('toolCards.generativeWidget.exportHtmlSuccess'));
} catch (error) {
log.error('Generative UI export HTML failed', error);
notificationService.error(t('toolCards.generativeWidget.exportHtmlFailed'));
} finally {
setIsExportingHtml(false);
}
}, [currentLanguage, isClickable, isExportingHtml, t, title, widgetCode]);

const statusText = status === 'cancelled' ? t('toolCards.default.cancelled')
: status === 'rejected' ? t('toolCards.default.rejected')
: isFailed ? t('toolCards.default.failed')
Expand All @@ -252,6 +275,9 @@ export const GenerativeWidgetToolCard: React.FC<ToolCardProps> = ({ toolItem, se

const summary = (
<ProminentToolCardSummary
expandAffordance={isClickable || isFailed}
affordanceKind={isFailed ? 'expand' : 'open-panel-right'}
onAffordanceClick={isClickable ? handleOpenPanel : undefined}
icon={<span className="generative-widget-card__icon"><Icon name="panels-top-left" size="md" /></span>}
action={t('toolCards.generativeUI.action')}
content={<span data-openbitfun-component="generative-widget-tool-card" data-openbitfun-part="title" className="generative-widget-card__title">{title}</span>}
Expand All @@ -266,26 +292,51 @@ export const GenerativeWidgetToolCard: React.FC<ToolCardProps> = ({ toolItem, se
</span>
</div>
)}
actions={isClickable && readyCode === widgetCode ? (
<Tooltip
content={isExporting ? t('exportImage.exporting') : t('exportImage.exportToImage')}
placement="top"
>
<span
data-openbitfun-component="generative-widget-tool-card"
data-openbitfun-part="exportAction"
data-openbitfun-state={isExporting ? 'exporting' : undefined}
actions={isClickable ? (
<>
{readyCode === widgetCode && (
<Tooltip
content={isExporting ? t('exportImage.exporting') : t('exportImage.exportToImage')}
placement="top"
>
<span
className="generative-widget-card__export-action"
data-openbitfun-component="generative-widget-tool-card"
data-openbitfun-part="exportAction"
data-openbitfun-state={isExporting ? 'exporting' : undefined}
>
<IconButton
onClick={handleExportImage}
loading={isExporting}
size="sm"
variant="quiet"
aria-label={t('exportImage.exportToImage')}
icon={<Icon name="image" size="sm" />}
/>
</span>
</Tooltip>
)}
<Tooltip
content={isExportingHtml ? t('toolCards.generativeWidget.exportingHtml') : t('toolCards.generativeWidget.exportHtml')}
placement="top"
>
<IconButton
onClick={handleExportImage}
loading={isExporting}
size="sm"
variant="quiet"
aria-label={t('exportImage.exportToImage')}
icon={<Icon name="image" size="sm" />}
/>
</span>
</Tooltip>
<span
className="generative-widget-card__export-action"
data-openbitfun-component="generative-widget-tool-card"
data-openbitfun-part="htmlExportAction"
data-openbitfun-state={isExportingHtml ? 'exporting' : undefined}
>
<IconButton
onClick={handleExportHtml}
loading={isExportingHtml}
size="sm"
variant="quiet"
aria-label={t('toolCards.generativeWidget.exportHtml')}
icon={<Icon name="code" size="sm" />}
/>
</span>
</Tooltip>
</>
) : undefined}
statusIcon={isLoading ? <ToolProcessingDots size={16} /> : null}
/>
Expand Down Expand Up @@ -324,18 +375,16 @@ export const GenerativeWidgetToolCard: React.FC<ToolCardProps> = ({ toolItem, se
onPointerDownCapture={() => setRetainInstance(true)} onFocusCapture={() => setRetainInstance(true)}
data-tool-card-id={toolItem.id ?? toolCall?.id ?? ''} data-openbitfun-component="generative-widget-tool-card" data-openbitfun-part="root" data-openbitfun-state={isFailed ? 'failed' : undefined}>
<ProminentToolCard
title={isClickable ? t('toolCards.generativeUI.openSource') : undefined}
status={isFailed ? 'error' : status}
isExpanded={isCardExpanded}
onToggle={isFailed || isClickable ? handleCardClick : undefined}
className={`generative-widget-card ${isClickable || isFailed ? 'clickable' : ''}`.trim()}
onToggle={isFailed ? handleToggle : undefined}
className="generative-widget-card"
summary={summary}
expandedContent={expandedBody}
expandedContentLayout="flush"
allowExpandedWhenFailed
isFailed={isFailed}
summaryExpandAffordance={isClickable || isFailed}
summaryAffordanceKind={isFailed ? 'expand' : 'open-panel-right'}
summaryExpandAffordance={isFailed}
/>
</div>
{shouldRenderExportClone && hasRenderableWidget && (
Expand Down
Loading
Loading