feat(apps): AppsScreen and InspectorView wiring (#1263) (#1286)

* feat(apps): AppsScreen and InspectorView wiring (#1263)

Adds the Apps tab as a top-level screen composed of `AppListItem`,
`AppDetailPanel`, and `AppRenderer`. The screen is presentational —
selection, form values, running, and maximize state are local; data and
side effects come in via props and callbacks.

InspectorView filters `tools` through `isAppTool` (wrapped in a try/catch
so a malformed `_meta.ui.resourceUri` skips one tool instead of dropping
every following App), exposes the Apps tab in `ALL_TABS` after Tools, and
wires Phase-3 stub callbacks plus a stub bridge factory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(apps): extract AppControls and rename Refresh button

Pulls the sidebar list, search input, "List updated" indicator, and
Refresh button out of `AppsScreen` into a dedicated `AppControls`
group, mirroring `ToolControls` and `TaskControls`. The Refresh button
moves from a full-width row beneath the list to the top-right of the
title row (single label "Refresh", matching `TaskControls`).

`AppsScreen` keeps the right-pane orchestration (selection, form,
running, maximize, header actions) and now delegates the entire
sidebar to `AppControls`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): seed InspectorView demo tools with MCP Apps

Adds three apps (Cohort Data, Weather Widget, Ops Dashboard) carrying
real `_meta.ui.resourceUri` to the demo tools list so the Apps tab has
content in the Default story alongside the existing tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(apps): drop AppDetailPanel title row to avoid duplicate name

The parent AppsScreen header already shows the app name, so the panel's
own title/icon row rendered the name twice. Remove the title row (and
its now-unused imports, styled subcomponents, and the four tests that
asserted on title/icon rendering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(resources): drop annotation badges from ResourceListItem

The list row now renders just the resource title/name. Audience and
priority badges remain visible in the preview/template panels, so the
data is still surfaced — just not duplicated in the picker. Drops the
now-unused AnnotationBadge import, the badge-only stories, and the
badge-rendering test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(apps): address review — share hasInputFields, show header icon

- Extracts the duplicated `Object.keys(inputSchema.properties ?? {}).length > 0`
  check from `AppsScreen` (and the equivalent in `AppDetailPanel`'s
  `hasFields`) into a shared `hasInputFields` helper in `toolUtils`,
  with tests.
- Renders the selected tool's first icon next to the right-pane header
  title in `AppsScreen`, restoring the icon visibility that was lost
  when `AppDetailPanel`'s title row was removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(apps): finish hasInputFields extraction; document isOpening

- AppDetailPanel now actually calls the shared `hasInputFields` helper
  instead of inlining the same `Object.keys(...).length > 0` check.
- AppsScreen comments why `isOpening={false}` is hardcoded: the screen
  flips to AppRenderer synchronously, so the loading state is unreachable
  here — the prop remains for standalone use and Phase 3 wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-07 14:18:27 -04:00 • committed by GitHub
commit 5f296589ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,276 @@
import { useState, type Ref } from "react";
import {
ActionIcon,
Button,
Card,
Flex,
Group,
Image,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
MdArrowBack,
MdClose,
MdFullscreen,
MdFullscreenExit,
} from "react-icons/md";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import {
AppRenderer,
type AppRendererHandle,
type BridgeFactory,
} from "../../elements/AppRenderer/AppRenderer";
import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel";
import { AppControls } from "../../groups/AppControls/AppControls";
import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils";
export interface AppsScreenProps {
tools: Tool[];
listChanged: boolean;
sandboxPath: string;
bridgeFactory: BridgeFactory;
rendererRef: Ref<AppRendererHandle>;
onRefreshList: () => void;
onSelectApp: (name: string) => void;
onOpenApp: (name: string, args: Record<string, unknown>) => void;
onCloseApp: () => void;
}
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
align: "flex-start",
});
const Sidebar = Stack.withProps({
w: 340,
flex: "0 0 auto",
});
const SidebarCard = Card.withProps({
withBorder: true,
padding: "lg",
});
const ContentCard = Card.withProps({
withBorder: true,
padding: "lg",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
gap: "sm",
});
const HeaderLabel = Group.withProps({
gap: "sm",
wrap: "nowrap",
align: "center",
flex: 1,
miw: 0,
});
const HeaderIcon = Image.withProps({
w: 24,
h: 24,
fit: "contain",
});
const HeaderTitle = Text.withProps({
fw: 600,
size: "lg",
truncate: true,
flex: 1,
miw: 0,
});
const HeaderActions = Group.withProps({
gap: "xs",
wrap: "nowrap",
});
const RendererFrame = Stack.withProps({
flex: 1,
miw: 0,
mih: 0,
gap: 0,
});
const ContentStack = Stack.withProps({
gap: "md",
h: "100%",
});
export function AppsScreen({
tools,
listChanged,
sandboxPath,
bridgeFactory,
rendererRef,
onRefreshList,
onSelectApp,
onOpenApp,
onCloseApp,
}: AppsScreenProps) {
const [selectedAppName, setSelectedAppName] = useState<string | undefined>(
undefined,
);
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
const [running, setRunning] = useState(false);
const [maximized, setMaximized] = useState(false);
const selectedTool = selectedAppName
? tools.find((t) => t.name === selectedAppName)
: undefined;
const selectedHasFields = selectedTool ? hasInputFields(selectedTool) : false;
function handleSelect(name: string) {
if (name === selectedAppName) return;
const next = tools.find((t) => t.name === name);
if (!next) return;
setSelectedAppName(name);
setFormValues({});
setMaximized(false);
onSelectApp(name);
// No-input apps auto-launch on selection so the user lands directly in
// the running view; apps with fields wait for the explicit Open App click.
if (!hasInputFields(next)) {
setRunning(true);
onOpenApp(name, {});
} else {
setRunning(false);
}
}
function handleOpen() {
if (!selectedTool) return;
setRunning(true);
onOpenApp(selectedTool.name, formValues);
}
function handleClose() {
setRunning(false);
setSelectedAppName(undefined);
setFormValues({});
setMaximized(false);
onCloseApp();
}
function handleBackToInput() {
setRunning(false);
setMaximized(false);
}
return (
<ScreenLayout>
{!maximized && (
<Sidebar>
<SidebarCard>
<AppControls
tools={tools}
selectedName={selectedAppName}
listChanged={listChanged}
onRefreshList={onRefreshList}
onSelectApp={handleSelect}
/>
</SidebarCard>
</Sidebar>
)}
<ContentCard flex={1} h="100%">
{selectedTool ? (
<ContentStack>
<HeaderRow>
<HeaderLabel>
{selectedTool.icons?.[0]?.src && (
<HeaderIcon src={selectedTool.icons[0].src} alt="" />
)}
<HeaderTitle>
{resolveDisplayLabel(selectedTool.name, selectedTool.title)}
</HeaderTitle>
</HeaderLabel>
<HeaderActions>
{running && selectedHasFields && (
<Button
variant="subtle"
size="sm"
leftSection={<MdArrowBack aria-hidden size={16} />}
onClick={handleBackToInput}
>
Back to Input
</Button>
)}
{running && (
<Tooltip label={maximized ? "Restore" : "Maximize"}>
<ActionIcon
variant="subtle"
onClick={() => setMaximized((m) => !m)}
aria-label={maximized ? "Restore" : "Maximize"}
>
{maximized ? (
<MdFullscreenExit aria-hidden size={20} />
) : (
<MdFullscreen aria-hidden size={20} />
)}
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Close">
<ActionIcon
variant="subtle"
onClick={handleClose}
aria-label="Close"
>
<MdClose aria-hidden size={20} />
</ActionIcon>
</Tooltip>
</HeaderActions>
</HeaderRow>
{running ? (
<RendererFrame>
{/* Keying by name forces the renderer to remount when the
selected app changes, ensuring a fresh bridge and iframe
rather than reusing the previous app's transport. */}
<AppRenderer
key={selectedTool.name}
sandboxPath={sandboxPath}
tool={selectedTool}
bridgeFactory={bridgeFactory}
ref={rendererRef}
/>
</RendererFrame>
) : (
// `isOpening` is always false here because `handleOpen`
// synchronously flips `running` to true, swapping in the
// AppRenderer before the panel could render its loading
// state. The prop stays in `AppDetailPanel`'s API for
// 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={setFormValues}
onOpenApp={handleOpen}
/>
)}
</ContentStack>
) : (
<EmptyState>Select an app to view details</EmptyState>
)}
</ContentCard>
</ScreenLayout>
);
}