Compare commits

..

2 Commits

Author SHA1 Message Date
Haileyesus
04edf7d9c4 fix: harden token usage reporting
Token usage is shown as concrete counts, so malformed provider payloads must not leak NaN.

Gemini can emit token fields as strings or invalid values, so non-finite values now fall back to 0.

OpenCode token reads happen while the CLI is shutting down, when the DB may be missing or locked.

Those failures now return null instead of interrupting session completion.

/cost no longer invents an input breakdown from an aggregate total.

When a provider only supplies total usage, the UI now says the breakdown is unavailable.

This keeps the display honest instead of presenting made-up input and output rows.

Verification: npm run typecheck; targeted eslint.
2026-05-29 17:43:22 +03:00
Haileyesus
ed9f0d74aa fix: refine token usage reporting
The old token UI mixed context pressure, cache counters, and dollar estimates.

That made the percentage look precise even when provider data was incomplete or different.

The composer and /cost view now show concrete counts instead of a pie percentage.

Token payloads now share a smaller shape: used, inputTokens, outputTokens, and breakdown.

Claude uses per-step usage where available and Codex reads total_token_usage events.

Gemini reads its tokens object without inventing a context window.

OpenCode reads opencode.db session totals and includes all token columns in used.

The /cost backend no longer returns cache display fields or input/output dollar estimates.

This avoids derived values that look reliable but are not comparable across providers.

Verification: npm run typecheck; targeted eslint; OpenCode session provider test.
2026-05-29 15:30:21 +03:00
21 changed files with 232 additions and 513 deletions

View File

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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.33.0",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cloudcli-ai/cloudcli",
"version": "1.33.0",
"version": "1.32.0",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {

View File

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

View File

@@ -16,10 +16,6 @@ type ClaudeCredentialsStatus = {
error?: string;
};
const hasErrorCode = (error: unknown, code: string): boolean => (
error instanceof Error && 'code' in error && error.code === code
);
export class ClaudeProviderAuth implements IProviderAuth {
/**
* Checks whether the Claude Code CLI is available on this host.
@@ -81,8 +77,6 @@ export class ClaudeProviderAuth implements IProviderAuth {
* Checks Claude credentials in the same priority order used by Claude Code.
*/
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
@@ -116,33 +110,15 @@ export class ClaudeProviderAuth implements IProviderAuth {
return {
authenticated: false,
email: null,
method: null,
error: 'Claude login has expired. Run claude /login again.',
email,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login',
};
}
return {
authenticated: false,
email: null,
method: null,
error: missingCredentialsError,
};
} catch (error) {
let errorMessage = 'Unable to read Claude credentials. Run claude /login again.';
if (hasErrorCode(error, 'ENOENT')) {
errorMessage = missingCredentialsError;
} else if (error instanceof SyntaxError) {
errorMessage = 'Claude credentials are unreadable. Run claude /login again.';
}
return {
authenticated: false,
email: null,
method: null,
error: errorMessage,
};
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}
}

View File

@@ -26,15 +26,15 @@ export function handlePluginWsProxy(
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
});
upstream.on('message', (data, isBinary) => {
upstream.on('message', (data) => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data, { binary: isBinary });
clientWs.send(data);
}
});
clientWs.on('message', (data, isBinary) => {
clientWs.on('message', (data) => {
if (upstream.readyState === WebSocket.OPEN) {
upstream.send(data, { binary: isBinary });
upstream.send(data);
}
});

View File

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

View File

@@ -401,6 +401,14 @@ export default function ChatComposer({
<PromptInputSubmit
disabled={!input.trim() || isLoading}
className="h-10 w-10 sm:h-10 sm:w-10"
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event as unknown as MouseEvent<HTMLButtonElement>);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event as unknown as TouchEvent<HTMLButtonElement>);
}}
/>
</div>
</PromptInputFooter>

View File

