Inline ListChangedIndicator with titles, normalize screen gaps, and rename ResultPanel to ToolResultPanel

- Place ListChangedIndicator on same line as section titles using Group with space-between in ToolControls, ResourceControls, and PromptControls
- Add ListChanged stories for ResourceControls and show indicators in ConnectedView stories
- Normalize ScreenLayout gap from "xl" to "md" across all screens for consistent spacing
- Rename ResultPanel to ToolResultPanel (component, interface, files, and stories)
- Replace Grid layout in ToolsScreen with equal-width flex children for detail and result panels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-04-01 12:47:41 -04:00
commit 5594e940c9
16 changed files with 69 additions and 56 deletions

View file

@ -32,7 +32,7 @@ export function ListChangedIndicator({
<Group gap="xs"> <Group gap="xs">
<Dot /> <Dot />
<UpdateLabel>List updated</UpdateLabel> <UpdateLabel>List updated</UpdateLabel>
<RefreshButton onClick={onRefresh}>↻ Refresh</RefreshButton> <RefreshButton onClick={onRefresh}>Refresh</RefreshButton>
</Group> </Group>
); );
} }

View file

@ -25,7 +25,6 @@ function formatPlaceholder(name: string): string {
} }
export function PromptArgumentsForm({ export function PromptArgumentsForm({
name,
description, description,
arguments: promptArguments, arguments: promptArguments,
argumentValues, argumentValues,
@ -34,7 +33,7 @@ export function PromptArgumentsForm({
}: PromptArgumentsFormProps) { }: PromptArgumentsFormProps) {
return ( return (
<Stack gap="md"> <Stack gap="md">
<Title order={4}>{name}</Title> <Title order={4}>Prompt Arguments</Title>
{description && <DescriptionText>{description}</DescriptionText>} {description && <DescriptionText>{description}</DescriptionText>}
{promptArguments.length > 0 && ( {promptArguments.length > 0 && (
<Stack gap="sm"> <Stack gap="sm">

View file

@ -1,4 +1,4 @@
import { ScrollArea, Stack, TextInput, Title } from "@mantine/core"; import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { PromptListItem } from "../PromptListItem/PromptListItem"; import { PromptListItem } from "../PromptListItem/PromptListItem";
import type { PromptItem } from "../../screens/PromptsScreen/PromptsScreen"; import type { PromptItem } from "../../screens/PromptsScreen/PromptsScreen";
@ -30,8 +30,10 @@ export function PromptControls({
return ( return (
<Stack gap="sm"> <Stack gap="sm">
<Title order={4}>Prompts</Title> <Group justify="space-between">
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} /> <Title order={4}>Prompts</Title>
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
</Group>
<TextInput <TextInput
placeholder="Search prompts..." placeholder="Search prompts..."
value={searchText} value={searchText}

View file

@ -63,6 +63,13 @@ export const WithSearch: Story = {
}, },
}; };
export const ListChanged: Story = {
args: {
...Default.args,
listChanged: true,
},
};
export const Empty: Story = { export const Empty: Story = {
args: { args: {
resources: [], resources: [],

View file

@ -1,5 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Accordion, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; import {
Accordion,
Group,
ScrollArea,
Stack,
TextInput,
Title,
} from "@mantine/core";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ResourceListItem } from "../ResourceListItem/ResourceListItem"; import { ResourceListItem } from "../ResourceListItem/ResourceListItem";
import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem"; import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem";
@ -68,8 +75,10 @@ export function ResourceControls({
return ( return (
<Stack gap="sm"> <Stack gap="sm">
<Title order={4}>Resources</Title> <Group justify="space-between">
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} /> <Title order={4}>Resources</Title>
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
</Group>
<TextInput <TextInput
placeholder="Search..." placeholder="Search..."
value={searchText} value={searchText}

View file

@ -1,4 +1,4 @@
import { ScrollArea, Stack, TextInput, Title } from "@mantine/core"; import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ToolListItem } from "../ToolListItem/ToolListItem"; import { ToolListItem } from "../ToolListItem/ToolListItem";
import type { ToolListItemProps } from "../ToolListItem/ToolListItem"; import type { ToolListItemProps } from "../ToolListItem/ToolListItem";
@ -32,8 +32,10 @@ export function ToolControls({
return ( return (
<Stack gap="sm"> <Stack gap="sm">
<Title order={4}>Tools</Title> <Group justify="space-between">
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} /> <Title order={4}>Tools</Title>
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
</Group>
<TextInput <TextInput
placeholder="Search tools..." placeholder="Search tools..."
value={searchText} value={searchText}

View file

@ -0,0 +1,70 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ToolResultPanel } from "./ToolResultPanel";
const meta: Meta<typeof ToolResultPanel> = {
title: "Groups/ToolResultPanel",
component: ToolResultPanel,
args: {
onClear: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ToolResultPanel>;
export const Empty: Story = {
args: {
content: [],
},
};
export const TextResult: Story = {
args: {
content: [
{
type: "text",
text: "The current weather in San Francisco is 65°F and sunny.",
},
],
},
};
export const JsonResult: Story = {
args: {
content: [
{
type: "json",
text: '{"temperature":65,"unit":"fahrenheit","condition":"sunny","city":"San Francisco"}',
},
],
},
};
export const ImageResult: Story = {
args: {
content: [
{
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
mimeType: "image/png",
},
],
},
};
export const MixedContent: Story = {
args: {
content: [
{
type: "text",
text: "Here is the generated image based on your description:",
},
{
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
mimeType: "image/png",
},
],
},
};

View file

@ -0,0 +1,67 @@
import { Button, Group, Stack, Text, Title } from "@mantine/core";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
export interface ResultContentItem {
type: string;
text?: string;
data?: string;
mimeType?: string;
}
export interface ToolResultPanelProps {
content: ResultContentItem[];
onClear: () => void;
}
const ClearButton = Button.withProps({
variant: "light",
size: "sm",
});
function resolveTextType(type: string): "text" | "json" {
return type === "text" ? "text" : "json";
}
function resolveMediaType(mimeType?: string): "image" | "audio" {
return mimeType?.startsWith("image") ? "image" : "audio";
}
export function ToolResultPanel({ content, onClear }: ToolResultPanelProps) {
return (
<Stack>
<Title order={4}>Results</Title>
{content.length === 0 ? (
<Text c="dimmed">No results yet</Text>
) : (
<>
{content.map((item, index) => {
if (item.text !== undefined) {
return (
<ContentViewer
key={index}
type={resolveTextType(item.type)}
content={item.text}
copyable
/>
);
}
if (item.data !== undefined) {
return (
<ContentViewer
key={index}
type={resolveMediaType(item.mimeType)}
content={item.data}
mimeType={item.mimeType}
/>
);
}
return null;
})}
</>
)}
<Group>
<ClearButton onClick={onClear}>Clear</ClearButton>
</Group>
</Stack>
);
}

View file

@ -14,7 +14,7 @@ export interface HistoryScreenProps {
const ScreenLayout = Flex.withProps({ const ScreenLayout = Flex.withProps({
variant: "screen", variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))", h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "xl", gap: "md",
p: "xl", p: "xl",
}); });

View file

@ -29,7 +29,7 @@ const ALL_LEVELS_VISIBLE: Record<string, boolean> = {
const ScreenLayout = Flex.withProps({ const ScreenLayout = Flex.withProps({
variant: "screen", variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))", h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "xl", gap: "md",
p: "xl", p: "xl",
}); });

View file

@ -34,7 +34,7 @@ export interface PromptsScreenProps {
const ScreenLayout = Flex.withProps({ const ScreenLayout = Flex.withProps({
variant: "screen", variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))", h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "xl", gap: "md",
p: "xl", p: "xl",
}); });

View file

@ -60,7 +60,7 @@ export interface ResourcesScreenProps {
const ScreenLayout = Flex.withProps({ const ScreenLayout = Flex.withProps({
variant: "screen", variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))", h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "xl", gap: "md",
p: "xl", p: "xl",
}); });

View file

@ -13,7 +13,7 @@ export interface TasksScreenProps {
const ScreenLayout = Flex.withProps({ const ScreenLayout = Flex.withProps({
variant: "screen", variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))", h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "xl", gap: "md",
p: "xl", p: "xl",
}); });

View file

@ -3,7 +3,7 @@ import { fn } from "storybook/test";
import { ToolsScreen } from "./ToolsScreen"; import { ToolsScreen } from "./ToolsScreen";
import type { ToolListItemProps } from "../../groups/ToolListItem/ToolListItem"; import type { ToolListItemProps } from "../../groups/ToolListItem/ToolListItem";
import type { ToolDetailPanelProps } from "../../groups/ToolDetailPanel/ToolDetailPanel"; import type { ToolDetailPanelProps } from "../../groups/ToolDetailPanel/ToolDetailPanel";
import type { ResultPanelProps } from "../../groups/ResultPanel/ResultPanel"; import type { ToolResultPanelProps } from "../../groups/ToolResultPanel/ToolResultPanel";
const meta: Meta<typeof ToolsScreen> = { const meta: Meta<typeof ToolsScreen> = {
title: "Screens/ToolsScreen", title: "Screens/ToolsScreen",
@ -67,7 +67,7 @@ const selectedToolData: ToolDetailPanelProps = {
onCancel: fn(), onCancel: fn(),
}; };
const resultData: ResultPanelProps = { const resultData: ToolResultPanelProps = {
content: [ content: [
{ {
type: "text", type: "text",

View file

@ -1,15 +1,15 @@
import { Card, Flex, Grid, Stack, Text } from "@mantine/core"; import { Card, Flex, Stack, Text } from "@mantine/core";
import { ToolControls } from "../../groups/ToolControls/ToolControls"; import { ToolControls } from "../../groups/ToolControls/ToolControls";
import { ToolDetailPanel } from "../../groups/ToolDetailPanel/ToolDetailPanel"; import { ToolDetailPanel } from "../../groups/ToolDetailPanel/ToolDetailPanel";
import { ResultPanel } from "../../groups/ResultPanel/ResultPanel"; import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel";
import type { ToolListItemProps } from "../../groups/ToolListItem/ToolListItem"; import type { ToolListItemProps } from "../../groups/ToolListItem/ToolListItem";
import type { ToolDetailPanelProps } from "../../groups/ToolDetailPanel/ToolDetailPanel"; import type { ToolDetailPanelProps } from "../../groups/ToolDetailPanel/ToolDetailPanel";
import type { ResultPanelProps } from "../../groups/ResultPanel/ResultPanel"; import type { ToolResultPanelProps } from "../../groups/ToolResultPanel/ToolResultPanel";
export interface ToolsScreenProps { export interface ToolsScreenProps {
tools: ToolListItemProps[]; tools: ToolListItemProps[];
selectedTool?: ToolDetailPanelProps; selectedTool?: ToolDetailPanelProps;
result?: ResultPanelProps; result?: ToolResultPanelProps;
listChanged: boolean; listChanged: boolean;
searchText: string; searchText: string;
onSearchChange: (text: string) => void; onSearchChange: (text: string) => void;
@ -20,8 +20,9 @@ export interface ToolsScreenProps {
const ScreenLayout = Flex.withProps({ const ScreenLayout = Flex.withProps({
variant: "screen", variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))", h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "xl", gap: "md",
p: "xl", p: "xl",
align: "flex-start",
}); });
const Sidebar = Stack.withProps({ const Sidebar = Stack.withProps({
@ -34,10 +35,9 @@ const SidebarCard = Card.withProps({
padding: "lg", padding: "lg",
}); });
const FullHeightCard = Card.withProps({ const ContentCard = Card.withProps({
withBorder: true, withBorder: true,
padding: "lg", padding: "lg",
h: "100%",
}); });
const EmptyState = Text.withProps({ const EmptyState = Text.withProps({
@ -71,27 +71,21 @@ export function ToolsScreen({
</SidebarCard> </SidebarCard>
</Sidebar> </Sidebar>
<Grid align="stretch" flex={1}> <ContentCard flex={1}>
<Grid.Col span={7}> {selectedTool ? (
<FullHeightCard> <ToolDetailPanel {...selectedTool} />
{selectedTool ? ( ) : (
<ToolDetailPanel {...selectedTool} /> <EmptyState>Select a tool to view details</EmptyState>
) : ( )}
<EmptyState>Select a tool to view details</EmptyState> </ContentCard>
)}
</FullHeightCard>
</Grid.Col>
<Grid.Col span={5}> <ContentCard flex={1}>
<FullHeightCard> {result ? (
{result ? ( <ToolResultPanel {...result} />
<ResultPanel {...result} /> ) : (
) : ( <EmptyState>Results will appear here</EmptyState>
<EmptyState>Results will appear here</EmptyState> )}
)} </ContentCard>
</FullHeightCard>
</Grid.Col>
</Grid>
</ScreenLayout> </ScreenLayout>
); );
} }

View file

@ -230,7 +230,7 @@ export const ToolsActive: Story = {
onClear: fn(), onClear: fn(),
}} }}
searchText="" searchText=""
listChanged={false} listChanged={true}
onSearchChange={fn()} onSearchChange={fn()}
onRefreshList={fn()} onRefreshList={fn()}
onSelectTool={fn()} onSelectTool={fn()}
@ -446,7 +446,7 @@ export const ResourcesActive: Story = {
lastUpdated: "2026-03-17T11:15:00Z", lastUpdated: "2026-03-17T11:15:00Z",
isSubscribed: false, isSubscribed: false,
}} }}
listChanged={false} listChanged={true}
searchText="" searchText=""
onSearchChange={fn()} onSearchChange={fn()}
onRefreshList={fn()} onRefreshList={fn()}
@ -513,7 +513,7 @@ export const PromptsActive: Story = {
{ role: "assistant", content: "Hola, como estas?" }, { role: "assistant", content: "Hola, como estas?" },
], ],
}} }}
listChanged={false} listChanged={true}
searchText="" searchText=""
onSearchChange={fn()} onSearchChange={fn()}
onRefreshList={fn()} onRefreshList={fn()}