diff --git a/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx b/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx index da649def..60ac8044 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx @@ -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(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) => { 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(); diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts index fd26a4f0..2814a668 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -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]; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index fa9ba598..2b9bcf4a 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -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 = ({ 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 = ({ running={pillRunning} todos={todos} artifact={pillArtifact} + askPair={pillAskPair} + sessionId={session.id} browserShot={browserShot} selected={isSelected} highlighted={isHighlighted} diff --git a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx index 336b95d5..4b1fb9ae 100644 --- a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx @@ -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 ( - {artifact ? ( + {liveAsk ? ( + + + + ) : artifact ? ( diff --git a/frontend/src/toolui/toolui.css b/frontend/src/toolui/toolui.css index d4ffec5b..c4412f47 100644 --- a/frontend/src/toolui/toolui.css +++ b/frontend/src/toolui/toolui.css @@ -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);