mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-06 04:55:45 +08:00
Compare commits
10 Commits
fix/claude
...
fix/restar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a14aa08d8 | ||
|
|
9e608b8426 | ||
|
|
7704fde228 | ||
|
|
c667b6a179 | ||
|
|
fa9eaf5573 | ||
|
|
2edfef2e3f | ||
|
|
96b16b42e4 | ||
|
|
f082cdc63b | ||
|
|
d9e9df183f | ||
|
|
43c33d5cb1 |
788
package-lock.json
generated
788
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,7 @@
|
||||
"author": "CloudCLI UI Contributors",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"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-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -96,6 +96,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"dompurify": "^3.4.7",
|
||||
"express": "^4.18.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
|
||||
@@ -11,7 +11,7 @@ export const CLAUDE_MODELS = {
|
||||
{
|
||||
value: "default",
|
||||
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",
|
||||
|
||||
@@ -18,6 +18,7 @@ type ShellIncomingMessage = {
|
||||
provider?: string;
|
||||
initialCommand?: string;
|
||||
isPlainShell?: boolean;
|
||||
forceRestart?: boolean;
|
||||
};
|
||||
|
||||
type PtySessionEntry = {
|
||||
@@ -180,6 +181,7 @@ export function handleShellConnection(
|
||||
const hasSession = readBoolean(data.hasSession);
|
||||
const provider = readString(data.provider, 'claude');
|
||||
const initialCommand = readString(data.initialCommand);
|
||||
const forceRestart = readBoolean(data.forceRestart);
|
||||
const isPlainShell =
|
||||
readBoolean(data.isPlainShell) ||
|
||||
(!!initialCommand && !hasSession) ||
|
||||
@@ -200,7 +202,7 @@ export function handleShellConnection(
|
||||
: '';
|
||||
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
|
||||
|
||||
if (isLoginCommand) {
|
||||
if (isLoginCommand || forceRestart) {
|
||||
const oldSession = ptySessionsMap.get(ptySessionKey);
|
||||
if (oldSession) {
|
||||
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) {
|
||||
shellProcess = existingSession.pty;
|
||||
if (existingSession.timeoutId) {
|
||||
@@ -368,6 +371,10 @@ export function handleShellConnection(
|
||||
}
|
||||
|
||||
const session = ptySessionsMap.get(ptySessionKey);
|
||||
if (session && session.pty !== shellProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
@@ -451,6 +458,10 @@ export function handleShellConnection(
|
||||
|
||||
session.ws = null;
|
||||
session.timeoutId = setTimeout(() => {
|
||||
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.pty.kill();
|
||||
ptySessionsMap.delete(ptySessionKey as string);
|
||||
}, PTY_SESSION_TIMEOUT);
|
||||
|
||||
@@ -31,6 +31,24 @@ export function createWebSocketServer(
|
||||
});
|
||||
|
||||
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 url = incomingRequest.url ?? '/';
|
||||
const pathname = new URL(url, 'http://localhost').pathname;
|
||||
|
||||
@@ -295,6 +295,7 @@ export default function ChatComposer({
|
||||
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
value={input}
|
||||
onChange={onInputChange}
|
||||
onClick={onTextareaClick}
|
||||
|
||||
@@ -120,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
/* 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="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}
|
||||
</div>
|
||||
{message.images && message.images.length > 0 && (
|
||||
@@ -405,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
</ReasoningContent>
|
||||
</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 */}
|
||||
{showThinking && message.reasoning && (
|
||||
<Reasoning className="mb-3" defaultOpen={false}>
|
||||
|
||||
@@ -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">
|
||||
<Trans
|
||||
ns="chat"
|
||||
i18nKey="providerSelection.pressToSearch"
|
||||
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
|
||||
components={{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
type Props = {
|
||||
@@ -10,6 +12,48 @@ type Props = {
|
||||
// Module-level cache so repeated renders don't re-fetch
|
||||
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) {
|
||||
const url = iconFile
|
||||
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
||||
@@ -24,9 +68,11 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||
return r.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (text && text.trimStart().startsWith('<svg')) {
|
||||
svgCache.set(url, text);
|
||||
setSvg(text);
|
||||
if (!text) return;
|
||||
const sanitized = sanitizeSvg(text);
|
||||
if (sanitized) {
|
||||
svgCache.set(url, sanitized);
|
||||
setSvg(sanitized);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -35,10 +81,6 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||
if (!svg) return <span className={className} />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
<span className={className} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
|
||||
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
|
||||
@@ -31,8 +32,8 @@ type UseShellConnectionResult = {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
closeSocket: () => void;
|
||||
connectToShell: () => void;
|
||||
disconnectFromShell: () => void;
|
||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||
};
|
||||
|
||||
export function useShellConnection({
|
||||
@@ -54,6 +55,8 @@ export function useShellConnection({
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const connectingRef = useRef(false);
|
||||
const forceRestartOnInitRef = useRef(false);
|
||||
const suppressAutoConnectRef = useRef(false);
|
||||
|
||||
const handleProcessCompletion = useCallback(
|
||||
(output: string) => {
|
||||
@@ -141,6 +144,8 @@ export function useShellConnection({
|
||||
}
|
||||
|
||||
currentFitAddon.fit();
|
||||
const forceRestart = forceRestartOnInitRef.current;
|
||||
forceRestartOnInitRef.current = false;
|
||||
|
||||
sendSocketMessage(socket, {
|
||||
type: 'init',
|
||||
@@ -152,6 +157,7 @@ export function useShellConnection({
|
||||
rows: currentTerminal.rows,
|
||||
initialCommand: initialCommandRef.current,
|
||||
isPlainShell: isPlainShellRef.current,
|
||||
forceRestart,
|
||||
});
|
||||
}, TERMINAL_INIT_DELAY_MS);
|
||||
};
|
||||
@@ -177,6 +183,7 @@ export function useShellConnection({
|
||||
setIsConnected(false);
|
||||
setIsConnecting(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) {
|
||||
return;
|
||||
}
|
||||
|
||||
forceRestartOnInitRef.current = Boolean(options?.forceRestart);
|
||||
suppressAutoConnectRef.current = false;
|
||||
connectingRef.current = true;
|
||||
setIsConnecting(true);
|
||||
connectWebSocket(true);
|
||||
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
|
||||
|
||||
const disconnectFromShell = useCallback(() => {
|
||||
const disconnectFromShell = useCallback((options?: { suppressAutoConnect?: boolean }) => {
|
||||
if (options?.suppressAutoConnect) {
|
||||
suppressAutoConnectRef.current = true;
|
||||
}
|
||||
|
||||
closeSocket();
|
||||
clearTerminalScreen();
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
connectingRef.current = false;
|
||||
forceRestartOnInitRef.current = false;
|
||||
setAuthUrl('');
|
||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoConnect || !isInitialized || isConnecting || isConnected) {
|
||||
if (
|
||||
!autoConnect ||
|
||||
suppressAutoConnectRef.current ||
|
||||
!isInitialized ||
|
||||
isConnecting ||
|
||||
isConnected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MutableRefObject, RefObject } from 'react';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
|
||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
||||
@@ -15,6 +16,7 @@ export type ShellInitMessage = {
|
||||
rows: number;
|
||||
initialCommand: string | null | undefined;
|
||||
isPlainShell: boolean;
|
||||
forceRestart?: boolean;
|
||||
};
|
||||
|
||||
export type ShellResizeMessage = {
|
||||
@@ -69,8 +71,8 @@ export type UseShellRuntimeResult = {
|
||||
isConnecting: boolean;
|
||||
authUrl: string;
|
||||
authUrlVersion: number;
|
||||
connectToShell: () => void;
|
||||
disconnectFromShell: () => void;
|
||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { useShellRuntime } from '../hooks/useShellRuntime';
|
||||
import { sendSocketMessage } from '../utils/socket';
|
||||
import { getSessionDisplayName } from '../utils/auth';
|
||||
|
||||
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
|
||||
import ShellEmptyState from './subcomponents/ShellEmptyState';
|
||||
import ShellHeader from './subcomponents/ShellHeader';
|
||||
@@ -46,6 +48,8 @@ export default function Shell({
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | 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 {
|
||||
@@ -140,6 +144,7 @@ export default function Shell({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
|
||||
if (restartTimerRef.current) clearTimeout(restartTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -190,12 +195,42 @@ export default function Shell({
|
||||
);
|
||||
|
||||
const handleRestartShell = useCallback(() => {
|
||||
restartAfterInitRef.current = true;
|
||||
setIsRestarting(true);
|
||||
window.setTimeout(() => {
|
||||
if (restartTimerRef.current) {
|
||||
clearTimeout(restartTimerRef.current);
|
||||
}
|
||||
restartTimerRef.current = setTimeout(() => {
|
||||
setIsRestarting(false);
|
||||
restartTimerRef.current = null;
|
||||
}, 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) {
|
||||
return (
|
||||
<ShellEmptyState
|
||||
@@ -254,7 +289,7 @@ export default function Shell({
|
||||
isRestarting={isRestarting}
|
||||
hasSession={Boolean(selectedSession)}
|
||||
sessionDisplayNameShort={sessionDisplayNameShort}
|
||||
onDisconnect={disconnectFromShell}
|
||||
onDisconnect={handleDisconnectShell}
|
||||
onRestart={handleRestartShell}
|
||||
statusNewSessionText={t('shell.status.newSession')}
|
||||
statusInitializingText={t('shell.status.initializing')}
|
||||
@@ -263,7 +298,7 @@ export default function Shell({
|
||||
disconnectTitle={t('shell.actions.disconnectTitle')}
|
||||
restartLabel={t('shell.actions.restart')}
|
||||
restartTitle={t('shell.actions.restartTitle')}
|
||||
disableRestart={isRestarting || isConnected}
|
||||
disableRestart={isRestarting || !isInitialized}
|
||||
/>
|
||||
|
||||
<div className="relative flex-1 overflow-hidden p-2">
|
||||
@@ -281,7 +316,7 @@ export default function Shell({
|
||||
connectLabel={t('shell.actions.connect')}
|
||||
connectTitle={t('shell.actions.connectTitle')}
|
||||
connectingLabel={t('shell.connecting')}
|
||||
onConnect={connectToShell}
|
||||
onConnect={handleRestartShell}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Loader2, RotateCcw } from 'lucide-react';
|
||||
|
||||
type ShellConnectionOverlayProps = {
|
||||
mode: 'loading' | 'connect' | 'connecting';
|
||||
description: string;
|
||||
@@ -19,40 +21,42 @@ export default function ShellConnectionOverlay({
|
||||
}: ShellConnectionOverlayProps) {
|
||||
if (mode === 'loading') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||
<div className="text-white">{loadingLabel}</div>
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'connect') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>{connectLabel}</span>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">{connectLabel}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
|
||||
<div className="flex items-center justify-center gap-3 text-yellow-300">
|
||||
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
|
||||
<span className="text-base font-medium">{connectingLabel}</span>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RotateCcw, X } from 'lucide-react';
|
||||
|
||||
type ShellHeaderProps = {
|
||||
isConnected: boolean;
|
||||
isInitialized: boolean;
|
||||
@@ -50,34 +52,27 @@ export default function ShellHeader({
|
||||
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected && (
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{disconnectLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
<RotateCcw className={`h-3.5 w-3.5 ${isRestarting ? 'animate-spin' : ''}`} aria-hidden="true" />
|
||||
<span>{restartLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -36,8 +36,12 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
||||
const { token } = useAuth();
|
||||
|
||||
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();
|
||||
|
||||
|
||||
return () => {
|
||||
unmountedRef.current = true;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"disconnect": "Disconnect",
|
||||
"disconnectTitle": "Disconnect from shell",
|
||||
"restart": "Restart",
|
||||
"restartTitle": "Restart Shell (disconnect first)",
|
||||
"restartTitle": "Restart Shell",
|
||||
"connect": "Continue in Shell",
|
||||
"connectTitle": "Connect to shell"
|
||||
},
|
||||
|
||||
@@ -37,6 +37,10 @@ export default defineConfig(({ mode }) => {
|
||||
'/shell': {
|
||||
target: `ws://${proxyHost}:${serverPort}`,
|
||||
ws: true
|
||||
},
|
||||
'/plugin-ws': {
|
||||
target: `ws://${proxyHost}:${serverPort}`,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user