A field that cannot turn its text into a value reports `undefined` — which is exactly what an empty field reports. That made the two indistinguishable to every caller of `SchemaForm`, so invalid text in an optional field was submittable and the argument simply arrived absent (#2020). `SchemaForm` now tracks per-field draft validity and reports it through `onValidityChange`, covering both fields that hold text the form cannot send: the JSON editor (unparseable text) and the number input (a magnitude JS cannot hold exactly, which is dropped rather than silently rounded). The number field also states it on itself, as the JSON editor already did. A nested object form reports through the same channel, and both the field-level and form-level effects report "valid" on cleanup so a draft cannot outlive the field that held it — a remount under a new `resetKey`, or an unmounted panel. `ToolDetailPanel` gated Execute on `isExecuting` alone, so it also had no required-argument check at all — unlike the App and elicitation panels. It now uses `hasMissingRequiredFields` like they do. `InlineElicitationRequest` had neither gate and gets both. Signed-off-by: cliffhall <cliff@futurescale.com>
105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
import { Button, Divider, ScrollArea, Stack, Text } from "@mantine/core";
|
|
import { useState } from "react";
|
|
import { MdPlayArrow } from "react-icons/md";
|
|
import type { Tool } from "@modelcontextprotocol/client";
|
|
import { SchemaForm } from "../SchemaForm/SchemaForm";
|
|
import { hasInputFields } from "../../../utils/toolUtils";
|
|
import {
|
|
hasMissingRequiredFields,
|
|
toFormSchema,
|
|
} from "../../../utils/jsonUtils";
|
|
|
|
export interface AppDetailPanelProps {
|
|
tool: Tool;
|
|
formValues: Record<string, unknown>;
|
|
isOpening: boolean;
|
|
onFormChange: (values: Record<string, unknown>) => void;
|
|
onOpenApp: () => void;
|
|
}
|
|
|
|
const DescriptionText = Text.withProps({
|
|
size: "sm",
|
|
c: "dimmed",
|
|
});
|
|
|
|
// Fills the available height inside AppsScreen's full-height card and scrolls
|
|
// the form (description + fields + Open App) when it would overflow, instead of
|
|
// bleeding past the viewport. `mih: 0` lets it shrink within the flex parent;
|
|
// standalone (no flex parent) it just sizes to content.
|
|
const PanelScroll = ScrollArea.withProps({
|
|
flex: 1,
|
|
mih: 0,
|
|
type: "auto",
|
|
scrollbars: "y",
|
|
offsetScrollbars: true,
|
|
});
|
|
|
|
const PanelStack = Stack.withProps({
|
|
gap: "md",
|
|
miw: 0,
|
|
});
|
|
|
|
const OpenAppButton = Button.withProps({
|
|
size: "md",
|
|
fullWidth: true,
|
|
leftSection: <MdPlayArrow aria-hidden size={18} />,
|
|
});
|
|
|
|
export function AppDetailPanel({
|
|
tool,
|
|
formValues,
|
|
isOpening,
|
|
onFormChange,
|
|
onOpenApp,
|
|
}: AppDetailPanelProps) {
|
|
const { description, inputSchema } = tool;
|
|
// Narrow the SDK protocol schema to the form renderer's schema type. A Tool's
|
|
// `inputSchema` is always an object per the SDK types, so `toFormSchema` never
|
|
// returns null here — the `?? {}` is a defensive fallback that can't be hit.
|
|
/* v8 ignore next -- unreachable: Tool.inputSchema is always an object */
|
|
const formSchema = toFormSchema(inputSchema) ?? {};
|
|
const hasErrors = hasMissingRequiredFields(formSchema, formValues);
|
|
// A field holding text it could not turn into a value (unparseable JSON, an
|
|
// unrepresentable number) reports `undefined`, which is indistinguishable
|
|
// from empty once it reaches `formValues` — so the form reports it directly
|
|
// and an optional argument can no longer be dropped silently (#2020).
|
|
const [hasInvalidDraft, setHasInvalidDraft] = useState(false);
|
|
const disabled = isOpening || hasErrors || hasInvalidDraft;
|
|
const hasFields = hasInputFields(tool);
|
|
|
|
return (
|
|
<PanelScroll>
|
|
<PanelStack>
|
|
{description && <DescriptionText>{description}</DescriptionText>}
|
|
|
|
{hasFields && <Divider />}
|
|
|
|
{/* Form stays editable while validation fails so users can finish
|
|
filling required fields. The disabled-when-incomplete gate is on
|
|
the Open App button below, not on the form itself. */}
|
|
<SchemaForm
|
|
schema={formSchema}
|
|
values={formValues}
|
|
onChange={onFormChange}
|
|
disabled={isOpening}
|
|
// Like ToolDetailPanel, this panel is reused across app selections
|
|
// rather than remounted (AppsScreen.handleSelect swaps
|
|
// selectedAppName + formValues in place), so the form needs the app
|
|
// tool's name to drop another app's in-progress field text. See
|
|
// SchemaFormProps.resetKey.
|
|
resetKey={tool.name}
|
|
onValidityChange={setHasInvalidDraft}
|
|
/>
|
|
|
|
<OpenAppButton
|
|
onClick={onOpenApp}
|
|
disabled={disabled}
|
|
loading={isOpening}
|
|
data-testid="open-app"
|
|
>
|
|
Open App
|
|
</OpenAppButton>
|
|
</PanelStack>
|
|
</PanelScroll>
|
|
);
|
|
}
|