Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const tool: Tool = {

interface MockBridge {
sendToolInput: ReturnType<typeof vi.fn>;
sendToolInputPartial: ReturnType<typeof vi.fn>;
sendToolResult: ReturnType<typeof vi.fn>;
sendToolCancelled: ReturnType<typeof vi.fn>;
sendHostContextChange: ReturnType<typeof vi.fn>;
Expand All @@ -40,6 +41,7 @@ function createMockBridge(): MockBridge {
const listeners: Record<string, ((payload: unknown) => 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),
Expand Down Expand Up @@ -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<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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();
Expand Down
24 changes: 24 additions & 0 deletions clients/web/src/components/elements/AppRenderer/AppRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[];
/**
* The host-controlled box the app renders within, used to derive
* `hostContext.containerDimensions`. This MUST be an element whose size is
Expand Down Expand Up @@ -138,12 +146,14 @@ export function AppRenderer({
onRequestDisplayMode,
onMessage,
onLog,
partialInputs,
containerRef,
ref,
}: AppRendererProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const bridgeRef = useRef<AppBridge | null>(null);
const initializedRef = useRef(false);
const pendingPartialsRef = useRef<Record<string, unknown>[]>([]);
const pendingInputRef = useRef<Record<string, unknown> | null>(null);
const pendingResultRef = useRef<CallToolResult | null>(null);
const teardownStartedRef = useRef(false);
Expand All @@ -163,13 +173,15 @@ 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;
displayModeRef.current = displayMode;
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
Expand All @@ -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;
Expand Down Expand Up @@ -212,6 +230,7 @@ export function AppRenderer({
bridgeRef.current = null;
initializedRef.current = false;
lastDepsRef.current = null;
pendingPartialsRef.current = [];
if (bridge) void disposeBridge(bridge);
});
}, []);
Expand Down Expand Up @@ -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<AppBridge>;
try {
Expand Down
74 changes: 74 additions & 0 deletions clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const cohortApp: Tool = {
const okBridgeFactory: BridgeFactory = () =>
({
sendToolInput: async () => {},
sendToolInputPartial: async () => {},
sendToolResult: async () => {},
sendToolCancelled: async () => {},
sendHostContextChange: async () => {},
Expand All @@ -79,6 +80,7 @@ function createEventBridgeFactory(): {
const factory: BridgeFactory = () => {
const bridge = {
sendToolInput: async () => {},
sendToolInputPartial: async () => {},
sendToolResult: async () => {},
sendToolCancelled: async () => {},
sendHostContextChange: async () => {},
Expand Down Expand Up @@ -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(<ControlledAppsScreen />);
// 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<string, unknown>[] = [];
let onInitialized: (() => void) | undefined;
let sawInput = false;
const factory: BridgeFactory = () => {
const listeners: Record<string, ((p: unknown) => void)[]> = {};
const bridge = {
sendToolInput: async () => {
sawInput = true;
},
sendToolInputPartial: async (params: {
arguments?: Record<string, unknown>;
}) => {
// 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(<ControlledAppsScreen bridgeFactory={factory} />);
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);
});
});
82 changes: 70 additions & 12 deletions clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Record<string, unknown>[]>(
[],
);

const selectedTool = selectedAppName
? tools.find((t) => t.name === selectedAppName)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -545,6 +581,7 @@ export function AppsScreen({
onRequestDisplayMode={handleRequestDisplayMode}
onMessage={handleMessage}
onLog={handleLog}
partialInputs={partialStages}
containerRef={rendererContainerRef}
ref={rendererRef}
/>
Expand All @@ -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`.
<AppDetailPanel
tool={selectedTool}
formValues={formValues}
isOpening={false}
onFormChange={(values) =>
onUiChange({ ...ui, formValues: values })
}
onOpenApp={handleOpen}
/>
<>
{selectedHasFields && (
<PartialStageControls>
<StagePartialButton onClick={handleStagePartialInput}>
Stage partial input
</StagePartialButton>
{partialStages.length > 0 && (
<>
<PartialStageCount>
{partialStages.length} staged
</PartialStageCount>
<CompactSubtleButton
onClick={() => setPartialStages([])}
>
Clear staged
</CompactSubtleButton>
</>
)}
</PartialStageControls>
)}
<AppDetailPanel
tool={selectedTool}
formValues={formValues}
isOpening={false}
onFormChange={(values) =>
onUiChange({ ...ui, formValues: values })
}
onOpenApp={handleOpen}
/>
</>
)}
{running && messages.length > 0 && (
<PinnedPanel data-testid="apps-messages">
Expand Down
Loading