mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 06:22:22 +02:00
[eric] dashboard: a collapsed card's tool output sizes to its content, stays clickable, and drag-resizes
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user