From 224fd6884addf611ae55ab38c53f5c76de67ecf2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 5 Sep 2026 09:29:00 -0700 Subject: [PATCH] [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 --- .../AgentChat/tool-ui/ShowUiWidgetView.tsx | 16 +++++++-- .../pages/AgentChat/tool-ui/ToolUiBubble.tsx | 15 +++----- .../AgentChat/tool-ui/showUiAmbient.test.ts | 34 +++++++++++++++++++ .../pages/AgentChat/tool-ui/showUiAmbient.ts | 25 ++++++++++++++ .../AgentChat/tool-ui/toolUiBubbleEqual.ts | 19 +++++++++++ .../tool-ui/toolUiBubbleMemo.test.ts | 28 +++++++++++++++ .../app/pages/Dashboard/DashboardToolbar.tsx | 19 ++++++++++- .../Dashboard/canvas/spawnPillCover.test.ts | 30 ++++++++++++++++ .../pages/Dashboard/canvas/spawnPillCover.ts | 14 ++++++++ frontend/src/shared/perfBaseline.ts | 24 ++++++++----- .../stats-display/stats-display.tsx | 17 +++++++--- .../stats-display/statsCompact.test.ts | 14 ++++++++ 12 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/showUiAmbient.test.ts create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/showUiAmbient.ts create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/toolUiBubbleEqual.ts create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/toolUiBubbleMemo.test.ts create mode 100644 frontend/src/app/pages/Dashboard/canvas/spawnPillCover.test.ts create mode 100644 frontend/src/app/pages/Dashboard/canvas/spawnPillCover.ts create mode 100644 frontend/src/toolui/components/stats-display/statsCompact.test.ts diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx index bebdf390..849446d8 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx @@ -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 = ; + const shaped = ambient && !perfBaselineFor('ambient') ? ambientShape(payload.name, raw) : { props: payload.props, note: null }; + let widget = ; + if (shaped.note) { + widget = ( +
+ {widget} +
{shaped.note}
+
+ ); + } // 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); diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx index 1ce0712c..1ae258a5 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx @@ -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(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); diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiAmbient.test.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiAmbient.test.ts new file mode 100644 index 00000000..9c0ae779 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiAmbient.test.ts @@ -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)")); +}); diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiAmbient.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiAmbient.ts new file mode 100644 index 00000000..0ff1bf8b --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiAmbient.ts @@ -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; + /** One line under the widget when something was left out, or null. */ + note: string | null; +} + +export function ambientShape(name: string, props: Record): 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 }; +} diff --git a/frontend/src/app/pages/AgentChat/tool-ui/toolUiBubbleEqual.ts b/frontend/src/app/pages/AgentChat/tool-ui/toolUiBubbleEqual.ts new file mode 100644 index 00000000..46d99ec0 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/toolUiBubbleEqual.ts @@ -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); +} diff --git a/frontend/src/app/pages/AgentChat/tool-ui/toolUiBubbleMemo.test.ts b/frontend/src/app/pages/AgentChat/tool-ui/toolUiBubbleMemo.test.ts new file mode 100644 index 00000000..7f1b88f4 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/toolUiBubbleMemo.test.ts @@ -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])')); +}); diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 2598a3a3..5ed7d07a 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -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( const dispatch = useAppDispatch(); const elementSelection = useElementSelection(); const containerRef = useRef(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(null); const historyInputRef = useRef(null); const historyListRef = useRef(null); @@ -465,7 +482,7 @@ const DashboardToolbar = React.forwardRef( onHistoryScroll={handleHistoryScroll} /> - ) : canvasEmpty ? null : ( + ) : canvasEmpty || pillCovered ? null : ( { if (newAgentBounce) onNewAgentBounceEnd?.(); diff --git a/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.test.ts b/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.test.ts new file mode 100644 index 00000000..5a8ea305 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.test.ts @@ -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 = { + 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); +}); diff --git a/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.ts b/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.ts new file mode 100644 index 00000000..0feca023 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.ts @@ -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; +} diff --git a/frontend/src/shared/perfBaseline.ts b/frontend/src/shared/perfBaseline.ts index 51f37d6b..13b18ab2 100644 --- a/frontend/src/shared/perfBaseline.ts +++ b/frontend/src/shared/perfBaseline.ts @@ -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 | null = null; +function p_flags(): Set { 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'); +} diff --git a/frontend/src/toolui/components/stats-display/stats-display.tsx b/frontend/src/toolui/components/stats-display/stats-display.tsx index 87eca275..65e82945 100644 --- a/frontend/src/toolui/components/stats-display/stats-display.tsx +++ b/frontend/src/toolui/components/stats-display/stats-display.tsx @@ -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 (
@@ -200,7 +203,7 @@ function StatCard({ {stats.map((stat, index) => ( @@ -270,6 +276,7 @@ export function StatsDisplay({ locale={locale} isSingle={isSingle} index={index} + compact={compact} />
))} diff --git a/frontend/src/toolui/components/stats-display/statsCompact.test.ts b/frontend/src/toolui/components/stats-display/statsCompact.test.ts new file mode 100644 index 00000000..ec05d29e --- /dev/null +++ b/frontend/src/toolui/components/stats-display/statsCompact.test.ts @@ -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'); +});