diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx index 75c752c50..6514135e9 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx @@ -18,6 +18,7 @@ const tool: Tool = { interface MockBridge { sendToolInput: ReturnType; + sendToolInputPartial: ReturnType; sendToolResult: ReturnType; sendToolCancelled: ReturnType; sendHostContextChange: ReturnType; @@ -40,6 +41,7 @@ function createMockBridge(): MockBridge { const listeners: Record void)[]> = {}; return { sendToolInput: vi.fn().mockResolvedValue(undefined), + sendToolInputPartial: vi.fn().mockResolvedValue(undefined), sendToolResult: vi.fn().mockResolvedValue(undefined), sendToolCancelled: vi.fn().mockResolvedValue(undefined), sendHostContextChange: vi.fn().mockResolvedValue(undefined), @@ -325,6 +327,54 @@ describe("AppRenderer", () => { ).resolves.toEqual({ mode: "inline" }); }); + it("replays partialInputs in order before the complete tool-input on initialize", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + partialInputs={[{ city: "N" }, { city: "New" }]} + />, + ); + await flushAsync(); + await act(async () => { + await ref.current?.sendToolInput({ city: "New York" }); + bridge.emit("initialized"); + }); + expect(bridge.sendToolInputPartial).toHaveBeenCalledTimes(2); + expect(bridge.sendToolInputPartial).toHaveBeenNthCalledWith(1, { + arguments: { city: "N" }, + }); + expect(bridge.sendToolInputPartial).toHaveBeenNthCalledWith(2, { + arguments: { city: "New" }, + }); + expect( + bridge.sendToolInputPartial.mock.invocationCallOrder[1], + ).toBeLessThan(bridge.sendToolInput.mock.invocationCallOrder[0]); + }); + + it("sends no tool-input-partial when partialInputs is omitted", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + await ref.current?.sendToolInput({ city: "NYC" }); + bridge.emit("initialized"); + }); + expect(bridge.sendToolInputPartial).not.toHaveBeenCalled(); + }); + it("forwards MCP log notifications to onLog", async () => { const bridge = createMockBridge(); const onLog = vi.fn(); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index 7afa8f5d8..2dd8b1c83 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -78,6 +78,14 @@ export interface AppRendererProps { * running view emits. Backs the advertised `logging` host capability. */ onLog?: (params: LoggingMessageNotification["params"]) => void; + /** + * Ordered tool-input fragments to replay via + * `ui/notifications/tool-input-partial` BEFORE the complete `tool-input`, + * exercising widgets that render progressively. Captured at bridge-build + * time (see `pendingPartialsRef`) so prop churn never rebuilds the iframe. + * Nothing is sent when omitted/empty. + */ + partialInputs?: Record[]; /** * The host-controlled box the app renders within, used to derive * `hostContext.containerDimensions`. This MUST be an element whose size is @@ -138,12 +146,14 @@ export function AppRenderer({ onRequestDisplayMode, onMessage, onLog, + partialInputs, containerRef, ref, }: AppRendererProps) { const iframeRef = useRef(null); const bridgeRef = useRef(null); const initializedRef = useRef(false); + const pendingPartialsRef = useRef[]>([]); const pendingInputRef = useRef | null>(null); const pendingResultRef = useRef(null); const teardownStartedRef = useRef(false); @@ -163,6 +173,7 @@ export function AppRenderer({ const onRequestDisplayModeRef = useRef(onRequestDisplayMode); const onMessageRef = useRef(onMessage); const onLogRef = useRef(onLog); + const partialInputsRef = useRef(partialInputs); useEffect(() => { onErrorRef.current = onError; onSizeChangeRef.current = onSizeChange; @@ -170,6 +181,7 @@ export function AppRenderer({ onRequestDisplayModeRef.current = onRequestDisplayMode; onMessageRef.current = onMessage; onLogRef.current = onLog; + partialInputsRef.current = partialInputs; }); // Flush buffered tool input/result to the view, but only once the bridge @@ -181,6 +193,12 @@ export function AppRenderer({ const flushPending = useCallback(() => { const bridge = bridgeRef.current; if (!bridge || !initializedRef.current) return; + // Partial-input fragments first, in staged order, BEFORE the complete + // tool-input — the spec requires partials to precede the final input. + for (const args of pendingPartialsRef.current) { + void bridge.sendToolInputPartial({ arguments: args }); + } + pendingPartialsRef.current = []; if (pendingInputRef.current !== null) { const args = pendingInputRef.current; pendingInputRef.current = null; @@ -212,6 +230,7 @@ export function AppRenderer({ bridgeRef.current = null; initializedRef.current = false; lastDepsRef.current = null; + pendingPartialsRef.current = []; if (bridge) void disposeBridge(bridge); }); }, []); @@ -259,6 +278,11 @@ export function AppRenderer({ const buildId = ++buildIdRef.current; teardownStartedRef.current = false; initializedRef.current = false; + // Snapshot the staged partial-input fragments for THIS bridge build (read + // via the ref so the prop is not a dep — adding/removing fragments must not + // rebuild the iframe). The StrictMode reuse path above returned before + // reaching here, so a reused bridge keeps the queue it was built with. + pendingPartialsRef.current = [...(partialInputsRef.current ?? [])]; let pending: Promise; try { diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx index ca0ccc431..b08d3c96e 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx @@ -55,6 +55,7 @@ const cohortApp: Tool = { const okBridgeFactory: BridgeFactory = () => ({ sendToolInput: async () => {}, + sendToolInputPartial: async () => {}, sendToolResult: async () => {}, sendToolCancelled: async () => {}, sendHostContextChange: async () => {}, @@ -79,6 +80,7 @@ function createEventBridgeFactory(): { const factory: BridgeFactory = () => { const bridge = { sendToolInput: async () => {}, + sendToolInputPartial: async () => {}, sendToolResult: async () => {}, sendToolCancelled: async () => {}, sendHostContextChange: async () => {}, @@ -658,4 +660,76 @@ describe("AppsScreen", () => { ).not.toBeInTheDocument(); expect(screen.getByText("Select an app to view details")).toBeVisible(); }); + + it("stages partial-input snapshots from the form and clears them", async () => { + const user = userEvent.setup(); + renderWithMantine(); + // No-fields apps don't show the control. + await user.click(screen.getByText("Ops Dashboard")); + expect( + screen.queryByRole("button", { name: "Stage partial input" }), + ).not.toBeInTheDocument(); + // Fielded apps do. + await user.click(screen.getByText("Weather Widget")); + const stage = screen.getByRole("button", { name: "Stage partial input" }); + expect(stage).toBeInTheDocument(); + expect(screen.queryByText(/staged/)).not.toBeInTheDocument(); + await user.click(stage); + await user.click(stage); + expect(screen.getByText("2 staged")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Clear staged" })); + expect(screen.queryByText(/staged/)).not.toBeInTheDocument(); + }); + + it("replays staged partial inputs before the final tool-input on Open App", async () => { + const user = userEvent.setup(); + const partials: Record[] = []; + let onInitialized: (() => void) | undefined; + let sawInput = false; + const factory: BridgeFactory = () => { + const listeners: Record void)[]> = {}; + const bridge = { + sendToolInput: async () => { + sawInput = true; + }, + sendToolInputPartial: async (params: { + arguments?: Record; + }) => { + // Assert partials arrive before the final input. + expect(sawInput).toBe(false); + partials.push(params.arguments ?? {}); + }, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + sendHostContextChange: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + addEventListener: (event: string, handler: () => void) => { + (listeners[event] ??= []).push(handler); + if (event === "initialized") onInitialized = handler; + }, + removeEventListener: () => {}, + } as unknown as AppBridge; + return bridge; + }; + renderWithMantine(); + await user.click(screen.getByText("Weather Widget")); + // Fill the required field so Open App is enabled, then stage two snapshots. + await user.type(screen.getByRole("textbox", { name: /city/i }), "NYC"); + const stage = screen.getByRole("button", { name: "Stage partial input" }); + await user.click(stage); + await user.click(stage); + await user.click(screen.getByRole("button", { name: /Open App/ })); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + onInitialized?.(); + }); + // Both staged snapshots were replayed (staged partials survive Open). No + // final tool-input arrives because the parent's onOpenApp is a no-op here + // (App drives the renderer handle), so sawInput stays false — the partials + // are what this screen is responsible for. + expect(partials).toHaveLength(2); + expect(sawInput).toBe(false); + }); }); diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx index 677885116..26051d4bc 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -231,6 +231,23 @@ const PanelHeaderRow = Group.withProps({ gap: "sm", }); +const PartialStageControls = Group.withProps({ + gap: "sm", + wrap: "nowrap", + align: "center", + flex: "0 0 auto", +}); + +const StagePartialButton = Button.withProps({ + variant: "default", + size: "compact-xs", +}); + +const PartialStageCount = Text.withProps({ + size: "xs", + c: "dimmed", +}); + /** Render a log payload as a string for display. */ function formatLogData(data: unknown): string { if (typeof data === "string") return data; @@ -323,6 +340,13 @@ export function AppsScreen({ // Expanded by default so a widget developer sees the entries without an extra // click. The user can still collapse it for the rest of the run. const [appLogsExpanded, setAppLogsExpanded] = useState(true); + // Snapshots of the input form captured via "Stage partial input". On Open + // App they're passed to AppRenderer as `partialInputs` and replayed via + // ui/notifications/tool-input-partial before the complete tool-input, so a + // widget's progressive-render path can be exercised. Cleared on switch/close. + const [partialStages, setPartialStages] = useState[]>( + [], + ); const selectedTool = selectedAppName ? tools.find((t) => t.name === selectedAppName) @@ -360,12 +384,19 @@ export function AppsScreen({ } // Clear the message + log panels (and the reported height). Called when a run - // ends or the selected app changes so a new run starts clean. - function resetAppChannels() { + // ends or the selected app changes so a new run starts clean. `keepPartials` + // is set for `handleOpen`, where the staged fragments are about to be consumed + // by the renderer and must not be cleared first. + function resetAppChannels(opts?: { keepPartials?: boolean }) { setAppHeight(undefined); setMessages([]); setAppLogs([]); setAppLogsExpanded(true); + if (!opts?.keepPartials) setPartialStages([]); + } + + function handleStagePartialInput() { + setPartialStages((prev) => [...prev, { ...formValues }]); } // The app's display mode is derived from the existing maximized toggle. @@ -411,7 +442,12 @@ export function AppsScreen({ function handleOpen() { if (!selectedTool) return; - resetAppChannels(); + // `keepPartials` preserves the staged fragments: AppRenderer snapshots them + // into its own pendingPartialsRef at bridge-build time and replays them. + // The `partialStages` state is intentionally NOT cleared here — the staging + // UI only renders while not running, so the surviving state is invisible + // until the next select/close/back reset drains it. + resetAppChannels({ keepPartials: true }); setRunning(true); onOpenApp(selectedTool.name, formValues); } @@ -545,6 +581,7 @@ export function AppsScreen({ onRequestDisplayMode={handleRequestDisplayMode} onMessage={handleMessage} onLog={handleLog} + partialInputs={partialStages} containerRef={rendererContainerRef} ref={rendererRef} /> @@ -558,15 +595,36 @@ export function AppsScreen({ // standalone use (the `Opening` story) and for Phase 3 // wiring, where a managed-state hook can hold the panel // in a pending state across an awaited `tools/call`. - - onUiChange({ ...ui, formValues: values }) - } - onOpenApp={handleOpen} - /> + <> + {selectedHasFields && ( + + + Stage partial input + + {partialStages.length > 0 && ( + <> + + {partialStages.length} staged + + setPartialStages([])} + > + Clear staged + + + )} + + )} + + onUiChange({ ...ui, formValues: values }) + } + onOpenApp={handleOpen} + /> + )} {running && messages.length > 0 && (