mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-26 21:46:50 +08:00
Compare commits
5 Commits
main
...
feat/provi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5564af393c | ||
|
|
dc6208bc75 | ||
|
|
333625bdab | ||
|
|
e3b0416d0a | ||
|
|
be9fdd165e |
@@ -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.
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
const APP_ROOT = findAppRoot(__dirname);
|
||||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
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_MB = 200;
|
||||||
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||||
const MAX_FILE_UPLOAD_COUNT = 20;
|
const MAX_FILE_UPLOAD_COUNT = 20;
|
||||||
@@ -169,8 +156,7 @@ app.get('/health', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
installMode,
|
installMode
|
||||||
version: RUNNING_VERSION
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -171,62 +171,6 @@ function buildShellCommand(
|
|||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
|
||||||
const resolvedKey = Object.keys(env).find((envKey) => envKey.toLowerCase() === key.toLowerCase());
|
|
||||||
return resolvedKey ? env[resolvedKey] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPathEnvKey(env: NodeJS.ProcessEnv): string {
|
|
||||||
return Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'PATH';
|
|
||||||
}
|
|
||||||
|
|
||||||
function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; value: string | undefined } {
|
|
||||||
const pathKey = getPathEnvKey(env);
|
|
||||||
const currentPath = env[pathKey];
|
|
||||||
if (!currentPath) {
|
|
||||||
return { key: pathKey, value: currentPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
const delimiter = path.delimiter;
|
|
||||||
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
|
||||||
const npmPrefix = readEnvValue(env, 'npm_config_prefix');
|
|
||||||
const appData = readEnvValue(env, 'APPDATA');
|
|
||||||
const candidates = [
|
|
||||||
npmPrefix || '',
|
|
||||||
npmPrefix ? path.join(npmPrefix, 'bin') : '',
|
|
||||||
appData ? path.join(appData, 'npm') : '',
|
|
||||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm'),
|
|
||||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
const normalizedPathEntries = pathEntries.map((entry) => os.platform() === 'win32' ? entry.toLowerCase() : entry);
|
|
||||||
const preferredEntries = candidates.filter((candidate, index) => {
|
|
||||||
const normalizedCandidate = os.platform() === 'win32' ? candidate.toLowerCase() : candidate;
|
|
||||||
return (
|
|
||||||
candidates.indexOf(candidate) === index &&
|
|
||||||
normalizedPathEntries.includes(normalizedCandidate)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (preferredEntries.length === 0) {
|
|
||||||
return { key: pathKey, value: currentPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPreferredEntries = preferredEntries.map((entry) =>
|
|
||||||
os.platform() === 'win32' ? entry.toLowerCase() : entry
|
|
||||||
);
|
|
||||||
|
|
||||||
const value = [
|
|
||||||
...preferredEntries,
|
|
||||||
...pathEntries.filter((entry) => {
|
|
||||||
const normalizedEntry = os.platform() === 'win32' ? entry.toLowerCase() : entry;
|
|
||||||
return !normalizedPreferredEntries.includes(normalizedEntry);
|
|
||||||
}),
|
|
||||||
].join(delimiter);
|
|
||||||
|
|
||||||
return { key: pathKey, value };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles websocket connections used by the standalone shell terminal UI.
|
* Handles websocket connections used by the standalone shell terminal UI.
|
||||||
*/
|
*/
|
||||||
@@ -340,7 +284,6 @@ export function handleShellConnection(
|
|||||||
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
||||||
const termCols = readNumber(data.cols, 80);
|
const termCols = readNumber(data.cols, 80);
|
||||||
const termRows = readNumber(data.rows, 24);
|
const termRows = readNumber(data.rows, 24);
|
||||||
const prioritizedPath = prioritizeUserNpmGlobalBin(process.env);
|
|
||||||
|
|
||||||
shellProcess = pty.spawn(shell, shellArgs, {
|
shellProcess = pty.spawn(shell, shellArgs, {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
@@ -349,7 +292,6 @@ export function handleShellConnection(
|
|||||||
cwd: resolvedProjectPath,
|
cwd: resolvedProjectPath,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
[prioritizedPath.key]: prioritizedPath.value,
|
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
COLORTERM: 'truecolor',
|
COLORTERM: 'truecolor',
|
||||||
FORCE_COLOR: '3',
|
FORCE_COLOR: '3',
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
|
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
|
||||||
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
|
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const lastProviderRef = useRef(provider);
|
||||||
const providerModelsRequestIdRef = useRef(0);
|
const providerModelsRequestIdRef = useRef(0);
|
||||||
|
|
||||||
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
|
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
|
||||||
@@ -343,8 +344,14 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
localStorage.setItem('selected-provider', selectedSession.__provider);
|
localStorage.setItem('selected-provider', selectedSession.__provider);
|
||||||
}, [provider, selectedSession]);
|
}, [provider, selectedSession]);
|
||||||
|
|
||||||
// Permission prompts belong to a session, not to the transient provider
|
useEffect(() => {
|
||||||
// selection that is synchronized after navigation.
|
if (lastProviderRef.current === provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingPermissionRequests([]);
|
||||||
|
lastProviderRef.current = provider;
|
||||||
|
}, [provider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingPermissionRequests((previous) =>
|
setPendingPermissionRequests((previous) =>
|
||||||
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),
|
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { ServerEvent } from '../../../contexts/WebSocketContext';
|
import type { ServerEvent } from '../../../contexts/WebSocketContext';
|
||||||
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
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 { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||||
import type { PendingPermissionRequest } from '../types/types';
|
import type { PendingPermissionRequest } from '../types/types';
|
||||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
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 {
|
interface UseChatRealtimeHandlersArgs {
|
||||||
subscribe: (listener: (event: ServerEvent) => void) => () => void;
|
subscribe: (listener: (event: ServerEvent) => void) => () => void;
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||||
pendingPermissionRequests: PendingPermissionRequest[];
|
|
||||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||||
streamTimerRef: MutableRefObject<number | null>;
|
streamTimerRef: MutableRefObject<number | null>;
|
||||||
accumulatedStreamRef: MutableRefObject<string>;
|
accumulatedStreamRef: MutableRefObject<string>;
|
||||||
@@ -61,7 +52,6 @@ export function useChatRealtimeHandlers({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
pendingPermissionRequests,
|
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
@@ -72,29 +62,13 @@ export function useChatRealtimeHandlers({
|
|||||||
onWebSocketReconnect,
|
onWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
}: UseChatRealtimeHandlersArgs) {
|
}: UseChatRealtimeHandlersArgs) {
|
||||||
// Session switches can send `chat.subscribe` before this effect has a chance
|
|
||||||
// to rebind the websocket listener. Read the visible session id from a ref
|
|
||||||
// so a fast `chat_subscribed` ack is matched against the current view, not
|
|
||||||
// the previous render's closed-over selection.
|
|
||||||
const activeViewSessionIdRef = useRef<string | null>(selectedSession?.id || currentSessionId || null);
|
|
||||||
activeViewSessionIdRef.current = selectedSession?.id || currentSessionId || null;
|
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
const handleEvent = (msg: ServerEvent) => {
|
const handleEvent = (msg: ServerEvent) => {
|
||||||
if (!msg.kind) {
|
if (!msg.kind) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeViewSessionId = activeViewSessionIdRef.current;
|
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
|
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
|
||||||
|
|
||||||
// Record replay progress for every sequenced live event.
|
// Record replay progress for every sequenced live event.
|
||||||
@@ -127,16 +101,7 @@ export function useChatRealtimeHandlers({
|
|||||||
|
|
||||||
const isViewedSession = sid === activeViewSessionId;
|
const isViewedSession = sid === activeViewSessionId;
|
||||||
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
|
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
|
||||||
const nextPendingPermissionRequests = msg.pendingPermissions as PendingPermissionRequest[];
|
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
|
||||||
const hadActionablePermissionRequests = hasActionablePermissionRequests(pendingPermissionRequestsRef.current);
|
|
||||||
const hasPendingActionablePermissionRequests = hasActionablePermissionRequests(nextPendingPermissionRequests);
|
|
||||||
|
|
||||||
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
|
|
||||||
setPendingPermissionRequests(nextPendingPermissionRequests);
|
|
||||||
|
|
||||||
if (hasPendingActionablePermissionRequests && !hadActionablePermissionRequests) {
|
|
||||||
void playNotificationSound();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -238,7 +203,6 @@ export function useChatRealtimeHandlers({
|
|||||||
// hides it immediately and atomically.
|
// hides it immediately and atomically.
|
||||||
onSessionIdle?.(sid);
|
onSessionIdle?.(sid);
|
||||||
if (sid === activeViewSessionId) {
|
if (sid === activeViewSessionId) {
|
||||||
pendingPermissionRequestsRef.current = [];
|
|
||||||
setPendingPermissionRequests([]);
|
setPendingPermissionRequests([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,14 +234,10 @@ export function useChatRealtimeHandlers({
|
|||||||
|
|
||||||
case 'permission_request': {
|
case 'permission_request': {
|
||||||
if (!msg.requestId) break;
|
if (!msg.requestId) break;
|
||||||
if (isActionablePermissionRequest({ toolName: msg.toolName })) {
|
|
||||||
void playNotificationSound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sid === activeViewSessionId) {
|
if (sid === activeViewSessionId) {
|
||||||
const previousPendingPermissionRequests = pendingPermissionRequestsRef.current;
|
setPendingPermissionRequests((prev) => {
|
||||||
if (!previousPendingPermissionRequests.some((request) => request.requestId === msg.requestId)) {
|
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
||||||
const nextPendingPermissionRequests = [...previousPendingPermissionRequests, {
|
return [...prev, {
|
||||||
requestId: msg.requestId as string,
|
requestId: msg.requestId as string,
|
||||||
toolName: (msg.toolName as string) || 'UnknownTool',
|
toolName: (msg.toolName as string) || 'UnknownTool',
|
||||||
input: msg.input,
|
input: msg.input,
|
||||||
@@ -285,10 +245,7 @@ export function useChatRealtimeHandlers({
|
|||||||
sessionId: sid || null,
|
sessionId: sid || null,
|
||||||
receivedAt: new Date(),
|
receivedAt: new Date(),
|
||||||
}];
|
}];
|
||||||
|
});
|
||||||
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
|
|
||||||
setPendingPermissionRequests(nextPendingPermissionRequests);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (sid) {
|
if (sid) {
|
||||||
onSessionProcessing?.(sid);
|
onSessionProcessing?.(sid);
|
||||||
@@ -298,12 +255,7 @@ export function useChatRealtimeHandlers({
|
|||||||
|
|
||||||
case 'permission_cancelled': {
|
case 'permission_cancelled': {
|
||||||
if (msg.requestId && sid === activeViewSessionId) {
|
if (msg.requestId && sid === activeViewSessionId) {
|
||||||
const nextPendingPermissionRequests = pendingPermissionRequestsRef.current.filter(
|
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
||||||
(request: PendingPermissionRequest) => request.requestId !== msg.requestId,
|
|
||||||
);
|
|
||||||
|
|
||||||
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
|
|
||||||
setPendingPermissionRequests(nextPendingPermissionRequests);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -334,7 +286,6 @@ export function useChatRealtimeHandlers({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
pendingPermissionRequests,
|
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
|
|||||||
@@ -239,7 +239,6 @@ function ChatInterface({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
pendingPermissionRequests,
|
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function Sidebar({
|
|||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t } = useTranslation(['sidebar', 'common']);
|
const { t } = useTranslation(['sidebar', 'common']);
|
||||||
const { isPWA } = useDeviceSettings({ trackMobile: false });
|
const { isPWA } = useDeviceSettings({ trackMobile: false });
|
||||||
const { updateAvailable, restartRequired, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
|
const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
|
||||||
'siteboon',
|
'siteboon',
|
||||||
'claudecodeui',
|
'claudecodeui',
|
||||||
);
|
);
|
||||||
@@ -224,7 +224,6 @@ function Sidebar({
|
|||||||
onExpand={handleExpandSidebar}
|
onExpand={handleExpandSidebar}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
restartRequired={restartRequired}
|
|
||||||
onShowVersionModal={() => setShowVersionModal(true)}
|
onShowVersionModal={() => setShowVersionModal(true)}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
@@ -297,7 +296,6 @@ function Sidebar({
|
|||||||
onCreateProject={() => setShowNewProject(true)}
|
onCreateProject={() => setShowNewProject(true)}
|
||||||
onCollapseSidebar={handleCollapseSidebar}
|
onCollapseSidebar={handleCollapseSidebar}
|
||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
restartRequired={restartRequired}
|
|
||||||
releaseInfo={releaseInfo}
|
releaseInfo={releaseInfo}
|
||||||
latestVersion={latestVersion}
|
latestVersion={latestVersion}
|
||||||
currentVersion={currentVersion}
|
currentVersion={currentVersion}
|
||||||
|
|||||||
@@ -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';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
|
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
|
||||||
@@ -16,7 +16,6 @@ type SidebarCollapsedProps = {
|
|||||||
onExpand: () => void;
|
onExpand: () => void;
|
||||||
onShowSettings: () => void;
|
onShowSettings: () => void;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
restartRequired: boolean;
|
|
||||||
onShowVersionModal: () => void;
|
onShowVersionModal: () => void;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
};
|
};
|
||||||
@@ -25,7 +24,6 @@ export default function SidebarCollapsed({
|
|||||||
onExpand,
|
onExpand,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
restartRequired,
|
|
||||||
onShowVersionModal,
|
onShowVersionModal,
|
||||||
t,
|
t,
|
||||||
}: SidebarCollapsedProps) {
|
}: SidebarCollapsedProps) {
|
||||||
@@ -77,18 +75,6 @@ export default function SidebarCollapsed({
|
|||||||
<DiscordIcon className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
<DiscordIcon className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||||
</a>
|
</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 */}
|
{/* Update indicator */}
|
||||||
{updateAvailable && (
|
{updateAvailable && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ type SidebarContentProps = {
|
|||||||
onCreateProject: () => void;
|
onCreateProject: () => void;
|
||||||
onCollapseSidebar: () => void;
|
onCollapseSidebar: () => void;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
restartRequired: boolean;
|
|
||||||
releaseInfo: ReleaseInfo | null;
|
releaseInfo: ReleaseInfo | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
@@ -179,7 +178,6 @@ export default function SidebarContent({
|
|||||||
onCreateProject,
|
onCreateProject,
|
||||||
onCollapseSidebar,
|
onCollapseSidebar,
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
restartRequired,
|
|
||||||
releaseInfo,
|
releaseInfo,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
@@ -555,7 +553,6 @@ export default function SidebarContent({
|
|||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
restartRequired={restartRequired}
|
|
||||||
releaseInfo={releaseInfo}
|
releaseInfo={releaseInfo}
|
||||||
latestVersion={latestVersion}
|
latestVersion={latestVersion}
|
||||||
currentVersion={currentVersion}
|
currentVersion={currentVersion}
|
||||||
|
|||||||
@@ -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 type { TFunction } from 'i18next';
|
||||||
import { IS_PLATFORM } from '../../../../constants/config';
|
import { IS_PLATFORM } from '../../../../constants/config';
|
||||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||||
@@ -18,7 +18,6 @@ function DiscordIcon({ className }: { className?: string }) {
|
|||||||
|
|
||||||
type SidebarFooterProps = {
|
type SidebarFooterProps = {
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
restartRequired: boolean;
|
|
||||||
releaseInfo: ReleaseInfo | null;
|
releaseInfo: ReleaseInfo | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
@@ -29,7 +28,6 @@ type SidebarFooterProps = {
|
|||||||
|
|
||||||
export default function SidebarFooter({
|
export default function SidebarFooter({
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
restartRequired,
|
|
||||||
releaseInfo,
|
releaseInfo,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
@@ -39,22 +37,6 @@ export default function SidebarFooter({
|
|||||||
}: SidebarFooterProps) {
|
}: SidebarFooterProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0)' }}>
|
<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 */}
|
{/* Update banner */}
|
||||||
{updateAvailable && (
|
{updateAvailable && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -28,31 +28,20 @@ export const useVersionCheck = (owner: string, repo: string) => {
|
|||||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||||
const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null);
|
const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null);
|
||||||
const [installMode, setInstallMode] = useState<InstallMode>('git');
|
const [installMode, setInstallMode] = useState<InstallMode>('git');
|
||||||
const [runningVersion, setRunningVersion] = useState<string | null>(null);
|
|
||||||
const [restartRequired, setRestartRequired] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchHealth = async () => {
|
const fetchInstallMode = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/health');
|
const response = await fetch('/health');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.installMode === 'npm' || data.installMode === 'git') {
|
if (data.installMode === 'npm' || data.installMode === 'git') {
|
||||||
setInstallMode(data.installMode);
|
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 {
|
} catch {
|
||||||
// Default to git / no restart hint on error
|
// Default to git on error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchHealth();
|
fetchInstallMode();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,5 +84,5 @@ export const useVersionCheck = (owner: string, repo: string) => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [owner, repo]);
|
}, [owner, repo]);
|
||||||
|
|
||||||
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode, runningVersion, restartRequired };
|
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode };
|
||||||
};
|
};
|
||||||
@@ -115,8 +115,7 @@
|
|||||||
"restoreSessionError": "Fehler beim Wiederherstellen der Sitzung. Bitte erneut versuchen."
|
"restoreSessionError": "Fehler beim Wiederherstellen der Sitzung. Bitte erneut versuchen."
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "Update verfügbar",
|
"updateAvailable": "Update verfügbar"
|
||||||
"restartRequired": "Update installiert – zum Anwenden Server neu starten"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"modeProjects": "Projekte",
|
"modeProjects": "Projekte",
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
},
|
},
|
||||||
"sound": {
|
"sound": {
|
||||||
"title": "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",
|
"enabled": "Enabled",
|
||||||
"test": "Test sound"
|
"test": "Test sound"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -115,8 +115,7 @@
|
|||||||
"restoreSessionError": "Error restoring session. Please try again."
|
"restoreSessionError": "Error restoring session. Please try again."
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "Update available",
|
"updateAvailable": "Update available"
|
||||||
"restartRequired": "Update installed — restart the server to apply"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"modeProjects": "Projects",
|
"modeProjects": "Projects",
|
||||||
|
|||||||
@@ -115,8 +115,7 @@
|
|||||||
"restoreSessionError": "Erreur lors de la restauration de la session. Veuillez réessayer."
|
"restoreSessionError": "Erreur lors de la restauration de la session. Veuillez réessayer."
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "Mise à jour disponible",
|
"updateAvailable": "Mise à jour disponible"
|
||||||
"restartRequired": "Mise à jour installée — redémarrez le serveur pour l'appliquer"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"modeProjects": "Projets",
|
"modeProjects": "Projets",
|
||||||
|
|||||||
@@ -115,8 +115,7 @@
|
|||||||
"restoreSessionError": "Errore durante il ripristino della sessione. Riprova."
|
"restoreSessionError": "Errore durante il ripristino della sessione. Riprova."
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "Aggiornamento disponibile",
|
"updateAvailable": "Aggiornamento disponibile"
|
||||||
"restartRequired": "Aggiornamento installato — riavvia il server per applicarlo"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"modeProjects": "Progetti",
|
"modeProjects": "Progetti",
|
||||||
|
|||||||
@@ -114,8 +114,7 @@
|
|||||||
"restoreSessionError": "セッションの復元でエラーが発生しました。もう一度お試しください。"
|
"restoreSessionError": "セッションの復元でエラーが発生しました。もう一度お試しください。"
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "アップデートあり",
|
"updateAvailable": "アップデートあり"
|
||||||
"restartRequired": "更新が適用されていません。サーバーを再起動してください"
|
|
||||||
},
|
},
|
||||||
"deleteConfirmation": {
|
"deleteConfirmation": {
|
||||||
"deleteProject": "プロジェクトを除去",
|
"deleteProject": "プロジェクトを除去",
|
||||||
|
|||||||
@@ -114,8 +114,7 @@
|
|||||||
"restoreSessionError": "세션 복원 오류. 다시 시도해주세요."
|
"restoreSessionError": "세션 복원 오류. 다시 시도해주세요."
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "업데이트 가능",
|
"updateAvailable": "업데이트 가능"
|
||||||
"restartRequired": "업데이트가 설치됨 — 적용하려면 서버를 재시작하세요"
|
|
||||||
},
|
},
|
||||||
"deleteConfirmation": {
|
"deleteConfirmation": {
|
||||||
"deleteProject": "프로젝트 제거",
|
"deleteProject": "프로젝트 제거",
|
||||||
|
|||||||
@@ -115,8 +115,7 @@
|
|||||||
"restoreSessionError": "Ошибка при восстановлении сеанса. Попробуйте снова."
|
"restoreSessionError": "Ошибка при восстановлении сеанса. Попробуйте снова."
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "Доступно обновление",
|
"updateAvailable": "Доступно обновление"
|
||||||
"restartRequired": "Обновление установлено — перезапустите сервер для применения"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"modeProjects": "Проекты",
|
"modeProjects": "Проекты",
|
||||||
|
|||||||
@@ -115,8 +115,7 @@
|
|||||||
"restoreSessionError": "Oturum geri yüklenirken hata oluştu. Lütfen tekrar dene."
|
"restoreSessionError": "Oturum geri yüklenirken hata oluştu. Lütfen tekrar dene."
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "Güncelleme mevcut",
|
"updateAvailable": "Güncelleme mevcut"
|
||||||
"restartRequired": "Güncelleme yüklendi — uygulamak için sunucuyu yeniden başlatın"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"modeProjects": "Projeler",
|
"modeProjects": "Projeler",
|
||||||
|
|||||||
@@ -115,8 +115,7 @@
|
|||||||
"restoreSessionError": "恢复会话时出错,请重试。"
|
"restoreSessionError": "恢复会话时出错,请重试。"
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "有可用更新",
|
"updateAvailable": "有可用更新"
|
||||||
"restartRequired": "已安装更新 — 请重启服务器以生效"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"modeProjects": "项目",
|
"modeProjects": "项目",
|
||||||
|
|||||||
@@ -114,8 +114,7 @@
|
|||||||
"restoreSessionError": "還原工作階段時出錯,請重試。"
|
"restoreSessionError": "還原工作階段時出錯,請重試。"
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "有可用更新",
|
"updateAvailable": "有可用更新"
|
||||||
"restartRequired": "已安裝更新 — 請重新啟動伺服器以套用"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"modeProjects": "專案",
|
"modeProjects": "專案",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const playTone = (
|
|||||||
oscillator.stop(startsAt + duration + 0.02);
|
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()) {
|
if (!force && !isNotificationSoundEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,5 +81,3 @@ export const playNotificationSound = async ({ force = false } = {}): Promise<voi
|
|||||||
console.warn('Unable to play notification sound:', error);
|
console.warn('Unable to play notification sound:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playChatCompletionSound = (options = {}): Promise<void> => playNotificationSound(options);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user