@@ -1,93 +1,12 @@
import { useState, type ReactNode } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Activity,
BarChart3,
BookOpen,
Clock,
Download,
ExternalLink,
GitBranch,
Loader2,
RefreshCw,
ServerCrash,
ShieldAlert,
Terminal,
Trash2,
type LucideIcon,
} from 'lucide-react';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
import { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../../contexts/PluginsContext';
import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
type PluginRecommendation = {
id: string;
translationKey: string;
repoUrl: string;
installedNames: string[];
icon: LucideIcon;
source: 'official' | 'unofficial';
};
const OFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
{
id: 'project-stats',
translationKey: 'starterPlugin',
repoUrl: STARTER_PLUGIN_URL,
installedNames: ['project-stats'],
icon: BarChart3,
source: 'official',
},
{
id: 'web-terminal',
translationKey: 'terminalPlugin',
repoUrl: TERMINAL_PLUGIN_URL,
installedNames: ['web-terminal'],
icon: Terminal,
source: 'official',
},
];
const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
{
id: 'cloudcli-claude-watch',
translationKey: 'claudeWatchPlugin',
repoUrl: CLAUDE_WATCH_PLUGIN_URL,
installedNames: ['cloudcli-claude-watch'],
icon: Activity,
source: 'unofficial',
},
{
id: 'workspace-scheduled-prompts',
translationKey: 'scheduledPromptPlugin',
repoUrl: SCHEDULED_PROMPT_PLUGIN_URL,
installedNames: ['workspace-scheduled-prompts'],
icon: Clock,
source: 'unofficial',
},
];
function repoSlug(repoUrl: string) {
return repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '');
}
function normalizeRepoUrl(repoUrl: string | null) {
return repoUrl?.replace(/\.git$/, '').replace(/\/$/, '').toLowerCase() ?? null;
}
function pluginMatchesRecommendation(plugin: Plugin, recommendation: PluginRecommendation) {
return (
recommendation.installedNames.includes(plugin.name)
|| normalizeRepoUrl(plugin.repoUrl) === normalizeRepoUrl(recommendation.repoUrl)
);
}
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
@@ -289,95 +208,117 @@ function PluginCard({
);
}
/* ─── Recommendation Section ────────────────────────────────────────────── */
function RecommendationSection({
title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}) {
return (
<section className="space-y-2">
<div>
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</h4>
<p className="mt-0.5 text-xs text-muted-foreground/70">
{description}
</p>
</div>
<div className="space-y-2">
{children}
</div>
</section>
);
}
/* ─── Plugin Recommendation Card ────────────────────────────────────────── */
function PluginRecommendationCard({
recommendation,
onInstall,
disabled,
installing,
}: {
recommendation: PluginRecommendation;
onInstall: () => void;
disabled: boolean;
installing: boolean;
}) {
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
const { t } = useTranslation('settings');
const Icon = recommendation.icon;
const isOfficial = recommendation.source === 'official';
const accentClass = isOfficial ? 'bg-blue-500/30' : 'bg-amber-500/40';
const hoverClass = isOfficial ? 'hover:border-blue-400 dark:hover:border-blue-500' : 'hover:border-amber-400 dark:hover:border-amber-500';
const iconClass = isOfficial ? 'text-blue-500' : 'text-amber-500';
return (
<div className={`relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 ${hoverClass}`}>
<div className={`w-[3px] flex-shrink-0 ${accentClass}`} />
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
<div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className={`h-5 w-5 flex-shrink-0 ${iconClass}`}>
<Icon className="h-5 w-5" />
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
<BarChart3 className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground">
{t(`pluginSettings.${recommendation.translationKey}.name`)}
{t('pluginSettings.starterPlugin.name')}
</span>
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
{t('pluginSettings.starterPlugin.badge')}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t('pluginSettings.tab')}
</span>
</div>
<p className="mt-1 text-sm leading-snug text-muted-foreground">
{t(`pluginSettings.${recommendation.translationKey}.description`)}
{t('pluginSettings.starterPlugin.description')}
</p>
<a
href={recommendation.repoUrl}
href={STARTER_PLUGIN_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
<GitBranch className="h-3 w-3" />
{repoSlug(recommendation.repoUrl)}
cloudcli-ai/cloudcli-plugin-starter
</a>
</div>
</div>
<button
onClick={onInstall}
disabled={disabled}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50"
disabled={installing}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{installing ? t('pluginSettings.installing') : t(`pluginSettings.${recommendation.translationKey}.install`)}
{installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}
</button>
</div>
</div>
</div>
);
}
/* ─── Terminal Plugin Card ──────────────────────────────────────────────── */
function TerminalPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
const { t } = useTranslation('settings');
return (
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
<div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M7 8l4 4-4 4"/>
<line x1="13" y1="16" x2="17" y2="16"/>
</svg>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground">
{t('pluginSettings.terminalPlugin.name')}
</span>
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
{t('pluginSettings.terminalPlugin.badge')}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t('pluginSettings.tab')}
</span>
</div>
<p className="mt-1 text-sm leading-snug text-muted-foreground">
{t('pluginSettings.terminalPlugin.description')}
</p>
<a
href={TERMINAL_PLUGIN_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
<GitBranch className="h-3 w-3" />
cloudcli-ai/cloudcli-plugin-terminal
</a>
</div>
</div>
<button
onClick={onInstall}
disabled={installing}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{installing ? t('pluginSettings.installing') : t('pluginSettings.terminalPlugin.install')}
</button>
</div>
</div>
@@ -393,7 +334,8 @@ export default function PluginSettingsTab() {
const [gitUrl, setGitUrl] = useState('');
const [installing, setInstalling] = useState(false);
const [installingRecommendation, setInstallingRecommendation] = useState<string | null>(null);
const [installingStarter, setInstallingStarter] = useState(false);
const [installingTerminal, setInstallingTerminal] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
@@ -422,18 +364,24 @@ export default function PluginSettingsTab() {
setInstalling(false);
};
const handleInstallRecommendation = async (recommendation: PluginRecommendation) => {
if (installingRecommendation) return;
setInstallingRecommendation(recommendation.id);
const handleInstallStarter = async () => {
setInstallingStarter(true);
setInstallError(null);
try {
const result = await installPlugin(recommendation.repoUrl);
if (!result.success) {
setInstallError(result.error || t('pluginSettings.installFailed'));
}
} finally {
setInstallingRecommendation(null);
const result = await installPlugin(STARTER_PLUGIN_URL);
if (!result.success) {
setInstallError(result.error || t('pluginSettings.installFailed'));
}
setInstallingStarter(false);
};
const handleInstallTerminal = async () => {
setInstallingTerminal(true);
setInstallError(null);
const result = await installPlugin(TERMINAL_PLUGIN_URL);
if (!result.success) {
setInstallError(result.error || t('pluginSettings.installFailed'));
}
setInstallingTerminal(false);
};
const handleUninstall = async (name: string) => {
@@ -450,50 +398,8 @@ export default function PluginSettingsTab() {
}
};
const isRecommendationInstalled = (recommendation: PluginRecommendation) => {
return plugins.some((plugin) => pluginMatchesRecommendation(plugin, recommendation));
};
const isOfficialPlugin = (plugin: Plugin) => {
return OFFICIAL_PLUGIN_RECOMMENDATIONS.some((recommendation) => (
pluginMatchesRecommendation(plugin, recommendation)
));
};
const officialPlugins = plugins.filter(isOfficialPlugin);
const otherPlugins = plugins.filter((plugin) => !isOfficialPlugin(plugin));
const officialRecommendations = OFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
(recommendation) => !isRecommendationInstalled(recommendation),
);
const unofficialRecommendations = UNOFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
(recommendation) => !isRecommendationInstalled(recommendation),
);
const hasOfficialSection = officialPlugins.length > 0 || officialRecommendations.length > 0;
const hasOtherSection = otherPlugins.length > 0 || unofficialRecommendations.length > 0;
const renderPluginCard = (plugin: Plugin, index: number) => {
const handleToggle = async (enabled: boolean) => {
const r = await togglePlugin(plugin.name, enabled);
if (!r.success) {
setInstallError(r.error || t('pluginSettings.toggleFailed'));
}
};
return (
<PluginCard
key={plugin.name}
plugin={plugin}
index={index}
onToggle={(enabled) => void handleToggle(enabled)}
onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugins.has(plugin.name)}
confirmingUninstall={confirmUninstall === plugin.name}
onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null}
/>
);
};
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal');
return (
<div className="space-y-6">
@@ -550,49 +456,51 @@ export default function PluginSettingsTab() {
</span>
</p>
{/* Plugin sections */}
{/* Official plugin suggestions — above the list */}
{!loading && (!hasStarterInstalled || !hasTerminalInstalled) && (
<div className="space-y-2">
{!hasStarterInstalled && (
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
)}
{!hasTerminalInstalled && (
<TerminalPluginCard onInstall={handleInstallTerminal} installing={installingTerminal} />
)}
</div>
)}
{/* Plugin List */}
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('pluginSettings.scanningPlugins')}
</div>
) : plugins.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
) : (
<div className="space-y-4">
{hasOfficialSection && (
<RecommendationSection
title={t('pluginSettings.sections.officialTitle')}
description={t('pluginSettings.sections.officialDescription')}
>
{officialPlugins.map((plugin, index) => renderPluginCard(plugin, index))}
{officialRecommendations.map((recommendation) => (
<PluginRecommendationCard
key={recommendation.id}
recommendation={recommendation}
onInstall={() => void handleInstallRecommendation(recommendation)}
disabled={!!installingRecommendation}
installing={installingRecommendation === recommendation.id}
/>
))}
</RecommendationSection>
)}
<div className="space-y-2">
{plugins.map((plugin, index) => {
const handleToggle = async (enabled: boolean) => {
const r = await togglePlugin(plugin.name, enabled);
if (!r.success) {
setInstallError(r.error || t('pluginSettings.toggleFailed'));
}
};
{hasOtherSection && (
<RecommendationSection
title={t('pluginSettings.sections.unofficialTitle')}
description={t('pluginSettings.sections.unofficialDescription')}
>
{otherPlugins.map((plugin, index) => renderPluginCard(plugin, officialPlugins.length + index))}
{unofficialRecommendations.map((recommendation) => (
<PluginRecommendationCard
key={recommendation.id}
recommendation={recommendation}
onInstall={() => void handleInstallRecommendation(recommendation)}
disabled={!!installingRecommendation}
installing={installingRecommendation === recommendation.id}
/>
))}
</RecommendationSection>
)}
return (
<PluginCard
key={plugin.name}
plugin={plugin}
index={index}
onToggle={(enabled) => void handleToggle(enabled)}
onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugins.has(plugin.name)}
confirmingUninstall={confirmUninstall === plugin.name}
onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null}
/>
);
})}
</div>
)}

View File

@@ -70,39 +70,34 @@ export function useProviderAuthStatus(
}));
}, []);
const checkProviderAuthStatus = useCallback(async (provider: LLMProvider): Promise<ProviderAuthStatus> => {
const checkProviderAuthStatus = useCallback(async (provider: LLMProvider) => {
setProviderLoading(provider);
try {
const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]);
if (!response.ok) {
const status: ProviderAuthStatus = {
setProviderStatus(provider, {
authenticated: false,
email: null,
method: null,
loading: false,
error: FALLBACK_STATUS_ERROR,
};
setProviderStatus(provider, status);
return status;
});
return;
}
const payload = (await response.json()) as ProviderAuthStatusApiResponse;
const status = toProviderAuthStatus(payload.data);
setProviderStatus(provider, status);
return status;
setProviderStatus(provider, toProviderAuthStatus(payload.data));
} catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError);
const status: ProviderAuthStatus = {
setProviderStatus(provider, {
authenticated: false,
email: null,
method: null,
loading: false,
error: toErrorMessage(caughtError),
};
setProviderStatus(provider, status);
return status;
});
}
}, [setProviderLoading, setProviderStatus]);

View File

@@ -213,19 +213,12 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
}, []);
const handleLoginComplete = useCallback((exitCode: number) => {
if (!loginProvider) {
if (exitCode !== 0 || !loginProvider) {
return;
}
void (async () => {
const authStatus = await checkProviderAuthStatus(loginProvider);
if (exitCode !== 0) {
console.warn(`Login process exited with code ${exitCode}; refreshing auth status before setting save status.`);
}
setSaveStatus(authStatus.authenticated ? 'success' : 'error');
})();
setSaveStatus('success');
void checkProviderAuthStatus(loginProvider);
}, [checkProviderAuthStatus, loginProvider]);
const saveSettings = useCallback(async () => {

View File

@@ -1,4 +1,4 @@
import { Check, ChevronDown, ChevronRight, Edit3, Star, Trash2, X } from 'lucide-react';
import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui';
@@ -131,28 +131,18 @@ export default function SidebarProjectItem({
>
<div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3">
<button
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border',
isStarred
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
isExpanded ? 'bg-primary/10' : 'bg-muted',
)}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-4 h-4 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-gray-600 dark:text-gray-400',
)}
/>
</button>
{isExpanded ? (
<FolderOpen className="h-4 w-4 text-primary" />
) : (
<Folder className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
{isEditing ? (
@@ -222,6 +212,29 @@ export default function SidebarProjectItem({
</>
) : (
<>
<button
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border',
isStarred
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
)}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-4 h-4 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-gray-600 dark:text-gray-400',
)}
/>
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30"
onClick={(event) => {
@@ -268,28 +281,11 @@ export default function SidebarProjectItem({
onClick={selectAndToggleProject}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
'w-6 h-6 flex items-center justify-center rounded cursor-pointer transition-all duration-200',
isStarred
? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20'
: 'opacity-40 hover:opacity-100 hover:bg-accent',
)}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-3 h-3 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-muted-foreground',
)}
/>
</div>
{isExpanded ? (
<FolderOpen className="h-4 w-4 flex-shrink-0 text-primary" />
) : (
<Folder className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1 text-left">
{isEditing ? (
<div className="space-y-1">
@@ -356,6 +352,26 @@ export default function SidebarProjectItem({
</>
) : (
<>
<div
className={cn(
'w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center rounded cursor-pointer touch:opacity-100',
isStarred ? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20 opacity-100' : 'hover:bg-accent',
)}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-3 h-3 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-muted-foreground',
)}
/>
</div>
<div
className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100"
onClick={(event) => {

View File

@@ -1,8 +1,7 @@
import { useEffect, useRef } from 'react';
import { Check, Edit2, Trash2, X } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
import { Badge, Button } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { SessionWithProvider } from '../../types/types';
@@ -77,28 +76,7 @@ export default function SidebarSessionItem({
}: SidebarSessionItemProps) {
const sessionView = createSessionViewModel(session, currentTime, t);
const isSelected = selectedSession?.id === session.id;
const isEditing = editingSession === session.id;
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
const editingContainerRef = useRef<HTMLDivElement>(null);
// The rename panel sits inside a group-hover opacity wrapper, so leaving the row
// would visually hide it. While editing, dismiss only when the user clicks outside
// the panel (matches Escape / cancel-button behaviour).
useEffect(() => {
if (!isEditing) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
const container = editingContainerRef.current;
if (container && !container.contains(event.target as Node)) {
onCancelEditingSession();
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [isEditing, onCancelEditingSession]);
// Sessions are owned by a project identified by `projectId` (DB primary key)
// after the projectName → projectId migration.
@@ -119,13 +97,7 @@ export default function SidebarSessionItem({
<div className="group relative">
{sessionView.isActive && (
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
<div
role="status"
aria-label={t('tooltips.activeSessionIndicator')}
className="h-2 w-2 animate-pulse rounded-full bg-green-500"
/>
</Tooltip>
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500" />
</div>
)}
@@ -196,12 +168,7 @@ export default function SidebarSessionItem({
<div className="flex items-center gap-2">
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
{compactSessionAge && (
<span
className={cn(
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
)}
>
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
{compactSessionAge}
</span>
)}
@@ -213,14 +180,8 @@ export default function SidebarSessionItem({
</div>
</Button>
<div
ref={editingContainerRef}
className={cn(
'absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 transition-all duration-200',
isEditing ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
>
{isEditing ? (
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 opacity-0 transition-all duration-200 group-hover:opacity-100">
{editingSession === session.id ? (
<>
<input
type="text"

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Aus Favoriten entfernen",
"editSessionName": "Sitzungsname manuell bearbeiten",
"deleteSession": "Diese Sitzung dauerhaft löschen",
"activeSessionIndicator": "Kürzlich aktive Sitzung (letzte 10 Minuten)",
"save": "Speichern",
"cancel": "Abbrechen",
"clearSearch": "Suche leeren",

View File

@@ -472,12 +472,6 @@
"starterPluginLabel": "Starter Plugin",
"starter": "Starter",
"docs": "Docs",
"sections": {
"officialTitle": "Official Plugins",
"officialDescription": "Maintained by the CloudCLI team and ready for direct install.",
"unofficialTitle": "Other Plugins",
"unofficialDescription": "Unofficial plugins and integrations from other users. Review the source before installing."
},
"starterPlugin": {
"name": "Project Stats",
"badge": "starter",
@@ -490,18 +484,6 @@
"description": "Integrated terminal with full shell access directly within the interface.",
"install": "Install"
},
"scheduledPromptPlugin": {
"name": "Scheduled Prompts",
"badge": "unofficial",
"description": "Schedule workspace prompts, review run history, and manage recurring local tasks.",
"install": "Install"
},
"claudeWatchPlugin": {
"name": "Claude Watch",
"badge": "unofficial",
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
"install": "Install"
},
"morePlugins": "More",
"enable": "Enable",
"disable": "Disable",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Remove from favorites",
"editSessionName": "Manually edit session name",
"deleteSession": "Delete this session permanently",
"activeSessionIndicator": "Recently active session (last 10 minutes)",
"save": "Save",
"cancel": "Cancel",
"clearSearch": "Clear search",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Rimuovi dai preferiti",
"editSessionName": "Modifica manualmente il nome della sessione",
"deleteSession": "Elimina questa sessione permanentemente",
"activeSessionIndicator": "Sessione attiva di recente (ultimi 10 minuti)",
"save": "Salva",
"cancel": "Annulla",
"clearSearch": "Cancella ricerca",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "お気に入りから削除",
"editSessionName": "セッション名を手動で編集",
"deleteSession": "このセッションを完全に削除",
"activeSessionIndicator": "最近アクティブなセッション過去10分以内",
"save": "保存",
"cancel": "キャンセル",
"openCommandPalette": "コマンドパレットを開く"

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "즐겨찾기에서 제거",
"editSessionName": "세션 이름 직접 편집",
"deleteSession": "이 세션 영구 삭제",
"activeSessionIndicator": "최근 활성 세션 (지난 10분)",
"save": "저장",
"cancel": "취소",
"openCommandPalette": "명령 팔레트 열기"

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Удалить из избранного",
"editSessionName": "Вручную редактировать имя сеанса",
"deleteSession": "Удалить этот сеанс навсегда",
"activeSessionIndicator": "Недавно активный сеанс (последние 10 минут)",
"save": "Сохранить",
"cancel": "Отмена",
"clearSearch": "Очистить поиск",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Favorilerden çıkar",
"editSessionName": "Oturum adını elle düzenle",
"deleteSession": "Bu oturumu kalıcı olarak sil",
"activeSessionIndicator": "Yakın zamanda etkin oturum (son 10 dakika)",
"save": "Kaydet",
"cancel": "İptal",
"clearSearch": "Aramayı temizle",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "从收藏移除",
"editSessionName": "手动编辑会话名称",
"deleteSession": "永久删除此会话",
"activeSessionIndicator": "最近活跃的会话(最近 10 分钟)",
"save": "保存",
"cancel": "取消",
"clearSearch": "清除搜索",