mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
[eric] canvas: a pill caps its artifact (8 rows, compact stats), ShowUI rows memoized on message identity, spawn pill yields to a tiled composer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
05e8310691
commit
224fd6884a
@@ -7,6 +7,8 @@ import VendoredToolUi from '@toolui/VendoredToolUi';
|
||||
import type { ShowUiPayload } from './showUiPayload';
|
||||
import { useOpenUrlInBrowserCard } from './useOpenUrlInBrowserCard';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { ambientShape } from './showUiAmbient';
|
||||
import { perfBaselineFor } from '@/shared/perfBaseline';
|
||||
|
||||
/** 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 {
|
||||
@@ -32,7 +34,16 @@ function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambien
|
||||
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} />;
|
||||
const shaped = ambient && !perfBaselineFor('ambient') ? ambientShape(payload.name, raw) : { props: payload.props, note: null };
|
||||
let widget = <VendoredToolUi name={payload.name} props={shaped.props} quietFail={ambient} extraProps={nav} />;
|
||||
if (shaped.note) {
|
||||
widget = (
|
||||
<div>
|
||||
{widget}
|
||||
<div style={{ fontSize: '0.75rem', color: c.text.secondary, padding: '6px 8px 2px' }}>{shaped.note}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 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 = (
|
||||
@@ -67,4 +78,5 @@ function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambien
|
||||
return null;
|
||||
}
|
||||
|
||||
export default ShowUiWidgetView;
|
||||
// Memoized: a chat re-renders on every streamed delta and this sits inside every ShowUI message; the payload is memoized upstream on the message objects.
|
||||
export default perfBaselineFor('ambient') ? ShowUiWidgetView : React.memo(ShowUiWidgetView);
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import ToolCallBubble from '../tool-bubbles/ToolCallBubble';
|
||||
import type { ToolPair } from '../tool-bubbles/ToolCallBubble';
|
||||
import { toolUiBubblePropsEqual, type ToolUiBubbleProps } from './toolUiBubbleEqual';
|
||||
import { perfBaselineFor } from '@/shared/perfBaseline';
|
||||
import { parseShowUiPayload, freezeIfDone } from './showUiPayload';
|
||||
import ShowUiWidgetView from './ShowUiWidgetView';
|
||||
import WidgetCopyChip from './WidgetCopyChip';
|
||||
|
||||
interface ToolUiBubbleProps {
|
||||
pair: ToolPair;
|
||||
sessionId: string;
|
||||
isPending: boolean;
|
||||
suppressReveal: boolean;
|
||||
sessionRunning?: boolean;
|
||||
}
|
||||
|
||||
/** Renders a ShowUI call as its inline component; any schema mismatch falls back to the plain tool bubble. */
|
||||
function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunning = false }: ToolUiBubbleProps): React.ReactElement {
|
||||
const rawPayload = parseShowUiPayload(pair);
|
||||
// Keyed on the message objects, not the pair: the transcript rebuilds its pair list on every delta while the messages keep their identity.
|
||||
const rawPayload = useMemo(() => parseShowUiPayload(pair), [pair.call, pair.result]);
|
||||
const widgetRef = useRef<HTMLDivElement>(null);
|
||||
const payload = useMemo(
|
||||
() => (rawPayload ? freezeIfDone(rawPayload, sessionRunning) : null),
|
||||
@@ -57,4 +52,4 @@ function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunni
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolUiBubble;
|
||||
export default perfBaselineFor('ambient') ? ToolUiBubble : React.memo(ToolUiBubble, toolUiBubblePropsEqual);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ambientShape, AMBIENT_TABLE_ROWS } from './showUiAmbient';
|
||||
|
||||
// A collapsed pill on the drill board rendered a 5,000-row data-table in full: 370K of 424K fibers,
|
||||
// paid on every layout pass by every chat (ENG-467/468). The resting artifact gets a screenful.
|
||||
test('a pill data-table is cut to a screenful and says how much it left out', () => {
|
||||
const data = Array.from({ length: 5000 }, (_, i) => ({ id: i, name: `row ${i}` }));
|
||||
const shaped = ambientShape('data-table', { columns: [{ key: 'name' }], data });
|
||||
assert.equal((shaped.props.data as unknown[]).length, AMBIENT_TABLE_ROWS);
|
||||
assert.equal(shaped.note, 'Showing 8 of 5,000 rows. Open the chat for the whole table.');
|
||||
assert.equal(data.length, 5000, 'the original props are not mutated');
|
||||
});
|
||||
|
||||
test('a short table and other widgets pass through untouched', () => {
|
||||
const small = { columns: [], data: [{ id: 1 }, { id: 2 }] };
|
||||
assert.equal(ambientShape('data-table', small).props, small);
|
||||
assert.equal(ambientShape('data-table', small).note, null);
|
||||
const chart = { series: Array.from({ length: 5000 }, (_, i) => i) };
|
||||
assert.equal(ambientShape('line-chart', chart).props, chart);
|
||||
});
|
||||
|
||||
test('a pill stats card is asked for its compact density', () => {
|
||||
const shaped = ambientShape('stats-display', { stats: [] });
|
||||
assert.equal(shaped.props.compact, true);
|
||||
});
|
||||
|
||||
test('the widget view applies the shaping only on the ambient surface, and is memoized', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx'), 'utf8');
|
||||
assert.ok(src.includes("ambient && !perfBaselineFor('ambient') ? ambientShape(payload.name, raw)"), 'the chat keeps the whole table; only the pill is shaped');
|
||||
assert.ok(src.includes("export default perfBaselineFor('ambient') ? ShowUiWidgetView : React.memo(ShowUiWidgetView)"));
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// A widget under a collapsed pill is a resting surface, not the place to read 5,000 rows. On the
|
||||
// 150-card drill board one such pill carried a 5,000-row data-table: 370,000 of the page's 424,000
|
||||
// React fibers lived under it, and every layout or style pass on the board paid for them
|
||||
// (2026-09-05, ENG-467/468/469). The pill gets a screenful; the chat keeps the whole thing.
|
||||
|
||||
export const AMBIENT_TABLE_ROWS = 8;
|
||||
|
||||
export interface AmbientShape {
|
||||
props: Record<string, unknown>;
|
||||
/** One line under the widget when something was left out, or null. */
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export function ambientShape(name: string, props: Record<string, unknown>): AmbientShape {
|
||||
if (name === 'data-table' && Array.isArray(props.data) && props.data.length > AMBIENT_TABLE_ROWS) {
|
||||
const total = props.data.length;
|
||||
return {
|
||||
props: { ...props, data: props.data.slice(0, AMBIENT_TABLE_ROWS) },
|
||||
note: `Showing ${AMBIENT_TABLE_ROWS} of ${total.toLocaleString()} rows. Open the chat for the whole table.`,
|
||||
};
|
||||
}
|
||||
// The vendored stats card stacks its cells vertically under 440 px; the pill is narrower than that.
|
||||
if (name === 'stats-display') return { props: { ...props, compact: true }, note: null };
|
||||
return { props, note: null };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ToolPair } from '../tool-bubbles/ToolCallBubble';
|
||||
|
||||
export interface ToolUiBubbleProps {
|
||||
pair: ToolPair;
|
||||
sessionId: string;
|
||||
isPending: boolean;
|
||||
suppressReveal: boolean;
|
||||
sessionRunning?: boolean;
|
||||
}
|
||||
|
||||
/** The transcript re-renders on every streamed delta and rebuilds every pair object; the messages inside keep their identity, so that is what decides whether a ShowUI bubble has anything new to draw. */
|
||||
export function toolUiBubblePropsEqual(prev: ToolUiBubbleProps, next: ToolUiBubbleProps): boolean {
|
||||
return prev.pair.call === next.pair.call
|
||||
&& prev.pair.result === next.pair.result
|
||||
&& prev.sessionId === next.sessionId
|
||||
&& prev.isPending === next.isPending
|
||||
&& prev.suppressReveal === next.suppressReveal
|
||||
&& (prev.sessionRunning ?? false) === (next.sessionRunning ?? false);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { toolUiBubblePropsEqual } from './toolUiBubbleEqual';
|
||||
|
||||
// The transcript rebuilds every pair object on every streamed delta (the pair list is a useMemo on
|
||||
// the messages array), so a memo on pair identity would never bail. The messages inside keep their
|
||||
// identity across deltas, and that is what the comparator reads.
|
||||
const call = { id: 'c1', role: 'tool_call', content: { tool: 'ShowUI', input: {} } } as never;
|
||||
const result = { id: 'r1', role: 'tool_result', content: 'ok' } as never;
|
||||
const base = { pair: { type: 'tool_pair' as const, id: 'p', call, result }, sessionId: 's', isPending: false, suppressReveal: false, sessionRunning: false };
|
||||
|
||||
test('a rebuilt pair around the same messages is equal', () => {
|
||||
assert.equal(toolUiBubblePropsEqual(base, { ...base, pair: { ...base.pair } }), true);
|
||||
});
|
||||
|
||||
test('a new result object, a pending flip, or a run-state flip is not equal', () => {
|
||||
assert.equal(toolUiBubblePropsEqual(base, { ...base, pair: { ...base.pair, result: { ...(result as object) } as never } }), false);
|
||||
assert.equal(toolUiBubblePropsEqual(base, { ...base, isPending: true }), false);
|
||||
assert.equal(toolUiBubblePropsEqual(base, { ...base, sessionRunning: true }), false);
|
||||
});
|
||||
|
||||
test('the bubble is exported through React.memo with that comparator and parses the payload off the messages', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx'), 'utf8');
|
||||
assert.ok(src.includes("export default perfBaselineFor('ambient') ? ToolUiBubble : React.memo(ToolUiBubble, toolUiBubblePropsEqual)"));
|
||||
assert.ok(src.includes('useMemo(() => parseShowUiPayload(pair), [pair.call, pair.result])'));
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import Snackbar from '@mui/material/Snackbar';
|
||||
import Icon from '@mui/material/Icon';
|
||||
|
||||
import DesktopSpawnPill from './desktop/DesktopSpawnPill';
|
||||
import { coveredByTiledZones } from './canvas/spawnPillCover';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { motion } from 'framer-motion';
|
||||
import ChatInput from '@/app/pages/AgentChat/ChatInput';
|
||||
@@ -79,6 +80,22 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const dispatch = useAppDispatch();
|
||||
const elementSelection = useElementSelection();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// The pill steps aside when a tiled card sits on it; measured from its own rect, re-checked when the tiles or the window change.
|
||||
const tiledZonesKey = useAppSelector((s) => Object.values(s.dashboardLayout.tiledCards).sort().join(','));
|
||||
const [pillCovered, setPillCovered] = useState(false);
|
||||
useEffect(() => {
|
||||
const check = (): void => {
|
||||
const el = containerRef.current;
|
||||
if (!el) { setPillCovered(false); return; }
|
||||
const r = el.getBoundingClientRect();
|
||||
const zones = tiledZonesKey ? tiledZonesKey.split(',') : [];
|
||||
setPillCovered(zones.length > 0 && coveredByTiledZones(zones, { x: r.left, y: r.top, w: r.width, h: r.height }));
|
||||
};
|
||||
check();
|
||||
window.addEventListener('resize', check);
|
||||
const t = window.setTimeout(check, 400);
|
||||
return () => { window.removeEventListener('resize', check); window.clearTimeout(t); };
|
||||
}, [tiledZonesKey]);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const historyInputRef = useRef<HTMLInputElement>(null);
|
||||
const historyListRef = useRef<HTMLDivElement>(null);
|
||||
@@ -465,7 +482,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
onHistoryScroll={handleHistoryScroll}
|
||||
/>
|
||||
</div>
|
||||
) : canvasEmpty ? null : (
|
||||
) : canvasEmpty || pillCovered ? null : (
|
||||
<DesktopSpawnPill
|
||||
onOpenComposer={() => {
|
||||
if (newAgentBounce) onNewAgentBounceEnd?.();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { coveredByTiledZones, rectsIntersect } from './spawnPillCover';
|
||||
|
||||
// With a chat tiled to the bottom-left quarter, the floating "Ask me anything" pill sat on top of the
|
||||
// card's own composer, two inputs on one spot (ENG-469). The pill yields to any tile that covers it.
|
||||
const viewport = { w: 1400, h: 900 };
|
||||
const fakeZone = (zone: string) => {
|
||||
const z: Record<string, { x: number; y: number; w: number; h: number }> = {
|
||||
bl: { x: 0, y: 0.5, w: 0.5, h: 0.5 }, tl: { x: 0, y: 0, w: 0.5, h: 0.5 }, right: { x: 0.5, y: 0, w: 0.5, h: 1 },
|
||||
};
|
||||
const r = z[zone]; return r ? { x: r.x * viewport.w, y: r.y * viewport.h, w: r.w * viewport.w, h: r.h * viewport.h } : null;
|
||||
};
|
||||
const pill = { x: 480, y: 830, w: 440, h: 56 };
|
||||
|
||||
test('a bottom-left tile covers the pill; a top-left tile does not', () => {
|
||||
assert.equal(coveredByTiledZones(['bl'], pill, fakeZone), true);
|
||||
assert.equal(coveredByTiledZones(['tl'], pill, fakeZone), false);
|
||||
});
|
||||
|
||||
test('a right-half tile reaches the pill too, an unknown zone is ignored, no tiles means not covered', () => {
|
||||
assert.equal(coveredByTiledZones(['right'], pill, fakeZone), true);
|
||||
assert.equal(coveredByTiledZones(['nope'], pill, fakeZone), false);
|
||||
assert.equal(coveredByTiledZones([], pill, fakeZone), false);
|
||||
});
|
||||
|
||||
test('rectsIntersect is a strict overlap, not a touch', () => {
|
||||
assert.equal(rectsIntersect({ x: 0, y: 0, w: 10, h: 10 }, { x: 10, y: 0, w: 10, h: 10 }), false);
|
||||
assert.equal(rectsIntersect({ x: 0, y: 0, w: 10, h: 10 }, { x: 9, y: 9, w: 10, h: 10 }), true);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { zoneRect, type ZoneRect } from './tiledGeometry';
|
||||
|
||||
export function rectsIntersect(a: ZoneRect, b: ZoneRect): boolean {
|
||||
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
}
|
||||
|
||||
/** True when any tiled card's zone sits over the floating spawn pill (a bottom-left tile put "Ask me anything" on top of the card's own composer, ENG-469). */
|
||||
export function coveredByTiledZones(zones: string[], pill: ZoneRect, rectFor: (zone: string) => ZoneRect | null = zoneRect): boolean {
|
||||
for (const zone of zones) {
|
||||
const r = rectFor(zone);
|
||||
if (r && rectsIntersect(r, pill)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
// Drill seam for interleaved A/B runs on one live board: `localStorage.setItem('osw.perf.baseline', '1')`
|
||||
// restores the pre-2026-09-02 gesture behaviour (measure transcript heights on every commit, flush every
|
||||
// held stream in the same tick). Read once per page load, so flipping an arm is a reload, never a rebuild.
|
||||
let p_cached: boolean | null = null;
|
||||
|
||||
export function perfBaseline(): boolean {
|
||||
// Drill seam for interleaved A/B runs on one live board. `localStorage.setItem('osw.perf.baseline', '1')`
|
||||
// restores every pre-fix behaviour; a comma list names the fixes to switch off, so one arm can isolate one
|
||||
// change ('gesture': the 2026-09-02 transcript measure + stream flush; 'ambient': the pill artifact cap +
|
||||
// the ShowUI memo boundaries). Read once per page load, so flipping an arm is a reload, never a rebuild.
|
||||
export type PerfFix = 'gesture' | 'ambient';
|
||||
let p_cached: Set<string> | null = null;
|
||||
function p_flags(): Set<string> {
|
||||
if (p_cached === null) {
|
||||
try {
|
||||
p_cached = localStorage.getItem('osw.perf.baseline') === '1';
|
||||
const raw = (localStorage.getItem('osw.perf.baseline') || '').trim();
|
||||
p_cached = new Set(raw === '1' ? ['gesture', 'ambient'] : raw ? raw.split(',').map((s) => s.trim()) : []);
|
||||
} catch {
|
||||
p_cached = false;
|
||||
p_cached = new Set();
|
||||
}
|
||||
}
|
||||
return p_cached;
|
||||
}
|
||||
export function perfBaselineFor(fix: PerfFix): boolean {
|
||||
return p_flags().has(fix);
|
||||
}
|
||||
export function perfBaseline(): boolean {
|
||||
return perfBaselineFor('gesture');
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ interface StatCardProps {
|
||||
locale?: string;
|
||||
isSingle?: boolean;
|
||||
index?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
@@ -165,6 +166,7 @@ function StatCard({
|
||||
locale,
|
||||
isSingle = false,
|
||||
index = 0,
|
||||
compact = false,
|
||||
}: StatCardProps) {
|
||||
const sparklineColor = stat.sparkline?.color ?? "var(--muted-foreground)";
|
||||
const hasSparkline = Boolean(stat.sparkline);
|
||||
@@ -173,7 +175,8 @@ function StatCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-28 flex-col gap-1 px-6",
|
||||
"relative flex flex-col gap-1",
|
||||
compact ? "min-h-16 px-3" : "min-h-28 px-6",
|
||||
isSingle ? "justify-center" : "justify-end",
|
||||
)}
|
||||
>
|
||||
@@ -200,7 +203,7 @@ function StatCard({
|
||||
<span
|
||||
className={cn(
|
||||
"font-light tracking-normal",
|
||||
isSingle ? "text-5xl" : "text-3xl",
|
||||
isSingle ? "text-5xl" : compact ? "text-xl" : "text-3xl",
|
||||
)}
|
||||
>
|
||||
<FormattedValue
|
||||
@@ -222,7 +225,8 @@ export function StatsDisplay({
|
||||
stats,
|
||||
className,
|
||||
locale: localeProp,
|
||||
}: StatsDisplayProps) {
|
||||
compact = false,
|
||||
}: StatsDisplayProps & { compact?: boolean }) {
|
||||
const locale =
|
||||
localeProp ??
|
||||
(typeof navigator !== "undefined" ? navigator.language : undefined);
|
||||
@@ -234,7 +238,8 @@ export function StatsDisplay({
|
||||
data-slot="stats-display"
|
||||
data-tool-ui-id={id}
|
||||
className={cn(
|
||||
"w-full min-w-80 max-w-xl",
|
||||
"w-full max-w-xl",
|
||||
compact ? "min-w-0" : "min-w-80",
|
||||
isSingle && "max-w-sm",
|
||||
className,
|
||||
)}
|
||||
@@ -254,7 +259,8 @@ export function StatsDisplay({
|
||||
<div
|
||||
className="grid @[440px]:-ml-px @[440px]:-mt-px"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
// Under a collapsed pill (compact) three cells must sit side by side at ~380 px; the page-sized minimum stacked them into a 336 px column.
|
||||
gridTemplateColumns: compact ? "repeat(auto-fit, minmax(110px, 1fr))" : "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{stats.map((stat, index) => (
|
||||
@@ -270,6 +276,7 @@ export function StatsDisplay({
|
||||
locale={locale}
|
||||
isSingle={isSingle}
|
||||
index={index}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Under a collapsed pill the vendored stats card stacked its three cells into a 336 px column (its grid
|
||||
// minimum is 220 px per cell and the pill is 380 px wide). Compact density puts them side by side.
|
||||
test('compact density narrows the grid minimum and the cell so three stats fit a pill', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/toolui/components/stats-display/stats-display.tsx'), 'utf8');
|
||||
assert.ok(src.includes('compact ? "repeat(auto-fit, minmax(110px, 1fr))" : "repeat(auto-fit, minmax(220px, 1fr))"'));
|
||||
assert.ok(src.includes('compact ? "min-h-16 px-3" : "min-h-28 px-6"'));
|
||||
assert.ok(src.includes('compact={compact}'), 'the density reaches every cell');
|
||||
assert.ok(src.includes('compact ? "min-w-0" : "min-w-80"'), 'the 320 px floor would overflow the pill');
|
||||
});
|
||||
Reference in New Issue
Block a user