mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user