[eric] tool-ui: posts, links and images open as browser cards on click, leaving fullscreen first (ENG-234)

This commit is contained in:
ciregenz
2026-08-10 17:32:53 -07:00
parent 91c273c8e3
commit fb71f3b686
3 changed files with 55 additions and 4 deletions
@@ -5,9 +5,11 @@ import StatsWidget from './StatsWidget';
import LinksWidget from './LinksWidget';
import VendoredToolUi from '@toolui/VendoredToolUi';
import type { ShowUiPayload } from './showUiPayload';
import { useOpenUrlInBrowserCard } from './useOpenUrlInBrowserCard';
/** One switch for every surface that renders a ShowUI payload (chat bubble, pill artifact); ambient = low-cost render for resting surfaces. */
function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambient?: boolean }): React.ReactElement | null {
const openUrl = useOpenUrlInBrowserCard();
if (payload.component === 'weather') return <WeatherWidget props={payload.props} ambient={ambient} />;
if (payload.component === 'plan') return <PlanWidget props={payload.props} />;
if (payload.component === 'stats') return <StatsWidget props={payload.props} />;
@@ -18,7 +20,32 @@ function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambien
const title = [raw.title, raw.question, raw.prompt, raw.heading]
.find((v): v is string => typeof v === 'string' && v.trim().length > 0) ?? '';
const desc = typeof raw.description === 'string' && raw.description !== title ? raw.description : '';
const widget = <VendoredToolUi name={payload.name} props={payload.props} quietFail={ambient} />;
// The vendored media components ship real navigation callbacks that were never passed, so a rendered post went nowhere and links opened UNDER a fullscreen chat (ENG-234).
const nav: Record<string, unknown> = {};
const postUrl = /^(x|linkedin|instagram)-post$/.test(payload.name) && typeof raw.url === 'string' ? raw.url : null;
if (payload.name === 'x-post' && postUrl) nav.onOpen = () => openUrl(postUrl);
if (payload.name === 'link-preview' || payload.name === 'image') nav.onNavigate = (href: string) => openUrl(href);
// A bare image (src only) is unclickable by contract; defaulting href to the source makes the click open the full-size original.
if (payload.name === 'image' && typeof raw.href !== 'string') {
const fallback = [raw.src, raw.url].find((v): v is string => typeof v === 'string');
if (fallback) nav.href = fallback;
}
let widget = <VendoredToolUi name={payload.name} props={payload.props} quietFail={ambient} extraProps={nav} />;
// The post components only wire clicks on their media, so a text-only post has no way to reach the actual post; the whole card opens it, like the platforms themselves (skipped on ambient pills, where a click means expand).
if (postUrl && !ambient) {
widget = (
<div
style={{ cursor: 'pointer' }}
onClickCapture={(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('button, a, input, textarea, [role=button]')) return;
openUrl(postUrl);
}}
>
{widget}
</div>
);
}
if (!title && !desc) return widget;
return (
<div>
@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { useDispatch, useStore } from 'react-redux';
import { addBrowserCard, clearTiledCard } from '@/shared/state/dashboardLayoutSlice';
import type { RootState } from '@/shared/state/store';
/** Opens a widget link as an in-app browser card, leaving fullscreen first so the card is actually visible (ENG-234). */
export function useOpenUrlInBrowserCard(): (url: string) => void {
const dispatch = useDispatch();
const store = useStore<RootState>();
return useCallback(
(url: string) => {
if (!/^https?:\/\//i.test(url)) return;
// Lazy read, no subscription: this fires on a click, and widgets must not re-render on every tile change.
const tiled = store.getState().dashboardLayout.tiledCards || {};
for (const [cardId, zone] of Object.entries(tiled)) {
if (zone === 'fullscreen') dispatch(clearTiledCard(cardId));
}
dispatch(addBrowserCard({ url }));
},
[dispatch, store],
);
}
+5 -3
View File
@@ -11,9 +11,11 @@ export interface ToolUiEntry {
/* Every entry lazy-loads both the component and its zod contract so the chat bundle only pays
for components a transcript actually uses. Names mirror upstream tool-ui component slugs. */
// The social-post components take their data nested as {post}; the wire props ARE the post.
function wrapAsPost<P extends { id?: unknown }>(Inner: React.ComponentType<{ post: P }>): React.ComponentType<P> {
return function PostAdapter(props: P) {
return <Inner post={props} />;
// onOpen is the component's own top-level prop, not part of the post payload; nesting it made every post unclickable (ENG-234).
function wrapAsPost<P extends { id?: unknown }>(Inner: React.ComponentType<{ post: P; onOpen?: () => void }>): React.ComponentType<P & { onOpen?: () => void }> {
return function PostAdapter(props: P & { onOpen?: () => void }) {
const { onOpen, ...post } = props;
return <Inner post={post as unknown as P} onOpen={onOpen} />;
};
}