feat: Add Mantine theme, 12 atom components, and 25 molecule components with Storybook stories
- Mantine theme with component extensions (Badge, Card, Paper, Button, etc.) - CSS custom properties for design tokens (--inspector-* vars) - Dark/light mode support with auto detection - 12 atoms: StatusIndicator, AnnotationBadge, CapabilityItem, LogEntry, ProgressDisplay, ContentViewer, ListChangedIndicator, CopyButton, ConnectionToggle, TransportBadge, InlineError, MessageBubble - 25 molecules: ServerCard, AddServerMenu, SchemaForm, ToolListItem, ToolDetailPanel, ResultPanel, ResourceListItem, ResourcePreviewPanel, ResourceTemplateInput, PromptArgumentsForm, PromptMessagesDisplay, LogControls, TaskCard, HistoryEntry, ServerInfoContent, ServerSettingsForm, RootsTable, SamplingRequestPanel, ElicitationFormPanel, ElicitationUrlPanel, InlineSamplingRequest, InlineElicitationRequest, PendingClientRequests, ImportServerJsonPanel, ExperimentalFeaturesPanel - Exclude storybook-static from version control - Exclude .stories.tsx from app tsconfig (Storybook-only modules) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fcd0d156f4
commit
39ef184b53
151 changed files with 5895 additions and 102158 deletions
159
AGENTS.md
Normal file
159
AGENTS.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Forenzo
|
||||
|
||||
Forensic interview video platform for Children's Advocacy Centers (CACs). Upload forensic interview recordings, automatically transcribe with speaker diarization, generate AI review indices with timestamped key moments, and share securely with law enforcement, prosecutors, and MDT members via PIN-protected magic links.
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
### Keep documentation files up to date
|
||||
- When adding, removing, renaming, or changing the purpose of any file or folder, update the corresponding entry in the README.md
|
||||
- When the structure of the project, the tech stack, or the developer setup changes, update README.md with the details.
|
||||
- When adding new commands, dependencies, or architectural patterns, update the relevant sections of README.md as well.
|
||||
- When rules for implementation and testing change, update this file AGENTS.md
|
||||
|
||||
### CJIS session timeout - background polling rules
|
||||
- CJIS 5.5.5 requires a 30-minute inactivity timeout. Background pollers (setInterval) must NEVER refresh the session.
|
||||
- Any `setInterval` or recurring fetch that runs without user interaction MUST use `backgroundFetch` (from `src/utils/api.ts`), NOT `authFetch` or raw `fetch`.
|
||||
- `backgroundFetch` adds an `X-Background-Poll: true` header. The server sees this and authenticates WITHOUT extending the session expiry (via Better Auth's `disableRefresh: true`).
|
||||
- The ONLY pollers that should use regular `authFetch` are ones tied to active video playback (session heartbeat, watch progress) - because watching a video IS user activity.
|
||||
- If you add a new poller or setInterval that hits an authenticated endpoint, use `backgroundFetch`. If you use `authFetch` instead, the 30-minute idle timeout breaks and CJIS compliance is violated.
|
||||
- The CORS config in `server/index.ts` reflects whatever headers the client requests (no explicit allowlist). No special CORS setup needed for new custom headers.
|
||||
|
||||
### Always test new or modified code
|
||||
- Ensure all code has corresponding tests
|
||||
- Ensure test coverage for each file is at least 90%
|
||||
- In unit tests that expect error output, suppress it from the console
|
||||
|
||||
### Responding to Code Reviews
|
||||
- When asked to respond to a code review of a PR,
|
||||
- it is not necessary to implement all suggestions
|
||||
- you are free to implement suggestions in a different way or to ignore if there is a good reason
|
||||
- after making the changes, respond to each review comment with what was done (or why it was ignored)
|
||||
|
||||
### Lint-fixed, Formatted code
|
||||
- Ensure linting and formatting are applied after every change
|
||||
- ALWAYS do `npm run format`, `npm run lint`, `npm run typecheck`, `npm run test` and `npm run build` before pushing any changes
|
||||
|
||||
### Typescript instructions
|
||||
- Use TypeScript for all new code
|
||||
- Follow TypeScript best practices and coding standards
|
||||
- NEVER use 'any' as a type
|
||||
- NEVER suppress error types (e.g., no-unused-vars, no-explicit-any) in the typescript or eslint configuration as a way of satisfying the linter or compiler.
|
||||
- Utilize type annotations and interfaces to improve code clarity and maintainability
|
||||
- Leverage TypeScript's type inference and static analysis features for better code quality and refactoring
|
||||
- Use type guards and type assertions to handle potential type mismatches and ensure type safety
|
||||
- Take advantage of TypeScript's advanced features like generics, type aliases, and conditional types to write more expressive and reusable code
|
||||
- Regularly review and refactor TypeScript code to ensure it remains well-structured and adheres to evolving best practices
|
||||
|
||||
## React instructions
|
||||
- UI Components
|
||||
- We are using the Mantine component library for UI.
|
||||
- Instructions are at https://mantine.dev/llms.txt
|
||||
- Avoid using div and other basic HTML elements for layout purposes.
|
||||
- Prefer Mantine's Box, Group, and Stack components for layout.
|
||||
- Use Mantine's theme and styling utilities to ensure a consistent and responsive design.
|
||||
- NEVER use inline styles on a component.
|
||||
- NEVER use raw hex values (`#ddd`, `#94a3b8`, etc.) or `rgba()` literals for colors in component props or theme files. Use `--forenzo-*` CSS custom properties defined in `App.css :root` (e.g., `c: 'var(--forenzo-text-primary)'`). If no existing token fits, add one to `:root` first.
|
||||
- NEVER add a CSS class to a Mantine component when the styles can instead be expressed as component props or a theme variant. CSS classes are a last resort.
|
||||
- PREFER component props (via `.withProps()`) to CSS for behavioral and visual styles.
|
||||
- PREFER defining styles as theme variants (via `Component.extend()` in `src/theme/<Component>.ts`) over CSS classes. Each Mantine component with custom variants has its own file in `src/theme/`, exporting a `Theme<Name>` constant. The barrel `src/theme/index.ts` re-exports them all and `theme.ts` imports from the barrel. Flat CSS properties (margin, padding, background, border, color, font-size, etc.) belong in the theme. Only pseudo-selectors, nested child selectors, keyframes, and native HTML element styles belong in App.css.
|
||||
- App.css must contain ONLY styles that cannot be expressed in the Mantine theme: `@keyframes`, pseudo-selectors (`:hover`, `:focus`), cross-component hover relationships, nested child-element selectors for third-party HTML output (e.g. ReactMarkdown), and styles for native HTML elements (`img`, `iframe`). When refactoring a component, actively move any flat CSS properties out of App.css and into theme variants or `.withProps()` constants.
|
||||
- NEVER use inline code; instead extract to functions in the same file, exported or located in a shared location if immediately reusable.
|
||||
- In a component's file, for sub-components:
|
||||
- ALWAYS use Mantine components for layout and content, configured with props for styling and behavior.
|
||||
- ALWAYS declare a subcomponent as a named constant using `.withProps()` if it has two or more props.
|
||||
- NEVER use `Box` for subcomponent constants — `Box` does not support `.withProps()`. Use `Group`, `Stack`, `Flex`, `Text`, `Paper`, `UnstyledButton`, or `Image` instead. Pick the component that best matches the purpose: `Paper` for bordered/surfaced containers, `Text` for any text or content wrapper, `Stack`/`Group`/`Flex` for layout.
|
||||
- NEVER use a CSS class on a subcomponent constant when the styles can be expressed as a Mantine theme variant instead. Define variants in `src/theme/<Component>.ts` using `Component.extend({ styles: (_theme, props) => { ... } })` and reference them with `variant="variantName"` on the component or in `.withProps()`.
|
||||
- CSS classes are ONLY acceptable on subcomponents for styles that cannot be expressed as flat CSS-in-JS properties in the theme — specifically: pseudo-selectors (`:hover`, `:focus`), cross-component hover relationships (`.parent:hover .child`), nested child-element selectors (`.wrapper p`, `.wrapper code`), `@keyframes` definitions, and native HTML elements (`img`, `iframe`) that are not Mantine components.
|
||||
- When a theme variant needs a CSS class for nested/pseudo selectors, use `classNames` in the theme extension to auto-assign it — never add `className` manually in JSX for theme-styled components.
|
||||
- Example — subcomponent constant with `withProps`:
|
||||
```tsx
|
||||
const CardContent = Group.withProps({
|
||||
flex: 1,
|
||||
align: 'flex-start',
|
||||
justify: 'space-between',
|
||||
wrap: 'nowrap',
|
||||
});
|
||||
return <CardContent> ... </CardContent>
|
||||
```
|
||||
- Example — theme variant with auto-assigned className for nested selectors:
|
||||
```tsx
|
||||
// src/theme/Paper.ts
|
||||
export const ThemePaper = Paper.extend({
|
||||
classNames: (_theme, props) => {
|
||||
if (props.variant === 'message') return { root: 'message' };
|
||||
return {};
|
||||
},
|
||||
styles: (_theme, props) => {
|
||||
if (props.variant === 'message') {
|
||||
return { root: { padding: '1.5rem', borderRadius: 12 } };
|
||||
}
|
||||
return { root: {} };
|
||||
},
|
||||
}),
|
||||
|
||||
// Component.tsx
|
||||
const MessageContainer = Paper.withProps({ variant: 'message' });
|
||||
```
|
||||
|
||||
### Database & Prisma instructions
|
||||
|
||||
#### Schema management
|
||||
- The database schema is managed by [Prisma ORM](https://www.prisma.io/). The schema is defined in `prisma/schema.prisma`.
|
||||
- To change the schema (add/remove tables, columns, enums, etc.):
|
||||
1. Edit `prisma/schema.prisma`
|
||||
2. Run `npm run db:migrate` to generate and apply a migration
|
||||
3. Prisma auto-generates the migration SQL in `prisma/migrations/`
|
||||
- NEVER edit migration files in `prisma/migrations/` by hand — they are auto-generated.
|
||||
- NEVER write raw SQL for schema changes — use Prisma Migrate.
|
||||
|
||||
#### Two database pools
|
||||
- **Prisma client** (`server/lib/prisma.ts`) — all application queries. Import as `import { prisma } from '../lib/prisma'`.
|
||||
- **Better Auth pg Pool** (`server/lib/auth.ts`, 5 connections) — Better Auth's own pool for session/user management. Do NOT touch this pool in application code.
|
||||
- Better Auth tables (User, Session, Account, Organization, Member, etc.) are in the Prisma schema for type safety and can be READ via Prisma, but Better Auth manages their lifecycle.
|
||||
|
||||
#### Writing queries
|
||||
- Prefer Prisma's typed query builder over raw SQL for all new queries.
|
||||
- Import the client: `import { prisma } from '../lib/prisma'`
|
||||
- Import types when needed: `import { Prisma } from '@prisma/client'` or `import type { CaseStatus } from '@prisma/client'`
|
||||
- Model names use PascalCase: `Case`, `Video`, `Transcript`, `TranscriptSegment`, `AiSummary`, `Share`, `ProcessingJob`, `VideoTag`, `ApiKey`, `Billing`, `TagCategory`, `TagPreferences`, `MultipartUpload`, `McpToken`, `PinRateLimit`, `TranscriptCorrection`, `TranscriptReview`, `TranscriptQaCache`, `ReviewAttestation`, `OrgSettings`
|
||||
- Field names use camelCase (mapped to snake_case columns via `@map`): `caseNumber`, `childFirstName`, `videoId`, `fileSizeBytes`, `storagePath`, `speakerNames`, etc.
|
||||
|
||||
#### Type conversions in responses
|
||||
- `BigInt` fields (e.g., `fileSizeBytes`): use `Number()` before JSON serialization
|
||||
- `Decimal` fields (e.g., `durationSecs`, `confidence`): use `Number()` before JSON serialization
|
||||
- `Json` fields (e.g., `metadata`, `speakerNames`, `permissions`): cast with `as Record<string, unknown>` or appropriate type
|
||||
- Prisma enums (e.g., `CaseStatus`, `ProcessingStatus`): use directly, they serialize as strings
|
||||
|
||||
#### Transactions
|
||||
- Use `prisma.$transaction(async (tx) => { ... })` for multi-step operations
|
||||
- Inside the transaction callback, use `tx.model.method()` instead of `prisma.model.method()`
|
||||
- Raw SQL inside transactions: `tx.$queryRaw` / `tx.$executeRaw`
|
||||
|
||||
#### Raw SQL — last resort only
|
||||
- The goal is to minimize raw SQL in TypeScript. Always try Prisma's query builder first.
|
||||
- `$queryRaw` — returns rows. Use for SELECT queries that Prisma can't express.
|
||||
- `$executeRaw` — returns affected row count. Use for INSERT/UPDATE/DELETE that Prisma can't express.
|
||||
- Raw SQL is acceptable ONLY for these specific PostgreSQL features:
|
||||
- **`access_log` table** — partitioned, not modeled in Prisma. All reads/writes must be raw.
|
||||
- **Full-text search** — `websearch_to_tsquery`, `ts_headline`, `ts_rank`
|
||||
- **Advisory locks** — `pg_try_advisory_xact_lock`
|
||||
- **Atomic UPDATE ... RETURNING** with conditional WHERE (check-and-act in one statement)
|
||||
- **`make_interval()`** — PostgreSQL interval construction from numeric values
|
||||
- **LATERAL JOIN** — correlated subqueries in FROM clause
|
||||
- **SELECT ... FOR UPDATE** — row-level locking
|
||||
- If you find yourself writing raw SQL for anything else (simple CRUD, JOINs, aggregations, filters), stop and use the Prisma query builder instead.
|
||||
- Always use tagged template literals for safe parameterization:
|
||||
```ts
|
||||
prisma.$queryRaw`SELECT ... WHERE id = ${someVar}`
|
||||
```
|
||||
- NEVER use `prisma.$queryRawUnsafe` — SQL injection risk.
|
||||
- Type casts required in raw SQL (Prisma tagged templates send JS types, PostgreSQL won't auto-coerce):
|
||||
- `${id}::uuid` for UUID columns (but NOT for `access_log.resource_id` — it's TEXT)
|
||||
- `${ip}::inet` for inet columns
|
||||
- `${JSON.stringify(obj)}::jsonb` for jsonb columns
|
||||
- `${value}::access_action` for enum columns
|
||||
|
||||
#### Queries involving Better Auth user lookups
|
||||
- No FK relation exists from application tables to the `user` table (deliberate — Better Auth manages those tables)
|
||||
- For user name lookups, do a separate query: `prisma.user.findUnique({ where: { id }, select: { name: true } })`
|
||||
- For batch lookups: `prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })` and build a Map
|
||||
2
CLAUDE.md
Normal file
2
CLAUDE.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@./AGENTS.md
|
||||
@./README.md
|
||||
1
clients/web/.gitignore
vendored
1
clients/web/.gitignore
vendored
|
|
@ -10,6 +10,7 @@ lerna-debug.log*
|
|||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
storybook-static
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import type { Preview } from '@storybook/react-vite'
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import '@mantine/core/styles.css'
|
||||
import '@mantine/notifications/styles.css'
|
||||
import '../src/App.css'
|
||||
import { theme } from '../src/theme/theme'
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<MantineProvider>
|
||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||
<Notifications position="top-right" />
|
||||
<Story />
|
||||
</MantineProvider>
|
||||
),
|
||||
|
|
@ -21,6 +25,7 @@ const preview: Preview = {
|
|||
a11y: {
|
||||
test: 'todo',
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([globalIgnores(['dist']), {
|
||||
export default defineConfig([globalIgnores(['dist', 'storybook-static']), {
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
<title>MCP Inspector</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
105
clients/web/src/App.css
Normal file
105
clients/web/src/App.css
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* App.css — Global styles that CANNOT be expressed in the Mantine theme.
|
||||
*
|
||||
* Only the following belong here:
|
||||
* - :root CSS custom properties (--inspector-* design tokens)
|
||||
* - @keyframes animations
|
||||
* - Pseudo-selectors (:hover, :focus) that cross component boundaries
|
||||
* - Nested child-element selectors for third-party HTML output
|
||||
* - Styles for native HTML elements (img, iframe, etc.)
|
||||
*
|
||||
* Everything else (margin, padding, background, border, color, font-size)
|
||||
* belongs in the Mantine theme via Component.extend() or .withProps().
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Brand ─────────────────────────────────────────────── */
|
||||
--inspector-brand-primary: var(--mantine-primary-color-filled);
|
||||
|
||||
/* ── Text ──────────────────────────────────────────────── */
|
||||
--inspector-text-primary: var(--mantine-color-text);
|
||||
--inspector-text-secondary: var(--mantine-color-dimmed);
|
||||
--inspector-text-inverse: var(--mantine-color-white);
|
||||
|
||||
/* ── Surfaces ──────────────────────────────────────────── */
|
||||
--inspector-surface-body: var(--mantine-color-body);
|
||||
--inspector-surface-card: var(--mantine-color-default);
|
||||
--inspector-surface-code: var(--mantine-color-gray-0);
|
||||
|
||||
/* ── Borders ───────────────────────────────────────────── */
|
||||
--inspector-border-default: var(--mantine-color-default-border);
|
||||
--inspector-border-subtle: var(--mantine-color-gray-3);
|
||||
|
||||
/* ── Connection status ─────────────────────────────────── */
|
||||
--inspector-status-connected: var(--mantine-color-green-6);
|
||||
--inspector-status-connecting: var(--mantine-color-yellow-6);
|
||||
--inspector-status-disconnected: var(--mantine-color-gray-5);
|
||||
--inspector-status-failed: var(--mantine-color-red-6);
|
||||
|
||||
/* ── Log levels (RFC 5424) ─────────────────────────────── */
|
||||
--inspector-log-debug: var(--mantine-color-gray-6);
|
||||
--inspector-log-info: var(--mantine-color-blue-6);
|
||||
--inspector-log-notice: var(--mantine-color-teal-6);
|
||||
--inspector-log-warning: var(--mantine-color-yellow-6);
|
||||
--inspector-log-error: var(--mantine-color-red-6);
|
||||
--inspector-log-critical: var(--mantine-color-red-8);
|
||||
--inspector-log-alert: var(--mantine-color-red-9);
|
||||
--inspector-log-emergency: var(--mantine-color-red-9);
|
||||
|
||||
/* ── Annotations ───────────────────────────────────────── */
|
||||
--inspector-annotation-destructive: var(--mantine-color-red-6);
|
||||
--inspector-annotation-long-run: var(--mantine-color-yellow-6);
|
||||
--inspector-annotation-read-only: var(--mantine-color-dark-6);
|
||||
--inspector-annotation-audience: var(--mantine-color-gray-6);
|
||||
--inspector-annotation-priority-high: var(--mantine-color-orange-6);
|
||||
--inspector-annotation-priority-medium: var(--mantine-color-yellow-6);
|
||||
--inspector-annotation-priority-low: var(--mantine-color-green-6);
|
||||
|
||||
/* ── Task status ───────────────────────────────────────── */
|
||||
--inspector-task-waiting: var(--mantine-color-gray-5);
|
||||
--inspector-task-running: var(--mantine-color-blue-6);
|
||||
--inspector-task-completed: var(--mantine-color-green-6);
|
||||
--inspector-task-failed: var(--mantine-color-red-6);
|
||||
--inspector-task-cancelled: var(--mantine-color-yellow-6);
|
||||
|
||||
/* ── History ───────────────────────────────────────────── */
|
||||
--inspector-history-success: var(--mantine-color-green-6);
|
||||
--inspector-history-error: var(--mantine-color-red-6);
|
||||
}
|
||||
|
||||
/* Dark mode overrides — tokens that need adjustment in dark scheme */
|
||||
[data-mantine-color-scheme='dark'] {
|
||||
--inspector-surface-code: var(--mantine-color-dark-6);
|
||||
--inspector-border-subtle: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
/* ── Animations ──────────────────────────────────────────── */
|
||||
|
||||
@keyframes inspector-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes inspector-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Status dot animations ───────────────────────────────── */
|
||||
|
||||
.inspector-pulse {
|
||||
animation: inspector-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.inspector-spin {
|
||||
animation: inspector-spin 1s linear infinite;
|
||||
}
|
||||
|
|
@ -1,11 +1,39 @@
|
|||
import { Title, Text, Container, Stack } from '@mantine/core'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Container,
|
||||
Stack,
|
||||
useMantineColorScheme,
|
||||
useComputedColorScheme,
|
||||
ActionIcon,
|
||||
Group,
|
||||
} from '@mantine/core'
|
||||
|
||||
function App() {
|
||||
const { setColorScheme } = useMantineColorScheme()
|
||||
const computedColorScheme = useComputedColorScheme('light')
|
||||
|
||||
const toggleColorScheme = () => {
|
||||
setColorScheme(computedColorScheme === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Title order={1}>MCP Inspector</Title>
|
||||
<Text c="dimmed">Web client for the Model Context Protocol Inspector</Text>
|
||||
<Group>
|
||||
<Title order={1}>MCP Inspector</Title>
|
||||
<ActionIcon
|
||||
onClick={toggleColorScheme}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label="Toggle color scheme"
|
||||
>
|
||||
{computedColorScheme === 'dark' ? '\u2600' : '\u263E'}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Text c="var(--inspector-text-secondary)">
|
||||
Web client for the Model Context Protocol Inspector
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { AnnotationBadge } from './AnnotationBadge'
|
||||
|
||||
const meta: Meta<typeof AnnotationBadge> = {
|
||||
title: 'Atoms/AnnotationBadge',
|
||||
component: AnnotationBadge,
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof AnnotationBadge>
|
||||
|
||||
export const Audience: Story = {
|
||||
args: {
|
||||
label: 'user',
|
||||
variant: 'audience',
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
label: 'read-only',
|
||||
variant: 'readOnly',
|
||||
},
|
||||
}
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
label: 'destructive',
|
||||
variant: 'destructive',
|
||||
},
|
||||
}
|
||||
|
||||
export const LongRun: Story = {
|
||||
args: {
|
||||
label: 'long-run',
|
||||
variant: 'longRun',
|
||||
},
|
||||
}
|
||||
|
||||
export const PriorityHigh: Story = {
|
||||
args: {
|
||||
label: 'priority: high',
|
||||
variant: 'priority',
|
||||
},
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'custom',
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Badge } from '@mantine/core'
|
||||
|
||||
export type AnnotationVariant = 'audience' | 'readOnly' | 'destructive' | 'longRun' | 'priority' | 'default'
|
||||
|
||||
export interface AnnotationBadgeProps {
|
||||
label: string
|
||||
variant?: AnnotationVariant
|
||||
}
|
||||
|
||||
const variantMap: Record<AnnotationVariant, { color: string; variant: string }> = {
|
||||
audience: { color: 'gray', variant: 'outline' },
|
||||
readOnly: { color: 'dark', variant: 'filled' },
|
||||
destructive: { color: 'red', variant: 'outline' },
|
||||
longRun: { color: 'yellow', variant: 'outline' },
|
||||
priority: { color: 'orange', variant: 'light' },
|
||||
default: { color: 'gray', variant: 'light' },
|
||||
}
|
||||
|
||||
export function AnnotationBadge({ label, variant = 'default' }: AnnotationBadgeProps) {
|
||||
const { color, variant: badgeVariant } = variantMap[variant]
|
||||
|
||||
return (
|
||||
<Badge color={color} variant={badgeVariant}>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { CapabilityItem } from './CapabilityItem';
|
||||
|
||||
const meta: Meta<typeof CapabilityItem> = {
|
||||
title: 'Atoms/CapabilityItem',
|
||||
component: CapabilityItem,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CapabilityItem>;
|
||||
|
||||
export const Supported: Story = {
|
||||
args: {
|
||||
name: 'Tools',
|
||||
supported: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const SupportedWithCount: Story = {
|
||||
args: {
|
||||
name: 'Tools',
|
||||
supported: true,
|
||||
count: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export const NotSupported: Story = {
|
||||
args: {
|
||||
name: 'Completions',
|
||||
supported: false,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Group, Text } from '@mantine/core';
|
||||
|
||||
export interface CapabilityItemProps {
|
||||
name: string;
|
||||
supported: boolean;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function CapabilityItem({ name, supported, count }: CapabilityItemProps) {
|
||||
const label = count != null ? `${name} (${count})` : name;
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Text c={supported ? 'green' : 'red'}>{supported ? '\u2713' : '\u2717'}</Text>
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
import { ConnectionToggle } from './ConnectionToggle';
|
||||
|
||||
const meta: Meta<typeof ConnectionToggle> = {
|
||||
title: 'Atoms/ConnectionToggle',
|
||||
component: ConnectionToggle,
|
||||
args: {
|
||||
onChange: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ConnectionToggle>;
|
||||
|
||||
export const Connected: Story = {
|
||||
args: {
|
||||
checked: true,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disconnected: Story = {
|
||||
args: {
|
||||
checked: false,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
checked: false,
|
||||
loading: true,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
checked: false,
|
||||
loading: false,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Switch } from '@mantine/core';
|
||||
|
||||
export interface ConnectionToggleProps {
|
||||
checked: boolean;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConnectionToggle({ checked, loading, disabled, onChange }: ConnectionToggleProps) {
|
||||
return (
|
||||
<Switch
|
||||
size="lg"
|
||||
checked={checked}
|
||||
disabled={disabled || loading}
|
||||
onChange={(event) => onChange(event.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
import { ContentViewer } from './ContentViewer';
|
||||
|
||||
const meta: Meta<typeof ContentViewer> = {
|
||||
title: 'Atoms/ContentViewer',
|
||||
component: ContentViewer,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ContentViewer>;
|
||||
|
||||
export const PlainText: Story = {
|
||||
args: {
|
||||
type: 'text',
|
||||
content: 'Hello, world!\nThis is plain text.',
|
||||
},
|
||||
};
|
||||
|
||||
export const JsonContent: Story = {
|
||||
args: {
|
||||
type: 'json',
|
||||
content: '{"name":"my-app","version":"1.0.0","description":"Sample configuration file"}',
|
||||
},
|
||||
};
|
||||
|
||||
export const ImagePreview: Story = {
|
||||
args: {
|
||||
type: 'image',
|
||||
content:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
args: {
|
||||
type: 'json',
|
||||
content: JSON.stringify({
|
||||
name: 'my-app',
|
||||
version: '2.5.0',
|
||||
description: 'A comprehensive application configuration',
|
||||
author: 'Inspector Team',
|
||||
license: 'MIT',
|
||||
repository: 'https://github.com/example/my-app',
|
||||
dependencies: {
|
||||
react: '^18.0.0',
|
||||
typescript: '^5.0.0',
|
||||
mantine: '^7.0.0',
|
||||
},
|
||||
devDependencies: {
|
||||
vitest: '^1.0.0',
|
||||
storybook: '^8.0.0',
|
||||
eslint: '^9.0.0',
|
||||
},
|
||||
scripts: {
|
||||
dev: 'vite',
|
||||
build: 'tsc && vite build',
|
||||
test: 'vitest',
|
||||
lint: 'eslint .',
|
||||
},
|
||||
settings: {
|
||||
theme: 'dark',
|
||||
language: 'en',
|
||||
notifications: true,
|
||||
maxRetries: 3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCopyButton: Story = {
|
||||
args: {
|
||||
type: 'text',
|
||||
content: 'Hello, world!\nThis text can be copied.',
|
||||
onCopy: fn(),
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { Button, Code, Image, Stack } from '@mantine/core';
|
||||
|
||||
export interface ContentViewerProps {
|
||||
type: 'text' | 'json' | 'image' | 'audio';
|
||||
content: string;
|
||||
mimeType?: string;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
function formatJson(content: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, 2);
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
export function ContentViewer({ type, content, mimeType, onCopy }: ContentViewerProps) {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{type === 'json' && <Code block>{formatJson(content)}</Code>}
|
||||
{type === 'text' && <Code block>{content}</Code>}
|
||||
{type === 'image' && (
|
||||
<Image
|
||||
src={`data:${mimeType || 'image/png'};base64,${content}`}
|
||||
alt="Content preview"
|
||||
maw={400}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
{type === 'audio' && (
|
||||
<audio controls>
|
||||
<source src={`data:${mimeType || 'audio/wav'};base64,${content}`} />
|
||||
</audio>
|
||||
)}
|
||||
{onCopy && (
|
||||
<Button variant="light" size="xs" onClick={onCopy}>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { CopyButton } from './CopyButton';
|
||||
|
||||
const meta: Meta<typeof CopyButton> = {
|
||||
title: 'Atoms/CopyButton',
|
||||
component: CopyButton,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CopyButton>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: 'Hello, world!',
|
||||
},
|
||||
};
|
||||
|
||||
export const LongValue: Story = {
|
||||
args: {
|
||||
value:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
|
||||
},
|
||||
};
|
||||
19
clients/web/src/components/atoms/CopyButton/CopyButton.tsx
Normal file
19
clients/web/src/components/atoms/CopyButton/CopyButton.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { ActionIcon, CopyButton as MantineCopyButton, Tooltip } from '@mantine/core';
|
||||
|
||||
export interface CopyButtonProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ value }: CopyButtonProps) {
|
||||
return (
|
||||
<MantineCopyButton value={value}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? 'Copied' : 'Copy'}>
|
||||
<ActionIcon variant="subtle" color={copied ? 'green' : 'gray'} onClick={copy}>
|
||||
{copied ? '\u2713' : '\u2398'}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</MantineCopyButton>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { InlineError } from './InlineError';
|
||||
|
||||
const meta: Meta<typeof InlineError> = {
|
||||
title: 'Atoms/InlineError',
|
||||
component: InlineError,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InlineError>;
|
||||
|
||||
export const ShortError: Story = {
|
||||
args: {
|
||||
message: 'Connection timeout after 20s',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRetryCount: Story = {
|
||||
args: {
|
||||
message: 'Connection failed',
|
||||
retryCount: 3,
|
||||
maxRetries: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDetails: Story = {
|
||||
args: {
|
||||
message: 'Connection refused',
|
||||
details:
|
||||
'Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDocLink: Story = {
|
||||
args: {
|
||||
message: 'Connection failed',
|
||||
docLink: 'https://example.com/troubleshooting',
|
||||
},
|
||||
};
|
||||
|
||||
export const FullError: Story = {
|
||||
args: {
|
||||
message: 'Connection refused',
|
||||
details:
|
||||
'Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)',
|
||||
retryCount: 3,
|
||||
maxRetries: 5,
|
||||
docLink: 'https://example.com/troubleshooting',
|
||||
},
|
||||
};
|
||||
43
clients/web/src/components/atoms/InlineError/InlineError.tsx
Normal file
43
clients/web/src/components/atoms/InlineError/InlineError.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Alert, Anchor, Group, Spoiler, Stack, Text } from '@mantine/core';
|
||||
|
||||
export interface InlineErrorProps {
|
||||
message: string;
|
||||
details?: string;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
onShowMore?: () => void;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
export function InlineError({
|
||||
message,
|
||||
details,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
docLink,
|
||||
}: InlineErrorProps) {
|
||||
return (
|
||||
<Alert color="red" variant="light" title={message}>
|
||||
<Stack gap="xs">
|
||||
{retryCount !== undefined && (
|
||||
<Text size="sm">
|
||||
Retry attempt {retryCount}
|
||||
{maxRetries !== undefined && ` of ${maxRetries}`}
|
||||
</Text>
|
||||
)}
|
||||
{details && (
|
||||
<Spoiler maxHeight={0} showLabel="Show more" hideLabel="Show less">
|
||||
<Text size="sm">{details}</Text>
|
||||
</Spoiler>
|
||||
)}
|
||||
{docLink && (
|
||||
<Group>
|
||||
<Anchor href={docLink} target="_blank" size="sm">
|
||||
View Troubleshooting Guide →
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue