From 615d00e5f3e971fe9e6bcbbcb80f805461be78de Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 18:10:09 -0700 Subject: [PATCH] [eric] dashboard: a collapsed card's tool output sizes to its content, stays clickable, and drag-resizes --- .../pages/AgentChat/tool-ui/showUiPayload.ts | 6 + .../Dashboard/desktop/AgentNarratorPill.tsx | 11 +- .../Dashboard/desktop/PillArtifactFrame.tsx | 124 ++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/desktop/PillArtifactFrame.tsx diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts index 2814a668..4dde7d68 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -57,6 +57,12 @@ export type ShowUiPayload = | { component: 'links'; props: LinksProps } | { component: 'vendored'; name: string; props: Record }; +/** What this widget IS, for callers that size or key off the family (a table wants more room than a + * weather card). The vendored variant carries the real name; for the rest the component is it. */ +export function artifactName(payload: ShowUiPayload): string { + return payload.component === 'vendored' ? payload.name : payload.component; +} + function num(v: unknown): v is number { return typeof v === 'number' && Number.isFinite(v); } diff --git a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx index 5e75319b..8d0e5c65 100644 --- a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx @@ -6,8 +6,9 @@ import DashboardGlyph from '../canvas/DashboardGlyph'; import { GLASS_SURFACE, GLASS_SURFACE_BLUR } from '@/shared/styles/glassSurface'; import ShowUiWidgetView from '@/app/pages/AgentChat/tool-ui/ShowUiWidgetView'; import AskUiBubble from '@/app/pages/AgentChat/tool-ui/AskUiBubble'; +import PillArtifactFrame from './PillArtifactFrame'; import type { ToolPair } from '@/app/pages/AgentChat/tool-bubbles/ToolCallBubble'; -import type { ShowUiPayload } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; +import { artifactName, type ShowUiPayload } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import type { AgentTodoItem } from './agentTodos'; interface AgentNarratorPillProps { @@ -80,13 +81,13 @@ function AgentNarratorPill({ label, running, todos, artifact, askPair, sessionId {liveAsk ? ( - + - + ) : artifact ? ( - + - + ) : browserShot ? ( = [ + [/table|chart|gallery|carousel|terminal|code|diff/i, 560], + [/map|image|video|post/i, 460], + [/stats|plan|links|order|preferences/i, 380], +]; + +export function defaultWidthFor(name: string): number { + for (const [re, w] of FAMILY_WIDTHS) if (re.test(name)) return w; + return DEFAULT_W; +} + +// Keyed by COMPONENT, not by session: having sized a table once, you want every table that way, +// and a per-session key would make the drag feel like it never stuck. +const storageKey = (name: string): string => `osw.artifactWidth.${name}`; + +function storedWidth(name: string): number | null { + try { + const raw = window.localStorage.getItem(storageKey(name)); + const n = raw ? parseInt(raw, 10) : NaN; + return Number.isFinite(n) ? Math.min(Math.max(n, MIN_W), MAX_W) : null; + } catch { return null; } // private mode / quota: fall back to the family default +} + +interface Props { + /** Component name off the payload; picks the resting width and the persistence key. */ + name: string; + children: React.ReactNode; +} + +/** + * The collapsed card's artifact holder: sizes itself to the widget family, lets the user drag that + * width, and keeps the widget INTERACTIVE. + * + * The interactivity is the subtle half. The pill host owns pointerdown (to drag the card) and the + * card owns click/dblclick (select, expand), so every click that landed on a sort button inside a + * table also expanded the card, which is the opposite of what a control is for. Stopping those + * three here means the widget behaves like a widget; the card still drags by its pill and its + * chrome, which is what a user actually aims at to move it. + */ +function PillArtifactFrame({ name, children }: Props): React.ReactElement { + const [width, setWidth] = useState(() => storedWidth(name) ?? defaultWidthFor(name)); + // A different widget arriving in the same card is a different thing to size. + useEffect(() => { setWidth(storedWidth(name) ?? defaultWidthFor(name)); }, [name]); + + const dragRef = useRef<{ startX: number; startW: number } | null>(null); + + const onHandleDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragRef.current = { startX: e.clientX, startW: width }; + try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* capture is best-effort */ } + }, [width]); + + const onHandleMove = useCallback((e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + e.stopPropagation(); + setWidth(Math.min(Math.max(d.startW + (e.clientX - d.startX), MIN_W), MAX_W)); + }, []); + + const endDrag = useCallback((e: React.PointerEvent) => { + if (!dragRef.current) return; + dragRef.current = null; + e.stopPropagation(); + try { window.localStorage.setItem(storageKey(name), String(width)); } catch { /* nothing to do if storage is full */ } + try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* already released */ } + }, [name, width]); + + return ( + e.stopPropagation()} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + onDoubleClick={(e: React.MouseEvent) => e.stopPropagation()} + sx={{ position: 'relative', width, maxWidth: '90vw', '&:hover .osw-artifact-grip': { opacity: 1 } }} + > + {children} + + + ); +} + +export default PillArtifactFrame;