mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-23 09:57:32 +00:00
Compare commits
16 Commits
97ebef016a
...
feat/add-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da8e69476 | ||
|
|
15e4db386f | ||
|
|
66e85fb2c1 | ||
|
|
42b2d5e1d9 | ||
|
|
d3c4821258 | ||
|
|
72c4b0749e | ||
|
|
35e140b941 | ||
|
|
b70728254b | ||
|
|
64ebbaf387 | ||
|
|
cdaff9d146 | ||
|
|
3f66179e72 | ||
|
|
c654f489af | ||
|
|
ef44942767 | ||
|
|
7b63a68e7e | ||
|
|
ea33810a4f | ||
|
|
4fe6cc4272 |
@@ -13,6 +13,9 @@
|
||||
*/
|
||||
|
||||
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 path from 'path';
|
||||
import os from 'os';
|
||||
@@ -20,6 +23,124 @@ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
|
||||
// Session tracking: Map of session IDs to active query instances
|
||||
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 clause is used because randomUUID is not available in older Node.js versions
|
||||
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
|
||||
@@ -52,30 +173,29 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
if (settings.skipPermissions && permissionMode !== 'plan') {
|
||||
// When skipping permissions, use bypassPermissions mode
|
||||
sdkOptions.permissionMode = 'bypassPermissions';
|
||||
} else {
|
||||
// Map allowed tools
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Map allowed tools (always set to avoid implicit "allow all" defaults).
|
||||
// This does not grant permissions by itself; it just configures the SDK,
|
||||
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
|
||||
// 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)
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
||||
@@ -370,6 +490,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
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
|
||||
const queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
@@ -526,5 +716,6 @@ export {
|
||||
queryClaudeSDK,
|
||||
abortClaudeSDKSession,
|
||||
isClaudeSDKSessionActive,
|
||||
getActiveClaudeSDKSessions
|
||||
getActiveClaudeSDKSessions,
|
||||
resolveToolApproval
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ import fetch from 'node-fetch';
|
||||
import mime from 'mime-types';
|
||||
|
||||
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 { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||
import gitRoutes from './routes/git.js';
|
||||
@@ -804,6 +804,18 @@ function handleChatConnection(ws) {
|
||||
provider,
|
||||
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') {
|
||||
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
||||
const success = abortCursorSession(data.sessionId);
|
||||
@@ -1625,10 +1637,13 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
||||
// Debug: log all entries including hidden files
|
||||
|
||||
|
||||
// Skip only heavy build directories
|
||||
// Skip heavy build directories and VCS directories
|
||||
if (entry.name === 'node_modules' ||
|
||||
entry.name === 'dist' ||
|
||||
entry.name === 'build') continue;
|
||||
entry.name === 'build' ||
|
||||
entry.name === '.git' ||
|
||||
entry.name === '.svn' ||
|
||||
entry.name === '.hg') continue;
|
||||
|
||||
const itemPath = path.join(dirPath, entry.name);
|
||||
const item = {
|
||||
|
||||
@@ -37,6 +37,7 @@ import Fuse from 'fuse.js';
|
||||
import CommandMenu from './CommandMenu';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants';
|
||||
|
||||
import { safeJsonParse } from '../lib/utils.js';
|
||||
|
||||
// Helper function to decode HTML entities in text
|
||||
function decodeHtmlEntities(text) {
|
||||
@@ -92,6 +93,10 @@ function unescapeWithMathProtection(text) {
|
||||
return processedText;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Small wrapper to keep markdown behavior consistent in one place
|
||||
const Markdown = ({ children, className }) => {
|
||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||
@@ -236,6 +241,102 @@ const safeLocalStorage = {
|
||||
}
|
||||
};
|
||||
|
||||
const CLAUDE_SETTINGS_KEY = 'claude-settings';
|
||||
|
||||
|
||||
function getClaudeSettings() {
|
||||
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
projectSortOrder: 'name'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
...parsed,
|
||||
allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [],
|
||||
disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [],
|
||||
skipPermissions: Boolean(parsed.skipPermissions),
|
||||
projectSortOrder: parsed.projectSortOrder || 'name'
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
projectSortOrder: 'name'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildClaudeToolPermissionEntry(toolName, toolInput) {
|
||||
if (!toolName) return null;
|
||||
if (toolName !== 'Bash') return toolName;
|
||||
|
||||
const parsed = safeJsonParse(toolInput);
|
||||
const command = typeof parsed?.command === 'string' ? parsed.command.trim() : '';
|
||||
if (!command) return toolName;
|
||||
|
||||
const tokens = command.split(/\s+/);
|
||||
if (tokens.length === 0) return toolName;
|
||||
|
||||
// For Bash, allow the command family instead of every Bash invocation.
|
||||
if (tokens[0] === 'git' && tokens[1]) {
|
||||
return `Bash(${tokens[0]} ${tokens[1]}:*)`;
|
||||
}
|
||||
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) {
|
||||
if (provider !== 'claude') return null;
|
||||
if (!message?.toolResult?.isError) return null;
|
||||
|
||||
const toolName = message?.toolName;
|
||||
const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
const settings = getClaudeSettings();
|
||||
const isAllowed = settings.allowedTools.includes(entry);
|
||||
return { toolName, entry, isAllowed };
|
||||
}
|
||||
|
||||
function grantClaudeToolPermission(entry) {
|
||||
if (!entry) return { success: false };
|
||||
|
||||
const settings = getClaudeSettings();
|
||||
const alreadyAllowed = settings.allowedTools.includes(entry);
|
||||
const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];
|
||||
const nextDisallowed = settings.disallowedTools.filter(tool => tool !== entry);
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
allowedTools: nextAllowed,
|
||||
disallowedTools: nextDisallowed,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));
|
||||
return { success: true, alreadyAllowed, updatedSettings };
|
||||
}
|
||||
|
||||
// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
|
||||
const markdownComponents = {
|
||||
code: ({ node, inline, className, children, ...props }) => {
|
||||
@@ -356,7 +457,7 @@ const markdownComponents = {
|
||||
};
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking, selectedProject }) => {
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => {
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
@@ -364,6 +465,13 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = React.useRef(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||
const [permissionGrantState, setPermissionGrantState] = React.useState('idle');
|
||||
|
||||
React.useEffect(() => {
|
||||
setPermissionGrantState('idle');
|
||||
}, [permissionSuggestion?.entry, message.toolId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoExpandTools || !messageRef.current || !message.isToolUse) return;
|
||||
|
||||
@@ -1358,6 +1466,59 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</Markdown>
|
||||
);
|
||||
})()}
|
||||
{permissionSuggestion && (
|
||||
<div className="mt-4 border-t border-red-200/60 dark:border-red-800/60 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!onGrantToolPermission) return;
|
||||
const result = onGrantToolPermission(permissionSuggestion);
|
||||
if (result?.success) {
|
||||
setPermissionGrantState('granted');
|
||||
} else {
|
||||
setPermissionGrantState('error');
|
||||
}
|
||||
}}
|
||||
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${
|
||||
permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
|
||||
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
|
||||
}`}
|
||||
>
|
||||
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? 'Permission added'
|
||||
: `Grant permission for ${permissionSuggestion.toolName}`}
|
||||
</button>
|
||||
{onShowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShowSettings();
|
||||
}}
|
||||
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100"
|
||||
>
|
||||
Open settings
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
||||
Adds <span className="font-mono">{permissionSuggestion.entry}</span> to Allowed Tools.
|
||||
</div>
|
||||
{permissionGrantState === 'error' && (
|
||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||
Unable to update permissions. Please try again.
|
||||
</div>
|
||||
)}
|
||||
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
||||
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
||||
Permission saved. Retry the request to use the tool.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1688,12 +1849,17 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const MESSAGES_PER_PAGE = 20;
|
||||
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
||||
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 [uploadingImages, setUploadingImages] = useState(new Map());
|
||||
const [imageErrors, setImageErrors] = useState(new Map());
|
||||
const messagesEndRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
const inputContainerRef = useRef(null);
|
||||
const inputHighlightRef = useRef(null);
|
||||
const scrollContainerRef = useRef(null);
|
||||
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
@@ -1706,6 +1872,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const [debouncedInput, setDebouncedInput] = useState('');
|
||||
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
||||
const [fileList, setFileList] = useState([]);
|
||||
const [fileMentions, setFileMentions] = useState([]);
|
||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
@@ -1735,6 +1902,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const [codexModel, setCodexModel] = useState(() => {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (selectedSession?.id) {
|
||||
@@ -1754,6 +1924,23 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
localStorage.setItem('selected-provider', selectedSession.__provider);
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
@@ -3014,6 +3201,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
if (onReplaceTemporarySession) {
|
||||
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;
|
||||
|
||||
@@ -3244,6 +3438,55 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}]);
|
||||
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':
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'error',
|
||||
@@ -3440,6 +3683,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
if (selectedProject && latestMessage.exitCode === 0) {
|
||||
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;
|
||||
|
||||
case 'codex-response':
|
||||
@@ -3615,6 +3861,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, {
|
||||
type: 'assistant',
|
||||
content: 'Session interrupted by user.',
|
||||
@@ -3718,6 +3968,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
// Handle @ symbol detection and file filtering
|
||||
useEffect(() => {
|
||||
const textBeforeCursor = input.slice(0, cursorPosition);
|
||||
@@ -3748,6 +3999,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, [input, cursorPosition, fileList]);
|
||||
|
||||
const activeFileMentions = useMemo(() => {
|
||||
if (!input || fileMentions.length === 0) return [];
|
||||
return fileMentions.filter(path => input.includes(path));
|
||||
}, [fileMentions, input]);
|
||||
|
||||
const sortedFileMentions = useMemo(() => {
|
||||
if (activeFileMentions.length === 0) return [];
|
||||
const unique = Array.from(new Set(activeFileMentions));
|
||||
return unique.sort((a, b) => b.length - a.length);
|
||||
}, [activeFileMentions]);
|
||||
|
||||
const fileMentionRegex = useMemo(() => {
|
||||
if (sortedFileMentions.length === 0) return null;
|
||||
const pattern = sortedFileMentions.map(escapeRegExp).join('|');
|
||||
return new RegExp(`(${pattern})`, 'g');
|
||||
}, [sortedFileMentions]);
|
||||
|
||||
const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]);
|
||||
|
||||
const renderInputWithMentions = useCallback((text) => {
|
||||
if (!text) return '';
|
||||
if (!fileMentionRegex) return text;
|
||||
const parts = text.split(fileMentionRegex);
|
||||
return parts.map((part, index) => (
|
||||
fileMentionSet.has(part) ? (
|
||||
<span
|
||||
key={`mention-${index}`}
|
||||
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`text-${index}`}>{part}</span>
|
||||
)
|
||||
));
|
||||
}, [fileMentionRegex, fileMentionSet]);
|
||||
|
||||
// Debounced input handling
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -4133,6 +4421,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
|
||||
|
||||
const handleGrantToolPermission = useCallback((suggestion) => {
|
||||
if (!suggestion || provider !== 'claude') {
|
||||
return { success: false };
|
||||
}
|
||||
return grantClaudeToolPermission(suggestion.entry);
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
@@ -4285,8 +4610,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
||||
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
||||
|
||||
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
|
||||
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
|
||||
const newInput = textBeforeAt + file.path + ' ' + textAfterQuery;
|
||||
const newCursorPos = textBeforeAt.length + file.path.length + 1;
|
||||
|
||||
// Immediately ensure focus is maintained
|
||||
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||||
@@ -4296,6 +4621,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Update input and cursor position
|
||||
setInput(newInput);
|
||||
setCursorPosition(newCursorPos);
|
||||
setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path]));
|
||||
|
||||
// Hide dropdown
|
||||
setShowFileDropdown(false);
|
||||
@@ -4389,6 +4715,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
};
|
||||
|
||||
const syncInputOverlayScroll = useCallback((target) => {
|
||||
if (!inputHighlightRef.current || !target) return;
|
||||
inputHighlightRef.current.scrollTop = target.scrollTop;
|
||||
inputHighlightRef.current.scrollLeft = target.scrollLeft;
|
||||
}, []);
|
||||
|
||||
const handleTextareaClick = (e) => {
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
};
|
||||
@@ -4711,10 +5043,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={handleGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -4769,6 +5103,101 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -4992,6 +5421,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 overflow-hidden ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
||||
<input {...getInputProps()} />
|
||||
<div
|
||||
ref={inputHighlightRef}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl"
|
||||
>
|
||||
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-sm sm:text-base leading-[21px] sm:leading-6 whitespace-pre-wrap break-words">
|
||||
{renderInputWithMentions(input)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
@@ -4999,6 +5438,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
onClick={handleTextareaClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onScroll={(e) => syncInputOverlayScroll(e.target)}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onInput={(e) => {
|
||||
@@ -5006,6 +5446,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
syncInputOverlayScroll(e.target);
|
||||
|
||||
// Check if textarea is expanded (more than 2 lines worth of height)
|
||||
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
|
||||
@@ -5074,6 +5515,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
||||
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Eye,
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Eye,
|
||||
Settings2,
|
||||
Moon,
|
||||
Sun,
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Languages
|
||||
Languages,
|
||||
GripVertical
|
||||
} from 'lucide-react';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
@@ -38,11 +39,170 @@ const QuickSettingsPanel = ({
|
||||
});
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// Draggable handle state
|
||||
const [handlePosition, setHandlePosition] = useState(() => {
|
||||
const saved = localStorage.getItem('quickSettingsHandlePosition');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed.y ?? 50;
|
||||
} catch {
|
||||
// Remove corrupted data
|
||||
localStorage.removeItem('quickSettingsHandlePosition');
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
return 50; // Default to 50% (middle of screen)
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartY, setDragStartY] = useState(0);
|
||||
const [dragStartPosition, setDragStartPosition] = useState(0);
|
||||
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
|
||||
const handleRef = useRef(null);
|
||||
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
|
||||
const dragThreshold = 5; // Pixels to move before it's considered a drag
|
||||
|
||||
useEffect(() => {
|
||||
setLocalIsOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = () => {
|
||||
// Save handle position to localStorage when it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
|
||||
}, [handlePosition]);
|
||||
|
||||
// Calculate position from percentage
|
||||
const getPositionStyle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
// On mobile, convert percentage to pixels from bottom
|
||||
const bottomPixels = (window.innerHeight * handlePosition) / 100;
|
||||
return { bottom: `${bottomPixels}px` };
|
||||
} else {
|
||||
// On desktop, use top with percentage
|
||||
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
|
||||
}
|
||||
}, [handlePosition, isMobile]);
|
||||
|
||||
// Handle mouse/touch start
|
||||
const handleDragStart = useCallback((e) => {
|
||||
// Don't prevent default yet - we want to allow click if no drag happens
|
||||
e.stopPropagation();
|
||||
|
||||
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||||
setDragStartY(clientY);
|
||||
setDragStartPosition(handlePosition);
|
||||
setHasMoved(false);
|
||||
setIsDragging(false); // Don't set dragging until threshold is passed
|
||||
}, [handlePosition]);
|
||||
|
||||
// Handle mouse/touch move
|
||||
const handleDragMove = useCallback((e) => {
|
||||
if (dragStartY === 0) return; // Not in a potential drag
|
||||
|
||||
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||||
const deltaY = Math.abs(clientY - dragStartY);
|
||||
|
||||
// Check if we've moved past threshold
|
||||
if (!isDragging && deltaY > dragThreshold) {
|
||||
setIsDragging(true);
|
||||
setHasMoved(true);
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
// Prevent body scroll on mobile during drag
|
||||
if (e.type.includes('touch')) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDragging) return;
|
||||
|
||||
// Prevent scrolling on touch move
|
||||
if (e.type.includes('touch')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const actualDeltaY = clientY - dragStartY;
|
||||
|
||||
// For top-based positioning (desktop), moving down increases top percentage
|
||||
// For bottom-based positioning (mobile), we need to invert
|
||||
let percentageDelta;
|
||||
if (isMobile) {
|
||||
// On mobile, moving down should decrease bottom position (increase percentage from top)
|
||||
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
|
||||
} else {
|
||||
// On desktop, moving down should increase top position
|
||||
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
|
||||
}
|
||||
|
||||
let newPosition = dragStartPosition + percentageDelta;
|
||||
|
||||
// Apply constraints
|
||||
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
|
||||
|
||||
setHandlePosition(newPosition);
|
||||
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
|
||||
|
||||
// Handle mouse/touch end
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setDragStartY(0);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
// Restore body scroll on mobile
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}, []);
|
||||
|
||||
// Cleanup body styles on unmount in case component unmounts while dragging
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set up global event listeners for drag
|
||||
useEffect(() => {
|
||||
if (dragStartY !== 0) {
|
||||
// Mouse events
|
||||
const handleMouseMove = (e) => handleDragMove(e);
|
||||
const handleMouseUp = () => handleDragEnd();
|
||||
|
||||
// Touch events
|
||||
const handleTouchMove = (e) => handleDragMove(e);
|
||||
const handleTouchEnd = () => handleDragEnd();
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}
|
||||
}, [dragStartY, handleDragMove, handleDragEnd]);
|
||||
|
||||
const handleToggle = (e) => {
|
||||
// Don't toggle if user was dragging
|
||||
if (hasMoved) {
|
||||
e.preventDefault();
|
||||
setHasMoved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = !localIsOpen;
|
||||
setLocalIsOpen(newState);
|
||||
onToggle(newState);
|
||||
@@ -50,24 +210,37 @@ const QuickSettingsPanel = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pull Tab */}
|
||||
<div
|
||||
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${
|
||||
{/* Pull Tab - Combined drag handle and toggle button */}
|
||||
<button
|
||||
ref={handleRef}
|
||||
onClick={handleToggle}
|
||||
onMouseDown={(e) => {
|
||||
// Start drag on mousedown
|
||||
handleDragStart(e);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
// Start drag on touchstart
|
||||
handleDragStart(e);
|
||||
}}
|
||||
className={`fixed ${
|
||||
localIsOpen ? 'right-64' : 'right-0'
|
||||
} z-50 transition-all duration-150 ease-out`}
|
||||
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
|
||||
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
|
||||
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
} touch-none`}
|
||||
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
||||
aria-label={isDragging ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
|
||||
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
|
||||
>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg"
|
||||
aria-label={localIsOpen ? 'Close settings panel' : 'Open settings panel'}
|
||||
>
|
||||
{localIsOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isDragging ? (
|
||||
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
|
||||
) : localIsOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
|
||||
@@ -3,4 +3,13 @@ import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
}
|
||||
|
||||
export function safeJsonParse(value) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user