diff --git a/frontend/src/app/components/overlays/ProviderHealthToast.tsx b/frontend/src/app/components/overlays/ProviderHealthToast.tsx index e6ce37cd..bffeae20 100644 --- a/frontend/src/app/components/overlays/ProviderHealthToast.tsx +++ b/frontend/src/app/components/overlays/ProviderHealthToast.tsx @@ -10,6 +10,12 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { hideProviderHealthToast } from '@/shared/state/subscriptionsSlice'; import { openSettingsCard } from '@/shared/state/dashboardLayoutSlice'; +import { healthToastAnchor, type HealthToastAnchor } from '@/app/pages/Dashboard/canvas/spawnPillCover'; + +// Where the toast would sit at its bottom-left home (MUI's 24 px inset), so a toast already moved up is judged on the spot it came from. +function HOME_RECT(r: DOMRect): { x: number; y: number; w: number; h: number } { + return { x: 24, y: window.innerHeight - 24 - r.height, w: r.width, h: r.height }; +} export default function ProviderHealthToast() { const c = useClaudeTokens(); @@ -17,6 +23,18 @@ export default function ProviderHealthToast() { const open = useAppSelector((s) => s.subscriptions.healthToastOpen); const dead = useAppSelector((s) => s.subscriptions.healthDead); const cliMissing = useAppSelector((s) => s.subscriptions.healthCliMissing); + const tiledZonesKey = useAppSelector((s) => Object.values(s.dashboardLayout.tiledCards).sort().join(',')); + const rootRef = React.useRef(null); + const [anchor, setAnchor] = React.useState({ vertical: 'bottom', horizontal: 'left' }); + React.useLayoutEffect(() => { + // Measured at its home spot, never at the moved one, or a toast that moved up would never come back down. + const el = rootRef.current; + if (!el || !open) return; + const r = el.getBoundingClientRect(); + const zones = tiledZonesKey ? tiledZonesKey.split(',') : []; + setAnchor(healthToastAnchor(zones, anchor.vertical === 'bottom' ? { x: r.x, y: r.y, w: r.width, h: r.height } : HOME_RECT(r))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tiledZonesKey, open]); const onReconnect = React.useCallback(() => { dispatch(openSettingsCard({ tab: 'models' })); @@ -32,7 +50,8 @@ export default function ProviderHealthToast() { autoHideDuration={null} // Clickaway would kill the pill on the user's first canvas click, before they read it; only the X or Reconnect dismisses. onClose={(event, reason) => { if (reason !== 'clickaway') dispatch(hideProviderHealthToast()); }} - anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + anchorOrigin={anchor} + ref={rootRef} > = { 'opus-4-8': 1_000_000, @@ -2119,7 +2122,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose return ( {rendered} - {browserSlotBody} + {browserSlotBody} ); } @@ -2194,7 +2197,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} {/* Fallback dock slot for a browser that docked before any browser tool row exists (or whose row was compacted away); once a row appears the slot anchors at it instead (see browserAnchorItemId). The real card overlays this rect geometrically, so the webview never remounts; the mini hides itself when its slot scrolls mostly out of view, since a live webview can't be clipped by the scroller. */} {hasDockedBrowser && !browserAnchorItemId && ( - {browserSlotBody} + {browserSlotBody} )} diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx index fada029f..2d45dfd1 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx @@ -6,7 +6,7 @@ import LinksWidget from './LinksWidget'; import VendoredToolUi from '@toolui/VendoredToolUi'; import type { ShowUiPayload } from './showUiPayload'; import { useOpenUrlInBrowserCard } from './useOpenUrlInBrowserCard'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext'; import { ambientShape } from './showUiAmbient'; import { perfBaselineFor } from '@/shared/perfBaseline'; @@ -14,6 +14,7 @@ import { perfBaselineFor } from '@/shared/perfBaseline'; function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambient?: boolean }): React.ReactElement | null { const openUrl = useOpenUrlInBrowserCard(); const c = useClaudeTokens(); + const { mode } = useThemeMode(); if (payload.component === 'weather') return ; if (payload.component === 'plan') return ; if (payload.component === 'stats') return ; @@ -37,10 +38,11 @@ function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambien const shaped = ambient && !perfBaselineFor('ambient') ? ambientShape(payload.name, raw) : { props: payload.props, note: null, extraProps: {} }; let widget = ; if (shaped.note) { + // The note sits on the widget's own surface (the tool-ui theme's card colour, tucked under the table's rounded bottom) so it reads as the artifact's footer, not stray canvas text (ENG-474). widget = ( -
+
{widget} -
{shaped.note}
+
{shaped.note}
); } diff --git a/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.test.ts b/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.test.ts index 5a8ea305..0f24e397 100644 --- a/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.test.ts +++ b/frontend/src/app/pages/Dashboard/canvas/spawnPillCover.test.ts @@ -1,6 +1,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { coveredByTiledZones, rectsIntersect } from './spawnPillCover'; +import fs from 'node:fs'; +import path from 'node:path'; +import { coveredByTiledZones, healthToastAnchor, 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. @@ -28,3 +30,22 @@ 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); }); + +test('the reconnect pill moves to the top centre only while a tile covers its bottom-left home, and never hides', () => { + const home = { x: 24, y: 840, w: 420, h: 48 }; + const rects: Record = { bl: { x: 0, y: 450, w: 700, h: 450 }, tr: { x: 700, y: 0, w: 700, h: 450 } }; + const rectFor = (z: string) => rects[z] ?? null; + assert.deepEqual(healthToastAnchor([], home, rectFor), { vertical: 'bottom', horizontal: 'left' }); + assert.deepEqual(healthToastAnchor(['tr'], home, rectFor), { vertical: 'bottom', horizontal: 'left' }); + assert.deepEqual(healthToastAnchor(['bl'], home, rectFor), { vertical: 'top', horizontal: 'center' }); + assert.deepEqual(healthToastAnchor(['bl'], null, rectFor), { vertical: 'bottom', horizontal: 'left' }, 'unmeasured stays home'); +}); + +test('the toast wires the anchor to the tiled zones, and the capped-table note lives on the widget surface', () => { + const toast = fs.readFileSync(path.join(process.cwd(), 'src/app/components/overlays/ProviderHealthToast.tsx'), 'utf8'); + assert.ok(toast.includes('anchorOrigin={anchor}') && toast.includes('healthToastAnchor(')); + const view = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx'), 'utf8'); + assert.ok(view.includes('className="bg-card text-muted-foreground border border-border border-t-0 rounded-b-xl text-xs"'), 'ENG-474: the note is painted on the tool-ui card surface'); + const chat = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/AgentChat.tsx'), 'utf8'); + assert.equal((chat.match(/