From 9551a3f54a3549962a2672161e90e5c784c53413 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 13:53:30 -0400 Subject: [PATCH 1/2] =?UTF-8?q?web:=20stage=20partial=20input=20=E2=80=94?= =?UTF-8?q?=20replay=20tool-input-partial=20before=20final=20tool=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppRenderer: - New `partialInputs` prop (ordered fragments), snapshotted into pendingPartials at bridge-build time (read via a ref so prop churn never rebuilds the iframe) and cleared on dispose. flushPending replays them via bridge.sendToolInputPartial BEFORE the complete tool-input, per spec. AppsScreen: - New `partialStages` state + `handleStagePartialInput` (snapshots current form values). resetAppChannels gains a `keepPartials` option so Open App preserves the staged fragments (they're consumed by the renderer) while select/close/ back clear them. - Adds a "Stage partial input" control (fielded apps only) with a staged count and "Clear staged", wired to AppRenderer's `partialInputs`. Adds AppRenderer tests (ordered replay before tool-input; none when omitted) and AppsScreen tests (stage/count/clear gated on fields; staged partials survive Open and are replayed). Closes #1571 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- .../elements/AppRenderer/AppRenderer.test.tsx | 50 ++++++++++++ .../elements/AppRenderer/AppRenderer.tsx | 24 ++++++ .../screens/AppsScreen/AppsScreen.test.tsx | 74 ++++++++++++++++++ .../screens/AppsScreen/AppsScreen.tsx | 77 ++++++++++++++++--- 4 files changed, 213 insertions(+), 12 deletions(-) diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx index 548d15091..6666b54d1 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 b88d35f13..da4f374c4 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 36a4e6bae..e7c3f64e3 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 () => {}, @@ -600,4 +602,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 831afb95b..4ec2ab91b 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -219,6 +219,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; @@ -289,6 +306,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) @@ -322,12 +346,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. @@ -373,7 +404,7 @@ export function AppsScreen({ function handleOpen() { if (!selectedTool) return; - resetAppChannels(); + resetAppChannels({ keepPartials: true }); setRunning(true); onOpenApp(selectedTool.name, formValues); } @@ -502,6 +533,7 @@ export function AppsScreen({ onRequestDisplayMode={handleRequestDisplayMode} onMessage={handleMessage} onLog={handleLog} + partialInputs={partialStages} containerRef={rendererContainerRef} ref={rendererRef} /> @@ -515,15 +547,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 && ( From 541d2c4aa5fb72acb3b5dc16c0d5bbdd411e74e7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 19:37:10 -0400 Subject: [PATCH 2/2] review: note partialStages intentionally survives handleOpen (PR #1659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the claude[bot] review's observation #2: add a comment at the handleOpen call site clarifying that partialStages is intentionally NOT cleared there — AppRenderer snapshots the fragments into its own pendingPartialsRef at build time, and the staging UI only renders while not running, so the surviving state is invisible until the next reset drains it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/web/src/components/screens/AppsScreen/AppsScreen.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx index ce0e83de3..26051d4bc 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -442,6 +442,11 @@ export function AppsScreen({ function handleOpen() { if (!selectedTool) return; + // `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);