[eric] canvas: fullscreen glides straight to the zone, shell wash renders identically to the canvas wash (grain seam class dead), grain hook survives two consumers, tool-ui repairs learn agent id aliases and array rows

This commit is contained in:
ciregenz
2026-08-06 12:22:00 -07:00
parent 0f4226dfef
commit 5221540e17
4 changed files with 47 additions and 11 deletions
@@ -31,7 +31,8 @@ import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
import { byPreviewRecency } from '@/shared/previewOrder';
import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip';
import { washBackgroundUrl, effectiveWashStops } from '@/shared/styles/washBackground';
import { washOpaqueBackgroundUrl, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
import { useGrainTileUrl } from '@/shared/styles/useGrainTileUrl';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
const AppShell: React.FC = () => {
@@ -76,7 +77,8 @@ const AppShell: React.FC = () => {
// Arc/Zen fullscreen ground: ONE themed wash across the whole window (sidebar sits on it borderless,
// the content floats as a rounded card). Mirrors the DashboardCanvas wash formula.
const { accent: themeAccent, gradient: themeGradient } = useThemeAccent();
const { washOpacity: themeWashOpacity } = useThemeWash();
const { washOpacity: themeWashOpacity, grain: themeWashGrain } = useThemeWash();
const shellGrainUrl = useGrainTileUrl(themeWashGrain);
const fsWashStops = effectiveWashStops(themeGradient, themeAccent);
// During an active free trial the user CAN run things, so a red "no model connected" warning is misleading and discouraging (it sits right above the working starter chips). The trial flips connection_mode back to own_key the moment it's spent, so this banner returns then, landing the connect-a-model nudge after the win, not before it.
const freeTrialActive = useAppSelector((s) => {
@@ -410,7 +412,16 @@ const AppShell: React.FC = () => {
return (
<Box sx={{
display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary,
...(fsWashStops ? { backgroundImage: washBackgroundUrl(fsWashStops, themeWashOpacity), backgroundSize: '100% 100%' } : {}),
// Identical rendering to the canvas wash (opaque pre-blend + the same baked grain tile), so any
// sliver of shell peeking past the viewport reads as continuous texture, never a tint/grain seam.
...(fsWashStops ? {
backgroundColor: washUnderlayColor(fsWashStops, themeWashOpacity, c.bg.page),
backgroundImage: shellGrainUrl
? `${shellGrainUrl}, ${washOpaqueBackgroundUrl(fsWashStops, themeWashOpacity, c.bg.page)}`
: washOpaqueBackgroundUrl(fsWashStops, themeWashOpacity, c.bg.page),
backgroundSize: shellGrainUrl ? 'auto, 100% 100%' : '100% 100%',
backgroundRepeat: shellGrainUrl ? 'repeat, no-repeat' : 'no-repeat',
} : {}),
}}>
{/* Sidebar retired: dashboards switch via the macOS-Spaces top strip; a slim band below the
spaces hot zone keeps the frameless window draggable (the sidebar's drag strip is gone). */}
@@ -193,7 +193,18 @@ function rebaseline(entry: TiledEntry, cam: Camera, tx: number, ty: number): voi
export function registerTiledCard(id: string, zone: string, origin: { x: number; y: number }, cam: Camera): void {
const el = document.querySelector<HTMLElement>(`[data-select-id="${CSS.escape(id)}"]`);
if (!el) return;
const entry: TiledEntry = { el, zone, originX: origin.x, originY: origin.y };
// Derive the TRUE origin up front from the painted rect and the element's current transform
// (matrix e/f are its translate in canvas units), so the enter glide heads straight for the zone
// instead of gliding to a wrong spot and snapping at settle (the left-then-center jerk).
let ox = origin.x;
let oy = origin.y;
try {
const r0 = el.getBoundingClientRect();
const m = new DOMMatrixReadOnly(getComputedStyle(el).transform);
ox = (r0.left - cam.panX) / cam.zoom - m.e;
oy = (r0.top - cam.panY) / cam.zoom - m.f;
} catch { /* keep the passed origin */ }
const entry: TiledEntry = { el, zone, originX: ox, originY: oy };
entries.set(id, entry);
startObserving();
// Tiling usually commits alongside chrome collapsing, so a cached workspace is untrustworthy here.
@@ -32,7 +32,9 @@ export function useGrainTileUrl(opacity: number): string | null {
} else {
const img = sourceImage ?? new Image();
sourceImage = img;
img.onload = () => bake(img);
// addEventListener, never onload=: with two consumers (shell + canvas) the second onload
// assignment silently erased the first, and that instance stayed grainless forever.
img.addEventListener('load', () => bake(img), { once: true });
if (!img.src) img.src = './grain-texture.png';
if (img.complete && img.naturalWidth > 0) bake(img);
}
+18 -6
View File
@@ -39,6 +39,8 @@ interface VendoredToolUiProps {
quietFail?: boolean;
}
const warnedShapes = new Set<string>();
type Gate =
| { state: 'pending' }
| { state: 'ok'; parsed: Record<string, unknown> }
@@ -67,9 +69,11 @@ function repairCommonAgentShapes(props: Record<string, unknown>): Record<string,
if (typeof o === 'string') return { id: slugFor(o, i), label: o };
if (o && typeof o === 'object' && !Array.isArray(o)) {
const obj = { ...(o as Record<string, unknown>) };
if (obj.id == null || obj.id === '') obj.id = slugFor(obj.label, i);
// Agents reach for value/name/key and title/text as synonyms; honor them before inventing a slug.
if (obj.id == null || obj.id === '') obj.id = obj.value ?? obj.key ?? obj.name ?? null;
if (obj.id == null || obj.id === '') obj.id = slugFor(obj.label ?? obj.title ?? obj.text, i);
else if (typeof obj.id !== 'string') obj.id = String(obj.id);
if (typeof obj.label !== 'string' || !obj.label) obj.label = String(obj.label ?? obj.id);
if (typeof obj.label !== 'string' || !obj.label) obj.label = String(obj.label ?? obj.title ?? obj.text ?? obj.name ?? obj.id);
return obj;
}
return o;
@@ -93,7 +97,14 @@ function repairCommonAgentShapes(props: Record<string, unknown>): Record<string,
});
}
if (Array.isArray(out.data)) {
// Row arrays (instead of keyed objects) zip against the column keys, in order.
const colKeys = Array.isArray(out.columns)
? (out.columns as Array<Record<string, unknown>>).map((c, i) => String((c && typeof c === 'object' ? (c.key ?? c.id ?? c.label) : c) ?? `col${i + 1}`))
: null;
out.data = out.data.map((row) => {
if (Array.isArray(row) && colKeys && colKeys.length > 0) {
return Object.fromEntries(row.map((v, i) => [colKeys[i] ?? `col${i + 1}`, v]));
}
if (!row || typeof row !== 'object' || Array.isArray(row)) return row;
return Object.fromEntries(Object.entries(row as Record<string, unknown>).map(([k, v]) => {
if (v !== null && typeof v === 'object' && !Array.isArray(v)) return [k, JSON.stringify(v)];
@@ -171,12 +182,13 @@ function VendoredToolUi({ name, props, extraProps, quietFail = false }: Vendored
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry, propsKey]);
const warnedRef = useRef<string | null>(null);
if (!entry) return null;
if (gate.state === 'bad') {
// Schema jargon is for the console; the transcript gets one quiet human line. Once per payload, not per render.
if (warnedRef.current !== propsKey) {
warnedRef.current = propsKey;
// Schema jargon is for the console, once per component+issue SHAPE for the whole session; a
// transcript full of the same agent mistake used to print 37 copies of the identical warning.
const shapeKey = `${name}:${gate.problem}`;
if (!warnedShapes.has(shapeKey)) {
warnedShapes.add(shapeKey);
console.warn(`[tool-ui] ${name} payload didn't validate:`, gate.problem);
}
if (quietFail) return null;