Compare commits

..

5 Commits

Author SHA1 Message Date
Haileyesus
5564af393c fix(skills): overwrite existing installations
Replace an existing skill directory instead of rejecting a duplicate installation.

Remove stale supporting files so the installed directory exactly matches the new upload.
2026-06-22 14:35:59 +03:00
Haileyesus
dc6208bc75 fix(skills): validate installs before writing
Preserve bundled files and normalize fallback names across skill installation paths.

Validate complete batches before writing and reject existing targets to avoid partial installs.

Keep project metadata and make folder selection tolerant of casing and cancelled dialogs.
2026-06-22 14:21:03 +03:00
Haileyesus
333625bdab fix(skills): restrict standalone skill uploads
Only show Markdown files when selecting standalone skills.

Normalize browser file paths so SKILL.md is not mistaken for a folder named dot.
2026-06-22 14:04:57 +03:00
Haileyesus
e3b0416d0a fix(skills): preserve uploaded skill folders
Folder drops discarded supporting scripts and assets.

Keep relative paths and upload every file from the selected skill folder.

Use the selected folder name for installation and cover it in provider tests.
2026-06-21 01:23:02 +03:00
Haileyesus
be9fdd165e feat(skills): add provider skill management
Users need one settings surface to discover and install skills without manually navigating provider-specific directories.

Add provider-backed global skill installation for Claude, Codex, Gemini, and Cursor, while keeping OpenCode read-only because it reuses other providers' skill locations.

Add a responsive Skills settings tab with scoped discovery, search, refresh controls, markdown and folder uploads, upload feedback, and overflow-safe layouts.

Validate bundled skill files and paths before writing them, preserve scripts and assets, and cover provider discovery and installation behavior with tests.
2026-06-21 01:17:23 +03:00
20 changed files with 28 additions and 148 deletions

View File

@@ -76,19 +76,6 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
// Version of the code that is actually running, captured once at process
// startup. This intentionally does NOT re-read package.json per request: after
// an update replaces the files on disk, package.json reflects the NEW version
// while this long-lived process still runs the OLD code. The frontend bundle is
// rebuilt on update, so a mismatch between this value and the frontend's
// build-time version means the server was updated but not restarted.
const RUNNING_VERSION = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version || null;
} catch {
return null;
}
})();
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
@@ -169,8 +156,7 @@ app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
installMode,
version: RUNNING_VERSION
installMode
});
});

View File

