Compare commits

...

11 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
Simos Mikelatos
c667b6a179 Update model version in OPTIONS description 2026-06-04 23:43:48 +02: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
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
viper151
b988e0da51 chore(release): v1.33.0 2026-06-01 20:57:51 +00:00
Haile
f132a21cd7 Fix/router basename root prefix (#815)
* fix: harden router basename detection

* fix: broaden icon basename detection

* fix: ignore cross-origin basename hints

* fix: keep root deployments from inheriting asset basenames

Router basename detection must support root hosting and path-prefix hosting at runtime.

The icon fallback used /icons/icon-192x192.png as a basename on root deployments.

After login, React Router mounted at /icons while the current URL was /.

That mismatch made authenticated root deployments render a blank page.

Strip known asset directories even when they are the only path segment.

Root icon URLs now keep basename ''. Prefixed /ai/icons/... URLs still resolve to /ai.

---------

Co-authored-by: JohnGenri <myname945@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-01 22:45:57 +02:00
13 changed files with 373 additions and 636 deletions

View File

@@ -3,6 +3,25 @@
All notable changes to CloudCLI UI will be documented in this file.
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
### New Features
* add opencode support ([#762](https://github.com/siteboon/claudecodeui/issues/762)) ([374e9de](https://github.com/siteboon/claudecodeui/commit/374e9de71934c41ce2c19c796e35a19234b240ec))
* **sidebar:** tooltip for the active-session indicator dot ([#782](https://github.com/siteboon/claudecodeui/issues/782)) ([27e509a](https://github.com/siteboon/claudecodeui/commit/27e509a9b8bb25c35ae0abbda44c536e15c332c8))
### Bug Fixes
* **chat:** prevent double send on mobile by removing redundant submit handlers ([#719](https://github.com/siteboon/claudecodeui/issues/719)) ([dbc41dc](https://github.com/siteboon/claudecodeui/commit/dbc41dc91dbf1fb54f92f5536d64646b4e924f31))
* preserve WebSocket frame type in plugin proxy ([#594](https://github.com/siteboon/claudecodeui/issues/594)) ([36b860e](https://github.com/siteboon/claudecodeui/commit/36b860e322454df62ebf5309018590b596e6b913)), closes [CoderLuii/HolyClaude#11](https://github.com/CoderLuii/HolyClaude/issues/11)
* refine token usage reporting ([#807](https://github.com/siteboon/claudecodeui/issues/807)) ([38bf21d](https://github.com/siteboon/claudecodeui/commit/38bf21ddf554ed28676d86b5221c25adf6f07afd))
* refresh Claude auth status after login flow ([#617](https://github.com/siteboon/claudecodeui/issues/617)) ([1e125f3](https://github.com/siteboon/claudecodeui/commit/1e125f3db5248399cd50dc3d40b1f8f44cf7ccb6))
* **sidebar:** keep session rename input visible while editing ([#781](https://github.com/siteboon/claudecodeui/issues/781)) ([951f587](https://github.com/siteboon/claudecodeui/commit/951f58751c152fbbb3f8b3ce3c814c06c061de18))
### Styling
* fix project star button location by replacing folder icon ([#793](https://github.com/siteboon/claudecodeui/issues/793)) ([295bad9](https://github.com/siteboon/claudecodeui/commit/295bad9c006b669878cbf52940794f29f7370178))
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
### Bug Fixes

792
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.32.0",
"version": "1.33.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",
@@ -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",

View File

@@ -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",

View File

@@ -83,6 +83,10 @@ export class ClaudeProviderAuth implements IProviderAuth {
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
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()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}

View File

@@ -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;

View File

@@ -1,5 +1,6 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext';
@@ -9,7 +10,99 @@ import { PluginsProvider } from './contexts/PluginsContext';
import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js';
const DEPLOYMENT_ASSET_DIRECTORIES = new Set(['assets', 'static', 'icons', 'images']);
/**
* Detect the router basename from explicit runtime config or deployment hints.
*
* CloudCLI can be served from a path prefix by a reverse proxy, for example:
* /ai/manifest.json
* /ai/assets/index-abc123.js
* /ai/icons/icon-192x192.png
*
* React Router needs that prefix as its basename, but the packaged app should
* also keep working when served directly from the domain root. The direct-root
* case is easy to misread because asset URLs such as /icons/icon-192x192.png
* contain a directory even though there is no application basename.
*/
function detectRouterBasename() {
const explicitBasename = typeof window !== 'undefined' ? window.__ROUTER_BASENAME__ || '' : '';
if (explicitBasename) {
// Keep the deployment escape hatch authoritative. A trailing slash is
// harmless for humans but React Router expects a normalized basename.
return explicitBasename.replace(/\/+$/, '');
}
if (typeof window === 'undefined' || typeof document === 'undefined') {
return '';
}
const candidatePaths = [
{ kind: 'manifest' as const, value: document.querySelector('link[rel="manifest"]')?.getAttribute('href') },
{ kind: 'script' as const, value: document.querySelector('script[type="module"][src]')?.getAttribute('src') },
...Array.from(
document.querySelectorAll(
'link[rel~="icon"][href], link[rel="apple-touch-icon"][href], link[rel="apple-touch-icon-precomposed"][href], link[rel="mask-icon"][href]'
)
).map((node) => ({
kind: 'icon' as const,
value: node.getAttribute('href'),
})),
].filter((candidate): candidate is { kind: 'manifest' | 'script' | 'icon'; value: string } => Boolean(candidate.value));
let detectedBasename = '';
for (const candidate of candidatePaths) {
try {
const candidateUrl = new URL(candidate.value, document.baseURI || window.location.href);
if (candidateUrl.origin !== window.location.origin) {
continue;
}
const pathname = candidateUrl.pathname;
const normalizedPathname = pathname.replace(/\/+$/, '');
let normalized = '';
if (candidate.kind === 'script') {
const match = normalizedPathname.match(/^(.*)\/assets\//);
normalized = match?.[1] ? match[1].replace(/\/+$/, '') : '';
} else {
const manifestMatch = normalizedPathname.match(/^(.*)\/(?:manifest\.json|site\.webmanifest)$/);
const iconMatch = normalizedPathname.match(
/^(.*)\/(?:favicon(?:\.[^/]+)?|apple-touch-icon(?:-[^/]+)?(?:\.[^/]+)?|mask-icon(?:\.[^/]+)?|[^/]*icon[^/]*)$/
);
const match = candidate.kind === 'manifest' ? manifestMatch : iconMatch;
if (match?.[1]) {
const segments = match[1].split('/').filter(Boolean);
// Strip directories that describe where static files live, not where
// the app is mounted. This must also run for a single segment:
// /icons/icon-192x192.png -> ''
// /ai/icons/icon-192x192.png -> '/ai'
// The previous implementation only stripped while more than one
// segment remained, which incorrectly turned root deployments into a
// Router basename of /icons and caused a blank page after login.
while (segments.length > 0 && DEPLOYMENT_ASSET_DIRECTORIES.has(segments[segments.length - 1])) {
segments.pop();
}
normalized = segments.length > 0 ? `/${segments.join('/')}` : '';
}
}
if (normalized.length > detectedBasename.length) {
detectedBasename = normalized;
}
} catch {
// Ignore invalid candidate URLs and continue checking other hints.
}
}
return detectedBasename;
}
export default function App() {
const routerBasename = detectRouterBasename();
return (
<I18nextProvider i18n={i18n}>
<ThemeProvider>
@@ -19,7 +112,7 @@ export default function App() {
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router basename={window.__ROUTER_BASENAME__ || ''}>
<Router basename={routerBasename}>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />

View File

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

View File

@@ -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}>

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">
<Trans
ns="chat"
i18nKey="providerSelection.pressToSearch"
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
components={{

View File

@@ -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 }} />
);
}

View File

@@ -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) {

View File

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