Compare commits

...

16 Commits

Author SHA1 Message Date
Simos Mikelatos
762ecb0c3b Merge branch 'main' into fix/file-tree-concurrency 2026-06-05 14:21:26 +02:00
Haile
ef2fd48b46 fix(shell): disconnect and restart buttons (#831)
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-05 14:17:20 +02:00
Haile
9e608b8426 Fixes/minor fixes (#832)
* chore: update claude agent sdk to latest version

* fix: show CTRL+K correctly in chatview
2026-06-05 13:56:34 +02:00
Simos Mikelatos
c667b6a179 Update model version in OPTIONS description 2026-06-04 23:43:48 +02:00
Haile
c7938e4f2b Merge branch 'main' into fix/file-tree-concurrency 2026-06-04 22:26:24 +03:00
Reza Moghaddam
fa9eaf5573 feat(chat): auto-detect text direction for RTL languages (#729)
Add dir="auto" to chat message content and composer textarea so
Persian and Arabic text automatically renders right-to-left
while English and other LTR text remains unaffected.

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-04 22:24:07 +03:00
Vojtech
2edfef2e3f fix(websocket): add 30s server-side heartbeat to prevent proxy idle disconnects (#770)
The WebSocket gateway never sent ping frames, so any reverse proxy with
an idle timeout (Cloudflare Tunnel ~100s, AWS ALB 60s, nginx 60s, etc.)
would silently tear down /shell, /ws and /plugin-ws/* connections after
the idle window. The UI reconnects automatically but users see a
"Connecting to shell" toast every 1–3 minutes during normal use and any
in-flight PTY/chat traffic can race the reconnect.

Schedule a 30s ws.ping() per connection at the gateway level, cleared on
close/error. ping/pong counts as protocol activity for all proxies that
implement WebSocket correctly, so this single change covers every
deployment topology without per-proxy tuning.

Fixes #769

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-04 22:07:59 +03:00
ehsanmim
96b16b42e4 fix(vite): proxy /plugin-ws WebSocket requests to the backend in dev (#757)
Plugin WebSocket connections (e.g. the official Terminal plugin) hang
in `npm run dev` because Vite proxies /api, /ws, and /shell but not
/plugin-ws/*. Production is unaffected because the same Express server
serves both the frontend and the WS gateway.

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-04 20:57:24 +03:00
Peter Buchegger
f082cdc63b fix(websocket): reset unmountedRef on each effect re-run so token refresh reconnects (#721)
The effect cleanup sets unmountedRef.current = true to prevent reconnects after
the provider unmounts. Without an inverse reset at the start of the effect,
re-running the effect (e.g. when the auth token rotates) leaves the ref true,
and connect() short-circuits at its unmounted guard. The socket then stays
permanently disconnected for the lifetime of the provider.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-04 20:50:02 +03:00
Haileyesus
a9fa6eb6b6 fix(file-tree): inspect entries with lstat
Use lstat for file-tree metadata so symlink entries are identified without following targets.
2026-06-04 17:33:58 +03:00
Haileyesus
37d363c1aa fix(file-tree): bound filesystem traversal concurrency
Prevent large file-tree scans from launching unbounded stat and readdir work.

Keep the parallel traversal benefit on high-latency mounts with a bounded queue.

Ignore skipped names only for directories so same-named files stay visible.
2026-06-04 17:07:41 +03:00
Haile
4658a97952 Merge branch 'main' into perf/parallel-file-tree 2026-06-04 13:51:22 +03:00
Haile
d9e9df183f fix: plugin svg icon sanitization (#817)
* fix(security)(components): unsanitized svg content injected via `dangerouslys

The plugin icon renderer fetches SVG text from `/api/plugins/.../assets/...` and injects it directly into the DOM using `dangerouslySetInnerHTML` after only checking that the payload starts with `<svg`. This does not remove malicious attributes/elements (e.g., event handlers, scriptable SVG payloads), enabling DOM-based XSS if a plugin asset is malicious or compromised.

Affected files: PluginIcon.tsx

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>

* fix: sanitize plugin svg icons

---------

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>
Co-authored-by: tuanaiseo <tuanaiseo@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-02 13:24:38 +02:00
Haile
43c33d5cb1 fix: recognize claude auth token env (#818) 2026-06-02 13:23:30 +02:00
Haile
137c7c4f3c Merge branch 'main' into perf/parallel-file-tree 2026-05-04 13:04:07 +03:00
leonkong via Claude
153f1e54b4 perf(file-tree): parallelize directory traversal and widen default ignore list
The project file-tree endpoint walked children sequentially with
`await fsPromises.stat()` inside a for-loop plus a separate
`fsPromises.access()` probe before recursing. On high-latency
filesystems (NFS/SMB) every one of those round-trips was serialized,
so a 120k-file SMB-mounted project took ~2 minutes to load.

This change:
* Runs stat() and recursive getFileTree() calls in parallel via
  `Promise.all` — pipelines round-trips and lets subtree traversals
  overlap.
* Drops the redundant access() probe; any EACCES now surfaces from
  readdir's own try/catch in the recursive call, saving one RTT per
  directory.
* Extracts the hardcoded skip list into an IGNORED_DIRS Set and
  extends it to cover common Python / Rust / JVM / IDE build
  artefacts (.next, __pycache__, .pytest_cache, .tox, .venv,
  target, .gradle, .idea, coverage, etc).

No API shape change; existing consumers get the same tree structure,
only much faster on large or remote-mounted projects.
2026-04-18 16:57:07 +08:00
19 changed files with 483 additions and 732 deletions

788
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,7 @@
"author": "CloudCLI UI Contributors", "author": "CloudCLI UI Contributors",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116", "@anthropic-ai/claude-agent-sdk": "^0.3.165",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
@@ -96,6 +96,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",

View File

@@ -11,7 +11,7 @@ export const CLAUDE_MODELS = {
{ {
value: "default", value: "default",
label: "Default (recommended)", label: "Default (recommended)",
description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok", description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
}, },
{ {
value: "sonnet", value: "sonnet",

View File

@@ -1483,25 +1483,76 @@ 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
const items = []; let entries;
try { try {
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true }); await acquire();
try {
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
} finally {
release();
}
} catch (error) {
// Only log non-permission errors to avoid spam
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
console.error('Error reading directory:', error);
}
return [];
}
for (const entry of entries) { const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
// Debug: log all entries including hidden files
// 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;
// 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 itemPath = path.join(dirPath, entry.name);
const item = { const item = {
name: entry.name, name: entry.name,
@@ -1511,17 +1562,33 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
// Get file stats for additional metadata // Get file stats for additional metadata
try { try {
const stats = await fsPromises.stat(itemPath); await acquire();
try {
const stats = await fsPromises.lstat(itemPath);
item.size = stats.size; item.size = stats.size;
item.modified = stats.mtime.toISOString(); item.modified = stats.mtime.toISOString();
// Mark symlinks so UI can distinguish them
if (stats.isSymbolicLink()) {
item.isSymlink = true;
}
// Convert permissions to rwx format // Convert permissions to rwx format
const mode = stats.mode; const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7; const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7; const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7; const otherPerm = mode & 7;
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString(); item.permissions =
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm); ((mode >> 6) & 7).toString() +
((mode >> 3) & 7).toString() +
(mode & 7).toString();
item.permissionsRwx =
permToRwx(ownerPerm) +
permToRwx(groupPerm) +
permToRwx(otherPerm);
} finally {
release();
}
} catch (statError) { } catch (statError) {
// If stat fails, provide default values // If stat fails, provide default values
item.size = 0; item.size = 0;
@@ -1531,25 +1598,17 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
} }
if (entry.isDirectory() && currentDepth < maxDepth) { if (entry.isDirectory() && currentDepth < maxDepth) {
// Recursively get subdirectories but limit depth // Recurse. Let readdir's own EACCES bubble up through the catch in
try { // the recursive call rather than doing a separate access() probe
// Check if we can access the directory before trying to read it // (which doubled the round-trip count on SMB without adding info).
await fsPromises.access(item.path, fs.constants.R_OK); // The recursive call starts with a bounded readdir; holding a permit
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden); // for the whole subtree can deadlock when sibling directories are
} catch (e) { // waiting on their own children.
// Silently skip directories we can't access (permission denied, etc.) item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
item.children = [];
}
} }
items.push(item); return item;
} }));
} catch (error) {
// Only log non-permission errors to avoid spam
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
console.error('Error reading directory:', error);
}
}
return items.sort((a, b) => { return items.sort((a, b) => {
if (a.type !== b.type) { if (a.type !== b.type) {

View File

@@ -83,6 +83,10 @@ export class ClaudeProviderAuth implements IProviderAuth {
private async checkCredentials(): Promise<ClaudeCredentialsStatus> { private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.'; const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) {
return { authenticated: true, email: 'Auth Token', method: 'api_key' };
}
if (process.env.ANTHROPIC_API_KEY?.trim()) { if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
} }

View File

@@ -18,6 +18,7 @@ type ShellIncomingMessage = {
provider?: string; provider?: string;
initialCommand?: string; initialCommand?: string;
isPlainShell?: boolean; isPlainShell?: boolean;
forceRestart?: boolean;
}; };
type PtySessionEntry = { type PtySessionEntry = {
@@ -180,6 +181,7 @@ 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) ||
@@ -200,7 +202,7 @@ export function handleShellConnection(
: ''; : '';
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`; ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
if (isLoginCommand) { if (isLoginCommand || forceRestart) {
const oldSession = ptySessionsMap.get(ptySessionKey); const oldSession = ptySessionsMap.get(ptySessionKey);
if (oldSession) { if (oldSession) {
if (oldSession.timeoutId) { if (oldSession.timeoutId) {
@@ -211,7 +213,8 @@ export function handleShellConnection(
} }
} }
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey); const existingSession =
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
if (existingSession) { if (existingSession) {
shellProcess = existingSession.pty; shellProcess = existingSession.pty;
if (existingSession.timeoutId) { if (existingSession.timeoutId) {
@@ -368,6 +371,10 @@ 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({
@@ -451,6 +458,10 @@ 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

@@ -31,6 +31,24 @@ export function createWebSocketServer(
}); });
wss.on('connection', (ws, request) => { wss.on('connection', (ws, request) => {
// Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
// AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
// are silently torn down even when the UI is active, causing repeated
// reconnect cycles. ws library heartbeat is opt-in.
const HEARTBEAT_INTERVAL_MS = 30_000;
const heartbeat = setInterval(() => {
if (ws.readyState === ws.OPEN) {
try {
ws.ping();
} catch {
// socket may have been closed concurrently — interval will be cleared below
}
}
}, HEARTBEAT_INTERVAL_MS);
const stopHeartbeat = () => clearInterval(heartbeat);
ws.on('close', stopHeartbeat);
ws.on('error', stopHeartbeat);
const incomingRequest = request as AuthenticatedWebSocketRequest; const incomingRequest = request as AuthenticatedWebSocketRequest;
const url = incomingRequest.url ?? '/'; const url = incomingRequest.url ?? '/';
const pathname = new URL(url, 'http://localhost').pathname; const pathname = new URL(url, 'http://localhost').pathname;

View File

@@ -295,6 +295,7 @@ export default function ChatComposer({
<PromptInputTextarea <PromptInputTextarea
ref={textareaRef} ref={textareaRef}
dir="auto"
value={input} value={input}
onChange={onInputChange} onChange={onInputChange}
onClick={onTextareaClick} onClick={onTextareaClick}

View File

@@ -120,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
/* User message bubble on the right */ /* User message bubble on the right */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl"> <div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4"> <div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div className="whitespace-pre-wrap break-words text-sm"> <div dir="auto" className="whitespace-pre-wrap break-words text-sm">
{message.content} {message.content}
</div> </div>
{message.images && message.images.length > 0 && ( {message.images && message.images.length > 0 && (
@@ -405,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</ReasoningContent> </ReasoningContent>
</Reasoning> </Reasoning>
) : ( ) : (
<div className="text-sm text-gray-700 dark:text-gray-300"> <div dir="auto" className="text-sm text-gray-700 dark:text-gray-300">
{/* Reasoning accordion */} {/* Reasoning accordion */}
{showThinking && message.reasoning && ( {showThinking && message.reasoning && (
<Reasoning className="mb-3" defaultOpen={false}> <Reasoning className="mb-3" defaultOpen={false}>

View File

@@ -321,6 +321,7 @@ export default function ProviderSelectionEmptyState({
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60"> <p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
<Trans <Trans
ns="chat"
i18nKey="providerSelection.pressToSearch" i18nKey="providerSelection.pressToSearch"
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }} values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
components={{ components={{

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
type Props = { type Props = {
@@ -10,6 +12,48 @@ type Props = {
// Module-level cache so repeated renders don't re-fetch // Module-level cache so repeated renders don't re-fetch
const svgCache = new Map<string, string>(); const svgCache = new Map<string, string>();
const FORBIDDEN_SVG_TAGS = [
'script',
'foreignObject',
'iframe',
'object',
'embed',
'link',
'meta',
'style',
'animate',
'set',
'animateTransform',
'animateMotion',
];
const FORBIDDEN_SVG_ATTRS = [
'href',
'xlink:href',
'src',
'style',
];
function sanitizeSvg(svgText: string): string | null {
const sanitized = DOMPurify.sanitize(svgText, {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: FORBIDDEN_SVG_TAGS,
FORBID_ATTR: FORBIDDEN_SVG_ATTRS,
});
if (!sanitized) return null;
try {
const doc = new DOMParser().parseFromString(sanitized, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.nodeName.toLowerCase() !== 'svg') return null;
if (doc.querySelector('parsererror')) return null;
return sanitized;
} catch {
return null;
}
}
export default function PluginIcon({ pluginName, iconFile, className }: Props) { export default function PluginIcon({ pluginName, iconFile, className }: Props) {
const url = iconFile const url = iconFile
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}` ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
@@ -24,9 +68,11 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
return r.text(); return r.text();
}) })
.then((text) => { .then((text) => {
if (text && text.trimStart().startsWith('<svg')) { if (!text) return;
svgCache.set(url, text); const sanitized = sanitizeSvg(text);
setSvg(text); if (sanitized) {
svgCache.set(url, sanitized);
setSvg(sanitized);
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -35,10 +81,6 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
if (!svg) return <span className={className} />; if (!svg) return <span className={className} />;
return ( return (
<span <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />
className={className}
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
dangerouslySetInnerHTML={{ __html: svg }}
/>
); );
} }

View File

@@ -2,6 +2,7 @@ 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';
@@ -31,8 +32,8 @@ type UseShellConnectionResult = {
isConnected: boolean; isConnected: boolean;
isConnecting: boolean; isConnecting: boolean;
closeSocket: () => void; closeSocket: () => void;
connectToShell: () => void; connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: () => void; disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
}; };
export function useShellConnection({ export function useShellConnection({
@@ -54,6 +55,8 @@ 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) => {
@@ -141,6 +144,8 @@ export function useShellConnection({
} }
currentFitAddon.fit(); currentFitAddon.fit();
const forceRestart = forceRestartOnInitRef.current;
forceRestartOnInitRef.current = false;
sendSocketMessage(socket, { sendSocketMessage(socket, {
type: 'init', type: 'init',
@@ -152,6 +157,7 @@ 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);
}; };
@@ -177,6 +183,7 @@ export function useShellConnection({
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
forceRestartOnInitRef.current = false;
} }
}, },
[ [
@@ -195,27 +202,40 @@ export function useShellConnection({
], ],
); );
const connectToShell = useCallback(() => { const connectToShell = useCallback((options?: { forceRestart?: boolean }) => {
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(() => { const disconnectFromShell = useCallback((options?: { suppressAutoConnect?: boolean }) => {
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 (!autoConnect || !isInitialized || isConnecting || isConnected) { if (
!autoConnect ||
suppressAutoConnectRef.current ||
!isInitialized ||
isConnecting ||
isConnected
) {
return; return;
} }

View File

@@ -1,6 +1,7 @@
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';
@@ -15,6 +16,7 @@ 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 = {
@@ -69,8 +71,8 @@ export type UseShellRuntimeResult = {
isConnecting: boolean; isConnecting: boolean;
authUrl: string; authUrl: string;
authUrlVersion: number; authUrlVersion: number;
connectToShell: () => void; connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: () => void; disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
openAuthUrlInBrowser: (url?: string) => boolean; openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>; copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
}; };

View File

@@ -1,5 +1,6 @@
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 {
@@ -13,6 +14,7 @@ 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';
@@ -46,6 +48,8 @@ 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 {
@@ -140,6 +144,7 @@ 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);
}; };
}, []); }, []);
@@ -190,12 +195,42 @@ export default function Shell({
); );
const handleRestartShell = useCallback(() => { const handleRestartShell = useCallback(() => {
restartAfterInitRef.current = true;
setIsRestarting(true); setIsRestarting(true);
window.setTimeout(() => { if (restartTimerRef.current) {
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
@@ -254,7 +289,7 @@ export default function Shell({
isRestarting={isRestarting} isRestarting={isRestarting}
hasSession={Boolean(selectedSession)} hasSession={Boolean(selectedSession)}
sessionDisplayNameShort={sessionDisplayNameShort} sessionDisplayNameShort={sessionDisplayNameShort}
onDisconnect={disconnectFromShell} onDisconnect={handleDisconnectShell}
onRestart={handleRestartShell} onRestart={handleRestartShell}
statusNewSessionText={t('shell.status.newSession')} statusNewSessionText={t('shell.status.newSession')}
statusInitializingText={t('shell.status.initializing')} statusInitializingText={t('shell.status.initializing')}
@@ -263,7 +298,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 || isConnected} disableRestart={isRestarting || !isInitialized}
/> />
<div className="relative flex-1 overflow-hidden p-2"> <div className="relative flex-1 overflow-hidden p-2">
@@ -281,7 +316,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={connectToShell} onConnect={handleRestartShell}
/> />
)} )}

View File

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

View File

@@ -1,3 +1,5 @@
import { RotateCcw, X } from 'lucide-react';
type ShellHeaderProps = { type ShellHeaderProps = {
isConnected: boolean; isConnected: boolean;
isInitialized: boolean; isInitialized: boolean;
@@ -50,34 +52,27 @@ 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 space-x-3"> <div className="flex items-center gap-2">
{isConnected && ( {isConnected && (
<button <button
type="button"
onClick={onDisconnect} onClick={onDisconnect}
className="flex items-center space-x-1 rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700" 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"
title={disconnectTitle} title={disconnectTitle}
> >
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <X className="h-3.5 w-3.5" aria-hidden="true" />
<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="flex items-center space-x-1 text-xs text-gray-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50" 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"
title={restartTitle} title={restartTitle}
> >
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <RotateCcw className={`h-3.5 w-3.5 ${isRestarting ? 'animate-spin' : ''}`} aria-hidden="true" />
<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

@@ -36,6 +36,10 @@ const useWebSocketProviderState = (): WebSocketContextType => {
const { token } = useAuth(); const { token } = useAuth();
useEffect(() => { useEffect(() => {
// The cleanup below sets unmountedRef = true. Without this reset, every
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
// at its unmounted guard and leave the socket permanently disconnected.
unmountedRef.current = false;
connect(); connect();
return () => { return () => {

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 (disconnect first)", "restartTitle": "Restart Shell",
"connect": "Continue in Shell", "connect": "Continue in Shell",
"connectTitle": "Connect to shell" "connectTitle": "Connect to shell"
}, },

View File

@@ -37,6 +37,10 @@ export default defineConfig(({ mode }) => {
'/shell': { '/shell': {
target: `ws://${proxyHost}:${serverPort}`, target: `ws://${proxyHost}:${serverPort}`,
ws: true ws: true
},
'/plugin-ws': {
target: `ws://${proxyHost}:${serverPort}`,
ws: true
} }
} }
}, },