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 @@ -33,7 +33,7 @@ impl Tool for GenerativeUITool {
}
async fn description(&self) -> OpenBitFunResult<String> {
let mut description: String =
"Stream an OpenUI Lang answer using the selected component collection. The selected profile renders the source in FlowChat as it arrives, with local state and actions. The result is transient in the current answer. Never generate TSX, React source or a per-app compilation artifact. Keep the same app ID when revising an application. Preserve requested features and user state when repairing errors. Query and Mutation calls are allowed only through explicitly registered host providers; never invent a network or storage fallback.".into()
"When an interactive and vivid presentation would communicate the answer better, use this tool proactively; do not wait for the user to explicitly ask for GenUI. Prefer a normal text answer when interactivity and visual presentation add little value. Stream an OpenUI Lang answer using the selected component collection. The selected profile renders the source in FlowChat as it arrives, with local state and actions. The result is transient in the current answer. Never generate TSX, React source or a per-app compilation artifact. Keep the same app ID when revising an application. Preserve requested features and user state when repairing errors. Query and Mutation calls are allowed only through explicitly registered host providers; never invent a network or storage fallback.".into()
;
if let Ok(profile) = Self::selected_profile().await {
if let Some(resource_id) =
Expand All @@ -56,7 +56,7 @@ impl Tool for GenerativeUITool {
Ok(description)
}
fn short_description(&self) -> String {
"Create a streaming OpenUI Lang component answer.".into()
"Use this tool when an interactive and vivid presentation would communicate the answer better; don't wait for the user to ask for GenUI. Use text when visual presentation adds little.".into()
}
fn default_exposure(&self) -> ToolExposure {
ToolExposure::Deferred
Expand Down Expand Up @@ -273,4 +273,12 @@ mod tests {
json!(["protocol", "messages"])
);
}

#[test]
fn deferred_summary_explains_when_to_use_genui() {
let summary = GenerativeUITool::new().short_description();
assert!(summary.contains("interactive and vivid presentation"));
assert!(summary.contains("don't wait for the user to ask for GenUI"));
assert!(summary.contains("Use text when visual presentation adds little"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -562,17 +562,14 @@ describe('UserMessageItem steering tag', () => {
expect(container.querySelector('.user-message-item__edit-rollback-btn')).toBeNull();
});

it('presents GenUI host repair input as evidence instead of a user message', () => {
it('does not render GenUI host repair input as a user message or evidence bubble', () => {
act(() => root.render(
<FlowChatContext.Provider value={{ allowUserMessageRollback: true, allowUserMessageEdit: true }}>
<UserMessageItem message={{ id: 'genui-evidence', content: 'internal diagnostics payload', timestamp: 1000,
metadata: { genUIHostRepair: true } }} turnId="genui-repair-turn" />
</FlowChatContext.Provider>,
));
expect(container.querySelector('.user-message-item__host-evidence')?.textContent).toContain('toolCards.generativeUI.hostEvidence');
expect(container.querySelector('[data-testid="chat-user-message-content"]')).toBeNull();
expect(container.querySelector('.user-message-item__edit-rollback-btn')).toBeNull();
expect(container.textContent).not.toContain('internal diagnostics payload');
expect(container.innerHTML).toBe('');
});

it('does not render a steering tag after steering is triggered', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,10 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
);
}

// Alternate transcript surfaces may render this component without the
// virtual-item projection. Host repair evidence stays invisible there too.
if (isGenUIHostEvidence) return null;

const messageImageGallery = messageImages.length > 0 ? (
<div
className="user-message-item__images"
Expand Down Expand Up @@ -726,10 +730,9 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
</div>
) : (
<div className="user-message-item__main" data-openbitfun-product-component="user-message-item" data-openbitfun-product-part="main">
{isGenUIHostEvidence ? <p className="user-message-item__host-evidence" role="note">{t('toolCards.generativeUI.hostEvidence')}</p> : null}
{backgroundDeliveryFromMetadata(message.metadata, turnId)?.reason === 'recovery_completed'
? <p>{t('backgroundDelivery.recoveryCompleted')}</p> : null}
{isGenUIHostEvidence ? null : <div
<div
className={
isFailed
? 'user-message-item__failed-inline-cluster'
Expand Down Expand Up @@ -784,7 +787,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
)}
</>
)}
</div>}
</div>
</div>
)}

Expand Down
12 changes: 12 additions & 0 deletions src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ function makeSession(overrides: Partial<Session> = {}): Session {
}

describe('sessionToVirtualItems unified work grouping', () => {
it('omits host GenUI repair input rows while retaining repair output and stored diagnostics', () => {
const session = makeSession();
const turn = session.dialogTurns[0];
turn.userMessage = { ...turn.userMessage, content: 'Internal rendering diagnostics', metadata: { genUIHostRepair: true } };
turn.modelRounds = [makeRound({ items: [makeTextItem('repair-result', 'Repaired UI')] })];
const original = JSON.stringify(session);
const items = sessionToVirtualItems(session);
expect(items.some(item => item.type === 'user-message')).toBe(false);
expect(items.some(item => item.type === 'model-round' && item.data.items.some(value => value.id === 'repair-result'))).toBe(true);
expect(JSON.stringify(session)).toBe(original);
expect(sessionToVirtualItems({ ...session })).toEqual(items);
});
it('projects quiet runs as stable execution receipts without losing raw output or catalog positions', () => {
const session = makeSession();
const turn = session.dialogTurns[0];
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/flow_chat/store/modernFlowChatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] {
}
const turnItemStart = items.length;

if (turn.userMessage) {
// Host rendering evidence drives a repair turn but is not a user utterance.
// Keep its model output while omitting the input row (and its spacing).
if (turn.userMessage && !turn.userMessage.metadata?.genUIHostRepair) {
items.push({
type: 'user-message',
data: turn.userMessage,
Expand Down
6 changes: 3 additions & 3 deletions src/web-ui/src/tools/generative-ui/GenUIApplication.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { OpenUIError } from '@openuidev/lang-core';
import type { GenUIRenderDiagnostic } from './genUIRenderDiagnostics';
import { isFatalGenUIError } from './genUIRenderDiagnostics';
import type { GenUIAppDefinition, GenUIAppEnvelope, GenUIAppState } from './genUITypes';
import { designSpecForApp, libraryFor, preflightGenUIApp } from './genUIContract';
Expand Down Expand Up @@ -80,8 +80,8 @@ function Application({ product = genUIProduct, definition, initialState, readOnl
const [state, setState] = useState(() => store.current.initialize(app, initialState));
const [persistence, setPersistence] = useState<GenUIVerification['persistence']>('unverified');
const [saveError, setSaveError] = useState('');
const [openUIReport, setOpenUIReport] = useState<{ key: string; errors: OpenUIError[] }>();
const receiveOpenUIErrors = useCallback((errors: OpenUIError[]) => {
const [openUIReport, setOpenUIReport] = useState<{ key: string; errors: GenUIRenderDiagnostic[] }>();
const receiveOpenUIErrors = useCallback((errors: GenUIRenderDiagnostic[]) => {
setOpenUIReport({ key: definitionKey, errors });
}, [definitionKey]);
const openUIErrors = !streaming && openUIReport?.key === definitionKey ? openUIReport.errors : [];
Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/tools/generative-ui/GenUISourceDocument.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { GenUIDesignSpec } from './genUIContract';
import type { OpenUIError } from '@openuidev/lang-core';
import type { GenUIRenderDiagnostic } from './genUIRenderDiagnostics';
import GenUIHtmlDocument from './GenUIHtmlDocument';
import type { GenUIHtmlBridge } from './GenUIHtmlDocument';
import GenUIOpenUIDocument from './genUIOpenUI';
Expand Down Expand Up @@ -50,7 +50,7 @@ export default function GenUISourceDocument({
isStreaming?: boolean;
readOnly?: boolean;
onStateUpdate?: (state: GenUIAppState) => void;
onError?: (errors: OpenUIError[]) => void;
onError?: (errors: GenUIRenderDiagnostic[]) => void;
onAction?: (event: GenUIOpenUIActionEvent) => void | Promise<void>;
toolProvider?: GenUIOpenUIToolProvider;
asyncProvider?: GenUIProviderInput;
Expand Down
7 changes: 7 additions & 0 deletions src/web-ui/src/tools/generative-ui/genUIAutoRepair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ describe('automatic GenUI repair admission', () => {
expect(fixture.repair.mock.calls[1][2]).toMatchObject({ chainId: first.chainId, attempt: 2 });
});

it.each(['unresolved-reference', 'missing-required', 'null-required'] as const)('repairs %s while the remaining controls stay usable', async code => {
const degraded: GenUIVerification = { ...report(), render: 'passed', diagnostics: [{ source: 'parser', code, message: 'Missing content', statementId: 'detail' }] };
expect(await requestGenUIAutoRepair(origin, { ...degraded, render: 'unverified' })).toBe(false);
expect(await requestGenUIAutoRepair(origin, degraded)).toBe(true);
expect(await requestGenUIAutoRepair(origin, degraded)).toBe(false);
});

it('does not reset the limit when a repair produces a different application id or another device renders it', async () => {
await requestGenUIAutoRepair(origin, report());
const chainId = fixture.repair.mock.calls[0][2].chainId;
Expand Down
16 changes: 8 additions & 8 deletions src/web-ui/src/tools/generative-ui/genUIAutoRepair.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { OpenUIError } from '@openuidev/lang-core';
import type { GenUIRenderDiagnostic } from './genUIRenderDiagnostics';
import type { GenerativeWidgetOrigin } from '@/flow_chat/utils/generativeWidgetProjection';
import type { GenUIVerification } from './genUIVerification';
import { isFatalGenUIError } from './genUIRenderDiagnostics';
import { isRepairWorthyGenUIError } from './genUIRenderDiagnostics';
import { requestGenUIRepair } from './genUIRepair';
import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface';
import { flowChatStore } from '@/flow_chat/store/FlowChatStore';
Expand All @@ -11,8 +11,8 @@ const STORAGE_KEY = 'openbitfun.genui.auto-repair.v1';
type Entry = { attempts: number; errors: string[] };
type State = Record<string, Entry>;

function fatalErrorKey(errors: OpenUIError[]): string {
return JSON.stringify(errors.filter(isFatalGenUIError).map(({ source, code, message, statementId }) =>
function repairErrorKey(errors: GenUIRenderDiagnostic[]): string {
return JSON.stringify(errors.filter(isRepairWorthyGenUIError).map(({ source, code, message, statementId }) =>
({ source, code, message, statementId })).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))));
}

Expand All @@ -30,7 +30,7 @@ function appIdentity(value: string): string {
/** Only a just-finished, active rendering surface calls this; persisted claims
* deduplicate multiple tabs and keep a failed error from retrying on reload. */
export async function requestGenUIAutoRepair(origin: GenerativeWidgetOrigin, report: GenUIVerification): Promise<boolean> {
if (report.render !== 'failed' || !report.diagnostics?.some(isFatalGenUIError)) return false;
if (report.render === 'unverified' || !report.diagnostics?.some(isRepairWorthyGenUIError)) return false;
if (getActiveSurfaceScope().surfaceId !== origin.surfaceId
|| flowChatStore.getState().activeSessionId !== origin.sessionId) return false;
if (document.visibilityState === 'hidden') return false;
Expand All @@ -41,9 +41,9 @@ export async function requestGenUIAutoRepair(origin: GenerativeWidgetOrigin, rep
: appIdentity(JSON.stringify([origin.sessionId, report.appId ?? origin.toolCallId]));
const inheritedAttempts = Number.isSafeInteger(inherited?.attempt) ? inherited.attempt as number : 0;
const scopeKey = appIdentity(JSON.stringify([origin.surfaceId, chainId]));
const fatalKey = fatalErrorKey(report.diagnostics);
if (fatalKey === '[]') return false;
const errorKey = appIdentity(JSON.stringify([report.definitionKey, fatalKey]));
const diagnosticKey = repairErrorKey(report.diagnostics);
if (diagnosticKey === '[]') return false;
const errorKey = appIdentity(JSON.stringify([report.definitionKey, diagnosticKey]));
const send = async () => {
const state = readState();
const previous = state[scopeKey] ?? { attempts: 0, errors: [] };
Expand Down
48 changes: 43 additions & 5 deletions src/web-ui/src/tools/generative-ui/genUIOpenUI.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import './disableOpenUIDevtools';
import type { OpenUIError } from '@openuidev/lang-core';
import type { OpenUIError, ParseResult } from '@openuidev/lang-core';
import { mergeGenUIRenderDiagnostics, type GenUIRenderDiagnostic } from './genUIRenderDiagnostics';
import { action, createLibrary, defineComponent, defineFunction, reactive, Renderer, useIsStreaming, useStateField, useTriggerAction, type ActionPlan } from '@openuidev/react-lang';
import { z } from 'zod/v4';
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
Expand Down Expand Up @@ -343,7 +344,7 @@ export default function GenUIOpenUIDocument({
/** Keep product side effects out of disposable verification probes. */
executeActions?: boolean;
onStateUpdate?: (state: GenUIAppState) => void;
onError?: (errors: OpenUIError[]) => void;
onError?: (errors: GenUIRenderDiagnostic[]) => void;
onAction?: (event: GenUIOpenUIActionEvent) => void | Promise<void>;
onInspect?: (event: GenUIOpenUIInspectEvent) => void;
/** Explicit owner-registered Query/Mutation capabilities; absent by default. */
Expand Down Expand Up @@ -419,9 +420,46 @@ export default function GenUIOpenUIDocument({
if (executeActions) void runGenUIAction(event);
void onAction?.(event);
}, [executeActions, onAction]);
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
// Errors and parse metadata arrive in separate effects. Coalesce both before
// publishing, and discard pending evidence when the source/library changes.
const diagnosticFrame = useMemo(() => ({
sourceContent: source.content, library: openUILibrary, isStreaming,
errors: [] as OpenUIError[], meta: undefined as ParseResult['meta'] | undefined,
parsed: false, pending: false, lastKey: undefined as string | undefined,
}), [source.content, openUILibrary, isStreaming]);
const currentDiagnosticFrame = useRef<typeof diagnosticFrame | undefined>(diagnosticFrame);
currentDiagnosticFrame.current = diagnosticFrame;
useEffect(() => {
currentDiagnosticFrame.current = diagnosticFrame;
return () => {
if (currentDiagnosticFrame.current === diagnosticFrame) currentDiagnosticFrame.current = undefined;
};
}, [diagnosticFrame]);
const publishDiagnostics = useCallback(() => {
if (diagnosticFrame.pending) return;
diagnosticFrame.pending = true;
queueMicrotask(() => {
diagnosticFrame.pending = false;
if (currentDiagnosticFrame.current !== diagnosticFrame || (!diagnosticFrame.isStreaming && !diagnosticFrame.parsed)) return;
const merged = diagnosticFrame.isStreaming ? [] : mergeGenUIRenderDiagnostics(diagnosticFrame.meta, diagnosticFrame.errors);
const key = JSON.stringify(merged);
if (key === diagnosticFrame.lastKey) return;
diagnosticFrame.lastKey = key;
onErrorRef.current?.(merged);
});
}, [diagnosticFrame]);
const handleError = useCallback((errors: OpenUIError[]) => {
onError?.(errors);
}, [onError]);
diagnosticFrame.errors = errors;
publishDiagnostics();
}, [diagnosticFrame, publishDiagnostics]);
const handleParseResult = useCallback((result: ParseResult | null) => {
diagnosticFrame.meta = result?.meta;
diagnosticFrame.parsed = true;
publishDiagnostics();
onParseResult?.(result);
}, [diagnosticFrame, publishDiagnostics, onParseResult]);
return <OpenUIAsyncRuntimeContext.Provider value={runtime}>
<Renderer
response={source.content}
Expand All @@ -431,7 +469,7 @@ export default function GenUIOpenUIDocument({
onAction={handleAction as never}
toolProvider={runtime && (asyncProvider ?? toolProvider) ? createOpenUIToolProvider(asyncProvider ?? toolProvider!, runtime) : undefined}
onStateUpdate={handleStateUpdate}
onParseResult={result => onParseResult?.(result)}
onParseResult={handleParseResult}
onError={handleError}
/>
</OpenUIAsyncRuntimeContext.Provider>;
Expand Down
Loading
Loading