mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-12 01:35:45 +08:00
Compare commits
30 Commits
v1.13.6
...
9da8e69476
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da8e69476 | ||
|
|
15e4db386f | ||
|
|
66e85fb2c1 | ||
|
|
42b2d5e1d9 | ||
|
|
d3c4821258 | ||
|
|
72c4b0749e | ||
|
|
35e140b941 | ||
|
|
b70728254b | ||
|
|
64ebbaf387 | ||
|
|
cdaff9d146 | ||
|
|
3f66179e72 | ||
|
|
c654f489af | ||
|
|
97ebef016a | ||
|
|
ef44942767 | ||
|
|
7b63a68e7e | ||
|
|
005033136b | ||
|
|
8fb43d358c | ||
|
|
4c40a33255 | ||
|
|
4086fdaa4e | ||
|
|
124c1ac600 | ||
|
|
9efe433d99 | ||
|
|
189a1b174c | ||
|
|
04a0ff311e | ||
|
|
efae890e34 | ||
|
|
ea33810a4f | ||
|
|
4fe6cc4272 | ||
|
|
ba70ad8e81 | ||
|
|
b066ec4c01 | ||
|
|
29783f609f | ||
|
|
73a0b5bebd |
@@ -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,124 @@ 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 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
|
* Maps CLI options to SDK-compatible options format
|
||||||
@@ -52,30 +173,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 +490,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 +716,6 @@ export {
|
|||||||
queryClaudeSDK,
|
queryClaudeSDK,
|
||||||
abortClaudeSDKSession,
|
abortClaudeSDKSession,
|
||||||
isClaudeSDKSessionActive,
|
isClaudeSDKSessionActive,
|
||||||
getActiveClaudeSDKSessions
|
getActiveClaudeSDKSessions,
|
||||||
|
resolveToolApproval
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1625,10 +1637,13 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
|||||||
// Debug: log all entries including hidden files
|
// Debug: log all entries including hidden files
|
||||||
|
|
||||||
|
|
||||||
// Skip only heavy build directories
|
// Skip heavy build directories and VCS directories
|
||||||
if (entry.name === 'node_modules' ||
|
if (entry.name === 'node_modules' ||
|
||||||
entry.name === 'dist' ||
|
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 itemPath = path.join(dirPath, entry.name);
|
||||||
const item = {
|
const item = {
|
||||||
|
|||||||
@@ -280,7 +280,8 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
// Send completion event
|
// Send completion event
|
||||||
sendMessage(ws, {
|
sendMessage(ws, {
|
||||||
type: 'codex-complete',
|
type: 'codex-complete',
|
||||||
sessionId: currentSessionId
|
sessionId: currentSessionId,
|
||||||
|
actualSessionId: thread.id
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1206,7 +1206,12 @@ async function getCodexSessions(projectPath) {
|
|||||||
const sessionData = await parseCodexSessionFile(filePath);
|
const sessionData = await parseCodexSessionFile(filePath);
|
||||||
|
|
||||||
// Check if this session matches the project path
|
// Check if this session matches the project path
|
||||||
if (sessionData && sessionData.cwd === projectPath) {
|
// Handle Windows long paths with \\?\ prefix
|
||||||
|
const sessionCwd = sessionData?.cwd || '';
|
||||||
|
const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
|
||||||
|
const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
|
||||||
|
|
||||||
|
if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
|
||||||
sessions.push({
|
sessions.push({
|
||||||
id: sessionData.id,
|
id: sessionData.id,
|
||||||
summary: sessionData.summary || 'Codex Session',
|
summary: sessionData.summary || 'Codex Session',
|
||||||
@@ -1273,12 +1278,12 @@ async function parseCodexSessionFile(filePath) {
|
|||||||
// Count messages and extract user messages for summary
|
// Count messages and extract user messages for summary
|
||||||
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
||||||
messageCount++;
|
messageCount++;
|
||||||
if (entry.payload.text) {
|
if (entry.payload.message) {
|
||||||
lastUserMessage = entry.payload.text;
|
lastUserMessage = entry.payload.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
|
if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
|
||||||
messageCount++;
|
messageCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '.
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
function createCliResponder(res) {
|
||||||
|
let responded = false;
|
||||||
|
return (status, payload) => {
|
||||||
|
if (responded || res.headersSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
responded = true;
|
||||||
|
res.status(status).json(payload);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/config', async (req, res) => {
|
router.get('/config', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||||
@@ -88,24 +99,30 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
|||||||
|
|
||||||
router.get('/mcp/cli/list', async (req, res) => {
|
router.get('/mcp/cli/list', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const respond = createCliResponder(res);
|
||||||
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
||||||
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||||
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on('close', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) });
|
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ error: 'Codex CLI command failed', details: stderr });
|
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (error) => {
|
proc.on('error', (error) => {
|
||||||
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
|
const isMissing = error?.code === 'ENOENT';
|
||||||
|
respond(isMissing ? 503 : 500, {
|
||||||
|
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||||
|
details: error.message,
|
||||||
|
code: error.code
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
|
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
|
||||||
@@ -133,24 +150,30 @@ router.post('/mcp/cli/add', async (req, res) => {
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const respond = createCliResponder(res);
|
||||||
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
||||||
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||||
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on('close', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
|
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
|
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (error) => {
|
proc.on('error', (error) => {
|
||||||
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
|
const isMissing = error?.code === 'ENOENT';
|
||||||
|
respond(isMissing ? 503 : 500, {
|
||||||
|
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||||
|
details: error.message,
|
||||||
|
code: error.code
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||||
@@ -161,24 +184,30 @@ router.delete('/mcp/cli/remove/:name', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
|
const respond = createCliResponder(res);
|
||||||
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
||||||
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||||
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on('close', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
|
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
|
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (error) => {
|
proc.on('error', (error) => {
|
||||||
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
|
const isMissing = error?.code === 'ENOENT';
|
||||||
|
respond(isMissing ? 503 : 500, {
|
||||||
|
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||||
|
details: error.message,
|
||||||
|
code: error.code
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
|
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
|
||||||
@@ -189,24 +218,30 @@ router.get('/mcp/cli/get/:name', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
|
const respond = createCliResponder(res);
|
||||||
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
||||||
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||||
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on('close', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) });
|
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ error: 'Codex CLI command failed', details: stderr });
|
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (error) => {
|
proc.on('error', (error) => {
|
||||||
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
|
const isMissing = error?.code === 'ENOENT';
|
||||||
|
respond(isMissing ? 503 : 500, {
|
||||||
|
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||||
|
details: error.message,
|
||||||
|
code: error.code
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
|
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
|
||||||
|
|||||||
@@ -177,7 +177,9 @@ function AppContent() {
|
|||||||
// If so, and the session is not active, trigger a message reload in ChatInterface
|
// If so, and the session is not active, trigger a message reload in ChatInterface
|
||||||
if (latestMessage.changedFile && selectedSession && selectedProject) {
|
if (latestMessage.changedFile && selectedSession && selectedProject) {
|
||||||
// Extract session ID from changedFile (format: "project-name/session-id.jsonl")
|
// Extract session ID from changedFile (format: "project-name/session-id.jsonl")
|
||||||
const changedFileParts = latestMessage.changedFile.split('/');
|
const normalized = latestMessage.changedFile.replace(/\\/g, '/');
|
||||||
|
const changedFileParts = normalized.split('/');
|
||||||
|
|
||||||
if (changedFileParts.length >= 2) {
|
if (changedFileParts.length >= 2) {
|
||||||
const filename = changedFileParts[changedFileParts.length - 1];
|
const filename = changedFileParts[changedFileParts.length - 1];
|
||||||
const changedSessionId = filename.replace('.jsonl', '');
|
const changedSessionId = filename.replace('.jsonl', '');
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates.
|
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect, memo } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import remarkMath from 'remark-math';
|
import remarkMath from 'remark-math';
|
||||||
@@ -37,6 +37,7 @@ import Fuse from 'fuse.js';
|
|||||||
import CommandMenu from './CommandMenu';
|
import CommandMenu from './CommandMenu';
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants';
|
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants';
|
||||||
|
|
||||||
|
import { safeJsonParse } from '../lib/utils.js';
|
||||||
|
|
||||||
// Helper function to decode HTML entities in text
|
// Helper function to decode HTML entities in text
|
||||||
function decodeHtmlEntities(text) {
|
function decodeHtmlEntities(text) {
|
||||||
@@ -92,6 +93,10 @@ function unescapeWithMathProtection(text) {
|
|||||||
return processedText;
|
return processedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
// Small wrapper to keep markdown behavior consistent in one place
|
// Small wrapper to keep markdown behavior consistent in one place
|
||||||
const Markdown = ({ children, className }) => {
|
const Markdown = ({ children, className }) => {
|
||||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
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.)
|
// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
|
||||||
const markdownComponents = {
|
const markdownComponents = {
|
||||||
code: ({ node, inline, className, children, ...props }) => {
|
code: ({ node, inline, className, children, ...props }) => {
|
||||||
@@ -356,7 +457,7 @@ const markdownComponents = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Memoized message component to prevent unnecessary re-renders
|
// 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 &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
((prevMessage.type === 'assistant') ||
|
((prevMessage.type === 'assistant') ||
|
||||||
(prevMessage.type === 'user') ||
|
(prevMessage.type === 'user') ||
|
||||||
@@ -364,6 +465,13 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = React.useRef(null);
|
const messageRef = React.useRef(null);
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!autoExpandTools || !messageRef.current || !message.isToolUse) return;
|
if (!autoExpandTools || !messageRef.current || !message.isToolUse) return;
|
||||||
|
|
||||||
@@ -1358,6 +1466,59 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
</Markdown>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1688,14 +1849,22 @@ 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());
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const inputContainerRef = useRef(null);
|
const inputContainerRef = useRef(null);
|
||||||
|
const inputHighlightRef = useRef(null);
|
||||||
const scrollContainerRef = useRef(null);
|
const scrollContainerRef = useRef(null);
|
||||||
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
||||||
|
const isLoadingMoreRef = useRef(false);
|
||||||
|
const topLoadLockRef = useRef(false);
|
||||||
|
const pendingScrollRestoreRef = useRef(null);
|
||||||
// Streaming throttle buffers
|
// Streaming throttle buffers
|
||||||
const streamBufferRef = useRef('');
|
const streamBufferRef = useRef('');
|
||||||
const streamTimerRef = useRef(null);
|
const streamTimerRef = useRef(null);
|
||||||
@@ -1703,6 +1872,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const [debouncedInput, setDebouncedInput] = useState('');
|
const [debouncedInput, setDebouncedInput] = useState('');
|
||||||
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
||||||
const [fileList, setFileList] = useState([]);
|
const [fileList, setFileList] = useState([]);
|
||||||
|
const [fileMentions, setFileMentions] = useState([]);
|
||||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||||
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
@@ -1732,6 +1902,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) {
|
||||||
@@ -1751,6 +1924,23 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
localStorage.setItem('selected-provider', selectedSession.__provider);
|
localStorage.setItem('selected-provider', selectedSession.__provider);
|
||||||
}
|
}
|
||||||
}, [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(() => {
|
||||||
@@ -2710,6 +2900,39 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
return scrollHeight - scrollTop - clientHeight < 50;
|
return scrollHeight - scrollTop - clientHeight < 50;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadOlderMessages = useCallback(async (container) => {
|
||||||
|
if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false;
|
||||||
|
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
|
||||||
|
|
||||||
|
const sessionProvider = selectedSession.__provider || 'claude';
|
||||||
|
if (sessionProvider === 'cursor') return false;
|
||||||
|
|
||||||
|
isLoadingMoreRef.current = true;
|
||||||
|
const previousScrollHeight = container.scrollHeight;
|
||||||
|
const previousScrollTop = container.scrollTop;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moreMessages = await loadSessionMessages(
|
||||||
|
selectedProject.name,
|
||||||
|
selectedSession.id,
|
||||||
|
true,
|
||||||
|
sessionProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
if (moreMessages.length > 0) {
|
||||||
|
pendingScrollRestoreRef.current = {
|
||||||
|
height: previousScrollHeight,
|
||||||
|
top: previousScrollTop
|
||||||
|
};
|
||||||
|
// Prepend new messages to the existing ones
|
||||||
|
setSessionMessages(prev => [...moreMessages, ...prev]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
isLoadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
}, [hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
|
||||||
|
|
||||||
// Handle scroll events to detect when user manually scrolls up and load more messages
|
// Handle scroll events to detect when user manually scrolls up and load more messages
|
||||||
const handleScroll = useCallback(async () => {
|
const handleScroll = useCallback(async () => {
|
||||||
if (scrollContainerRef.current) {
|
if (scrollContainerRef.current) {
|
||||||
@@ -2719,32 +2942,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
// Check if we should load more messages (scrolled near top)
|
// Check if we should load more messages (scrolled near top)
|
||||||
const scrolledNearTop = container.scrollTop < 100;
|
const scrolledNearTop = container.scrollTop < 100;
|
||||||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
if (!scrolledNearTop) {
|
||||||
|
topLoadLockRef.current = false;
|
||||||
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') {
|
} else if (!topLoadLockRef.current) {
|
||||||
// Save current scroll position
|
const didLoad = await loadOlderMessages(container);
|
||||||
const previousScrollHeight = container.scrollHeight;
|
if (didLoad) {
|
||||||
const previousScrollTop = container.scrollTop;
|
topLoadLockRef.current = true;
|
||||||
|
|
||||||
// Load more messages
|
|
||||||
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true, selectedSession.__provider || 'claude');
|
|
||||||
|
|
||||||
if (moreMessages.length > 0) {
|
|
||||||
// Prepend new messages to the existing ones
|
|
||||||
setSessionMessages(prev => [...moreMessages, ...prev]);
|
|
||||||
|
|
||||||
// Restore scroll position after DOM update
|
|
||||||
setTimeout(() => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
const newScrollHeight = scrollContainerRef.current.scrollHeight;
|
|
||||||
const scrollDiff = newScrollHeight - previousScrollHeight;
|
|
||||||
scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff;
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
|
}, [isNearBottom, loadOlderMessages]);
|
||||||
|
|
||||||
|
// Restore scroll position after paginated messages render
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
|
||||||
|
|
||||||
|
const { height, top } = pendingScrollRestoreRef.current;
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
const newScrollHeight = container.scrollHeight;
|
||||||
|
const scrollDiff = newScrollHeight - height;
|
||||||
|
|
||||||
|
container.scrollTop = top + Math.max(scrollDiff, 0);
|
||||||
|
pendingScrollRestoreRef.current = null;
|
||||||
|
}, [chatMessages.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load session messages when session changes
|
// Load session messages when session changes
|
||||||
@@ -2873,7 +3093,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// convertedMessages will be automatically updated via useMemo
|
// convertedMessages will be automatically updated via useMemo
|
||||||
|
|
||||||
// Smart scroll behavior: only auto-scroll if user is near bottom
|
// Smart scroll behavior: only auto-scroll if user is near bottom
|
||||||
if (isNearBottom && autoScrollToBottom) {
|
const shouldAutoScroll = autoScrollToBottom && isNearBottom();
|
||||||
|
if (shouldAutoScroll) {
|
||||||
setTimeout(() => scrollToBottom(), 200);
|
setTimeout(() => scrollToBottom(), 200);
|
||||||
}
|
}
|
||||||
// If user scrolled up, preserve their position (they're reading history)
|
// If user scrolled up, preserve their position (they're reading history)
|
||||||
@@ -2971,12 +3192,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
if (latestMessage.sessionId && !currentSessionId) {
|
if (latestMessage.sessionId && !currentSessionId) {
|
||||||
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
|
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
|
||||||
|
|
||||||
|
// Mark as system change to prevent clearing messages when session ID updates
|
||||||
|
setIsSystemSessionChange(true);
|
||||||
|
|
||||||
// Session Protection: Replace temporary "new-session-*" identifier with real session ID
|
// Session Protection: Replace temporary "new-session-*" identifier with real session ID
|
||||||
// This maintains protection continuity - no gap between temp ID and real ID
|
// This maintains protection continuity - no gap between temp ID and real ID
|
||||||
// The temporary session is removed and real session is marked as active
|
// The temporary session is removed and real session is marked as active
|
||||||
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;
|
||||||
|
|
||||||
@@ -3207,6 +3438,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',
|
||||||
@@ -3403,6 +3683,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':
|
||||||
@@ -3530,8 +3813,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
|
|
||||||
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
|
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||||||
|
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
|
||||||
if (codexPendingSessionId && !currentSessionId) {
|
if (codexPendingSessionId && !currentSessionId) {
|
||||||
setCurrentSessionId(codexPendingSessionId);
|
setCurrentSessionId(codexActualSessionId);
|
||||||
|
setIsSystemSessionChange(true);
|
||||||
|
if (onNavigateToSession) {
|
||||||
|
onNavigateToSession(codexActualSessionId);
|
||||||
|
}
|
||||||
sessionStorage.removeItem('pendingSessionId');
|
sessionStorage.removeItem('pendingSessionId');
|
||||||
console.log('Codex session complete, ID set to:', codexPendingSessionId);
|
console.log('Codex session complete, ID set to:', codexPendingSessionId);
|
||||||
}
|
}
|
||||||
@@ -3573,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, {
|
setChatMessages(prev => [...prev, {
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: 'Session interrupted by user.',
|
content: 'Session interrupted by user.',
|
||||||
@@ -3676,6 +3968,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Handle @ symbol detection and file filtering
|
// Handle @ symbol detection and file filtering
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const textBeforeCursor = input.slice(0, cursorPosition);
|
const textBeforeCursor = input.slice(0, cursorPosition);
|
||||||
@@ -3706,6 +3999,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}, [input, cursorPosition, fileList]);
|
}, [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
|
// Debounced input handling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -4091,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]);
|
}, [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
|
// Store handleSubmit in ref so handleCustomCommand can access it
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleSubmitRef.current = handleSubmit;
|
handleSubmitRef.current = handleSubmit;
|
||||||
@@ -4243,8 +4610,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
||||||
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
||||||
|
|
||||||
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
|
const newInput = textBeforeAt + file.path + ' ' + textAfterQuery;
|
||||||
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
|
const newCursorPos = textBeforeAt.length + file.path.length + 1;
|
||||||
|
|
||||||
// Immediately ensure focus is maintained
|
// Immediately ensure focus is maintained
|
||||||
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||||||
@@ -4254,6 +4621,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Update input and cursor position
|
// Update input and cursor position
|
||||||
setInput(newInput);
|
setInput(newInput);
|
||||||
setCursorPosition(newCursorPos);
|
setCursorPosition(newCursorPos);
|
||||||
|
setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path]));
|
||||||
|
|
||||||
// Hide dropdown
|
// Hide dropdown
|
||||||
setShowFileDropdown(false);
|
setShowFileDropdown(false);
|
||||||
@@ -4347,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) => {
|
const handleTextareaClick = (e) => {
|
||||||
setCursorPosition(e.target.selectionStart);
|
setCursorPosition(e.target.selectionStart);
|
||||||
};
|
};
|
||||||
@@ -4410,6 +4784,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
{/* Messages Area - Scrollable Middle Section */}
|
{/* Messages Area - Scrollable Middle Section */}
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
|
onWheel={handleScroll}
|
||||||
|
onTouchMove={handleScroll}
|
||||||
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
|
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
|
||||||
>
|
>
|
||||||
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
||||||
@@ -4667,10 +5043,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
createDiff={createDiff}
|
createDiff={createDiff}
|
||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
|
onGrantToolPermission={handleGrantToolPermission}
|
||||||
autoExpandTools={autoExpandTools}
|
autoExpandTools={autoExpandTools}
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
|
provider={provider}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -4725,6 +5103,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"
|
||||||
@@ -4948,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' : ''}`}>
|
<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()} />
|
<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
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -4955,6 +5438,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
onClick={handleTextareaClick}
|
onClick={handleTextareaClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
|
onScroll={(e) => syncInputOverlayScroll(e.target)}
|
||||||
onFocus={() => setIsInputFocused(true)}
|
onFocus={() => setIsInputFocused(true)}
|
||||||
onBlur={() => setIsInputFocused(false)}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
@@ -4962,6 +5446,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
e.target.style.height = 'auto';
|
e.target.style.height = 'auto';
|
||||||
e.target.style.height = e.target.scrollHeight + 'px';
|
e.target.style.height = e.target.scrollHeight + 'px';
|
||||||
setCursorPosition(e.target.selectionStart);
|
setCursorPosition(e.target.selectionStart);
|
||||||
|
syncInputOverlayScroll(e.target);
|
||||||
|
|
||||||
// Check if textarea is expanded (more than 2 lines worth of height)
|
// Check if textarea is expanded (more than 2 lines worth of height)
|
||||||
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
|
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
|
||||||
@@ -5030,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"
|
? "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"}
|
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,13 +25,15 @@ function LoginModal({
|
|||||||
const getCommand = () => {
|
const getCommand = () => {
|
||||||
if (customCommand) return customCommand;
|
if (customCommand) return customCommand;
|
||||||
|
|
||||||
|
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return 'claude setup-token --dangerously-skip-permissions';
|
return 'claude setup-token --dangerously-skip-permissions';
|
||||||
case 'cursor':
|
case 'cursor':
|
||||||
return 'cursor-agent login';
|
return 'cursor-agent login';
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return 'codex login';
|
return isPlatform ? 'codex login --device-auth' : 'codex login';
|
||||||
default:
|
default:
|
||||||
return 'claude setup-token --dangerously-skip-permissions';
|
return 'claude setup-token --dangerously-skip-permissions';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Eye,
|
Eye,
|
||||||
Settings2,
|
Settings2,
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
FileText,
|
FileText,
|
||||||
Languages
|
Languages,
|
||||||
|
GripVertical
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import DarkModeToggle from './DarkModeToggle';
|
import DarkModeToggle from './DarkModeToggle';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
@@ -38,11 +39,170 @@ const QuickSettingsPanel = ({
|
|||||||
});
|
});
|
||||||
const { isDarkMode } = useTheme();
|
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(() => {
|
useEffect(() => {
|
||||||
setLocalIsOpen(isOpen);
|
setLocalIsOpen(isOpen);
|
||||||
}, [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;
|
const newState = !localIsOpen;
|
||||||
setLocalIsOpen(newState);
|
setLocalIsOpen(newState);
|
||||||
onToggle(newState);
|
onToggle(newState);
|
||||||
@@ -50,24 +210,37 @@ const QuickSettingsPanel = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Pull Tab */}
|
{/* Pull Tab - Combined drag handle and toggle button */}
|
||||||
<div
|
<button
|
||||||
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${
|
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'
|
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
|
{isDragging ? (
|
||||||
onClick={handleToggle}
|
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
|
||||||
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"
|
) : localIsOpen ? (
|
||||||
aria-label={localIsOpen ? 'Close settings panel' : 'Open settings panel'}
|
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||||
>
|
) : (
|
||||||
{localIsOpen ? (
|
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
)}
|
||||||
) : (
|
</button>
|
||||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -595,9 +595,44 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Filter and Actions */}
|
{/* Action Buttons - Desktop only - Always show when not loading */}
|
||||||
|
{!isLoading && !isMobile && (
|
||||||
|
<div className="px-3 md:px-4 py-2 border-b border-border">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
|
||||||
|
onClick={() => setShowNewProject(true)}
|
||||||
|
title="Create new project"
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
New Project
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
await onRefresh();
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
title="Refresh projects and sessions (Ctrl+R)"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Filter - Only show when there are projects */}
|
||||||
{projects.length > 0 && !isLoading && (
|
{projects.length > 0 && !isLoading && (
|
||||||
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
|
<div className="px-3 md:px-4 py-2 border-b border-border">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -616,39 +651,6 @@ function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons - Desktop only */}
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
|
|
||||||
onClick={() => setShowNewProject(true)}
|
|
||||||
title="Create new project (Ctrl+N)"
|
|
||||||
>
|
|
||||||
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
New Project
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
await onRefresh();
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
title="Refresh projects and sessions (Ctrl+R)"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1392,4 +1394,4 @@ function Sidebar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Sidebar;
|
export default Sidebar;
|
||||||
|
|||||||
@@ -3,4 +3,13 @@ import { twMerge } from "tailwind-merge"
|
|||||||
|
|
||||||
export function cn(...inputs) {
|
export function cn(...inputs) {
|
||||||
return twMerge(clsx(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