feat: setup canUseTool for claude messages

This commit is contained in:
Haileyesus Dessie
2026-01-09 19:08:59 +03:00
parent cdaff9d146
commit 64ebbaf387
4 changed files with 452 additions and 22 deletions

View File

@@ -13,6 +13,9 @@
*/ */
import { query } from '@anthropic-ai/claude-agent-sdk'; import { query } from '@anthropic-ai/claude-agent-sdk';
// Used to mint unique approval request IDs when randomUUID is not available.
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
import crypto from 'crypto';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
@@ -20,6 +23,123 @@ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
// Session tracking: Map of session IDs to active query instances // Session tracking: Map of session IDs to active query instances
const activeSessions = new Map(); const activeSessions = new Map();
// In-memory registry of pending tool approvals keyed by requestId.
// This does not persist approvals or share across processes; it exists so the
// SDK can pause tool execution while the UI decides what to do.
const pendingToolApprovals = new Map();
// Default approval timeout kept under the SDK's 60s control timeout.
// This does not change SDK limits; it only defines how long we wait for the UI,
// introduced to avoid hanging the run when no decision arrives.
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
// Generate a stable request ID for UI approval flows.
// This does not encode tool details or get shown to users; it exists so the UI
// can respond to the correct pending request without collisions.
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}
// Wait for a UI approval decision, honoring SDK cancellation.
// This does not auto-approve or auto-deny; it only resolves with UI input,
// and it cleans up the pending map to avoid leaks, introduced to prevent
// replying after the SDK cancels the control request.
function waitForToolApproval(requestId, options = {}) {
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
return new Promise(resolve => {
let settled = false;
const finalize = (decision) => {
if (settled) return;
settled = true;
cleanup();
resolve(decision);
};
const cleanup = () => {
pendingToolApprovals.delete(requestId);
clearTimeout(timeout);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
};
// Timeout is local to this process; it does not override SDK timing.
// It exists to prevent the UI prompt from lingering indefinitely.
const timeout = setTimeout(() => {
onCancel?.('timeout');
finalize(null);
}, timeoutMs);
const abortHandler = () => {
// If the SDK cancels the control request, stop waiting to avoid
// replying after the process is no longer ready for writes.
onCancel?.('cancelled');
finalize({ cancelled: true });
};
if (signal) {
if (signal.aborted) {
onCancel?.('cancelled');
finalize({ cancelled: true });
return;
}
signal.addEventListener('abort', abortHandler, { once: true });
}
pendingToolApprovals.set(requestId, (decision) => {
finalize(decision);
});
});
}
// Resolve a pending approval. This does not validate the decision payload;
// validation and tool matching remain in canUseTool, which keeps this as a
// lightweight WebSocket -> SDK relay.
function resolveToolApproval(requestId, decision) {
const resolver = pendingToolApprovals.get(requestId);
if (resolver) {
resolver(decision);
}
}
// Match stored permission entries against a tool + input combo.
// This only supports exact tool names and the Bash(command:*) shorthand
// used by the UI; it intentionally does not implement full glob semantics,
// introduced to stay consistent with the UI's "Allow rule" format.
function matchesToolPermission(entry, toolName, input) {
if (!entry || !toolName) {
return false;
}
if (entry === toolName) {
return true;
}
const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
if (toolName === 'Bash' && bashMatch) {
const allowedPrefix = bashMatch[1];
let command = '';
if (typeof input === 'string') {
command = input.trim();
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
command = input.command.trim();
}
if (!command) {
return false;
}
return command.startsWith(allowedPrefix);
}
return false;
}
/** /**
* Maps CLI options to SDK-compatible options format * Maps CLI options to SDK-compatible options format
@@ -52,30 +172,29 @@ function mapCliOptionsToSDK(options = {}) {
if (settings.skipPermissions && permissionMode !== 'plan') { if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode // When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions'; sdkOptions.permissionMode = 'bypassPermissions';
} else { }
// Map allowed tools
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode default tools // Map allowed tools (always set to avoid implicit "allow all" defaults).
if (permissionMode === 'plan') { // This does not grant permissions by itself; it just configures the SDK,
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; // introduced because leaving it undefined made the SDK treat it as "all tools allowed."
for (const tool of planModeTools) { let allowedTools = [...(settings.allowedTools || [])];
if (!allowedTools.includes(tool)) {
allowedTools.push(tool); // Add plan mode default tools
} if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
} }
} }
if (allowedTools.length > 0) {
sdkOptions.allowedTools = allowedTools;
}
// Map disallowed tools
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
sdkOptions.disallowedTools = settings.disallowedTools;
}
} }
sdkOptions.allowedTools = allowedTools;
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
// This does not override allowlists; it only feeds the canUseTool gate.
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet) // Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT; sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
@@ -370,6 +489,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths; tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir; tempDir = imageResult.tempDir;
// Gate tool usage with explicit UI approval when not auto-approved.
// This does not render UI or persist permissions; it only bridges to the UI
// via WebSocket and waits for the response, introduced so tool calls pause
// instead of auto-running when the allowlist is empty.
sdkOptions.canUseTool = async (toolName, input, context) => {
if (sdkOptions.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input };
}
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isDisallowed) {
return { behavior: 'deny', message: 'Tool disallowed by settings' };
}
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isAllowed) {
return { behavior: 'allow', updatedInput: input };
}
const requestId = createRequestId();
ws.send({
type: 'claude-permission-request',
requestId,
toolName,
input,
sessionId: capturedSessionId || sessionId || null
});
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
// This does not retry or resurface the prompt; it just reflects the cancellation.
const decision = await waitForToolApproval(requestId, {
signal: context?.signal,
onCancel: (reason) => {
ws.send({
type: 'claude-permission-cancelled',
requestId,
reason,
sessionId: capturedSessionId || sessionId || null
});
}
});
if (!decision) {
return { behavior: 'deny', message: 'Permission request timed out' };
}
if (decision.cancelled) {
return { behavior: 'deny', message: 'Permission request cancelled' };
}
if (decision.allow) {
// rememberEntry only updates this run's in-memory allowlist to prevent
// repeated prompts in the same session; persistence is handled by the UI.
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
sdkOptions.allowedTools.push(decision.rememberEntry);
}
if (Array.isArray(sdkOptions.disallowedTools)) {
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
}
}
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
}
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
};
// Create SDK query instance // Create SDK query instance
const queryInstance = query({ const queryInstance = query({
prompt: finalCommand, prompt: finalCommand,
@@ -526,5 +715,6 @@ export {
queryClaudeSDK, queryClaudeSDK,
abortClaudeSDKSession, abortClaudeSDKSession,
isClaudeSDKSessionActive, isClaudeSDKSessionActive,
getActiveClaudeSDKSessions getActiveClaudeSDKSessions,
resolveToolApproval
}; };

View File

@@ -58,7 +58,7 @@ import fetch from 'node-fetch';
import mime from 'mime-types'; import mime from 'mime-types';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
import gitRoutes from './routes/git.js'; import gitRoutes from './routes/git.js';
@@ -804,6 +804,18 @@ function handleChatConnection(ws) {
provider, provider,
success success
}); });
} else if (data.type === 'claude-permission-response') {
// Relay UI approval decisions back into the SDK control flow.
// This does not persist permissions; it only resolves the in-flight request,
// introduced so the SDK can resume once the user clicks Allow/Deny.
if (data.requestId) {
resolveToolApproval(data.requestId, {
allow: Boolean(data.allow),
updatedInput: data.updatedInput,
message: data.message,
rememberEntry: data.rememberEntry
});
}
} else if (data.type === 'cursor-abort') { } else if (data.type === 'cursor-abort') {
console.log('[DEBUG] Abort Cursor session:', data.sessionId); console.log('[DEBUG] Abort Cursor session:', data.sessionId);
const success = abortCursorSession(data.sessionId); const success = abortCursorSession(data.sessionId);

View File

@@ -295,6 +295,19 @@ function buildClaudeToolPermissionEntry(toolName, toolInput) {
return `Bash(${tokens[0]}:*)`; return `Bash(${tokens[0]}:*)`;
} }
// Normalize tool inputs for display in the permission banner.
// This does not sanitize/redact secrets; it is strictly formatting so users
// can see the raw input that triggered the permission prompt.
function formatToolInputForDisplay(input) {
if (input === undefined || input === null) return '';
if (typeof input === 'string') return input;
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
function getClaudePermissionSuggestion(message, provider) { function getClaudePermissionSuggestion(message, provider) {
if (provider !== 'claude') return null; if (provider !== 'claude') return null;
if (!message?.toolResult?.isError) return null; if (!message?.toolResult?.isError) return null;
@@ -1839,6 +1852,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const MESSAGES_PER_PAGE = 20; const MESSAGES_PER_PAGE = 20;
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
const [permissionMode, setPermissionMode] = useState('default'); const [permissionMode, setPermissionMode] = useState('default');
// In-memory queue of tool permission prompts for the current UI view.
// These are not persisted and do not survive a page refresh; introduced so
// the UI can present pending approvals while the SDK waits.
const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]);
const [attachedImages, setAttachedImages] = useState([]); const [attachedImages, setAttachedImages] = useState([]);
const [uploadingImages, setUploadingImages] = useState(new Map()); const [uploadingImages, setUploadingImages] = useState(new Map());
const [imageErrors, setImageErrors] = useState(new Map()); const [imageErrors, setImageErrors] = useState(new Map());
@@ -1886,6 +1903,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [codexModel, setCodexModel] = useState(() => { const [codexModel, setCodexModel] = useState(() => {
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT; return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
}); });
// Track provider transitions so we only clear approvals when provider truly changes.
// This does not sync with the backend; it just prevents UI prompts from disappearing.
const lastProviderRef = useRef(provider);
// Load permission mode for the current session // Load permission mode for the current session
useEffect(() => { useEffect(() => {
if (selectedSession?.id) { if (selectedSession?.id) {
@@ -1906,6 +1926,23 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
}, [selectedSession]); }, [selectedSession]);
// Clear pending permission prompts when switching providers; filter when switching sessions.
// This does not preserve prompts across provider changes; it exists to keep the
// Claude approval flow intact while preventing prompts from a different provider.
useEffect(() => {
if (lastProviderRef.current !== provider) {
setPendingPermissionRequests([]);
lastProviderRef.current = provider;
}
}, [provider]);
// When the selected session changes, drop prompts that belong to other sessions.
// This does not attempt to migrate prompts across sessions; it only filters,
// introduced so the UI does not show approvals for a session the user is no longer viewing.
useEffect(() => {
setPendingPermissionRequests(prev => prev.filter(req => !req.sessionId || req.sessionId === selectedSession?.id));
}, [selectedSession?.id]);
// Load Cursor default model from config // Load Cursor default model from config
useEffect(() => { useEffect(() => {
if (provider === 'cursor') { if (provider === 'cursor') {
@@ -3165,6 +3202,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (onReplaceTemporarySession) { if (onReplaceTemporarySession) {
onReplaceTemporarySession(latestMessage.sessionId); onReplaceTemporarySession(latestMessage.sessionId);
} }
// Attach the real session ID to any pending permission requests so they
// do not disappear during the "new-session -> real-session" transition.
// This does not create or auto-approve requests; it only keeps UI state aligned.
setPendingPermissionRequests(prev => prev.map(req => (
req.sessionId ? req : { ...req, sessionId: latestMessage.sessionId }
)));
} }
break; break;
@@ -3395,6 +3439,55 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}]); }]);
break; break;
case 'claude-permission-request': {
// Receive a tool approval request from the backend and surface it in the UI.
// This does not approve anything automatically; it only queues a prompt,
// introduced so the user can decide before the SDK continues.
if (provider !== 'claude' || !latestMessage.requestId) {
break;
}
setPendingPermissionRequests(prev => {
if (prev.some(req => req.requestId === latestMessage.requestId)) {
return prev;
}
return [
...prev,
{
requestId: latestMessage.requestId,
toolName: latestMessage.toolName || 'UnknownTool',
input: latestMessage.input,
context: latestMessage.context,
sessionId: latestMessage.sessionId || null,
receivedAt: new Date()
}
];
});
// Keep the session in a "waiting" state while approval is pending.
// This does not resume the run; it only updates the UI status so the
// user knows Claude is blocked on a decision.
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Waiting for permission',
tokens: 0,
can_interrupt: true
});
break;
}
case 'claude-permission-cancelled': {
// Backend cancelled the approval (timeout or SDK cancel); remove the banner.
// We currently do not show a user-facing warning here; this is intentional
// to avoid noisy alerts when the SDK cancels in the background.
if (!latestMessage.requestId) {
break;
}
setPendingPermissionRequests(prev => prev.filter(req => req.requestId !== latestMessage.requestId));
break;
}
case 'claude-error': case 'claude-error':
setChatMessages(prev => [...prev, { setChatMessages(prev => [...prev, {
type: 'error', type: 'error',
@@ -3591,6 +3684,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (selectedProject && latestMessage.exitCode === 0) { if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
} }
// Conversation finished; clear any stale permission prompts.
// This does not remove saved permissions; it only resets transient UI state.
setPendingPermissionRequests([]);
break; break;
case 'codex-response': case 'codex-response':
@@ -3766,6 +3862,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
} }
// Abort ends the run; clear permission prompts to avoid dangling UI state.
// This does not change allowlists; it only clears the current banner.
setPendingPermissionRequests([]);
setChatMessages(prev => [...prev, { setChatMessages(prev => [...prev, {
type: 'assistant', type: 'assistant',
content: 'Session interrupted by user.', content: 'Session interrupted by user.',
@@ -4291,6 +4391,36 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return grantClaudeToolPermission(suggestion.entry); return grantClaudeToolPermission(suggestion.entry);
}, [provider]); }, [provider]);
// Send a UI decision back to the server (single or batched request IDs).
// This does not validate tool inputs or permissions; the backend enforces rules.
// It exists so "Allow & remember" can resolve multiple queued prompts at once.
const handlePermissionDecision = useCallback((requestIds, decision) => {
const ids = Array.isArray(requestIds) ? requestIds : [requestIds];
const validIds = ids.filter(Boolean);
if (validIds.length === 0) {
return;
}
validIds.forEach((requestId) => {
sendMessage({
type: 'claude-permission-response',
requestId,
allow: Boolean(decision?.allow),
updatedInput: decision?.updatedInput,
message: decision?.message,
rememberEntry: decision?.rememberEntry
});
});
setPendingPermissionRequests(prev => {
const next = prev.filter(req => !validIds.includes(req.requestId));
if (next.length === 0) {
setClaudeStatus(null);
}
return next;
});
}, [sendMessage]);
// Store handleSubmit in ref so handleCustomCommand can access it // Store handleSubmit in ref so handleCustomCommand can access it
useEffect(() => { useEffect(() => {
handleSubmitRef.current = handleSubmit; handleSubmitRef.current = handleSubmit;
@@ -4929,6 +5059,101 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div> </div>
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */} {/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
<div ref={inputContainerRef} className="max-w-4xl mx-auto mb-3"> <div ref={inputContainerRef} className="max-w-4xl mx-auto mb-3">
{pendingPermissionRequests.length > 0 && (
// Permission banner for tool approvals. This renders the input, allows
// "allow once" or "allow & remember", and supports batching similar requests.
// It does not persist permissions by itself; persistence is handled by
// the existing localStorage-based settings helpers, introduced to surface
// approvals before tool execution resumes.
<div className="mb-3 space-y-2">
{pendingPermissionRequests.map((request) => {
const rawInput = formatToolInputForDisplay(request.input);
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
const settings = getClaudeSettings();
const alreadyAllowed = permissionEntry
? settings.allowedTools.includes(permissionEntry)
: false;
const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember';
// Group pending prompts that resolve to the same allow rule so
// a single "Allow & remember" can clear them in one click.
// This does not attempt fuzzy matching; it only batches identical rules.
const matchingRequestIds = permissionEntry
? pendingPermissionRequests
.filter(item => buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry)
.map(item => item.requestId)
: [request.requestId];
return (
<div
key={request.requestId}
className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">
Permission required
</div>
<div className="text-xs text-amber-800 dark:text-amber-200">
Tool: <span className="font-mono">{request.toolName}</span>
</div>
</div>
{permissionEntry && (
<div className="text-xs text-amber-700 dark:text-amber-300">
Allow rule: <span className="font-mono">{permissionEntry}</span>
</div>
)}
</div>
{rawInput && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 dark:text-amber-200 hover:text-amber-900 dark:hover:text-amber-100">
View tool input
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-md bg-white/80 dark:bg-gray-900/60 border border-amber-200/60 dark:border-amber-800/60 p-2 text-xs text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
{rawInput}
</pre>
</details>
)}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors"
>
Allow once
</button>
<button
type="button"
onClick={() => {
if (permissionEntry && !alreadyAllowed) {
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
}
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}}
className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${
permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'border-gray-300 text-gray-400 cursor-not-allowed'
}`}
disabled={!permissionEntry}
>
{rememberLabel}
</button>
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors"
>
Deny
</button>
</div>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<button <button
type="button" type="button"

View File

@@ -4,3 +4,6 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs) { export function cn(...inputs) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
// TODO: Add safeJsonParse here
//