[eric] dashboard: a collapsed card's tool output sizes to its content, stays clickable, and drag-resizes

This commit is contained in:
ciregenz
2026-07-31 18:10:09 -07:00
parent 50dc2a1f8b
commit 615d00e5f3
3 changed files with 136 additions and 5 deletions
@@ -57,6 +57,12 @@ export type ShowUiPayload =
| { component: 'links'; props: LinksProps }
| { component: 'vendored'; name: string; props: Record<string, unknown> };
/** 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);
}
@@ -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
</Box>
{liveAsk ? (
<Box key={artifactKey} className="osw-artifact" sx={{ width: 340, maxWidth: '80vw' }}>
<PillArtifactFrame key={artifactKey} name="question">
<AskUiBubble pair={liveAsk} sessionId={sessionId!} isPending suppressReveal />
</Box>
</PillArtifactFrame>
) : artifact ? (
<Box key={artifactKey} className="osw-artifact">
<PillArtifactFrame key={artifactKey} name={artifactName(artifact)}>
<ShowUiWidgetView payload={artifact} ambient />
</Box>
</PillArtifactFrame>
) : browserShot ? (
<Box
key={artifactKey}
@@ -0,0 +1,124 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
/** Widest and narrowest a collapsed artifact may get dragged to. */
const MIN_W = 240;
const MAX_W = 900;
/** Anything with no family rule: the old fixed pill width, so nothing narrows unexpectedly. */
const DEFAULT_W = 320;
// A table at 320px wraps every column into unreadable slivers, while a weather card at 520px is
// mostly empty. So the resting width follows what the widget IS, matched against the component
// name the payload already carries. Ordered, first match wins.
const FAMILY_WIDTHS: Array<[RegExp, number]> = [
[/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<number>(() => 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 (
<Box
className="osw-artifact"
onPointerDown={(e: React.PointerEvent) => 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}
<Box
className="osw-artifact-grip"
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
sx={{
position: 'absolute',
top: 0,
right: -3,
width: 10,
height: '100%',
cursor: 'ew-resize',
opacity: 0,
transition: 'opacity 140ms ease',
touchAction: 'none',
// The visible grip is a thin bar centred in a wider hit area: easy to grab, quiet to look at.
'&::after': {
content: '""',
position: 'absolute',
top: '50%',
left: 3,
width: 3,
height: 34,
marginTop: '-17px',
borderRadius: 999,
background: 'rgba(255,255,255,0.45)',
},
}}
/>
</Box>
);
}
export default PillArtifactFrame;