Compare commits

..

2 Commits

Author SHA1 Message Date
Haileyesus
58f7247a2d fix: show CTRL+K correctly in chatview 2026-06-05 09:48:12 +03:00
Haileyesus
e2ff79bb82 chore: update claude agent sdk to latest version 2026-06-05 09:45:15 +03:00
13 changed files with 163 additions and 489 deletions

View File

@@ -1,218 +0,0 @@
# CloudCLI UI Nginx subpath deployment template.
#
# Purpose:
# Serve CloudCLI UI from a path prefix such as:
# http://localhost/ai/
# https://example.com/ai/
#
# CloudCLI itself still runs at the root of its own HTTP server, for example:
# http://127.0.0.1:3001/
#
# Nginx receives public requests under /ai, strips that prefix, and forwards the
# remaining path to CloudCLI. For example:
# /ai/ -> /
# /ai/session/abc -> /session/abc
# /ai/assets/index.js -> /assets/index.js
#
# Important Nginx limitation:
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
# The configurable variables below are still useful for proxy/filter values,
# but if you change /ai to a different subpath, also update every line marked:
# [SUBPATH LITERAL]
#
# To use a different subpath, replace these literal matchers:
# location = /ai
# location ^~ /ai/
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
#
# Recommended deployment shape:
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
# are also proxied because the current frontend still calls those endpoints
# with root-relative URLs.
worker_processes 1;
events {
# Maximum simultaneous connections handled by each worker process.
# The default is enough for local testing and small self-hosted deployments.
worker_connections 1024;
}
http {
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
# This map gives us the right Connection header for both cases:
# Upgrade present -> "upgrade"
# Upgrade absent -> "close"
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
# add ssl_certificate / ssl_certificate_key lines.
listen 80 default_server;
# Use your real hostname in production, for example:
# server_name cloudcli.example.com;
server_name localhost 127.0.0.1;
# ---- User settings -------------------------------------------------
#
# Public path prefix where users access CloudCLI.
# Do not add a trailing slash.
#
# This variable can be used in redirects and response rewrites. It
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
# lines too if you change it.
set $cloudcli_subpath /ai;
# Private upstream URL where the CloudCLI server is listening.
# For a default local server this is usually http://127.0.0.1:3001.
set $cloudcli_upstream http://127.0.0.1:3001;
# Allow larger file uploads through the code editor/project file APIs.
client_max_body_size 100m;
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
location = /ai {
return 301 $cloudcli_subpath/;
}
# Main prefixed CloudCLI UI route.
#
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
# $cloudcli_subpath.
location ^~ /ai/ {
# Strip the public subpath before proxying. CloudCLI expects to see
# root paths such as /, /session/:id, /assets/..., /manifest.json.
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
# Forward the rewritten request to the private CloudCLI server.
proxy_pass $cloudcli_upstream;
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
# browser reaches a socket endpoint under the subpath.
proxy_http_version 1.1;
# Preserve useful request metadata for logs and future app support.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
# WebSocket upgrade headers. Harmless for normal HTTP requests.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Long-running agent and terminal sessions can stay open for a long
# time, so avoid closing idle proxied connections too aggressively.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Disable gzip from the upstream response so sub_filter can inspect
# and rewrite HTML/JSON/JS response bodies.
proxy_set_header Accept-Encoding "";
# Rewrite browser-visible root-relative URLs so the runtime can
# discover that the app is mounted under the subpath.
#
# Examples:
# href="/manifest.json" -> href="/ai/manifest.json"
# src="/assets/app.js" -> src="/ai/assets/app.js"
#
# These rewrites are important for React Router basename detection.
sub_filter_once off;
sub_filter_types
application/json
application/manifest+json
application/javascript
text/javascript;
sub_filter 'href="/' 'href="$cloudcli_subpath/';
sub_filter 'src="/' 'src="$cloudcli_subpath/';
# The production HTML and JS register the service worker at /sw.js.
# Rewrite that registration so the worker is served from /ai/sw.js.
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
# The manifest and service worker contain root-relative paths too.
# Rewriting them keeps PWA metadata and cached manifest requests
# under the same public subpath.
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
}
# Root API proxy.
#
# The current CloudCLI frontend calls APIs with root-relative URLs such
# as /api/auth/login. Keep this location unless the frontend becomes
# fully prefix-aware for API requests.
location ^~ /api/ {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Main app WebSocket proxy.
#
# The frontend opens /ws for realtime chat/session/task updates.
location /ws {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Shell WebSocket proxy.
#
# The browser terminal uses /shell. It requires the same WebSocket
# upgrade handling as /ws.
location /shell {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Optional health endpoint proxy used by the frontend version checker.
location = /health {
proxy_pass $cloudcli_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
}
}
}

View File

@@ -1483,133 +1483,74 @@ function permToRwx(perm) {
return r + w + x; return r + w + x;
} }
// Directories that are almost never interesting for a project tree but can
// contain tens of thousands of files. Skipping them before recursion keeps
// traversal time bounded on large monorepos and high-latency filesystems
// (NFS / SMB).
const IGNORED_DIRS = new Set([
// JS / TS toolchains
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
// VCS
'.git', '.svn', '.hg',
// Python
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
// Rust / Go / Java / Ruby
'target', 'vendor',
// Build output / IDE
'.gradle', '.idea', 'coverage', '.nyc_output'
]);
const DEFAULT_FS_CONCURRENCY = 64;
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
? parsedFsConcurrency
: DEFAULT_FS_CONCURRENCY;
let activeFsOperations = 0;
const pendingFsOperations = [];
async function acquire() {
if (activeFsOperations < FS_CONCURRENCY) {
activeFsOperations += 1;
return;
}
await new Promise((resolve) => {
pendingFsOperations.push(resolve);
});
}
function release() {
const next = pendingFsOperations.shift();
if (next) {
next();
return;
}
activeFsOperations = Math.max(0, activeFsOperations - 1);
}
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) { async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
// Using fsPromises from import // Using fsPromises from import
let entries; const items = [];
try { try {
await acquire(); const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
try {
entries = await fsPromises.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) {
} finally { // Debug: log all entries including hidden files
release();
// Skip heavy build directories and VCS directories
if (entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '.git' ||
entry.name === '.svn' ||
entry.name === '.hg') continue;
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
const stats = await fsPromises.stat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recursively get subdirectories but limit depth
try {
// Check if we can access the directory before trying to read it
await fsPromises.access(item.path, fs.constants.R_OK);
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
} catch (e) {
// Silently skip directories we can't access (permission denied, etc.)
item.children = [];
}
}
items.push(item);
} }
} catch (error) { } catch (error) {
// Only log non-permission errors to avoid spam // Only log non-permission errors to avoid spam
if (error.code !== 'EACCES' && error.code !== 'EPERM') { if (error.code !== 'EACCES' && error.code !== 'EPERM') {
console.error('Error reading directory:', error); console.error('Error reading directory:', error);
} }
return [];
} }
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
// serial stat() was the real bottleneck — issuing them concurrently lets
// the kernel pipeline the round-trips and the recursive calls overlap too.
const items = await Promise.all(filteredEntries.map(async (entry) => {
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
await acquire();
try {
const stats = await fsPromises.lstat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Mark symlinks so UI can distinguish them
if (stats.isSymbolicLink()) {
item.isSymlink = true;
}
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions =
((mode >> 6) & 7).toString() +
((mode >> 3) & 7).toString() +
(mode & 7).toString();
item.permissionsRwx =
permToRwx(ownerPerm) +
permToRwx(groupPerm) +
permToRwx(otherPerm);
} finally {
release();
}
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recurse. Let readdir's own EACCES bubble up through the catch in
// the recursive call rather than doing a separate access() probe
// (which doubled the round-trip count on SMB without adding info).
// The recursive call starts with a bounded readdir; holding a permit
// for the whole subtree can deadlock when sibling directories are
// waiting on their own children.
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
}
return item;
}));
return items.sort((a, b) => { return items.sort((a, b) => {
if (a.type !== b.type) { if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1; return a.type === 'directory' ? -1 : 1;

View File

@@ -18,7 +18,6 @@ type ShellIncomingMessage = {
provider?: string; provider?: string;
initialCommand?: string; initialCommand?: string;
isPlainShell?: boolean; isPlainShell?: boolean;
forceRestart?: boolean;
}; };
type PtySessionEntry = { type PtySessionEntry = {
@@ -181,7 +180,6 @@ export function handleShellConnection(
const hasSession = readBoolean(data.hasSession); const hasSession = readBoolean(data.hasSession);
const provider = readString(data.provider, 'claude'); const provider = readString(data.provider, 'claude');
const initialCommand = readString(data.initialCommand); const initialCommand = readString(data.initialCommand);
const forceRestart = readBoolean(data.forceRestart);
const isPlainShell = const isPlainShell =
readBoolean(data.isPlainShell) || readBoolean(data.isPlainShell) ||
(!!initialCommand && !hasSession) || (!!initialCommand && !hasSession) ||
@@ -202,7 +200,7 @@ export function handleShellConnection(
: ''; : '';
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`; ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
if (isLoginCommand || forceRestart) { if (isLoginCommand) {
const oldSession = ptySessionsMap.get(ptySessionKey); const oldSession = ptySessionsMap.get(ptySessionKey);
if (oldSession) { if (oldSession) {
if (oldSession.timeoutId) { if (oldSession.timeoutId) {
@@ -213,8 +211,7 @@ export function handleShellConnection(
} }
} }
const existingSession = const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
if (existingSession) { if (existingSession) {
shellProcess = existingSession.pty; shellProcess = existingSession.pty;
if (existingSession.timeoutId) { if (existingSession.timeoutId) {
@@ -371,10 +368,6 @@ export function handleShellConnection(
} }
const session = ptySessionsMap.get(ptySessionKey); const session = ptySessionsMap.get(ptySessionKey);
if (session && session.pty !== shellProcess) {
return;
}
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send( session.ws.send(
JSON.stringify({ JSON.stringify({
@@ -458,10 +451,6 @@ export function handleShellConnection(
session.ws = null; session.ws = null;
session.timeoutId = setTimeout(() => { session.timeoutId = setTimeout(() => {
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
return;
}
session.pty.kill(); session.pty.kill();
ptySessionsMap.delete(ptySessionKey as string); ptySessionsMap.delete(ptySessionKey as string);
}, PTY_SESSION_TIMEOUT); }, PTY_SESSION_TIMEOUT);

View File

@@ -20,13 +20,7 @@ export function verifyWebSocketClient(
dependencies: WebSocketAuthDependencies dependencies: WebSocketAuthDependencies
): boolean { ): boolean {
const request = info.req as AuthenticatedWebSocketRequest; const request = info.req as AuthenticatedWebSocketRequest;
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost'); console.log('WebSocket connection attempt to:', request.url);
const loggedUrl = new URL(upgradeUrl);
if (loggedUrl.searchParams.has('token')) {
loggedUrl.searchParams.set('token', 'REDACTED');
}
console.log('WebSocket connection attempt to:', `${loggedUrl.pathname}${loggedUrl.search}`);
// Platform mode: use the first DB user and skip token checks. // Platform mode: use the first DB user and skip token checks.
if (dependencies.isPlatform) { if (dependencies.isPlatform) {
@@ -42,6 +36,7 @@ export function verifyWebSocketClient(
} }
// OSS mode: read JWT from query string first, then Authorization header. // OSS mode: read JWT from query string first, then Authorization header.
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
const token = const token =
upgradeUrl.searchParams.get('token') ?? upgradeUrl.searchParams.get('token') ??
request.headers.authorization?.split(' ')[1] ?? request.headers.authorization?.split(' ')[1] ??

View File

@@ -7,12 +7,6 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, SubagentChildTool } from '../types/types'; import type { ChatMessage, SubagentChildTool } from '../types/types';
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting'; import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
function formatToolResultContent(content: unknown): string {
const text = typeof content === 'string' ? content : JSON.stringify(content);
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
}
/** /**
* Convert NormalizedMessage[] from the session store into ChatMessage[] * Convert NormalizedMessage[] from the session store into ChatMessage[]
* that the existing UI components expect. * that the existing UI components expect.
@@ -26,12 +20,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
// First pass: collect tool results for attachment // First pass: collect tool results for attachment
const toolResultMap = new Map<string, NormalizedMessage>(); const toolResultMap = new Map<string, NormalizedMessage>();
const toolUseIds = new Set<string>();
for (const msg of messages) { for (const msg of messages) {
if (msg.kind === 'tool_use' && msg.toolId) {
toolUseIds.add(msg.toolId);
}
if (msg.kind === 'tool_result' && msg.toolId) { if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg); toolResultMap.set(msg.toolId, msg);
} }
@@ -108,7 +97,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
const toolResult = tr const toolResult = tr
? { ? {
content: formatToolResultContent(tr.content), content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: Boolean(tr.isError), isError: Boolean(tr.isError),
toolUseResult: (tr as any).toolUseResult, toolUseResult: (tr as any).toolUseResult,
} }
@@ -202,25 +191,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
break; break;
// tool_result is handled via attachment to tool_use above // tool_result is handled via attachment to tool_use above
case 'tool_result': { case 'tool_result':
if (msg.toolId && toolUseIds.has(msg.toolId)) {
break;
}
const content = formatToolResultContent(msg.content || '');
if (!content.trim()) {
break;
}
converted.push({
type: msg.isError ? 'error' : 'assistant',
content,
timestamp: msg.timestamp,
toolId: msg.toolId,
...sharedMetadata,
});
break; break;
}
default: default:
break; break;

View File

@@ -564,15 +564,11 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
if (!config.result) return false; if (!config.result) return false;
// Hidden/success-only configs suppress noisy successful output, but errors
// still need to be visible so failed tool calls are diagnosable.
if (toolResult?.isError) return false;
// Always hidden // Always hidden
if (config.result.hidden) return true; if (config.result.hidden) return true;
// Hide on success only // Hide on success only
if (config.result.hideOnSuccess && toolResult) { if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
return true; return true;
} }

View File

@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { import type {
ChatMessage, ChatMessage,
@@ -9,10 +8,10 @@ import type {
Provider, Provider,
} from '../../types/types'; } from '../../types/types';
import { formatUsageLimitText } from '../../utils/chatFormatting'; import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app'; import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools'; import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui'; import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl'; import MessageCopyControl from './MessageCopyControl';
@@ -42,9 +41,10 @@ type InteractiveOption = {
isSelected: boolean; isSelected: boolean;
}; };
type PermissionGrantState = 'idle' | 'granted' | 'error';
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']); const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => { const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type && const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') || ((prevMessage.type === 'assistant') ||
@@ -53,6 +53,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'error')); (prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null); const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
const userCopyContent = String(message.content || ''); const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo( const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')), () => formatUsageLimitText(String(message.content || '')),
@@ -71,6 +73,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!message.isThinking; !message.isThinking;
useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
useEffect(() => { useEffect(() => {
const node = messageRef.current; const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return; if (!autoExpandTools || !node || !message.isToolUse) return;
@@ -235,6 +241,55 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert"> <Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
{String(message.toolResult.content || '')} {String(message.toolResult.content || '')}
</Markdown> </Markdown>
{permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
<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 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? t('permissions.added')
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
</button>
{onShowSettings && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
>
{t('permissions.openSettings')}
</button>
)}
</div>
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
</div>
{permissionGrantState === 'error' && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t('permissions.error')}
</div>
)}
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
{t('permissions.retry')}
</div>
)}
</div>
)}
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit'; import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants'; import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket'; import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
@@ -32,8 +31,8 @@ type UseShellConnectionResult = {
isConnected: boolean; isConnected: boolean;
isConnecting: boolean; isConnecting: boolean;
closeSocket: () => void; closeSocket: () => void;
connectToShell: (options?: { forceRestart?: boolean }) => void; connectToShell: () => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; disconnectFromShell: () => void;
}; };
export function useShellConnection({ export function useShellConnection({
@@ -55,8 +54,6 @@ export function useShellConnection({
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const connectingRef = useRef(false); const connectingRef = useRef(false);
const forceRestartOnInitRef = useRef(false);
const suppressAutoConnectRef = useRef(false);
const handleProcessCompletion = useCallback( const handleProcessCompletion = useCallback(
(output: string) => { (output: string) => {
@@ -144,8 +141,6 @@ export function useShellConnection({
} }
currentFitAddon.fit(); currentFitAddon.fit();
const forceRestart = forceRestartOnInitRef.current;
forceRestartOnInitRef.current = false;
sendSocketMessage(socket, { sendSocketMessage(socket, {
type: 'init', type: 'init',
@@ -157,7 +152,6 @@ export function useShellConnection({
rows: currentTerminal.rows, rows: currentTerminal.rows,
initialCommand: initialCommandRef.current, initialCommand: initialCommandRef.current,
isPlainShell: isPlainShellRef.current, isPlainShell: isPlainShellRef.current,
forceRestart,
}); });
}, TERMINAL_INIT_DELAY_MS); }, TERMINAL_INIT_DELAY_MS);
}; };
@@ -183,7 +177,6 @@ export function useShellConnection({
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
forceRestartOnInitRef.current = false;
} }
}, },
[ [
@@ -202,40 +195,27 @@ export function useShellConnection({
], ],
); );
const connectToShell = useCallback((options?: { forceRestart?: boolean }) => { const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting || connectingRef.current) { if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
return; return;
} }
forceRestartOnInitRef.current = Boolean(options?.forceRestart);
suppressAutoConnectRef.current = false;
connectingRef.current = true; connectingRef.current = true;
setIsConnecting(true); setIsConnecting(true);
connectWebSocket(true); connectWebSocket(true);
}, [connectWebSocket, isConnected, isConnecting, isInitialized]); }, [connectWebSocket, isConnected, isConnecting, isInitialized]);
const disconnectFromShell = useCallback((options?: { suppressAutoConnect?: boolean }) => { const disconnectFromShell = useCallback(() => {
if (options?.suppressAutoConnect) {
suppressAutoConnectRef.current = true;
}
closeSocket(); closeSocket();
clearTerminalScreen(); clearTerminalScreen();
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
forceRestartOnInitRef.current = false;
setAuthUrl(''); setAuthUrl('');
}, [clearTerminalScreen, closeSocket, setAuthUrl]); }, [clearTerminalScreen, closeSocket, setAuthUrl]);
useEffect(() => { useEffect(() => {
if ( if (!autoConnect || !isInitialized || isConnecting || isConnected) {
!autoConnect ||
suppressAutoConnectRef.current ||
!isInitialized ||
isConnecting ||
isConnected
) {
return; return;
} }

View File

@@ -1,7 +1,6 @@
import type { MutableRefObject, RefObject } from 'react'; import type { MutableRefObject, RefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit'; import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed'; export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
@@ -16,7 +15,6 @@ export type ShellInitMessage = {
rows: number; rows: number;
initialCommand: string | null | undefined; initialCommand: string | null | undefined;
isPlainShell: boolean; isPlainShell: boolean;
forceRestart?: boolean;
}; };
export type ShellResizeMessage = { export type ShellResizeMessage = {
@@ -71,8 +69,8 @@ export type UseShellRuntimeResult = {
isConnecting: boolean; isConnecting: boolean;
authUrl: string; authUrl: string;
authUrlVersion: number; authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void; connectToShell: () => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; disconnectFromShell: () => void;
openAuthUrlInBrowser: (url?: string) => boolean; openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>; copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
}; };

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
import { import {
@@ -14,7 +13,6 @@ import {
import { useShellRuntime } from '../hooks/useShellRuntime'; import { useShellRuntime } from '../hooks/useShellRuntime';
import { sendSocketMessage } from '../utils/socket'; import { sendSocketMessage } from '../utils/socket';
import { getSessionDisplayName } from '../utils/auth'; import { getSessionDisplayName } from '../utils/auth';
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay'; import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
import ShellEmptyState from './subcomponents/ShellEmptyState'; import ShellEmptyState from './subcomponents/ShellEmptyState';
import ShellHeader from './subcomponents/ShellHeader'; import ShellHeader from './subcomponents/ShellHeader';
@@ -48,8 +46,6 @@ export default function Shell({
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null); const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const restartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const restartAfterInitRef = useRef(false);
const onOutputRef = useRef<(() => void) | null>(null); const onOutputRef = useRef<(() => void) | null>(null);
const { const {
@@ -144,7 +140,6 @@ export default function Shell({
useEffect(() => { useEffect(() => {
return () => { return () => {
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current); if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
if (restartTimerRef.current) clearTimeout(restartTimerRef.current);
}; };
}, []); }, []);
@@ -195,42 +190,12 @@ export default function Shell({
); );
const handleRestartShell = useCallback(() => { const handleRestartShell = useCallback(() => {
restartAfterInitRef.current = true;
setIsRestarting(true); setIsRestarting(true);
if (restartTimerRef.current) { window.setTimeout(() => {
clearTimeout(restartTimerRef.current);
}
restartTimerRef.current = setTimeout(() => {
setIsRestarting(false); setIsRestarting(false);
restartTimerRef.current = null;
}, SHELL_RESTART_DELAY_MS); }, SHELL_RESTART_DELAY_MS);
}, []); }, []);
const handleDisconnectShell = useCallback(() => {
restartAfterInitRef.current = false;
if (restartTimerRef.current) {
clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
}
setIsRestarting(false);
disconnectFromShell({ suppressAutoConnect: true });
}, [disconnectFromShell]);
useEffect(() => {
if (
!restartAfterInitRef.current ||
isRestarting ||
!isInitialized ||
isConnected ||
isConnecting
) {
return;
}
restartAfterInitRef.current = false;
connectToShell({ forceRestart: true });
}, [connectToShell, isConnected, isConnecting, isInitialized, isRestarting]);
if (!selectedProject) { if (!selectedProject) {
return ( return (
<ShellEmptyState <ShellEmptyState
@@ -289,7 +254,7 @@ export default function Shell({
isRestarting={isRestarting} isRestarting={isRestarting}
hasSession={Boolean(selectedSession)} hasSession={Boolean(selectedSession)}
sessionDisplayNameShort={sessionDisplayNameShort} sessionDisplayNameShort={sessionDisplayNameShort}
onDisconnect={handleDisconnectShell} onDisconnect={disconnectFromShell}
onRestart={handleRestartShell} onRestart={handleRestartShell}
statusNewSessionText={t('shell.status.newSession')} statusNewSessionText={t('shell.status.newSession')}
statusInitializingText={t('shell.status.initializing')} statusInitializingText={t('shell.status.initializing')}
@@ -298,7 +263,7 @@ export default function Shell({
disconnectTitle={t('shell.actions.disconnectTitle')} disconnectTitle={t('shell.actions.disconnectTitle')}
restartLabel={t('shell.actions.restart')} restartLabel={t('shell.actions.restart')}
restartTitle={t('shell.actions.restartTitle')} restartTitle={t('shell.actions.restartTitle')}
disableRestart={isRestarting || !isInitialized} disableRestart={isRestarting || isConnected}
/> />
<div className="relative flex-1 overflow-hidden p-2"> <div className="relative flex-1 overflow-hidden p-2">
@@ -316,7 +281,7 @@ export default function Shell({
connectLabel={t('shell.actions.connect')} connectLabel={t('shell.actions.connect')}
connectTitle={t('shell.actions.connectTitle')} connectTitle={t('shell.actions.connectTitle')}
connectingLabel={t('shell.connecting')} connectingLabel={t('shell.connecting')}
onConnect={handleRestartShell} onConnect={connectToShell}
/> />
)} )}

View File

@@ -1,5 +1,3 @@
import { Loader2, RotateCcw } from 'lucide-react';
type ShellConnectionOverlayProps = { type ShellConnectionOverlayProps = {
mode: 'loading' | 'connect' | 'connecting'; mode: 'loading' | 'connect' | 'connecting';
description: string; description: string;
@@ -21,42 +19,40 @@ export default function ShellConnectionOverlay({
}: ShellConnectionOverlayProps) { }: ShellConnectionOverlayProps) {
if (mode === 'loading') { if (mode === 'loading') {
return ( return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="inline-flex items-center gap-2 text-sm font-medium text-gray-100"> <div className="text-white">{loadingLabel}</div>
<Loader2 className="h-4 w-4 animate-spin text-blue-300" aria-hidden="true" />
<span>{loadingLabel}</span>
</div>
</div> </div>
); );
} }
if (mode === 'connect') { if (mode === 'connect') {
return ( return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center"> <div className="w-full max-w-sm text-center">
<button <button
type="button"
onClick={onConnect} onClick={onConnect}
className="pointer-events-auto inline-flex min-h-12 w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-md bg-emerald-600 px-5 py-3 text-base font-semibold text-white shadow-lg shadow-emerald-950/30 transition-colors hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-300 focus:ring-offset-2 focus:ring-offset-gray-950 active:bg-emerald-700" className="flex w-full items-center justify-center space-x-2 rounded-lg bg-green-600 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-green-700 sm:w-auto"
title={connectTitle} title={connectTitle}
> >
<RotateCcw className="h-4 w-4" aria-hidden="true" /> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span className="min-w-0 truncate">{connectLabel}</span> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{connectLabel}</span>
</button> </button>
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p> <p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center"> <div className="w-full max-w-sm text-center">
<div className="flex items-center justify-center gap-3 text-yellow-300"> <div className="flex items-center justify-center space-x-3 text-yellow-400">
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" /> <div className="h-6 w-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">{connectingLabel}</span> <span className="text-base font-medium">{connectingLabel}</span>
</div> </div>
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p> <p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,3 @@
import { RotateCcw, X } from 'lucide-react';
type ShellHeaderProps = { type ShellHeaderProps = {
isConnected: boolean; isConnected: boolean;
isInitialized: boolean; isInitialized: boolean;
@@ -52,27 +50,34 @@ export default function ShellHeader({
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>} {isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center space-x-3">
{isConnected && ( {isConnected && (
<button <button
type="button"
onClick={onDisconnect} onClick={onDisconnect}
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-red-600 px-3 text-xs font-medium text-white transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70 focus:ring-offset-2 focus:ring-offset-gray-800" className="flex items-center space-x-1 rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
title={disconnectTitle} title={disconnectTitle}
> >
<X className="h-3.5 w-3.5" aria-hidden="true" /> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>{disconnectLabel}</span> <span>{disconnectLabel}</span>
</button> </button>
)} )}
<button <button
type="button"
onClick={onRestart} onClick={onRestart}
disabled={disableRestart} disabled={disableRestart}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-gray-600/80 bg-gray-700/70 px-3 text-xs font-medium text-gray-100 transition-colors hover:border-blue-400/70 hover:bg-blue-600/80 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-400/70 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:cursor-not-allowed disabled:border-transparent disabled:bg-transparent disabled:text-gray-500 disabled:opacity-60" className="flex items-center space-x-1 text-xs text-gray-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
title={restartTitle} title={restartTitle}
> >
<RotateCcw className={`h-3.5 w-3.5 ${isRestarting ? 'animate-spin' : ''}`} aria-hidden="true" /> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>{restartLabel}</span> <span>{restartLabel}</span>
</button> </button>
</div> </div>

View File

@@ -229,7 +229,7 @@
"disconnect": "Disconnect", "disconnect": "Disconnect",
"disconnectTitle": "Disconnect from shell", "disconnectTitle": "Disconnect from shell",
"restart": "Restart", "restart": "Restart",
"restartTitle": "Restart Shell", "restartTitle": "Restart Shell (disconnect first)",
"connect": "Continue in Shell", "connect": "Continue in Shell",
"connectTitle": "Connect to shell" "connectTitle": "Connect to shell"
}, },