From fb71f3b686eaf367b3a5181bdb10a17c38547152 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 10 Aug 2026 17:32:53 -0700 Subject: [PATCH] [eric] tool-ui: posts, links and images open as browser cards on click, leaving fullscreen first (ENG-234) --- .../AgentChat/tool-ui/ShowUiWidgetView.tsx | 29 ++++++++++++++++++- .../tool-ui/useOpenUrlInBrowserCard.ts | 22 ++++++++++++++ frontend/src/toolui/registry.tsx | 8 +++-- 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/useOpenUrlInBrowserCard.ts diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx index 02604329..31315853 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx @@ -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 ; if (payload.component === 'plan') return ; if (payload.component === 'stats') return ; @@ -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 = ; + // 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 = {}; + 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 = ; + // 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 = ( +
{ + const target = e.target as HTMLElement; + if (target.closest('button, a, input, textarea, [role=button]')) return; + openUrl(postUrl); + }} + > + {widget} +
+ ); + } if (!title && !desc) return widget; return (
diff --git a/frontend/src/app/pages/AgentChat/tool-ui/useOpenUrlInBrowserCard.ts b/frontend/src/app/pages/AgentChat/tool-ui/useOpenUrlInBrowserCard.ts new file mode 100644 index 00000000..c148df31 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/useOpenUrlInBrowserCard.ts @@ -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(); + 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], + ); +} diff --git a/frontend/src/toolui/registry.tsx b/frontend/src/toolui/registry.tsx index 2d4eecc6..d168fc28 100644 --- a/frontend/src/toolui/registry.tsx +++ b/frontend/src/toolui/registry.tsx @@ -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

(Inner: React.ComponentType<{ post: P }>): React.ComponentType

{ - return function PostAdapter(props: P) { - return ; +// 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

(Inner: React.ComponentType<{ post: P; onOpen?: () => void }>): React.ComponentType

void }> { + return function PostAdapter(props: P & { onOpen?: () => void }) { + const { onOpen, ...post } = props; + return ; }; }