mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-30 11:49:50 +02:00
[eric] tool-ui: posts, links and images open as browser cards on click, leaving fullscreen first (ENG-234)
This commit is contained in:
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user