@@ -1,29 +1,20 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { ServerEvent } from '../../../contexts/WebSocketContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound, playNotificationSound } from '../../../utils/notificationSound';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
const isActionablePermissionRequest = (request: { toolName?: unknown } | null | undefined): boolean => {
return request?.toolName !== 'ExitPlanMode' && request?.toolName !== 'exit_plan_mode';
};
const hasActionablePermissionRequests = (requests: Array<{ toolName?: unknown }> | null | undefined): boolean => {
return Array.isArray(requests) && requests.some((request) => isActionablePermissionRequest(request));
};
interface UseChatRealtimeHandlersArgs {
subscribe: (listener: (event: ServerEvent) => void) => () => void;
provider: LLMProvider;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
pendingPermissionRequests: PendingPermissionRequest[];
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
@@ -61,7 +52,6 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
@@ -72,15 +62,6 @@ export function useChatRealtimeHandlers({
onWebSocketReconnect,
sessionStore,
}: UseChatRealtimeHandlersArgs) {
// Keep the latest pending-permission snapshot available to the websocket
// listener so back-to-back permission events can dedupe and re-arm the
// notification sound before React finishes a rerender.
const pendingPermissionRequestsRef = useRef(pendingPermissionRequests);
useEffect(() => {
pendingPermissionRequestsRef.current = pendingPermissionRequests;
}, [pendingPermissionRequests]);
useEffect(() => {
const handleEvent = (msg: ServerEvent) => {
if (!msg.kind) {
@@ -120,16 +101,7 @@ export function useChatRealtimeHandlers({
const isViewedSession = sid === activeViewSessionId;
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
const nextPendingPermissionRequests = msg.pendingPermissions as PendingPermissionRequest[];
const hadActionablePermissionRequests = hasActionablePermissionRequests(pendingPermissionRequestsRef.current);
const hasPendingActionablePermissionRequests = hasActionablePermissionRequests(nextPendingPermissionRequests);
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
if (hasPendingActionablePermissionRequests && !hadActionablePermissionRequests) {
void playNotificationSound();
}
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
}
return;
}
@@ -231,7 +203,6 @@ export function useChatRealtimeHandlers({
// hides it immediately and atomically.
onSessionIdle?.(sid);
if (sid === activeViewSessionId) {
pendingPermissionRequestsRef.current = [];
setPendingPermissionRequests([]);
}
@@ -264,9 +235,9 @@ export function useChatRealtimeHandlers({
case 'permission_request': {
if (!msg.requestId) break;
if (sid === activeViewSessionId) {
const previousPendingPermissionRequests = pendingPermissionRequestsRef.current;
if (!previousPendingPermissionRequests.some((request) => request.requestId === msg.requestId)) {
const nextPendingPermissionRequests = [...previousPendingPermissionRequests, {
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
requestId: msg.requestId as string,
toolName: (msg.toolName as string) || 'UnknownTool',
input: msg.input,
@@ -274,17 +245,7 @@ export function useChatRealtimeHandlers({
sessionId: sid || null,
receivedAt: new Date(),
}];
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
if (
isActionablePermissionRequest({ toolName: msg.toolName })
&& !hasActionablePermissionRequests(previousPendingPermissionRequests)
) {
void playNotificationSound();
}
}
});
}
if (sid) {
onSessionProcessing?.(sid);
@@ -294,12 +255,7 @@ export function useChatRealtimeHandlers({
case 'permission_cancelled': {
if (msg.requestId && sid === activeViewSessionId) {
const nextPendingPermissionRequests = pendingPermissionRequestsRef.current.filter(
(request: PendingPermissionRequest) => request.requestId !== msg.requestId,
);
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
}
break;
}
@@ -330,7 +286,6 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,

View File

@@ -239,7 +239,6 @@ function ChatInterface({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,

View File

@@ -43,7 +43,7 @@ function Sidebar({
}: SidebarProps) {
const { t } = useTranslation(['sidebar', 'common']);
const { isPWA } = useDeviceSettings({ trackMobile: false });
const { updateAvailable, restartRequired, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
'siteboon',
'claudecodeui',
);
@@ -224,7 +224,6 @@ function Sidebar({
onExpand={handleExpandSidebar}
onShowSettings={onShowSettings}
updateAvailable={updateAvailable}
restartRequired={restartRequired}
onShowVersionModal={() => setShowVersionModal(true)}
t={t}
/>
@@ -297,7 +296,6 @@ function Sidebar({
onCreateProject={() => setShowNewProject(true)}
onCollapseSidebar={handleCollapseSidebar}
updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, Sparkles, PanelLeftOpen, Bug, AlertTriangle } from 'lucide-react';
import { Settings, Sparkles, PanelLeftOpen, Bug } from 'lucide-react';
import type { TFunction } from 'i18next';
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
@@ -16,7 +16,6 @@ type SidebarCollapsedProps = {
onExpand: () => void;
onShowSettings: () => void;
updateAvailable: boolean;
restartRequired: boolean;
onShowVersionModal: () => void;
t: TFunction;
};
@@ -25,7 +24,6 @@ export default function SidebarCollapsed({
onExpand,
onShowSettings,
updateAvailable,
restartRequired,
onShowVersionModal,
t,
}: SidebarCollapsedProps) {
@@ -77,18 +75,6 @@ export default function SidebarCollapsed({
<DiscordIcon className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
{/* Restart-required indicator */}
{restartRequired && (
<div
className="relative flex h-8 w-8 items-center justify-center rounded-lg"
aria-label={t('version.restartRequired')}
title={t('version.restartRequired')}
>
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span className="absolute right-1.5 top-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" />
</div>
)}
{/* Update indicator */}
{updateAvailable && (
<button

View File

@@ -141,7 +141,6 @@ type SidebarContentProps = {
onCreateProject: () => void;
onCollapseSidebar: () => void;
updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
@@ -179,7 +178,6 @@ export default function SidebarContent({
onCreateProject,
onCollapseSidebar,
updateAvailable,
restartRequired,
releaseInfo,
latestVersion,
currentVersion,
@@ -555,7 +553,6 @@ export default function SidebarContent({
<SidebarFooter
updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, ArrowUpCircle, Bug, AlertTriangle } 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';
@@ -18,7 +18,6 @@ function DiscordIcon({ className }: { className?: string }) {
type SidebarFooterProps = {
updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
@@ -29,7 +28,6 @@ type SidebarFooterProps = {
export default function SidebarFooter({
updateAvailable,
restartRequired,
releaseInfo,
latestVersion,
currentVersion,
@@ -39,22 +37,6 @@ export default function SidebarFooter({
}: SidebarFooterProps) {
return (
<div className="flex-shrink-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0)' }}>
{/* Restart-required banner: the running server version differs from the
installed/frontend version (updated but not restarted). */}
{restartRequired && (
<>
<div className="nav-divider" />
<div className="px-2 py-1.5 md:px-2 md:py-1.5">
<div className="flex items-center gap-2.5 rounded-lg border border-amber-300/60 bg-amber-50/80 px-2.5 py-2 dark:border-amber-700/40 dark:bg-amber-900/15">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-500 dark:text-amber-400" />
<span className="min-w-0 flex-1 text-xs font-medium text-amber-700 dark:text-amber-300">
{t('version.restartRequired')}
</span>
</div>
</div>
</>
)}
{/* Update banner */}
{updateAvailable && (
<>

View File

@@ -28,31 +28,20 @@ export const useVersionCheck = (owner: string, repo: string) => {
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null);
const [installMode, setInstallMode] = useState<InstallMode>('git');
const [runningVersion, setRunningVersion] = useState<string | null>(null);
const [restartRequired, setRestartRequired] = useState(false);
useEffect(() => {
const fetchHealth = async () => {
const fetchInstallMode = async () => {
try {
const response = await fetch('/health');
const data = await response.json();
if (data.installMode === 'npm' || data.installMode === 'git') {
setInstallMode(data.installMode);
}
// `data.version` is the version the server process is actually running.
// This module's `version` is baked into the frontend bundle at build
// time, so it reflects the installed (on-disk) package. If they differ,
// the package was updated but the server process was not restarted, and
// DB-backed actions may silently fail until it is.
if (typeof data.version === 'string' && data.version.length > 0) {
setRunningVersion(data.version);
setRestartRequired(data.version !== version);
}
} catch {
// Default to git / no restart hint on error
// Default to git on error
}
};
fetchHealth();
fetchInstallMode();
}, []);
useEffect(() => {
@@ -95,5 +84,5 @@ export const useVersionCheck = (owner: string, repo: string) => {
return () => clearInterval(interval);
}, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode, runningVersion, restartRequired };
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode };
};

View File

@@ -115,8 +115,7 @@
"restoreSessionError": "Fehler beim Wiederherstellen der Sitzung. Bitte erneut versuchen."
},
"version": {
"updateAvailable": "Update verfügbar",
"restartRequired": "Update installiert zum Anwenden Server neu starten"
"updateAvailable": "Update verfügbar"
},
"search": {
"modeProjects": "Projekte",

View File

@@ -114,7 +114,7 @@
},
"sound": {
"title": "Sound",
"description": "Play a short tone when a chat run finishes or needs tool approval.",
"description": "Play a short tone when a chat run finishes.",
"enabled": "Enabled",
"test": "Test sound"
},

View File

@@ -115,8 +115,7 @@
"restoreSessionError": "Error restoring session. Please try again."
},
"version": {
"updateAvailable": "Update available",
"restartRequired": "Update installed — restart the server to apply"
"updateAvailable": "Update available"
},
"search": {
"modeProjects": "Projects",

View File

@@ -115,8 +115,7 @@
"restoreSessionError": "Erreur lors de la restauration de la session. Veuillez réessayer."
},
"version": {
"updateAvailable": "Mise à jour disponible",
"restartRequired": "Mise à jour installée — redémarrez le serveur pour l'appliquer"
"updateAvailable": "Mise à jour disponible"
},
"search": {
"modeProjects": "Projets",

View File

@@ -115,8 +115,7 @@
"restoreSessionError": "Errore durante il ripristino della sessione. Riprova."
},
"version": {
"updateAvailable": "Aggiornamento disponibile",
"restartRequired": "Aggiornamento installato — riavvia il server per applicarlo"
"updateAvailable": "Aggiornamento disponibile"
},
"search": {
"modeProjects": "Progetti",

View File

@@ -114,8 +114,7 @@
"restoreSessionError": "セッションの復元でエラーが発生しました。もう一度お試しください。"
},
"version": {
"updateAvailable": "アップデートあり",
"restartRequired": "更新が適用されていません。サーバーを再起動してください"
"updateAvailable": "アップデートあり"
},
"deleteConfirmation": {
"deleteProject": "プロジェクトを除去",

View File

@@ -114,8 +114,7 @@
"restoreSessionError": "세션 복원 오류. 다시 시도해주세요."
},
"version": {
"updateAvailable": "업데이트 가능",
"restartRequired": "업데이트가 설치됨 — 적용하려면 서버를 재시작하세요"
"updateAvailable": "업데이트 가능"
},
"deleteConfirmation": {
"deleteProject": "프로젝트 제거",

View File

@@ -115,8 +115,7 @@
"restoreSessionError": "Ошибка при восстановлении сеанса. Попробуйте снова."
},
"version": {
"updateAvailable": "Доступно обновление",
"restartRequired": "Обновление установлено — перезапустите сервер для применения"
"updateAvailable": "Доступно обновление"
},
"search": {
"modeProjects": "Проекты",

View File

@@ -115,8 +115,7 @@
"restoreSessionError": "Oturum geri yüklenirken hata oluştu. Lütfen tekrar dene."
},
"version": {
"updateAvailable": "Güncelleme mevcut",
"restartRequired": "Güncelleme yüklendi — uygulamak için sunucuyu yeniden başlatın"
"updateAvailable": "Güncelleme mevcut"
},
"search": {
"modeProjects": "Projeler",

View File

@@ -115,8 +115,7 @@
"restoreSessionError": "恢复会话时出错,请重试。"
},
"version": {
"updateAvailable": "有可用更新",
"restartRequired": "已安装更新 — 请重启服务器以生效"
"updateAvailable": "有可用更新"
},
"search": {
"modeProjects": "项目",

View File

@@ -114,8 +114,7 @@
"restoreSessionError": "還原工作階段時出錯,請重試。"
},
"version": {
"updateAvailable": "有可用更新",
"restartRequired": "已安裝更新 — 請重新啟動伺服器以套用"
"updateAvailable": "有可用更新"
},
"search": {
"modeProjects": "專案",

View File

@@ -58,7 +58,7 @@ const playTone = (
oscillator.stop(startsAt + duration + 0.02);
};
export const playNotificationSound = async ({ force = false } = {}): Promise<void> => {
export const playChatCompletionSound = async ({ force = false } = {}): Promise<void> => {
if (!force && !isNotificationSoundEnabled()) {
return;
}
@@ -81,5 +81,3 @@ export const playNotificationSound = async ({ force = false } = {}): Promise<voi
console.warn('Unable to play notification sound:', error);
}
};
export const playChatCompletionSound = (options = {}): Promise<void> => playNotificationSound(options);