HooksPanel
Configure lifecycle hooks such as PreToolUse, PostToolUse, SessionStart or Stop. Hooks are grouped by event with a description of when it runs; each hook shows its matcher, command or prompt, timeout and scope, and can be toggled, edited in HookWizard or deleted. Handles loading, error and empty states.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
Wide
Narrow
Source
The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.
hooks-config/HooksPanel.tsx
import React, { memo, useMemo, useState } from 'react';
import {
Accordion,
Alert,
Badge,
Box,
Button,
Code,
EmptyState,
Group,
Loader,
Skeleton,
Stack,
Switch,
Text,
} from '@mantine/core';
import {
IconAlertCircle,
IconEdit,
IconMessageChatbot,
IconPlus,
IconTerminal2,
IconTrash,
IconWebhook,
} from '@tabler/icons-react';
import { usePendingActions } from '../hooks/use-pending-actions';
import { ConfirmDialog } from '../primitives/ConfirmDialog/ConfirmDialog';
import { EntityList } from '../primitives/EntityList/EntityList';
import { EntityListItem } from '../primitives/EntityList/EntityListItem';
import { cx } from '../utils/cx';
import {
DEFAULT_HOOK_MESSAGES,
countHooksByEvent,
describeMatcher,
getHookEventText,
getHookScopeText,
getHookSummary,
type HookMessages,
} from './hook-wizard';
import { HookWizard, type HookWizardLabels } from './HookWizard';
import {
HOOK_EVENTS,
HOOK_EVENT_ORDER,
type HookConfig,
type HookEvent,
type HookEventTextOverrides,
type HookScopeTextOverrides,
} from './types';
import classes from './HooksPanel.module.css';
import { formatTemplate } from '../utils/format-template';
export type HookSaveMode = 'create' | 'edit';
export interface HooksPanelLabels {
title: string;
description: string;
addHook: string;
edit: string;
delete: string;
enabled: string;
retry: string;
emptyTitle: string;
emptyDescription: string;
emptyEvent: string;
command: string;
prompt: string;
error: string;
cancel: string;
pending: string;
/** Title of the delete confirmation */
deleteTitle: string;
/** Delete confirmation text after the hook summary, `{event}` is replaced with the event label */
deleteMessage: string;
dismiss: string;
events: HookEventTextOverrides;
scopes: HookScopeTextOverrides;
messages: Partial<HookMessages>;
/** Labels of the hook wizard */
wizard: Partial<HookWizardLabels>;
}
export interface HooksPanelProps {
/** Configured hooks of all events */
hooks: HookConfig[];
/** Shows skeleton rows instead of hooks */
loading?: boolean;
/** Error message shown instead of hooks */
error?: React.ReactNode;
/** Called by the retry button of the error alert */
onRetry?: () => void;
/** Called with a hook created or edited in the wizard, add and edit actions are hidden when omitted */
onSave?: (hook: HookConfig, mode: HookSaveMode) => void | Promise<void>;
/** Called by the delete action, the action is hidden when omitted */
onDelete?: (hook: HookConfig) => void | Promise<void>;
/** Called by the enabled switch, the switch is read-only when omitted */
onToggle?: (hook: HookConfig, enabled: boolean) => void | Promise<void>;
/** Tool names suggested for matchers in the wizard */
knownTools?: string[];
/** Events expanded initially, events with hooks by default */
defaultExpanded?: HookEvent[];
/** Overrides of the default English labels of the panel */
labels?: Partial<HooksPanelLabels>;
/** Class name added to the root element */
className?: string;
/** Inline styles added to the root element */
style?: React.CSSProperties;
}
export const DEFAULT_HOOKS_PANEL_LABELS: HooksPanelLabels = {
title: 'Hooks',
description: 'Commands and prompts that run automatically on agent events.',
addHook: 'Add hook',
edit: 'Edit',
delete: 'Delete',
enabled: 'Enabled',
retry: 'Retry',
emptyTitle: 'No hooks yet',
emptyDescription:
'Run formatters after edits, block risky commands or add context on session start.',
emptyEvent: 'No hooks for this event',
command: 'command',
prompt: 'prompt',
error: 'Something went wrong',
cancel: 'Cancel',
pending: 'Saving',
deleteTitle: 'Delete hook',
deleteMessage: 'will no longer run on {event}.',
dismiss: 'Dismiss',
events: {},
scopes: {},
messages: {},
wizard: {},
};
/** Hooks grouped by event with counters, per-hook enable switch, edit and delete, and a wizard to add hooks */
export const HooksPanel = memo(function HooksPanel({
hooks,
loading = false,
error,
onRetry,
onSave,
onDelete,
onToggle,
knownTools,
defaultExpanded,
labels: labelsProp,
className,
style,
}: HooksPanelProps) {
const labels = { ...DEFAULT_HOOKS_PANEL_LABELS, ...labelsProp };
const messages = { ...DEFAULT_HOOK_MESSAGES, ...labels.messages };
const wizardText = useMemo(
() => ({
events: labels.events,
scopes: labels.scopes,
messages: labels.messages,
...labels.wizard,
}),
[labels.events, labels.scopes, labels.messages, labels.wizard]
);
const [wizardOpened, setWizardOpened] = useState(false);
const [draft, setDraft] = useState<Partial<HookConfig> | undefined>(undefined);
const [expanded, setExpanded] = useState<string[] | null>(defaultExpanded ?? null);
const [deleteTarget, setDeleteTarget] = useState<HookConfig | null>(null);
const [deleteOpened, setDeleteOpened] = useState(false);
const rowActions = usePendingActions(labels.error);
const counts = useMemo(() => countHooksByEvent(hooks), [hooks]);
const expandedEvents = expanded ?? HOOK_EVENT_ORDER.filter((event) => counts[event] > 0);
const openWizard = (hook?: Partial<HookConfig>) => {
setDraft(hook);
setWizardOpened(true);
};
const addButton = onSave && (
<Button
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => openWizard()}
className={classes.add}
>
{labels.addHook}
</Button>
);
const renderHook = (hook: HookConfig) => {
const matcher = describeMatcher(hook.matcher, messages);
const supportsMatcher = HOOK_EVENTS[hook.event].supportsMatcher;
const enabled = hook.enabled !== false;
const pending = rowActions.isPending(hook.id);
const actions = [
...(onSave
? [
{
label: labels.edit,
icon: <IconEdit size={14} />,
disabled: pending,
onClick: () => openWizard(hook),
},
]
: []),
...(onDelete
? [
{
label: labels.delete,
icon: <IconTrash size={14} />,
color: 'red',
disabled: pending,
onClick: () => {
setDeleteTarget(hook);
setDeleteOpened(true);
},
},
]
: []),
];
return (
<EntityListItem
title={
supportsMatcher ? (
<Code className={classes.matcher}>{matcher}</Code>
) : (
<Text component="span" size="sm" fw={500}>
{hook.type === 'command' ? labels.command : labels.prompt}
</Text>
)
}
description={
<Text
component="span"
size="xs"
c="dimmed"
className={hook.type === 'command' ? classes.command : undefined}
>
{getHookSummary(hook)}
</Text>
}
descriptionLines={1}
icon={
hook.type === 'command' ? (
<IconTerminal2 size={16} aria-label={labels.command} />
) : (
<IconMessageChatbot size={16} aria-label={labels.prompt} />
)
}
badges={
<Badge size="xs" variant="light" color="gray" className={classes.badge}>
{getHookScopeText(hook.scope, labels.scopes).label}
</Badge>
}
meta={
<Group gap={6} wrap="nowrap">
{pending && <Loader size="xs" aria-label={labels.pending} />}
<Switch
size="xs"
checked={enabled}
disabled={!onToggle || pending}
aria-label={`${labels.enabled}: ${supportsMatcher ? matcher : getHookSummary(hook)}`}
onClick={(clickEvent) => clickEvent.stopPropagation()}
onChange={(changeEvent) => {
const checked = changeEvent.currentTarget.checked;
if (onToggle) {
rowActions.run(hook.id, () => onToggle(hook, checked));
}
}}
/>
</Group>
}
actions={actions.length > 0 ? actions : undefined}
className={enabled ? undefined : classes.disabled}
/>
);
};
let body: React.ReactNode;
if (loading) {
body = (
<Stack gap="xs" aria-busy="true">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} height={44} radius="md" />
))}
</Stack>
);
} else if (error) {
body = (
<Alert color="red" variant="light" icon={<IconAlertCircle size={18} />}>
<Stack gap="xs" align="flex-start">
<Text size="sm">{error}</Text>
{onRetry && (
<Button size="xs" variant="default" onClick={onRetry}>
{labels.retry}
</Button>
)}
</Stack>
</Alert>
);
} else if (hooks.length === 0) {
body = (
<EmptyState
size="sm"
py="xl"
icon={<IconWebhook size={20} />}
title={labels.emptyTitle}
description={labels.emptyDescription}
>
{addButton && <EmptyState.Actions>{addButton}</EmptyState.Actions>}
</EmptyState>
);
} else {
body = (
<Accordion
multiple
variant="default"
value={expandedEvents}
onChange={setExpanded}
classNames={{ control: classes.eventControl, content: classes.eventContent }}
>
{HOOK_EVENT_ORDER.map((eventName) => {
const event = getHookEventText(eventName, labels.events);
const eventHooks = hooks.filter((hook) => hook.event === eventName);
return (
<Accordion.Item key={eventName} value={eventName}>
<Accordion.Control>
<Group gap="xs" wrap="nowrap" justify="space-between" pr="xs">
<Stack gap={0} miw={0}>
<Text size="sm" fw={500} truncate>
{event.label}
</Text>
<Text size="xs" c="dimmed" ff="monospace" truncate>
{eventName}
</Text>
</Stack>
<Badge size="sm" variant="light" color="gray" circle>
{eventHooks.length}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="xs">
<Text size="xs" c="dimmed">
{event.description}
</Text>
{onSave && (
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconPlus size={12} />}
onClick={() => openWizard({ event: eventName })}
className={classes.eventAdd}
>
{labels.addHook}
</Button>
)}
</Group>
{eventHooks.length > 0 ? (
<EntityList
items={eventHooks}
getId={(hook) => hook.id}
renderItem={renderHook}
ariaLabel={event.label}
/>
) : (
<Text size="sm" c="dimmed" ta="center" py="xs">
{labels.emptyEvent}
</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
);
}
return (
<Stack gap="md" className={cx(classes.root, className)} style={style}>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
<Stack gap={2} miw={0}>
<Text component="h3" size="sm" fw={500}>
{labels.title}
</Text>
<Text size="xs" c="dimmed">
{labels.description}
</Text>
</Stack>
{!loading && !error && hooks.length > 0 && addButton}
</Group>
{rowActions.error && (
<Alert
color="red"
variant="light"
icon={<IconAlertCircle size={18} />}
withCloseButton
closeButtonLabel={labels.dismiss}
onClose={rowActions.clearError}
>
{rowActions.error}
</Alert>
)}
<Box>{body}</Box>
<ConfirmDialog
opened={deleteOpened}
onClose={() => setDeleteOpened(false)}
danger
title={labels.deleteTitle}
labels={{ confirm: labels.delete, cancel: labels.cancel, error: labels.error }}
message={
deleteTarget && (
<>
<Code>{getHookSummary(deleteTarget)}</Code>{' '}
{formatTemplate(labels.deleteMessage, {
event: getHookEventText(deleteTarget.event, labels.events).label,
})}
</>
)
}
onConfirm={() =>
deleteTarget && onDelete
? rowActions.run(deleteTarget.id, () => onDelete(deleteTarget), { rethrow: true })
: undefined
}
/>
{onSave && (
<HookWizard
opened={wizardOpened}
onClose={() => setWizardOpened(false)}
initialHook={draft}
knownTools={knownTools}
labels={wizardText}
onSubmit={(hook) => onSave(hook, draft?.id ? 'edit' : 'create')}
/>
)}
</Stack>
);
});
HooksPanel.displayName = 'HooksPanel';
Depends on:
API reference
Prop
Type
Required
hooks
HookConfig[]
Yes
Configured hooks of all events
loading
boolean
No
Shows skeleton rows instead of hooks
error
React.ReactNode
No
Error message shown instead of hooks
onRetry
() => void
No
Called by the retry button of the error alert
onSave
(hook: HookConfig, mode: HookSaveMode) => void | Promise<void>
No
Called with a hook created or edited in the wizard, add and edit actions are hidden when omitted
onDelete
(hook: HookConfig) => void | Promise<void>
No
Called by the delete action, the action is hidden when omitted
onToggle
(hook: HookConfig, enabled: boolean) => void | Promise<void>
No
Called by the enabled switch, the switch is read-only when omitted
knownTools
string[]
No
Tool names suggested for matchers in the wizard
defaultExpanded
HookEvent[]
No
Events expanded initially, events with hooks by default
labels
Partial<HooksPanelLabels>
No
Overrides of the default English labels of the panel
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element