[eric] toolui: preflight layered under utilities (borders+buttons live again), sans font + pinned scale, answers lock on submit, pending AskUI pins under the pill

This commit is contained in:
ciregenz
2026-07-28 11:46:38 -07:00
parent 05656a7491
commit 9f930c649b
5 changed files with 127 additions and 48 deletions
@@ -37,6 +37,9 @@ function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubble
const [submitted, setSubmitted] = useState(false);
const [orphaned, setOrphaned] = useState(false);
const [freeText, setFreeText] = useState('');
// The choice captured at click time, so the component flips to its receipt the INSTANT the user
// answers instead of staying clickable until the agent's tool result lands seconds later.
const [localChoice, setLocalChoice] = useState<unknown>(undefined);
const answered = parseResultResponse(pair);
const freeTextAnswer =
answered?.action === 'free_text' && answered.value && typeof answered.value === 'object'
@@ -49,6 +52,9 @@ function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubble
(response: Record<string, unknown>) => {
if (submitted) return;
setSubmitted(true);
if (response.action !== 'free_text') {
setLocalChoice(response.choice ?? response.value ?? undefined);
}
void fetch(`${API_BASE}/ui-requests/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getAuthToken()}` },
@@ -58,10 +64,11 @@ function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubble
if (!r.ok) {
// Nothing parked server-side (agent gone or this is a replayed transcript): say so instead of silently swallowing the click.
setSubmitted(false);
setLocalChoice(undefined);
setOrphaned(true);
}
})
.catch(() => setSubmitted(false));
.catch(() => { setSubmitted(false); setLocalChoice(undefined); });
},
[submitted, sessionId, componentId],
);
@@ -79,7 +86,7 @@ function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubble
onConfirm: () => respond({ action: 'confirm', choice: 'approved' }),
onCancel: () => respond({ action: 'cancel', choice: 'denied' }),
}
: { choice: (answered?.choice as string) || undefined };
: { choice: (answered?.choice as string) ?? (localChoice as string | undefined) };
}
if (waiting) {
return {
@@ -91,8 +98,10 @@ function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubble
}
// A free-text answer isn't an option id; passing it as `choice` would fail their contract.
if (freeTextAnswer !== null) return {};
return answered && 'value' in answered ? { choice: answered.value } : {};
}, [payload, waiting, respond, answered, freeTextAnswer]);
if (answered && 'value' in answered) return { choice: answered.value };
// Result not landed yet but the user already clicked: the captured choice renders the receipt now.
return localChoice !== undefined ? { choice: localChoice } : {};
}, [payload, waiting, respond, answered, freeTextAnswer, localChoice]);
const submitFreeText = useCallback(() => {
const text = freeText.trim();
@@ -76,6 +76,21 @@ export function isAskUiPair(pair: ToolPair): boolean {
}
/** Latest ShowUI payload anywhere in a transcript; the collapsed card pins this artifact under its pill. */
/** The newest UNANSWERED AskUI call, so a collapsed card can surface the live question under its
pill (a blocking question beats every other artifact; the agent is literally waiting on it). */
export function extractPendingAskUi(messages: Array<{ id: string; role: string; content: unknown }>): ToolPair | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== 'tool_call') continue;
const body = (typeof msg.content === 'object' && msg.content !== null ? msg.content : {}) as { tool?: unknown };
if (!/(^|__)AskUI$/.test(String(body.tool || ''))) continue;
const next = messages[i + 1];
if (next && next.role === 'tool_result') return null;
return { type: 'tool_pair', id: msg.id, call: msg as ToolPair['call'], result: null };
}
return null;
}
export function extractLatestShowUi(messages: Array<{ role: string; content: unknown }>): ShowUiPayload | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
@@ -39,7 +39,7 @@ import WindowControls, { ARC_CHIP_SX } from './WindowControls';
import { useTiledStyle } from './tileZones';
import AgentNarratorPill from '../desktop/AgentNarratorPill';
import { extractLatestTodos } from '../desktop/agentTodos';
import { extractLatestShowUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
import { extractLatestShowUi, extractPendingAskUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
import { getWebview } from '@/shared/browserRegistry';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar';
@@ -679,6 +679,10 @@ const AgentCard: React.FC<Props> = ({
const artifact = extractLatestShowUi(session.messages || []);
return artifact ? freezeIfDone(artifact, session.status === 'running') : null;
}, [session.messages, session.status]);
const pillAskPair = useMemo(
() => (session.status === 'running' ? extractPendingAskUi(session.messages || []) : null),
[session.messages, session.status],
);
const pillMode = !expanded && !hasPending && !isDraft && !tileZone;
const pillLabel = session.turn_label?.label || displayChatTitle(session);
const pillRunning = session.status === 'running';
@@ -980,6 +984,8 @@ const AgentCard: React.FC<Props> = ({
running={pillRunning}
todos={todos}
artifact={pillArtifact}
askPair={pillAskPair}
sessionId={session.id}
browserShot={browserShot}
selected={isSelected}
highlighted={isHighlighted}
@@ -4,6 +4,8 @@ import Typography from '@mui/material/Typography';
import CheckIcon from '@mui/icons-material/Check';
import DashboardGlyph from '../canvas/DashboardGlyph';
import ShowUiWidgetView from '@/app/pages/AgentChat/tool-ui/ShowUiWidgetView';
import AskUiBubble from '@/app/pages/AgentChat/tool-ui/AskUiBubble';
import type { ToolPair } from '@/app/pages/AgentChat/tool-bubbles/ToolCallBubble';
import type { ShowUiPayload } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
import type { AgentTodoItem } from './agentTodos';
@@ -12,6 +14,8 @@ interface AgentNarratorPillProps {
running: boolean;
todos: AgentTodoItem[] | null;
artifact: ShowUiPayload | null;
askPair?: ToolPair | null;
sessionId?: string;
browserShot: string | null;
selected: boolean;
highlighted: boolean;
@@ -21,13 +25,14 @@ const GLASS = 'rgba(24,14,32,0.8)';
const GLASS_BLUR = 'blur(18px) saturate(150%)';
const MAX_VISIBLE_TODOS = 4;
/** Collapsed agent as the desktop narrator pill; below it, the best artifact wins: widget > browser shot > plan > Thinking. */
function AgentNarratorPill({ label, running, todos, artifact, browserShot, selected, highlighted }: AgentNarratorPillProps): React.ReactElement {
/** Collapsed agent as the desktop narrator pill; below it, the best artifact wins: live question > widget > browser shot > plan > Thinking. */
function AgentNarratorPill({ label, running, todos, artifact, askPair, sessionId, browserShot, selected, highlighted }: AgentNarratorPillProps): React.ReactElement {
const visibleTodos = (todos || []).slice(0, MAX_VISIBLE_TODOS);
const hiddenCount = (todos?.length || 0) - visibleTodos.length;
const ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined;
const liveAsk = askPair && sessionId ? askPair : null;
// One key per ladder state so a state CHANGE remounts the artifact and replays the one-shot entrance; nothing loops.
const artifactKey = artifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : running ? 'thinking' : 'none';
const artifactKey = liveAsk ? `ask-${liveAsk.id}` : artifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : running ? 'thinking' : 'none';
return (
<Box
@@ -71,7 +76,11 @@ function AgentNarratorPill({ label, running, todos, artifact, browserShot, selec
</Typography>
</Box>
{artifact ? (
{liveAsk ? (
<Box key={artifactKey} className="osw-artifact" sx={{ width: 340, maxWidth: '80vw' }}>
<AskUiBubble pair={liveAsk} sessionId={sessionId!} isPending suppressReveal />
</Box>
) : artifact ? (
<Box key={artifactKey} className="osw-artifact">
<ShowUiWidgetView payload={artifact} ambient />
</Box>
+79 -39
View File
@@ -2,6 +2,10 @@
styles are untouched) plus the shadcn theme variables scoped to .tool-ui-scope. */
@custom-variant dark (&:is(.dark *));
/* Layer order is load-bearing: the scoped mini-preflight lives in `base` so utilities always beat
it. Unlayered it silently nuked every utility border and button background inside the scope. */
@layer theme, base, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@import "tw-animate-css";
@@ -39,49 +43,85 @@
--color-chart-5: var(--chart-5);
}
/* Scoped mini-preflight: exactly the resets the vendored components rely on, never global. */
.tool-ui-scope,
.tool-ui-scope *,
.tool-ui-scope *::before,
.tool-ui-scope *::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: var(--border);
}
.tool-ui-scope button {
background-color: transparent;
background-image: none;
font: inherit;
color: inherit;
padding: 0;
cursor: pointer;
}
.tool-ui-scope p,
.tool-ui-scope h1,
.tool-ui-scope h2,
.tool-ui-scope h3,
.tool-ui-scope h4,
.tool-ui-scope ul,
.tool-ui-scope ol,
.tool-ui-scope figure {
margin: 0;
}
.tool-ui-scope ul,
.tool-ui-scope ol {
list-style: none;
padding: 0;
}
.tool-ui-scope img,
.tool-ui-scope video,
.tool-ui-scope canvas {
max-width: 100%;
display: block;
/* Scoped mini-preflight: exactly the resets the vendored components rely on, never global.
Kept in @layer base so every Tailwind utility (layer utilities, later) wins over it. */
@layer base {
.tool-ui-scope,
.tool-ui-scope *,
.tool-ui-scope *::before,
.tool-ui-scope *::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: var(--border);
}
.tool-ui-scope button {
background-color: transparent;
background-image: none;
font: inherit;
color: inherit;
padding: 0;
cursor: pointer;
}
.tool-ui-scope p,
.tool-ui-scope h1,
.tool-ui-scope h2,
.tool-ui-scope h3,
.tool-ui-scope h4,
.tool-ui-scope ul,
.tool-ui-scope ol,
.tool-ui-scope figure {
margin: 0;
}
.tool-ui-scope ul,
.tool-ui-scope ol {
list-style: none;
padding: 0;
}
.tool-ui-scope img,
.tool-ui-scope video,
.tool-ui-scope canvas {
max-width: 100%;
display: block;
}
}
.tool-ui-scope {
color: var(--foreground);
/* The app's chat voice is a serif at a 19.2px root; generated UI needs the crisp product-sans
look (tool-ui.com / ChatGPT widgets), so the scope carries its own stack and smoothing. */
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Inter, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.5;
/* Rem rebase: the app scales the ROOT font-size (type scale + user text-size), which inflates
every rem-based Tailwind value 20%+ (text-base hit 19.2px, max-w-md hit 537px). Tailwind v4
utilities read these variables, so pinning them here gives the components a fixed optical
scale no matter what the app root does. */
--spacing: 4px;
--text-xs: 11px;
--text-xs--line-height: 1.45;
--text-sm: 13px;
--text-sm--line-height: 1.54;
--text-base: 14px;
--text-base--line-height: 1.57;
--text-lg: 16px;
--text-lg--line-height: 1.5;
--text-xl: 18px;
--text-xl--line-height: 1.44;
--text-2xl: 21px;
--text-2xl--line-height: 1.33;
--text-3xl: 26px;
--text-3xl--line-height: 1.25;
--container-3xs: 224px;
--container-2xs: 256px;
--container-xs: 320px;
--container-sm: 384px;
--container-md: 448px;
--container-lg: 512px;
--container-xl: 576px;
--container-2xl: 672px;
--container-3xl: 768px;
--container-4xl: 896px;
/* The vendored components (data-table, etc.) switch layout on @container width. Without a
containment context those queries never match, so a table is stuck in its narrow mobile
accordion forever (columns hidden behind a chevron). This makes width-responsive actually work:
@@ -91,7 +131,7 @@
.tool-ui-scope {
color-scheme: light;
--radius: 0.625rem;
--radius: 10px;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);