Compare commits

..

5 Commits

Author SHA1 Message Date
Simos Mikelatos
fcb37edd02 Merge branch 'main' into refactor/remove-unused-whisper-transcribe-logic 2026-04-10 15:32:59 +02:00
Haileyesus
91954daedd refactor: remove unused whispher transcribe logic 2026-04-10 16:24:33 +03:00
simosmik
2207d05c1c feat: add branding, community links, GitHub star badge, and About settings tab 2026-04-10 13:06:16 +00:00
Haile
a8dab0edcf fix(ui): remove mobile bottom nav, unify processing indicator, and improve tooltip behavior on mobile (#632)
* fix: update tooltip component

* fix: remove the mobile navigation component

In addition,
- the sidebar is also updated to take full space
- the terminal shortcuts in shell are updated to not interfere with the
shell content.

* fix: remove mobile nav component

* fix: remove "Thinking..." indicator

In addition, the claude status component has been restyled to be more
compact and less obtrusive.
- The type and prop arguments for ChatMessagesPane have been updated to
remove the isLoading prop, which was only used to control the display of
 the AssistantThinkingIndicator.

* fix: show elapsed time only when loading

---------

Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-04-10 12:36:06 +02:00
Haile
e61f8a543d fix: corrupted binary downloads (#634)
- The existing setup was using the text reader endpoint for downloading
files `fsPromises.readFile(..., 'utf8')` at line 801. This was incorrect

- In the old Files tab flow, the client then took that decoded string
and rebuilt it as a text blob. That UTF-8 decode/re-encode step changes
raw bytes, so the downloaded file no longer matches the original.
Folder ZIP export had the same problem for any binary file inside the
archive.

Co-authored-by: Haileyesus <something@gmail.com>
2026-04-10 12:35:23 +02:00
80 changed files with 889 additions and 1373 deletions

View File

@@ -1,4 +1,4 @@
# Claude Code UI — Docker Sandbox Templates
# CloudCLI — Docker Sandbox Templates
Run AI coding agents with a full web IDE inside [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/).
@@ -62,11 +62,11 @@ docker build -f docker/gemini/Dockerfile -t cloudcli-sandbox:gemini docker/
Each template extends Docker's official sandbox base image and adds:
1. **Node.js 22** — Runtime for Claude Code UI
2. **Claude Code UI** — Installed globally via `npm install -g @cloudcli-ai/cloudcli`
1. **Node.js 22** — Runtime for CloudCLI
2. **CloudCLI** — Installed globally via `npm install -g @cloudcli-ai/cloudcli`
3. **Auto-start** — The UI server starts in the background when the sandbox shell opens (port 3001)
The agent (Claude Code, Codex, or Gemini) comes from the base image. Claude Code UI connects to it and provides the web interface on top.
The agent (Claude Code, Codex, or Gemini) comes from the base image. CloudCLI connects to it and provides the web interface on top.
## Configuration
@@ -86,4 +86,4 @@ sbx policy allow network "localhost:3001"
## License
These templates are free and open-source under the same license as Claude Code UI (AGPL-3.0-or-later).
These templates are free and open-source under the same license as CloudCLI (AGPL-3.0-or-later).

View File

@@ -1,6 +1,6 @@
#!/bin/bash
# Auto-start Claude Code UI server in background if not already running.
# Auto-start CloudCLI server in background if not already running.
# This script is sourced from ~/.bashrc on sandbox shell open.
if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
@@ -13,7 +13,7 @@ if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
disown
echo ""
echo " Claude Code UI is starting on port 3001..."
echo " CloudCLI is starting on port 3001..."
echo ""
echo " To access the web UI, forward the port:"
echo " sbx ports \$(hostname) --publish 3001:3001"

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code UI - API Documentation</title>
<title>CloudCLI - API Documentation</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
@@ -418,7 +418,7 @@
</svg>
</div>
<div class="brand-text">
<h1>Claude Code UI</h1>
<h1>CloudCLI</h1>
<div class="subtitle">API Documentation</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
// Service Worker for Claude Code UI PWA
// Service Worker for CloudCLI PWA
// Cache only manifest (needed for PWA install). HTML and JS are never pre-cached
// so a rebuild + refresh always picks up the latest assets.
const CACHE_NAME = 'claude-ui-v2';
@@ -79,7 +79,7 @@ self.addEventListener('push', event => {
try {
payload = event.data.json();
} catch {
payload = { title: 'Claude Code UI', body: event.data.text() };
payload = { title: 'CloudCLI', body: event.data.text() };
}
const options = {
@@ -92,7 +92,7 @@ self.addEventListener('push', event => {
};
event.waitUntil(
self.registration.showNotification(payload.title || 'Claude Code UI', options)
self.registration.showNotification(payload.title || 'CloudCLI', options)
);
});

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* Claude Code UI CLI
* CloudCLI CLI
*
* Provides command-line utilities for managing Claude Code UI
* Provides command-line utilities for managing CloudCLI
*
* Commands:
* (no args) - Start the server (default)
@@ -84,7 +84,7 @@ function getInstallDir() {
// Show status command
function showStatus() {
console.log(`\n${c.bright('Claude Code UI - Status')}\n`);
console.log(`\n${c.bright('CloudCLI UI - Status')}\n`);
console.log(c.dim('═'.repeat(60)));
// Version info
@@ -141,7 +141,7 @@ function showStatus() {
function showHelp() {
console.log(`
╔═══════════════════════════════════════════════════════════════╗
║ Claude Code UI - Command Line Tool ║
║ CloudCLI - Command Line Tool ║
╚═══════════════════════════════════════════════════════════════╝
Usage:
@@ -149,7 +149,7 @@ Usage:
cloudcli [command] [options]
Commands:
start Start the Claude Code UI server (default)
start Start the CloudCLI server (default)
status Show configuration and data locations
update Update to the latest version
help Show this help information

View File

@@ -812,7 +812,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
}
});
// Serve binary file content endpoint (for images, etc.)
// Serve raw file bytes for previews and downloads.
app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
@@ -829,7 +829,11 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
return res.status(404).json({ error: 'Project not found' });
}
const resolved = path.resolve(filePath);
// Match the text reader endpoint so callers can pass either project-relative
// or absolute paths without changing how the bytes are served.
const resolved = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) {
return res.status(403).json({ error: 'Path must be under project root' });
@@ -1980,155 +1984,6 @@ function handleShellConnection(ws) {
console.error('[ERROR] Shell WebSocket error:', error);
});
}
// Audio transcription endpoint
app.post('/api/transcribe', authenticateToken, async (req, res) => {
try {
const multer = (await import('multer')).default;
const upload = multer({ storage: multer.memoryStorage() });
// Handle multipart form data
upload.single('audio')(req, res, async (err) => {
if (err) {
return res.status(400).json({ error: 'Failed to process audio file' });
}
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' });
}
try {
// Create form data for OpenAI
const FormData = (await import('form-data')).default;
const formData = new FormData();
formData.append('file', req.file.buffer, {
filename: req.file.originalname,
contentType: req.file.mimetype
});
formData.append('model', 'whisper-1');
formData.append('response_format', 'json');
formData.append('language', 'en');
// Make request to OpenAI
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
...formData.getHeaders()
},
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`);
}
const data = await response.json();
let transcribedText = data.text || '';
// Check if enhancement mode is enabled
const mode = req.body.mode || 'default';
// If no transcribed text, return empty
if (!transcribedText) {
return res.json({ text: '' });
}
// If default mode, return transcribed text without enhancement
if (mode === 'default') {
return res.json({ text: transcribedText });
}
// Handle different enhancement modes
try {
const OpenAI = (await import('openai')).default;
const openai = new OpenAI({ apiKey });
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
switch (mode) {
case 'prompt':
systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.
Your enhanced prompt should:
1. Be specific and unambiguous
2. Include relevant context and constraints
3. Specify the desired output format
4. Use clear, actionable language
5. Include examples where helpful
6. Consider edge cases and potential ambiguities
Transform this rough instruction into a well-crafted prompt:
"${transcribedText}"
Enhanced prompt:`;
break;
case 'vibe':
case 'instructions':
case 'architect':
systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
temperature = 0.5; // Lower temperature for more controlled output
prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.
IMPORTANT RULES:
- Format as clear, step-by-step instructions
- Add reasonable implementation details based on common patterns
- Only include details directly related to what was asked
- Do NOT add features or functionality not mentioned
- Keep the original intent and scope intact
- Use clear, actionable language an agent can follow
Transform this idea into agent-friendly instructions:
"${transcribedText}"
Agent instructions:`;
break;
default:
// No enhancement needed
break;
}
// Only make GPT call if we have a prompt
if (prompt) {
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemMessage },
{ role: 'user', content: prompt }
],
temperature: temperature,
max_tokens: maxTokens
});
transcribedText = completion.choices[0].message.content || transcribedText;
}
} catch (gptError) {
console.error('GPT processing error:', gptError);
// Fall back to original transcription if GPT fails
}
res.json({ text: transcribedText });
} catch (error) {
console.error('Transcription error:', error);
res.status(500).json({ error: error.message });
}
});
} catch (error) {
console.error('Endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Image upload endpoint
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
try {
@@ -2544,7 +2399,7 @@ async function startServer() {
console.log('');
console.log(c.dim('═'.repeat(63)));
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
console.log(` ${c.bright('CloudCLI Server - Ready')}`);
console.log(c.dim('═'.repeat(63)));
console.log('');
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`);

View File

@@ -1125,7 +1125,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
} else {
prBody += `Agent task: ${message}`;
}
prBody += '\n\n---\n*This pull request was automatically created by Claude Code UI Agent.*';
prBody += '\n\n---\n*This pull request was automatically created by CloudCLI.ai Agent.*';
console.log(`📝 PR Title: ${prTitle}`);

View File

@@ -125,7 +125,7 @@ function buildPushBody(event) {
const message = CODE_MAP[event.code] || 'You have a new notification';
return {
title: sessionName || 'Claude Code UI',
title: sessionName || 'CloudCLI',
body: `${providerLabel}: ${message}`,
data: {
sessionId: event.sessionId || null,

View File

@@ -7,7 +7,6 @@ import { useWebSocket } from '../../contexts/WebSocketContext';
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
import { useSessionProtection } from '../../hooks/useSessionProtection';
import { useProjectsState } from '../../hooks/useProjectsState';
import MobileNav from './MobileNav';
export default function AppContent() {
const navigate = useNavigate();
@@ -33,7 +32,6 @@ export default function AppContent() {
activeTab,
sidebarOpen,
isLoadingProjects,
isInputFocused,
externalMessageUpdate,
setActiveTab,
setSidebarOpen,
@@ -159,7 +157,7 @@ export default function AppContent() {
</div>
)}
<div className={`flex min-w-0 flex-1 flex-col ${isMobile ? 'pb-mobile-nav' : ''}`}>
<div className="flex min-w-0 flex-1 flex-col">
<MainContent
selectedProject={selectedProject}
selectedSession={selectedSession}
@@ -184,14 +182,6 @@ export default function AppContent() {
/>
</div>
{isMobile && (
<MobileNav
activeTab={activeTab}
setActiveTab={setActiveTab}
isInputFocused={isInputFocused}
/>
)}
</div>
);
}

View File

@@ -1,179 +0,0 @@
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import {
MessageSquare,
Folder,
Terminal,
GitBranch,
ClipboardCheck,
Ellipsis,
Puzzle,
Box,
Database,
Globe,
Wrench,
Zap,
BarChart3,
type LucideIcon,
} from 'lucide-react';
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app';
const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
};
type CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;
type CoreNavItem = {
id: CoreTabId;
icon: LucideIcon;
label: string;
};
type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
isInputFocused: boolean;
};
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
const { t } = useTranslation(['common', 'settings']);
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins();
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement | null>(null);
const enabledPlugins = plugins.filter((p) => p.enabled);
const hasPlugins = enabledPlugins.length > 0;
const isPluginActive = activeTab.startsWith('plugin:');
// Close the menu on outside tap
useEffect(() => {
if (!moreOpen) return;
const handleTap = (e: PointerEvent) => {
const target = e.target;
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
setMoreOpen(false);
}
};
document.addEventListener('pointerdown', handleTap);
return () => document.removeEventListener('pointerdown', handleTap);
}, [moreOpen]);
// Close menu when a plugin tab is selected
const selectPlugin = (name: string) => {
const pluginTab = `plugin:${name}` as AppTab;
setActiveTab(pluginTab);
setMoreOpen(false);
};
const baseCoreItems: CoreNavItem[] = [
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
{ id: 'shell', icon: Terminal, label: 'Shell' },
{ id: 'files', icon: Folder, label: 'Files' },
{ id: 'git', icon: GitBranch, label: 'Git' },
];
const coreItems: CoreNavItem[] = shouldShowTasksTab
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
: baseCoreItems;
return (
<div
className={`fixed bottom-0 left-0 right-0 z-50 transform px-3 pb-[max(8px,env(safe-area-inset-bottom))] transition-transform duration-300 ease-in-out ${isInputFocused ? 'translate-y-full' : 'translate-y-0'
}`}
>
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
<div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
{coreItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
onTouchStart={(e) => {
e.preventDefault();
setActiveTab(item.id);
}}
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
>
{isActive && (
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
)}
<Icon
className={`relative z-10 transition-all duration-200 ${isActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
strokeWidth={isActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
{item.label}
</span>
</button>
);
})}
{/* "More" button — only shown when there are enabled plugins */}
{hasPlugins && (
<div ref={moreRef} className="relative flex-1">
<button
onClick={() => setMoreOpen((v) => !v)}
onTouchStart={(e) => {
e.preventDefault();
setMoreOpen((v) => !v);
}}
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isPluginActive || moreOpen
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label="More plugins"
aria-expanded={moreOpen}
>
{(isPluginActive && !moreOpen) && (
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
)}
<Ellipsis
className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
strokeWidth={isPluginActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
{t('settings:pluginSettings.morePlugins')}
</span>
</button>
{/* Popover menu */}
{moreOpen && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full right-0 z-[60] mb-2 min-w-[180px] rounded-xl border border-border/40 bg-popover py-1.5 shadow-lg duration-150">
{enabledPlugins.map((p) => {
const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
const isActive = activeTab === `plugin:${p.name}`;
return (
<button
key={p.name}
onClick={() => selectPlugin(p.name)}
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${isActive
? 'bg-primary/8 text-primary'
: 'text-foreground hover:bg-muted/60'
}`}
>
<Icon className="h-4 w-4 flex-shrink-0" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="truncate">{p.displayName}</span>
</button>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -12,7 +12,7 @@ export default function AuthLoadingScreen() {
</div>
</div>
<h1 className="mb-2 text-2xl font-bold text-foreground">Claude Code UI</h1>
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
<div className="flex items-center justify-center space-x-2">
{loadingDotAnimationDelays.map((delay) => (

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../../../constants/config';
type AuthScreenLayoutProps = {
title: string;
@@ -37,6 +38,22 @@ export default function AuthScreenLayout({
<div className="text-center">
<p className="text-sm text-muted-foreground">{footerText}</p>
</div>
{!IS_PLATFORM && (
<div className="flex items-center justify-center gap-1.5 pt-2">
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
<a
href="https://github.com/siteboon/claudecodeui"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 transition-colors hover:text-muted-foreground"
>
CloudCLI is open source
</a>
</div>
)}
</div>
</div>
</div>

View File

@@ -58,7 +58,7 @@ export default function LoginForm() {
<AuthScreenLayout
title={t('login.title')}
description={t('login.description')}
footerText="Enter your credentials to access Claude Code UI"
footerText="Enter your credentials to access CloudCLI"
>
<form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField

View File

@@ -82,7 +82,7 @@ export default function SetupForm() {
return (
<AuthScreenLayout
title="Welcome to Claude Code UI"
title="Welcome to CloudCLI"
description="Set up your account to get started"
footerText="This is a single-user system. Only one account can be created."
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}

View File

@@ -878,30 +878,6 @@ export function useChatComposerState({
});
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
const handleTranscript = useCallback((text: string) => {
if (!text.trim()) {
return;
}
setInput((previousInput) => {
const newInput = previousInput.trim() ? `${previousInput} ${text}` : text;
inputValueRef.current = newInput;
setTimeout(() => {
if (!textareaRef.current) {
return;
}
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2);
}, 0);
return newInput;
});
}, []);
const handleGrantToolPermission = useCallback(
(suggestion: { entry: string; toolName: string }) => {
if (!suggestion || provider !== 'claude') {
@@ -994,7 +970,6 @@ export function useChatComposerState({
syncInputOverlayScroll,
handleClearInput,
handleAbortSession,
handleTranscript,
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,

View File

@@ -165,7 +165,6 @@ function ChatInterface({
syncInputOverlayScroll,
handleClearInput,
handleAbortSession,
handleTranscript,
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
@@ -338,7 +337,6 @@ function ChatInterface({
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
isLoading={isLoading}
/>
<ChatComposer
@@ -408,7 +406,6 @@ function ChatInterface({
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}
onTranscript={handleTranscript}
/>
</div>

View File

@@ -1,36 +0,0 @@
import { SessionProvider } from '../../../../types/app';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
type AssistantThinkingIndicatorProps = {
selectedProvider: SessionProvider;
}
export default function AssistantThinkingIndicator({ selectedProvider }: AssistantThinkingIndicatorProps) {
return (
<div className="chat-message assistant">
<div className="w-full">
<div className="mb-2 flex items-center space-x-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-transparent p-1 text-sm text-white">
<SessionProviderLogo provider={selectedProvider} className="h-full w-full" />
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
</div>
</div>
<div className="w-full pl-3 text-sm text-gray-500 dark:text-gray-400 sm:pl-0">
<div className="flex items-center space-x-1">
<div className="animate-pulse">.</div>
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}>
.
</div>
<div className="animate-pulse" style={{ animationDelay: '0.4s' }}>
.
</div>
<span className="ml-2">Thinking...</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -11,7 +11,6 @@ import type {
SetStateAction,
TouchEvent,
} from 'react';
import MicButton from '../../../mic-button/view/MicButton';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
@@ -91,7 +90,6 @@ interface ChatComposerProps {
placeholder: string;
isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean;
onTranscript: (text: string) => void;
}
export default function ChatComposer({
@@ -148,7 +146,6 @@ export default function ChatComposer({
placeholder,
isTextareaExpanded,
sendByCtrlEnter,
onTranscript,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
const textareaRect = textareaRef.current?.getBoundingClientRect();
@@ -321,10 +318,6 @@ export default function ChatComposer({
</svg>
</button>
<div className="absolute right-16 top-1/2 -translate-y-1/2 transform sm:right-16" style={{ display: 'none' }}>
<MicButton onTranscript={onTranscript} className="h-10 w-10 sm:h-10 sm:w-10" />
</div>
<button
type="submit"
disabled={!input.trim() || isLoading}

View File

@@ -6,7 +6,6 @@ import type { Project, ProjectSession, SessionProvider } from '../../../../types
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>;
@@ -51,7 +50,6 @@ interface ChatMessagesPaneProps {
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject: Project;
isLoading: boolean;
}
export default function ChatMessagesPane({
@@ -97,7 +95,6 @@ export default function ChatMessagesPane({
showRawParameters,
showThinking,
selectedProject,
isLoading,
}: ChatMessagesPaneProps) {
const { t } = useTranslation('chat');
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
@@ -261,8 +258,6 @@ export default function ChatMessagesPane({
})}
</>
)}
{isLoading && <AssistantThinkingIndicator selectedProvider={provider} />}
</div>
);
}

View File

@@ -23,7 +23,6 @@ const ACTION_KEYS = [
'claudeStatus.actions.reasoning',
];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const ANIMATION_STEPS = 40;
const PROVIDER_LABEL_KEYS: Record<string, string> = {
claude: 'messageTypes.claude',
@@ -32,19 +31,10 @@ const PROVIDER_LABEL_KEYS: Record<string, string> = {
gemini: 'messageTypes.gemini',
};
function formatElapsedTime(totalSeconds: number, t: (key: string, options?: Record<string, unknown>) => string) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 1) {
return t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' });
}
return t('claudeStatus.elapsed.minutesSeconds', {
minutes,
seconds,
defaultValue: '{{minutes}}m {{seconds}}s',
});
function formatElapsedTime(totalSeconds: number) {
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`;
}
export default function ClaudeStatus({
@@ -55,143 +45,85 @@ export default function ClaudeStatus({
}: ClaudeStatusProps) {
const { t } = useTranslation('chat');
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
const [dots, setDots] = useState('');
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const timer = window.setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed);
const timer = setInterval(() => {
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
return () => window.clearInterval(timer);
}, [isLoading]);
useEffect(() => {
if (!isLoading) {
return;
}
const timer = window.setInterval(() => {
setAnimationPhase((previous) => (previous + 1) % ANIMATION_STEPS);
const dotTimer = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
}, 500);
return () => window.clearInterval(timer);
return () => {
clearInterval(timer);
clearInterval(dotTimer);
};
}, [isLoading]);
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
if (!isLoading && !status) {
return null;
}
if (!isLoading && !status) return null;
const actionWords = ACTION_KEYS.map((key, index) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[index] }));
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
const statusText = status?.text || actionWords[actionIndex];
const cleanStatusText = statusText.replace(/[.]+$/, '');
const canInterrupt = isLoading && status?.can_interrupt !== false;
const providerLabelKey = PROVIDER_LABEL_KEYS[provider];
const providerLabel = providerLabelKey
? t(providerLabelKey)
: t('claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
const animatedDots = '.'.repeat((animationPhase % 3) + 1);
const elapsedLabel =
elapsedTime > 0
? t('claudeStatus.elapsed.label', {
time: formatElapsedTime(elapsedTime, t),
defaultValue: '{{time}} elapsed',
})
: t('claudeStatus.elapsed.startingNow', { defaultValue: 'Starting now' });
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, '');
const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
return (
<div className="animate-in slide-in-from-bottom mb-3 w-full duration-300 sm:mb-6">
<div className="relative mx-auto max-w-4xl overflow-hidden rounded-2xl border border-border/70 bg-card/90 shadow-md backdrop-blur-md">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-primary/10 via-transparent to-sky-500/10 dark:from-primary/20 dark:to-sky-400/20" />
<div className="animate-in fade-in slide-in-from-bottom-2 mb-3 w-full duration-500">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3 overflow-hidden rounded-full border border-border/50 bg-slate-100 px-3 py-1.5 shadow-sm backdrop-blur-md dark:bg-slate-900">
<div className="relative px-3 py-3 sm:px-4 sm:py-3.5">
<div className="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3" role="status" aria-live="polite">
<div className="relative mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-primary/25 bg-primary/10">
<SessionProviderLogo provider={provider} className="h-5 w-5" />
<span className="absolute -right-0.5 -top-0.5 flex h-2.5 w-2.5">
{isLoading && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/70" />
)}
<span
className={cn(
'relative inline-flex h-2.5 w-2.5 rounded-full',
isLoading ? 'bg-emerald-400' : 'bg-amber-400',
)}
/>
</span>
</div>
<div className="min-w-0">
<div className="mb-0.5 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
<span>{providerLabel}</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-[9px] tracking-[0.14em]',
isLoading
? 'bg-emerald-500/15 text-emerald-500 dark:text-emerald-400'
: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
)}
>
{isLoading
? t('claudeStatus.state.live', { defaultValue: 'Live' })
: t('claudeStatus.state.paused', { defaultValue: 'Paused' })}
</span>
</div>
<p className="truncate text-sm font-semibold text-foreground sm:text-[15px]">
{cleanStatusText}
{isLoading && (
<span aria-hidden="true" className="text-primary">
{animatedDots}
</span>
)}
</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground sm:text-xs">
<span
aria-hidden="true"
className="-ml-2 inline-flex items-center rounded-full border border-border/70 bg-background/60 px-2 py-0.5"
>
{elapsedLabel}
</span>
</div>
</div>
</div>
{canInterrupt && onAbort && (
<div className="w-full sm:w-auto sm:text-right">
<button
type="button"
onClick={onAbort}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-destructive px-3.5 py-2 text-sm font-semibold text-destructive-foreground shadow-sm ring-1 ring-destructive/40 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/70 active:opacity-90 sm:w-auto"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>{t('claudeStatus.controls.stopGeneration', { defaultValue: 'Stop Generation' })}</span>
<span className="rounded-md bg-black/20 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-destructive-foreground/95">
Esc
</span>
</button>
<p className="mt-1 hidden text-[11px] text-muted-foreground sm:block">
{t('claudeStatus.controls.pressEscToStop', { defaultValue: 'Press Esc anytime to stop' })}
</p>
</div>
{/* Left Side: Identity & Status */}
<div className="flex min-w-0 items-center gap-2.5">
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/20 ring-1 ring-primary/10">
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
{isLoading && (
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-emerald-500/20" />
)}
</div>
<div className="flex min-w-0 flex-col sm:flex-row sm:items-center sm:gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70">
{providerLabel}
</span>
<div className="flex items-center gap-1.5">
<span className={cn("h-1.5 w-1.5 rounded-full", isLoading ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<p className="truncate text-xs font-medium text-foreground">
{statusText}<span className="inline-block w-4 text-primary">{isLoading ? dots : ''}</span>
</p>
</div>
</div>
</div>
{/* Right Side: Metrics & Actions */}
<div className="flex items-center gap-2">
{isLoading && status?.can_interrupt !== false && onAbort && (
<>
<div className="hidden items-center rounded-md bg-muted/50 px-2 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground sm:flex">
{formatElapsedTime(elapsedTime)}
</div>
<button
type="button"
onClick={onAbort}
className="group flex items-center gap-1.5 rounded-full bg-destructive/10 px-2.5 py-1 text-[10px] font-bold text-destructive transition-all hover:bg-destructive hover:text-destructive-foreground"
>
<svg className="h-3 w-3 fill-current" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
<span className="hidden sm:inline">STOP</span>
<kbd className="hidden rounded bg-black/10 px-1 text-[9px] group-hover:bg-white/20 sm:block">
ESC
</kbd>
</button>
</>
)}
</div>
</div>
</div>
);
}
}

View File

@@ -248,6 +248,20 @@ export function useFileTreeOperations({
showToast(t('fileTree.toast.pathCopied', 'Path copied to clipboard'), 'success');
}, [showToast, t]);
const triggerBrowserDownload = useCallback((blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}, []);
// Download file or folder
const handleDownload = useCallback(async (item: FileTreeNode) => {
if (!selectedProject) return;
@@ -272,28 +286,16 @@ export function useFileTreeOperations({
const downloadSingleFile = useCallback(async (item: FileTreeNode) => {
if (!selectedProject) return;
const response = await api.readFile(selectedProject.name, item.path);
// Use the binary streaming endpoint so downloads preserve raw bytes.
const response = await api.readFileBlob(selectedProject.name, item.path);
if (!response.ok) {
throw new Error('Failed to download file');
}
const data = await response.json();
const content = data.content;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = item.name;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}, [selectedProject]);
const blob = await response.blob();
triggerBrowserDownload(blob, item.name);
}, [selectedProject, triggerBrowserDownload]);
// Download folder as ZIP
const downloadFolderAsZip = useCallback(async (folder: FileTreeNode) => {
@@ -306,12 +308,14 @@ export function useFileTreeOperations({
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
if (node.type === 'file') {
// Fetch file content
const response = await api.readFile(selectedProject.name, node.path);
if (response.ok) {
const data = await response.json();
zip.file(fullPath, data.content);
const response = await api.readFileBlob(selectedProject.name, node.path);
if (!response.ok) {
throw new Error(`Failed to download "${node.name}" for ZIP export`);
}
// Store raw bytes in the archive so binary files stay intact.
const fileBytes = await response.arrayBuffer();
zip.file(fullPath, fileBytes);
} else if (node.type === 'directory' && node.children) {
// Recursively process children
for (const child of node.children) {
@@ -329,20 +333,10 @@ export function useFileTreeOperations({
// Generate ZIP file
const zipBlob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(zipBlob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${folder.name}.zip`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
triggerBrowserDownload(zipBlob, `${folder.name}.zip`);
showToast(t('fileTree.toast.folderDownloaded', 'Folder downloaded as ZIP'), 'success');
}, [selectedProject, showToast, t]);
}, [selectedProject, showToast, t, triggerBrowserDownload]);
return {
// Rename operations

View File

@@ -167,7 +167,7 @@ export default function BranchesView({
}
return (
<div className={`flex flex-1 flex-col overflow-hidden ${isMobile ? 'pb-mobile-nav' : ''}`}>
<div className="flex flex-1 flex-col overflow-hidden">
{/* Create branch button */}
<div className="flex items-center justify-between border-b border-border/40 px-4 py-2.5">
<span className="text-sm text-muted-foreground">

View File

@@ -151,7 +151,7 @@ export default function ChangesView({
{!gitStatus?.error && <FileStatusLegend isMobile={isMobile} />}
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />

View File

@@ -1,6 +1,5 @@
import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
import { useState } from 'react';
import MicButton from '../../../mic-button/view/MicButton';
import type { ConfirmationRequest } from '../../types/types';
// Persists commit messages across unmount/remount, keyed by project path
@@ -147,13 +146,6 @@ export default function CommitComposer({
<Sparkles className="h-4 w-4" />
)}
</button>
<div style={{ display: 'none' }}>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
/>
</div>
</div>
</div>

View File

@@ -47,7 +47,7 @@ export default function HistoryView({
);
return (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />

View File

@@ -1,45 +0,0 @@
import type { MicButtonState } from '../types/types';
export const MIC_BUTTON_STATES = {
IDLE: 'idle',
RECORDING: 'recording',
TRANSCRIBING: 'transcribing',
PROCESSING: 'processing',
} as const;
export const MIC_TAP_DEBOUNCE_MS = 300;
export const PROCESSING_STATE_DELAY_MS = 2000;
export const DEFAULT_WHISPER_MODE = 'default';
// Modes that use post-transcription enhancement on the backend.
export const ENHANCEMENT_WHISPER_MODES = new Set([
'prompt',
'vibe',
'instructions',
'architect',
]);
export const BUTTON_BACKGROUND_BY_STATE: Record<MicButtonState, string> = {
idle: '#374151',
recording: '#ef4444',
transcribing: '#3b82f6',
processing: '#a855f7',
};
export const MIC_ERROR_BY_NAME = {
NotAllowedError: 'Microphone access denied. Please allow microphone permissions.',
NotFoundError: 'No microphone found. Please check your audio devices.',
NotSupportedError: 'Microphone not supported by this browser.',
NotReadableError: 'Microphone is being used by another application.',
} as const;
export const MIC_NOT_AVAILABLE_ERROR =
'Microphone access not available. Please use HTTPS or a supported browser.';
export const MIC_NOT_SUPPORTED_ERROR =
'Microphone not supported. Please use HTTPS or a modern browser.';
export const MIC_SECURE_CONTEXT_ERROR =
'Microphone requires HTTPS. Please use a secure connection.';

View File

@@ -1,52 +0,0 @@
import { api } from '../../../utils/api';
type WhisperStatus = 'transcribing';
type WhisperResponse = {
text?: string;
error?: string;
};
export async function transcribeWithWhisper(
audioBlob: Blob,
onStatusChange?: (status: WhisperStatus) => void,
): Promise<string> {
const formData = new FormData();
const fileName = `recording_${Date.now()}.webm`;
const file = new File([audioBlob], fileName, { type: audioBlob.type });
formData.append('audio', file);
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
formData.append('mode', whisperMode);
try {
// Keep existing status callback behavior.
if (onStatusChange) {
onStatusChange('transcribing');
}
const response = (await api.transcribe(formData)) as Response;
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as WhisperResponse;
throw new Error(
errorData.error ||
`Transcription error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as WhisperResponse;
return data.text || '';
} catch (error) {
if (
error instanceof Error
&& error.name === 'TypeError'
&& error.message.includes('fetch')
) {
throw new Error('Cannot connect to server. Please ensure the backend is running.');
}
throw error;
}
}

View File

@@ -1,204 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import { transcribeWithWhisper } from '../data/whisper';
import {
DEFAULT_WHISPER_MODE,
ENHANCEMENT_WHISPER_MODES,
MIC_BUTTON_STATES,
MIC_ERROR_BY_NAME,
MIC_NOT_AVAILABLE_ERROR,
MIC_NOT_SUPPORTED_ERROR,
MIC_SECURE_CONTEXT_ERROR,
MIC_TAP_DEBOUNCE_MS,
PROCESSING_STATE_DELAY_MS,
} from '../constants/constants';
import type { MicButtonState } from '../types/types';
type UseMicButtonControllerArgs = {
onTranscript?: (transcript: string) => void;
};
type UseMicButtonControllerResult = {
state: MicButtonState;
error: string | null;
isSupported: boolean;
handleButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;
};
const getRecordingErrorMessage = (error: unknown): string => {
if (error instanceof Error && error.message.includes('HTTPS')) {
return error.message;
}
if (error instanceof DOMException) {
return MIC_ERROR_BY_NAME[error.name as keyof typeof MIC_ERROR_BY_NAME] || 'Microphone access failed';
}
return 'Microphone access failed';
};
const getRecorderMimeType = (): string => (
MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'
);
export function useMicButtonController({
onTranscript,
}: UseMicButtonControllerArgs): UseMicButtonControllerResult {
const [state, setState] = useState<MicButtonState>(MIC_BUTTON_STATES.IDLE);
const [error, setError] = useState<string | null>(null);
const [isSupported, setIsSupported] = useState(true);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const lastTapRef = useRef(0);
const processingTimerRef = useRef<number | null>(null);
const clearProcessingTimer = (): void => {
if (processingTimerRef.current !== null) {
window.clearTimeout(processingTimerRef.current);
processingTimerRef.current = null;
}
};
const stopStreamTracks = (): void => {
if (!streamRef.current) {
return;
}
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
};
const handleStopRecording = async (mimeType: string): Promise<void> => {
const audioBlob = new Blob(chunksRef.current, { type: mimeType });
// Release the microphone immediately once recording ends.
stopStreamTracks();
setState(MIC_BUTTON_STATES.TRANSCRIBING);
const whisperMode = window.localStorage.getItem('whisperMode') || DEFAULT_WHISPER_MODE;
const shouldShowProcessingState = ENHANCEMENT_WHISPER_MODES.has(whisperMode);
if (shouldShowProcessingState) {
processingTimerRef.current = window.setTimeout(() => {
setState(MIC_BUTTON_STATES.PROCESSING);
}, PROCESSING_STATE_DELAY_MS);
}
try {
const transcript = await transcribeWithWhisper(audioBlob);
if (transcript && onTranscript) {
onTranscript(transcript);
}
} catch (transcriptionError) {
const message = transcriptionError instanceof Error ? transcriptionError.message : 'Transcription error';
setError(message);
} finally {
clearProcessingTimer();
setState(MIC_BUTTON_STATES.IDLE);
}
};
const startRecording = async (): Promise<void> => {
try {
setError(null);
chunksRef.current = [];
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error(MIC_NOT_AVAILABLE_ERROR);
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = getRecorderMimeType();
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
void handleStopRecording(mimeType);
};
recorder.start();
setState(MIC_BUTTON_STATES.RECORDING);
} catch (recordingError) {
stopStreamTracks();
setError(getRecordingErrorMessage(recordingError));
setState(MIC_BUTTON_STATES.IDLE);
}
};
const stopRecording = (): void => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
return;
}
stopStreamTracks();
setState(MIC_BUTTON_STATES.IDLE);
};
const handleButtonClick = (event?: MouseEvent<HTMLButtonElement>): void => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!isSupported) {
return;
}
// Mobile tap handling can trigger duplicate click events in quick succession.
const now = Date.now();
if (now - lastTapRef.current < MIC_TAP_DEBOUNCE_MS) {
return;
}
lastTapRef.current = now;
if (state === MIC_BUTTON_STATES.IDLE) {
void startRecording();
return;
}
if (state === MIC_BUTTON_STATES.RECORDING) {
stopRecording();
}
};
useEffect(() => {
// getUserMedia needs both browser support and a secure context.
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setIsSupported(false);
setError(MIC_NOT_SUPPORTED_ERROR);
return;
}
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
setIsSupported(false);
setError(MIC_SECURE_CONTEXT_ERROR);
return;
}
setIsSupported(true);
setError(null);
}, []);
useEffect(() => () => {
clearProcessingTimer();
stopStreamTracks();
}, []);
return {
state,
error,
isSupported,
handleButtonClick,
};
}

View File

@@ -1,2 +0,0 @@
export type MicButtonState = 'idle' | 'recording' | 'transcribing' | 'processing';

View File

@@ -1,32 +0,0 @@
import { useMicButtonController } from '../hooks/useMicButtonController';
import MicButtonView from './MicButtonView';
type MicButtonProps = {
onTranscript?: (transcript: string) => void;
className?: string;
mode?: string;
};
export default function MicButton({
onTranscript,
className = '',
mode: _mode,
}: MicButtonProps) {
const { state, error, isSupported, handleButtonClick } = useMicButtonController({
onTranscript,
});
// Keep `mode` in the public props for backwards compatibility.
void _mode;
return (
<MicButtonView
state={state}
error={error}
isSupported={isSupported}
className={className}
onButtonClick={handleButtonClick}
/>
);
}

View File

@@ -1,86 +0,0 @@
import { Brain, Loader2, Mic } from 'lucide-react';
import type { MouseEvent, ReactElement } from 'react';
import { BUTTON_BACKGROUND_BY_STATE, MIC_BUTTON_STATES } from '../constants/constants';
import type { MicButtonState } from '../types/types';
type MicButtonViewProps = {
state: MicButtonState;
error: string | null;
isSupported: boolean;
className: string;
onButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;
};
const getButtonIcon = (state: MicButtonState, isSupported: boolean): ReactElement => {
if (!isSupported) {
return <Mic className="h-5 w-5" />;
}
if (state === MIC_BUTTON_STATES.TRANSCRIBING) {
return <Loader2 className="h-5 w-5 animate-spin" />;
}
if (state === MIC_BUTTON_STATES.PROCESSING) {
return <Brain className="h-5 w-5 animate-pulse" />;
}
if (state === MIC_BUTTON_STATES.RECORDING) {
return <Mic className="h-5 w-5 text-white" />;
}
return <Mic className="h-5 w-5" />;
};
export default function MicButtonView({
state,
error,
isSupported,
className,
onButtonClick,
}: MicButtonViewProps) {
const isDisabled = !isSupported || state === MIC_BUTTON_STATES.TRANSCRIBING || state === MIC_BUTTON_STATES.PROCESSING;
const icon = getButtonIcon(state, isSupported);
return (
<div className="relative">
<button
type="button"
style={{ backgroundColor: BUTTON_BACKGROUND_BY_STATE[state] }}
className={`
touch-action-manipulation flex h-12
w-12 items-center justify-center
rounded-full text-white transition-all
duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500
focus:ring-offset-2
dark:ring-offset-gray-800
${isDisabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
${state === MIC_BUTTON_STATES.RECORDING ? 'animate-pulse' : ''}
hover:opacity-90
${className}
`}
onClick={onButtonClick}
disabled={isDisabled}
>
{icon}
</button>
{error && (
<div
className="animate-fade-in absolute left-1/2 top-full z-10 mt-2
-translate-x-1/2 transform whitespace-nowrap rounded bg-red-500 px-2 py-1 text-xs
text-white"
>
{error}
</div>
)}
{state === MIC_BUTTON_STATES.RECORDING && (
<div className="pointer-events-none absolute -inset-1 animate-ping rounded-full border-2 border-red-500" />
)}
{state === MIC_BUTTON_STATES.PROCESSING && (
<div className="pointer-events-none absolute -inset-1 animate-ping rounded-full border-2 border-purple-500" />
)}
</div>
);
}

View File

@@ -2,21 +2,12 @@ import {
ArrowDown,
Brain,
Eye,
FileText,
Languages,
Maximize2,
Mic,
Sparkles,
} from 'lucide-react';
import type {
PreferenceToggleItem,
WhisperMode,
WhisperOption,
} from './types';
import type { PreferenceToggleItem } from './types';
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
export const WHISPER_MODE_STORAGE_KEY = 'whisperMode';
export const WHISPER_MODE_CHANGED_EVENT = 'whisperModeChanged';
export const DEFAULT_HANDLE_POSITION = 50;
export const HANDLE_POSITION_MIN = 10;
@@ -64,30 +55,3 @@ export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
icon: Languages,
},
];
export const WHISPER_OPTIONS: WhisperOption[] = [
{
value: 'default',
titleKey: 'quickSettings.whisper.modes.default',
descriptionKey: 'quickSettings.whisper.modes.defaultDescription',
icon: Mic,
},
{
value: 'prompt',
titleKey: 'quickSettings.whisper.modes.prompt',
descriptionKey: 'quickSettings.whisper.modes.promptDescription',
icon: Sparkles,
},
{
value: 'vibe',
titleKey: 'quickSettings.whisper.modes.vibe',
descriptionKey: 'quickSettings.whisper.modes.vibeDescription',
icon: FileText,
},
];
export const VIBE_MODE_ALIASES: WhisperMode[] = [
'vibe',
'instructions',
'architect',
];

View File

@@ -1,59 +0,0 @@
import { useCallback, useState } from 'react';
import {
VIBE_MODE_ALIASES,
WHISPER_MODE_CHANGED_EVENT,
WHISPER_MODE_STORAGE_KEY,
} from '../constants';
import type { WhisperMode, WhisperOptionValue } from '../types';
const ALL_VALID_MODES: WhisperMode[] = [
'default',
'prompt',
'vibe',
'instructions',
'architect',
];
const isWhisperMode = (value: string): value is WhisperMode => (
ALL_VALID_MODES.includes(value as WhisperMode)
);
const readStoredMode = (): WhisperMode => {
if (typeof window === 'undefined') {
return 'default';
}
const storedValue = localStorage.getItem(WHISPER_MODE_STORAGE_KEY);
if (!storedValue) {
return 'default';
}
return isWhisperMode(storedValue) ? storedValue : 'default';
};
export function useWhisperMode() {
const [whisperMode, setWhisperModeState] = useState<WhisperMode>(readStoredMode);
const setWhisperMode = useCallback((value: WhisperOptionValue) => {
setWhisperModeState(value);
localStorage.setItem(WHISPER_MODE_STORAGE_KEY, value);
window.dispatchEvent(new Event(WHISPER_MODE_CHANGED_EVENT));
}, []);
const isOptionSelected = useCallback(
(value: WhisperOptionValue) => {
if (value === 'vibe') {
return VIBE_MODE_ALIASES.includes(whisperMode);
}
return whisperMode === value;
},
[whisperMode],
);
return {
whisperMode,
setWhisperMode,
isOptionSelected,
};
}

View File

@@ -16,20 +16,4 @@ export type PreferenceToggleItem = {
icon: LucideIcon;
};
export type WhisperMode =
| 'default'
| 'prompt'
| 'vibe'
| 'instructions'
| 'architect';
export type WhisperOptionValue = 'default' | 'prompt' | 'vibe';
export type WhisperOption = {
value: WhisperOptionValue;
titleKey: string;
descriptionKey: string;
icon: LucideIcon;
};
export type QuickSettingsHandleStyle = CSSProperties;

View File

@@ -15,18 +15,15 @@ import type {
} from '../types';
import QuickSettingsSection from './QuickSettingsSection';
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
import QuickSettingsWhisperSection from './QuickSettingsWhisperSection';
type QuickSettingsContentProps = {
isDarkMode: boolean;
isMobile: boolean;
preferences: QuickSettingsPreferences;
onPreferenceChange: (key: PreferenceToggleKey, value: boolean) => void;
};
export default function QuickSettingsContent({
isDarkMode,
isMobile,
preferences,
onPreferenceChange,
}: QuickSettingsContentProps) {
@@ -45,7 +42,7 @@ export default function QuickSettingsContent({
);
return (
<div className={`flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4 ${isMobile ? 'pb-mobile-nav' : ''}`}>
<div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
<div className={SETTING_ROW_CLASS}>
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
@@ -75,8 +72,6 @@ export default function QuickSettingsContent({
{t('quickSettings.sendByCtrlEnterDescription')}
</p>
</QuickSettingsSection>
<QuickSettingsWhisperSection />
</div>
);
}

View File

@@ -73,7 +73,6 @@ export default function QuickSettingsPanelView() {
<QuickSettingsPanelHeader />
<QuickSettingsContent
isDarkMode={isDarkMode}
isMobile={isMobile}
preferences={quickSettingsPreferences}
onPreferenceChange={handlePreferenceChange}
/>

View File

@@ -1,44 +0,0 @@
import { useTranslation } from 'react-i18next';
import { TOGGLE_ROW_CLASS, WHISPER_OPTIONS } from '../constants';
import { useWhisperMode } from '../hooks/useWhisperMode';
import QuickSettingsSection from './QuickSettingsSection';
export default function QuickSettingsWhisperSection() {
const { t } = useTranslation('settings');
const { setWhisperMode, isOptionSelected } = useWhisperMode();
return (
// This section stays hidden intentionally until dictation modes are reintroduced.
<QuickSettingsSection
title={t('quickSettings.sections.whisperDictation')}
className="hidden"
>
<div className="space-y-2">
{WHISPER_OPTIONS.map(({ value, icon: Icon, titleKey, descriptionKey }) => (
<label
key={value}
className={`${TOGGLE_ROW_CLASS} flex items-start`}
>
<input
type="radio"
name="whisperMode"
value={value}
checked={isOptionSelected(value)}
onChange={() => setWhisperMode(value)}
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-blue-500 dark:checked:bg-blue-600 dark:focus:ring-blue-400"
/>
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t(titleKey)}
</span>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t(descriptionKey)}
</p>
</div>
</label>
))}
</div>
</QuickSettingsSection>
);
}

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';

View File

@@ -0,0 +1,46 @@
import { ExternalLink, Lock } from 'lucide-react';
import type { ReactNode } from 'react';
const CLOUDCLI_URL = 'https://cloudcli.ai';
type PremiumFeatureCardProps = {
icon: ReactNode;
title: string;
description: string;
ctaText?: string;
};
export default function PremiumFeatureCard({
icon,
title,
description,
ctaText = 'Available with CloudCLI Pro',
}: PremiumFeatureCardProps) {
return (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground">
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground">{title}</h4>
<Lock className="h-3 w-3 text-muted-foreground/60" />
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{description}
</p>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
>
{ctaText}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
import AboutTab from '../view/tabs/AboutTab';
import { useSettingsController } from '../hooks/useSettingsController';
import { useWebPush } from '../../../hooks/useWebPush';
import type { SettingsProps } from '../types/types';
@@ -206,6 +207,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
{activeTab === 'api' && <CredentialsSettingsTab />}
{activeTab === 'plugins' && <PluginSettingsTab />}
{activeTab === 'about' && <AboutTab />}
</div>
</main>
</div>

View File

@@ -1,4 +1,4 @@
import { GitBranch, Key, Puzzle } from 'lucide-react';
import { GitBranch, Info, Key, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SettingsMainTab } from '../types/types';
@@ -22,6 +22,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
{ id: 'about', labelKey: 'mainTabs.about', icon: Info },
];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {

View File

@@ -1,4 +1,4 @@
import { Bell, Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
import { Bell, Bot, GitBranch, Info, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import { PillBar, Pill } from '../../../shared/view/ui';
@@ -23,6 +23,7 @@ const NAV_ITEMS: NavItem[] = [
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
{ id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },
{ id: 'about', labelKey: 'mainTabs.about', icon: Info },
];
export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) {

View File

@@ -0,0 +1,166 @@
import { ExternalLink, MessageSquare, Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { IS_PLATFORM } from '../../../../constants/config';
import { useVersionCheck } from '../../../../hooks/useVersionCheck';
import PremiumFeatureCard from '../PremiumFeatureCard';
import { Cloud, Users } from 'lucide-react';
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
const DOCS_URL = 'https://cloudcli.ai/docs/plugin-overview';
const CLOUDCLI_URL = 'https://cloudcli.ai';
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
function DiscordIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
export default function AboutTab() {
const { t } = useTranslation('settings');
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const releasesUrl = releaseInfo?.htmlUrl || `${GITHUB_REPO_URL}/releases`;
return (
<div className="space-y-6">
{/* Logo + name + version */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary/90 shadow-sm">
<MessageSquare className="h-5 w-5 text-primary-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-base font-semibold text-foreground">CloudCLI</span>
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
>
v{currentVersion}
</a>
{updateAvailable && latestVersion && (
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400"
>
{t('apiKeys.version.updateAvailable', { version: latestVersion })}
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
<p className="mt-0.5 text-sm text-muted-foreground">
Open-source AI coding assistant interface
</p>
</div>
</div>
{/* Star on GitHub button */}
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-background px-3.5 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<GitHubIcon className="h-4 w-4" />
<Star className="h-3.5 w-3.5" />
<span>Star on GitHub</span>
</a>
{/* Links */}
<div className="flex flex-wrap gap-4 text-sm">
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<GitHubIcon className="h-4 w-4" />
GitHub
</a>
<a
href={DISCORD_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<DiscordIcon className="h-4 w-4" />
Discord
</a>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
Docs
</a>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
cloudcli.ai
</a>
</div>
{/* Hosted CTA (OSS mode only) */}
{!IS_PLATFORM && (
<div className="rounded-xl border border-primary/10 bg-primary/5 p-4">
<h4 className="text-sm font-medium text-foreground">Try CloudCLI Hosted</h4>
<p className="mt-1 text-xs text-muted-foreground">
Team collaboration, shared MCP configs, settings sync across environments, and managed infrastructure.
</p>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
>
Learn more
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
{/* Premium feature placeholders (OSS mode only) */}
{!IS_PLATFORM && (
<div className="space-y-4 border-t border-border/50 pt-6">
<h3 className="text-sm font-medium text-foreground">CloudCLI Pro Features</h3>
<PremiumFeatureCard
icon={<Cloud className="h-5 w-5" />}
title="Sync Settings"
description="Keep your preferences, MCP configs, and theme in sync across all your environments."
/>
<PremiumFeatureCard
icon={<Users className="h-5 w-5" />}
title="Team Management"
description="Multiple users, role-based access, and shared projects for your team."
/>
</div>
)}
{/* License */}
<div className="border-t border-border/50 pt-4">
<p className="text-xs text-muted-foreground/60">
Licensed under AGPL-3.0
</p>
</div>
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Zap } from 'lucide-react';
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge, Button } from '../../../../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../../../../constants/config';
import PremiumFeatureCard from '../../../../PremiumFeatureCard';
import type { McpServer, McpToolsResult, McpTestResult } from '../../../../../types/types';
const getTransportIcon = (type: string | undefined) => {
@@ -179,6 +181,14 @@ function ClaudeMcpServers({
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
{!IS_PLATFORM && (
<PremiumFeatureCard
icon={<Users className="h-5 w-5" />}
title="Team MCP Configs"
description="Share MCP server configurations across your team. Everyone stays in sync automatically."
/>
)}
</div>
);
}

View File

@@ -1,14 +1,11 @@
import { useTranslation } from 'react-i18next';
import { useVersionCheck } from '../../../../../hooks/useVersionCheck';
import { useCredentialsSettings } from '../../../hooks/useCredentialsSettings';
import ApiKeysSection from './sections/ApiKeysSection';
import GithubCredentialsSection from './sections/GithubCredentialsSection';
import NewApiKeyAlert from './sections/NewApiKeyAlert';
import VersionInfoSection from './sections/VersionInfoSection';
export default function CredentialsSettingsTab() {
const { t } = useTranslation('settings');
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const {
apiKeys,
githubCredentials,
@@ -89,12 +86,6 @@ export default function CredentialsSettingsTab() {
onDeleteGithubCredential={deleteGithubCredential}
/>
<VersionInfoSection
currentVersion={currentVersion}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
releaseInfo={releaseInfo}
/>
</div>
);
}

View File

@@ -1,7 +1,29 @@
import { ExternalLink } from 'lucide-react';
import { ExternalLink, Star, MessageSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { IS_PLATFORM } from '../../../../../../constants/config';
import type { ReleaseInfo } from '../../../../../../types/sharedTypes';
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
const DOCS_URL = 'https://cloudcli.ai/docs/plugin-overview';
const CLOUDCLI_URL = 'https://cloudcli.ai';
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
function DiscordIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
type VersionInfoSectionProps = {
currentVersion: string;
updateAvailable: boolean;
@@ -16,29 +38,115 @@ export default function VersionInfoSection({
releaseInfo,
}: VersionInfoSectionProps) {
const { t } = useTranslation('settings');
const releasesUrl = releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases';
const releasesUrl = releaseInfo?.htmlUrl || `${GITHUB_REPO_URL}/releases`;
return (
<div className="border-t border-border/50 pt-6">
<div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
{/* About CloudCLI */}
<div className="space-y-4">
{/* Logo + name + version */}
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-primary/90 shadow-sm">
<MessageSquare className="h-4.5 w-4.5 text-primary-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">CloudCLI</span>
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground transition-colors hover:text-foreground"
>
v{currentVersion}
</a>
{updateAvailable && latestVersion && (
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400"
>
{t('apiKeys.version.updateAvailable', { version: latestVersion })}
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
Open-source AI coding assistant interface
</p>
</div>
</div>
{/* Star on GitHub button */}
<a
href={releasesUrl}
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-muted-foreground"
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
v{currentVersion}
<GitHubIcon className="h-4 w-4" />
<Star className="h-3.5 w-3.5" />
<span>Star on GitHub</span>
</a>
{updateAvailable && latestVersion && (
{/* Links */}
<div className="flex flex-wrap gap-3 text-xs">
<a
href={releasesUrl}
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-2 py-0.5 font-medium not-italic text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400"
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
<span className="text-[10px]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
<ExternalLink className="h-2.5 w-2.5" />
<GitHubIcon className="h-3.5 w-3.5" />
GitHub
</a>
<a
href={DISCORD_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
<DiscordIcon className="h-3.5 w-3.5" />
Discord
</a>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
Docs
</a>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
cloudcli.ai
</a>
</div>
{/* Hosted CTA (OSS mode only) */}
{!IS_PLATFORM && (
<div className="rounded-xl border border-primary/10 bg-primary/5 p-4">
<h4 className="text-sm font-medium text-foreground">Try CloudCLI Hosted</h4>
<p className="mt-1 text-xs text-muted-foreground">
Team collaboration, shared MCP configs, settings sync across environments, and managed infrastructure.
</p>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
>
Learn more
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
</div>
</div>

View File

@@ -55,7 +55,7 @@ export default function TerminalShortcutsPanel({
wsRef,
terminalRef,
isConnected,
bottomOffset = 'bottom-14',
bottomOffset = 'bottom-0',
}: TerminalShortcutsPanelProps) {
const { t } = useTranslation('settings');
const [ctrlActive, setCtrlActive] = useState(false);

View File

@@ -266,6 +266,7 @@ function Sidebar({
updateAvailable={updateAvailable}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={() => setShowVersionModal(true)}
onShowSettings={onShowSettings}
projectListProps={projectListProps}

View File

@@ -0,0 +1,48 @@
import { Star, X } from 'lucide-react';
import { useGitHubStars } from '../../../../hooks/useGitHubStars';
import { IS_PLATFORM } from '../../../../constants/config';
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
export default function GitHubStarBadge() {
const { formattedCount, isDismissed, dismiss } = useGitHubStars('siteboon', 'claudecodeui');
if (IS_PLATFORM || isDismissed) return null;
return (
<div className="group/star relative hidden md:block">
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<GitHubIcon className="h-3.5 w-3.5" />
<Star className="h-3 w-3" />
<span className="font-medium">Star</span>
{formattedCount && (
<span className="border-l border-border/50 pl-1.5 tabular-nums">{formattedCount}</span>
)}
</a>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
dismiss();
}}
className="absolute -right-1.5 -top-1.5 hidden h-4 w-4 items-center justify-center rounded-full border border-border/50 bg-muted text-muted-foreground transition-colors hover:text-foreground group-hover/star:flex"
aria-label="Dismiss"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { Settings, Sparkles, PanelLeftOpen } from 'lucide-react';
import { Settings, Sparkles, PanelLeftOpen, Bug } from 'lucide-react';
import type { TFunction } from 'i18next';
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
const GITHUB_ISSUES_URL = 'https://github.com/siteboon/claudecodeui/issues/new';
function DiscordIcon({ className }: { className?: string }) {
return (
@@ -50,6 +51,18 @@ export default function SidebarCollapsed({
<Settings className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</button>
{/* Report Issue */}
<a
href={GITHUB_ISSUES_URL}
target="_blank"
rel="noopener noreferrer"
className="group flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-accent/80"
aria-label={t('actions.reportIssue')}
title={t('actions.reportIssue')}
>
<Bug className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
{/* Discord */}
<a
href={DISCORD_INVITE_URL}

View File

@@ -56,6 +56,7 @@ type SidebarContentProps = {
updateAvailable: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
onShowVersionModal: () => void;
onShowSettings: () => void;
projectListProps: SidebarProjectListProps;
@@ -83,6 +84,7 @@ export default function SidebarContent({
updateAvailable,
releaseInfo,
latestVersion,
currentVersion,
onShowVersionModal,
onShowSettings,
projectListProps,
@@ -217,6 +219,7 @@ export default function SidebarContent({
updateAvailable={updateAvailable}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={onShowVersionModal}
onShowSettings={onShowSettings}
t={t}

View File

@@ -1,7 +1,11 @@
import { Settings, ArrowUpCircle } from 'lucide-react';
import { Settings, ArrowUpCircle, Bug } from 'lucide-react';
import type { TFunction } from 'i18next';
import { IS_PLATFORM } from '../../../../constants/config';
import type { ReleaseInfo } from '../../../../types/sharedTypes';
const GITHUB_ISSUES_URL = 'https://github.com/siteboon/claudecodeui/issues/new';
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
function DiscordIcon({ className }: { className?: string }) {
@@ -16,6 +20,7 @@ type SidebarFooterProps = {
updateAvailable: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
onShowVersionModal: () => void;
onShowSettings: () => void;
t: TFunction;
@@ -25,6 +30,7 @@ export default function SidebarFooter({
updateAvailable,
releaseInfo,
latestVersion,
currentVersion,
onShowVersionModal,
onShowSettings,
t,
@@ -79,11 +85,24 @@ export default function SidebarFooter({
</>
)}
{/* Discord + Settings */}
{/* Community + Settings */}
<div className="nav-divider" />
{/* Desktop Discord */}
{/* Desktop Report Issue */}
<div className="hidden px-2 pt-1.5 md:block">
<a
href={GITHUB_ISSUES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<Bug className="h-3.5 w-3.5" />
<span className="text-sm">{t('actions.reportIssue')}</span>
</a>
</div>
{/* Desktop Discord */}
<div className="hidden px-2 md:block">
<a
href={DISCORD_INVITE_URL}
target="_blank"
@@ -106,8 +125,37 @@ export default function SidebarFooter({
</button>
</div>
{/* Mobile Discord */}
{/* Desktop version brand line (OSS mode only) */}
{!IS_PLATFORM && (
<div className="hidden px-3 py-2 text-center md:block">
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-muted-foreground/40 transition-colors hover:text-muted-foreground"
>
CloudCLI v{currentVersion} {t('branding.openSource')}
</a>
</div>
)}
{/* Mobile Report Issue */}
<div className="px-3 pt-3 md:hidden">
<a
href={GITHUB_ISSUES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]"
>
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80">
<Bug className="w-4.5 h-4.5 text-muted-foreground" />
</div>
<span className="text-base font-medium text-foreground">{t('actions.reportIssue')}</span>
</a>
</div>
{/* Mobile Discord */}
<div className="px-3 pt-2 md:hidden">
<a
href={DISCORD_INVITE_URL}
target="_blank"
@@ -122,7 +170,7 @@ export default function SidebarFooter({
</div>
{/* Mobile settings */}
<div className="px-3 pb-20 pt-2 md:hidden">
<div className="px-3 pb-3 pt-2 md:hidden">
<button
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]"
onClick={onShowSettings}

View File

@@ -3,6 +3,7 @@ import type { TFunction } from 'i18next';
import { Button, Input } from '../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../constants/config';
import { cn } from '../../../../lib/utils';
import GitHubStarBadge from './GitHubStarBadge';
type SearchMode = 'projects' | 'conversations';
@@ -106,6 +107,8 @@ export default function SidebarHeader({
</div>
</div>
<GitHubStarBadge />
{/* Search bar */}
{projectsCount > 0 && !isLoading && (
<div className="mt-2.5 space-y-2">

View File

@@ -0,0 +1,77 @@
import { useState, useEffect, useCallback } from 'react';
const CACHE_KEY = 'CLOUDCLI_GITHUB_STARS';
const DISMISS_KEY = 'CLOUDCLI_HIDE_GITHUB_STAR';
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
type CachedStars = {
count: number;
timestamp: number;
};
export const useGitHubStars = (owner: string, repo: string) => {
const [starCount, setStarCount] = useState<number | null>(null);
const [isDismissed, setIsDismissed] = useState(() => {
try {
return localStorage.getItem(DISMISS_KEY) === 'true';
} catch {
return false;
}
});
useEffect(() => {
if (isDismissed) return;
// Check cache first
try {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const parsed: CachedStars = JSON.parse(cached);
if (Date.now() - parsed.timestamp < CACHE_TTL) {
setStarCount(parsed.count);
return;
}
}
} catch {
// ignore
}
const fetchStars = async () => {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
if (!response.ok) return;
const data = await response.json();
const count = data.stargazers_count;
if (typeof count === 'number') {
setStarCount(count);
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ count, timestamp: Date.now() }));
} catch {
// ignore
}
}
} catch {
// silent fail
}
};
void fetchStars();
}, [owner, repo, isDismissed]);
const dismiss = useCallback(() => {
setIsDismissed(true);
try {
localStorage.setItem(DISMISS_KEY, 'true');
} catch {
// ignore
}
}, []);
const formattedCount = starCount !== null
? starCount >= 1000
? `${(starCount / 1000).toFixed(1)}k`
: `${starCount}`
: null;
return { starCount, formattedCount, isDismissed, dismiss };
};

View File

@@ -1,7 +1,7 @@
{
"login": {
"title": "Willkommen zurück",
"description": "Meld dich bei deinem Claude Code UI-Konto an",
"description": "Meld dich bei deinem CloudCLI-Konto an",
"username": "Benutzername",
"password": "Passwort",
"submit": "Anmelden",

View File

@@ -84,7 +84,7 @@
"openInEditor": "Im Editor öffnen"
},
"mainContent": {
"loading": "Claude Code UI wird geladen",
"loading": "CloudCLI wird geladen",
"settingUpWorkspace": "Arbeitsbereich wird eingerichtet...",
"chooseProject": "Projekt auswählen",
"selectProjectDescription": "Wähl ein Projekt aus der Seitenleiste, um mit Claude zu programmieren. Jedes Projekt enthält deine Chat-Sitzungen und den Dateiverlauf.",

View File

@@ -55,8 +55,7 @@
"appearance": "Darstellung",
"toolDisplay": "Werkzeuganzeige",
"viewOptions": "Anzeigeoptionen",
"inputSettings": "Eingabeeinstellungen",
"whisperDictation": "Whisper-Diktat"
"inputSettings": "Eingabeeinstellungen"
},
"darkMode": "Darkmode",
"autoExpandTools": "Werkzeuge automatisch erweitern",
@@ -71,16 +70,6 @@
"openPanel": "Einstellungspanel öffnen",
"draggingStatus": "Wird gezogen...",
"toggleAndMove": "Klicken zum Umschalten, ziehen zum Verschieben"
},
"whisper": {
"modes": {
"default": "Standardmodus",
"defaultDescription": "Direkte Transkription deiner Sprache",
"prompt": "Prompt-Verbesserung",
"promptDescription": "Rohe Ideen in klare, detaillierte KI-Prompts umwandeln",
"vibe": "Vibe-Modus",
"vibeDescription": "Ideen als klare Agentenanweisungen mit Details formatieren"
}
}
},
"terminalShortcuts": {
@@ -105,7 +94,8 @@
"git": "Git",
"apiTokens": "API & Token",
"tasks": "Aufgaben",
"plugins": "Plugins"
"plugins": "Plugins",
"about": "Info"
},
"appearanceSettings": {
"darkMode": {

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "Führ Claude CLI in einem Projektverzeichnis aus, um zu beginnen"
},
"app": {
"title": "Claude Code UI",
"title": "CloudCLI",
"subtitle": "KI-Programmierassistent-Oberfläche"
},
"sessions": {
@@ -65,7 +65,12 @@
"save": "Speichern",
"delete": "Löschen",
"rename": "Umbenennen",
"joinCommunity": "Community beitreten"
"joinCommunity": "Community beitreten",
"reportIssue": "Problem melden",
"starOnGithub": "Stern auf GitHub"
},
"branding": {
"openSource": "Open Source"
},
"status": {
"active": "Aktiv",

View File

@@ -1,7 +1,7 @@
{
"login": {
"title": "Welcome Back",
"description": "Sign in to your Claude Code UI account",
"description": "Sign in to your CloudCLI self-hosted account",
"username": "Username",
"password": "Password",
"submit": "Sign In",

View File

@@ -84,7 +84,7 @@
"openInEditor": "Open in Editor"
},
"mainContent": {
"loading": "Loading Claude Code UI",
"loading": "Loading CloudCLI",
"settingUpWorkspace": "Setting up your workspace...",
"chooseProject": "Choose Your Project",
"selectProjectDescription": "Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.",

View File

@@ -55,8 +55,7 @@
"appearance": "Appearance",
"toolDisplay": "Tool Display",
"viewOptions": "View Options",
"inputSettings": "Input Settings",
"whisperDictation": "Whisper Dictation"
"inputSettings": "Input Settings"
},
"darkMode": "Dark Mode",
"autoExpandTools": "Auto-expand tools",
@@ -71,16 +70,6 @@
"openPanel": "Open settings panel",
"draggingStatus": "Dragging...",
"toggleAndMove": "Click to toggle, drag to move"
},
"whisper": {
"modes": {
"default": "Default Mode",
"defaultDescription": "Direct transcription of your speech",
"prompt": "Prompt Enhancement",
"promptDescription": "Transform rough ideas into clear, detailed AI prompts",
"vibe": "Vibe Mode",
"vibeDescription": "Format ideas as clear agent instructions with details"
}
}
},
"terminalShortcuts": {
@@ -106,8 +95,8 @@
"apiTokens": "API & Tokens",
"tasks": "Tasks",
"notifications": "Notifications",
"plugins": "Plugins"
"plugins": "Plugins",
"about": "About"
},
"notifications": {
"title": "Notifications",
@@ -498,4 +487,4 @@
"tab": "tab",
"runningStatus": "running"
}
}
}

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "Run Claude CLI in a project directory to get started"
},
"app": {
"title": "Claude Code UI",
"title": "CloudCLI",
"subtitle": "AI coding assistant interface"
},
"sessions": {
@@ -65,7 +65,12 @@
"save": "Save",
"delete": "Delete",
"rename": "Rename",
"joinCommunity": "Join Community"
"joinCommunity": "Join Community",
"reportIssue": "Report Issue",
"starOnGithub": "Star on GitHub"
},
"branding": {
"openSource": "Open Source"
},
"status": {
"active": "Active",

View File

@@ -1,7 +1,7 @@
{
"login": {
"title": "おかえりなさい",
"description": "Claude Code UIアカウントにサインイン",
"description": "CloudCLIアカウントにサインイン",
"username": "ユーザー名",
"password": "パスワード",
"submit": "サインイン",

View File

@@ -84,7 +84,7 @@
"openInEditor": "エディタで開く"
},
"mainContent": {
"loading": "Claude Code UI を読み込んでいます",
"loading": "CloudCLI を読み込んでいます",
"settingUpWorkspace": "ワークスペースを準備しています...",
"chooseProject": "プロジェクトを選択",
"selectProjectDescription": "サイドバーからプロジェクトを選択して、Claudeとコーディングを始めましょう。各プロジェクトにはチャットセッションとファイル履歴が含まれています。",

View File

@@ -55,8 +55,7 @@
"appearance": "外観",
"toolDisplay": "ツール表示",
"viewOptions": "表示オプション",
"inputSettings": "入力設定",
"whisperDictation": "Whisper音声入力"
"inputSettings": "入力設定"
},
"darkMode": "ダークモード",
"autoExpandTools": "ツールを自動展開",
@@ -71,16 +70,6 @@
"openPanel": "設定パネルを開く",
"draggingStatus": "ドラッグ中...",
"toggleAndMove": "クリックで切替、ドラッグで移動"
},
"whisper": {
"modes": {
"default": "標準モード",
"defaultDescription": "音声をそのまま文字起こしします",
"prompt": "プロンプト強化",
"promptDescription": "ラフなアイデアを明確で詳細なAIプロンプトに変換します",
"vibe": "バイブモード",
"vibeDescription": "アイデアを明確なエージェント指示に整形します"
}
}
},
"terminalShortcuts": {
@@ -106,8 +95,8 @@
"apiTokens": "API & トークン",
"tasks": "タスク",
"notifications": "通知",
"plugins": "プラグイン"
"plugins": "プラグイン",
"about": "概要"
},
"notifications": {
"title": "通知",
@@ -492,4 +481,4 @@
"tab": "タブ",
"runningStatus": "実行中"
}
}
}

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "プロジェクトディレクトリでClaude CLIを実行して始めましょう"
},
"app": {
"title": "Claude Code UI",
"title": "CloudCLI",
"subtitle": "AIコーディングアシスタント"
},
"sessions": {
@@ -64,7 +64,12 @@
"save": "保存",
"delete": "削除",
"rename": "名前の変更",
"joinCommunity": "コミュニティに参加"
"joinCommunity": "コミュニティに参加",
"reportIssue": "問題を報告",
"starOnGithub": "GitHubでスター"
},
"branding": {
"openSource": "オープンソース"
},
"status": {
"active": "アクティブ",

View File

@@ -1,7 +1,7 @@
{
"login": {
"title": "다시 오신 것을 환영합니다",
"description": "Claude Code UI 계정에 로그인하세요",
"description": "CloudCLI 계정에 로그인하세요",
"username": "사용자명",
"password": "비밀번호",
"submit": "로그인",

View File

@@ -84,7 +84,7 @@
"openInEditor": "에디터에서 열기"
},
"mainContent": {
"loading": "Claude Code UI 로딩 중",
"loading": "CloudCLI 로딩 중",
"settingUpWorkspace": "워크스페이스 설정 중...",
"chooseProject": "프로젝트 선택",
"selectProjectDescription": "사이드바에서 프로젝트를 선택하여 Claude와 코딩을 시작하세요. 각 프로젝트에는 채팅 세션과 파일 히스토리가 포함됩니다.",

View File

@@ -55,8 +55,7 @@
"appearance": "외관",
"toolDisplay": "도구 표시",
"viewOptions": "보기 옵션",
"inputSettings": "입력 설정",
"whisperDictation": "Whisper 음성 인식"
"inputSettings": "입력 설정"
},
"darkMode": "다크 모드",
"autoExpandTools": "도구 자동 펼치기",
@@ -71,16 +70,6 @@
"openPanel": "설정 패널 열기",
"draggingStatus": "드래그 중...",
"toggleAndMove": "클릭하여 토글, 드래그하여 이동"
},
"whisper": {
"modes": {
"default": "기본 모드",
"defaultDescription": "음성을 그대로 텍스트로 변환",
"prompt": "프롬프트 향상",
"promptDescription": "거친 아이디어를 명확하고 상세한 AI 프롬프트로 변환",
"vibe": "Vibe 모드",
"vibeDescription": "아이디어를 상세한 에이전트 지침 형식으로 변환"
}
}
},
"terminalShortcuts": {
@@ -106,8 +95,8 @@
"apiTokens": "API & 토큰",
"tasks": "작업",
"notifications": "알림",
"plugins": "플러그인"
"plugins": "플러그인",
"about": "정보"
},
"notifications": {
"title": "알림",
@@ -492,4 +481,4 @@
"tab": "탭",
"runningStatus": "실행 중"
}
}
}

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "프로젝트 디렉토리에서 Claude CLI를 실행하여 시작하세요"
},
"app": {
"title": "Claude Code UI",
"title": "CloudCLI",
"subtitle": "AI 코딩 어시스턴트 UI"
},
"sessions": {
@@ -64,7 +64,12 @@
"save": "저장",
"delete": "삭제",
"rename": "이름 변경",
"joinCommunity": "커뮤니티 참여"
"joinCommunity": "커뮤니티 참여",
"reportIssue": "문제 신고",
"starOnGithub": "GitHub에서 스타"
},
"branding": {
"openSource": "오픈 소스"
},
"status": {
"active": "활성",

View File

@@ -1,7 +1,7 @@
{
"login": {
"title": "Добро пожаловать",
"description": "Войдите в свой аккаунт Claude Code UI",
"description": "Войдите в свой аккаунт CloudCLI",
"username": "Имя пользователя",
"password": "Пароль",
"submit": "Войти",

View File

@@ -84,7 +84,7 @@
"openInEditor": "Открыть в редакторе"
},
"mainContent": {
"loading": "Загрузка Claude Code UI",
"loading": "Загрузка CloudCLI",
"settingUpWorkspace": "Настройка рабочего пространства...",
"chooseProject": "Выберите проект",
"selectProjectDescription": "Выберите проект на боковой панели, чтобы начать работу с Claude. Каждый проект содержит ваши сеансы чата и историю файлов.",

View File

@@ -55,8 +55,7 @@
"appearance": "Внешний вид",
"toolDisplay": "Отображение инструментов",
"viewOptions": "Параметры просмотра",
"inputSettings": "Настройки ввода",
"whisperDictation": "Диктовка Whisper"
"inputSettings": "Настройки ввода"
},
"darkMode": "Темная тема",
"autoExpandTools": "Автоматически разворачивать инструменты",
@@ -71,16 +70,6 @@
"openPanel": "Открыть панель настроек",
"draggingStatus": "Перетаскивание...",
"toggleAndMove": "Нажмите для переключения, перетащите для перемещения"
},
"whisper": {
"modes": {
"default": "Режим по умолчанию",
"defaultDescription": "Прямая транскрипция вашей речи",
"prompt": "Улучшение запроса",
"promptDescription": "Преобразование грубых идей в четкие, детальные AI-запросы",
"vibe": "Режим Vibe",
"vibeDescription": "Форматирование идей как четких инструкций агента с деталями"
}
}
},
"terminalShortcuts": {
@@ -105,7 +94,8 @@
"git": "Git",
"apiTokens": "API и токены",
"tasks": "Задачи",
"plugins": "Плагины"
"plugins": "Плагины",
"about": "О программе"
},
"appearanceSettings": {
"darkMode": {
@@ -471,4 +461,4 @@
"tab": "вкладка",
"runningStatus": "запущен"
}
}
}

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "Запустите Claude CLI в каталоге проекта для начала работы"
},
"app": {
"title": "Claude Code UI",
"title": "CloudCLI",
"subtitle": "Интерфейс AI помощника для программирования"
},
"sessions": {
@@ -65,7 +65,12 @@
"save": "Сохранить",
"delete": "Удалить",
"rename": "Переименовать",
"joinCommunity": "Присоединиться к сообществу"
"joinCommunity": "Присоединиться к сообществу",
"reportIssue": "Сообщить о проблеме",
"starOnGithub": "Звезда на GitHub"
},
"branding": {
"openSource": "Открытый исходный код"
},
"status": {
"active": "Активен",

View File

@@ -1,7 +1,7 @@
{
"login": {
"title": "欢迎回来",
"description": "登录您的 Claude Code UI 账户",
"description": "登录您的 CloudCLI 账户",
"username": "用户名",
"password": "密码",
"submit": "登录",

View File

@@ -84,7 +84,7 @@
"openInEditor": "在编辑器中打开"
},
"mainContent": {
"loading": "正在加载 Claude Code UI",
"loading": "正在加载 CloudCLI",
"settingUpWorkspace": "正在设置您的工作空间...",
"chooseProject": "选择您的项目",
"selectProjectDescription": "从侧边栏选择一个项目以开始使用 Claude 进行编程。每个项目包含您的聊天会话和文件历史。",

View File

@@ -55,8 +55,7 @@
"appearance": "外观",
"toolDisplay": "工具显示",
"viewOptions": "视图选项",
"inputSettings": "输入设置",
"whisperDictation": "Whisper 听写"
"inputSettings": "输入设置"
},
"darkMode": "深色模式",
"autoExpandTools": "自动展开工具",
@@ -71,16 +70,6 @@
"openPanel": "打开设置面板",
"draggingStatus": "正在拖拽...",
"toggleAndMove": "点击切换,拖拽移动"
},
"whisper": {
"modes": {
"default": "默认模式",
"defaultDescription": "直接转录您的语音",
"prompt": "提示词增强",
"promptDescription": "将粗略的想法转化为清晰、详细的 AI 提示词",
"vibe": "Vibe 模式",
"vibeDescription": "将想法格式化为带有详细说明的清晰智能体指令"
}
}
},
"terminalShortcuts": {
@@ -106,8 +95,8 @@
"apiTokens": "API 和令牌",
"tasks": "任务",
"notifications": "通知",
"plugins": "插件"
"plugins": "插件",
"about": "关于"
},
"notifications": {
"title": "通知",
@@ -492,4 +481,4 @@
"tab": "标签",
"runningStatus": "运行中"
}
}
}

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用"
},
"app": {
"title": "Claude Code UI",
"title": "CloudCLI",
"subtitle": "AI 编程助手"
},
"sessions": {
@@ -65,7 +65,12 @@
"save": "保存",
"delete": "删除",
"rename": "重命名",
"joinCommunity": "加入社区"
"joinCommunity": "加入社区",
"reportIssue": "报告问题",
"starOnGithub": "在GitHub上加星"
},
"branding": {
"openSource": "开源"
},
"status": {
"active": "活动",

View File

@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '../../../lib/utils';
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
@@ -11,21 +12,6 @@ type TooltipProps = {
delay?: number;
};
function getPositionClasses(position: TooltipPosition): string {
switch (position) {
case 'top':
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
case 'bottom':
return 'top-full left-1/2 transform -translate-x-1/2 mt-2';
case 'left':
return 'right-full top-1/2 transform -translate-y-1/2 mr-2';
case 'right':
return 'left-full top-1/2 transform -translate-y-1/2 ml-2';
default:
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
}
}
function getArrowClasses(position: TooltipPosition): string {
switch (position) {
case 'top':
@@ -46,11 +32,54 @@ function Tooltip({
content,
position = 'top',
className = '',
delay = 500,
delay = 350,
}: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
// Store the timer id without forcing re-renders while hovering.
const timeoutRef = useRef<number | null>(null);
const longPressTriggeredRef = useRef(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties | null>(null);
const updateTooltipPosition = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const spacing = 8;
const style: React.CSSProperties = {
position: 'fixed',
zIndex: 9999,
};
// Calculate tooltip position based on the specified position prop.
switch (position) {
case 'bottom':
style.left = rect.left + rect.width / 2;
style.top = rect.bottom + spacing;
style.transform = 'translateX(-50%)';
break;
case 'left':
style.left = rect.left - spacing;
style.top = rect.top + rect.height / 2;
style.transform = 'translate(-100%, -50%)';
break;
case 'right':
style.left = rect.right + spacing;
style.top = rect.top + rect.height / 2;
style.transform = 'translateY(-50%)';
break;
case 'top':
default:
style.left = rect.left + rect.width / 2;
style.top = rect.top - spacing;
style.transform = 'translate(-50%, -100%)';
break;
}
setTooltipStyle(style);
}, [position]);
const clearTooltipTimer = () => {
if (timeoutRef.current !== null) {
@@ -71,6 +100,23 @@ function Tooltip({
setIsVisible(false);
};
const handleTouchStart = () => {
clearTooltipTimer();
longPressTriggeredRef.current = false;
timeoutRef.current = window.setTimeout(() => {
longPressTriggeredRef.current = true;
setIsVisible(true);
}, delay);
};
const handleTouchEnd = () => {
clearTooltipTimer();
if (longPressTriggeredRef.current) {
return;
}
setIsVisible(false);
};
useEffect(() => {
// Avoid delayed updates after unmount.
return () => {
@@ -78,26 +124,73 @@ function Tooltip({
};
}, []);
useEffect(() => {
if (!isVisible || typeof document === 'undefined') {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && containerRef.current?.contains(target)) {
return;
}
setIsVisible(false);
longPressTriggeredRef.current = false;
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
}, [isVisible]);
useEffect(() => {
if (!isVisible) {
setTooltipStyle(null);
return;
}
const rafId = window.requestAnimationFrame(updateTooltipPosition);
const handleViewportChange = () => updateTooltipPosition();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [isVisible, updateTooltipPosition]);
if (!content) {
return <>{children}</>;
}
return (
<div className="relative inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div
ref={containerRef}
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{children}
{isVisible && (
{isVisible && typeof document !== 'undefined' && createPortal(
<div
ref={tooltipRef}
style={tooltipStyle || { position: 'fixed', top: '-9999px', left: '-9999px', opacity: 0 }}
className={cn(
'absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none',
'px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none',
'animate-in fade-in-0 zoom-in-95 duration-200',
getPositionClasses(position),
className
)}
>
{content}
{/* Arrow */}
<div className={cn('absolute w-0 h-0 border-4 border-transparent', getArrowClasses(position))} />
</div>
</div>,
document.body
)}
</div>
);

View File

@@ -111,6 +111,8 @@ export const api = {
}),
readFile: (projectName, filePath) =>
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
readFileBlob: (projectName, filePath) =>
authenticatedFetch(`/api/projects/${projectName}/files/content?path=${encodeURIComponent(filePath)}`),
saveFile: (projectName, filePath, content) =>
authenticatedFetch(`/api/projects/${projectName}/file`, {
method: 'PUT',
@@ -145,13 +147,6 @@ export const api = {
headers: {}, // Let browser set Content-Type for FormData
}),
transcribe: (formData) =>
authenticatedFetch('/api/transcribe', {
method: 'POST',
body: formData,
headers: {}, // Let browser set Content-Type for FormData
}),
// TaskMaster endpoints
taskmaster: {
// Initialize TaskMaster in a project
@@ -242,4 +237,4 @@ export const api = {
method: 'DELETE',
...options,
}),
};
};