mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-11 00:06:00 +08:00
Compare commits
19 Commits
feature/fi
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
029d159592 | ||
|
|
7c9ec8fa12 | ||
|
|
1b4d4b7278 | ||
|
|
b1a0afe9e0 | ||
|
|
88eb2009bb | ||
|
|
602e6ad4ac | ||
|
|
4a2453fe32 | ||
|
|
f439a8a3d5 | ||
|
|
23210bc40e | ||
|
|
beae8c6513 | ||
|
|
33a4e72ca4 | ||
|
|
f7c0024fe1 | ||
|
|
ca8fd0ee23 | ||
|
|
b7e6bca2e3 | ||
|
|
84c166c4cb | ||
|
|
d70dc077bf | ||
|
|
1faa1a6a00 | ||
|
|
3cd89956ba | ||
|
|
01dbe2a8bf |
@@ -10,6 +10,7 @@ type NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
sound: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
@@ -22,6 +23,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: false,
|
inApp: false,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -37,6 +39,7 @@ function normalizeNotificationPreferences(value: unknown): NotificationPreferenc
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: source.channels?.inApp === true,
|
inApp: source.channels?.inApp === true,
|
||||||
webPush: source.channels?.webPush === true,
|
webPush: source.channels?.webPush === true,
|
||||||
|
sound: source.channels?.sound !== false,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: source.events?.actionRequired !== false,
|
actionRequired: source.events?.actionRequired !== false,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { spawn } from 'child_process';
|
|
||||||
|
import { spawn } from 'cross-spawn';
|
||||||
|
|
||||||
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
||||||
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
||||||
|
|||||||
@@ -7,6 +7,41 @@ const runningPlugins = new Map();
|
|||||||
// Map<pluginName, Promise<port>> — in-flight start operations
|
// Map<pluginName, Promise<port>> — in-flight start operations
|
||||||
const startingPlugins = new Map();
|
const startingPlugins = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the environment handed to a plugin server subprocess.
|
||||||
|
*
|
||||||
|
* Intentionally minimal: only non-secret essentials, never the host's full
|
||||||
|
* environment. On Windows a handful of system variables are required for any
|
||||||
|
* child to bootstrap (Node itself, and any Python or CLI a plugin shells out
|
||||||
|
* to). Without APPDATA a `pip install --user` tool cannot locate its
|
||||||
|
* site-packages and fails to import; SystemRoot, PATHEXT and TEMP are needed to
|
||||||
|
* resolve system DLLs, executable extensions and a temp directory. None of
|
||||||
|
* these carry secrets, so the ones that are set get passed straight through.
|
||||||
|
*/
|
||||||
|
function buildPluginEnv(name) {
|
||||||
|
const env = {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
HOME: process.env.HOME,
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'production',
|
||||||
|
PLUGIN_NAME: name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const WINDOWS_ESSENTIALS = [
|
||||||
|
'SystemRoot', 'windir', 'SystemDrive',
|
||||||
|
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
||||||
|
'TEMP', 'TMP', 'PATHEXT',
|
||||||
|
];
|
||||||
|
for (const key of WINDOWS_ESSENTIALS) {
|
||||||
|
if (process.env[key] !== undefined) {
|
||||||
|
env[key] = process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a plugin's server subprocess.
|
* Start a plugin's server subprocess.
|
||||||
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
||||||
@@ -26,15 +61,9 @@ export function startPluginServer(name, pluginDir, serverEntry) {
|
|||||||
|
|
||||||
const serverPath = path.join(pluginDir, serverEntry);
|
const serverPath = path.join(pluginDir, serverEntry);
|
||||||
|
|
||||||
// Restricted env — only essentials, no host secrets
|
|
||||||
const pluginProcess = spawn('node', [serverPath], {
|
const pluginProcess = spawn('node', [serverPath], {
|
||||||
cwd: pluginDir,
|
cwd: pluginDir,
|
||||||
env: {
|
env: buildPluginEnv(name),
|
||||||
PATH: process.env.PATH,
|
|
||||||
HOME: process.env.HOME,
|
|
||||||
NODE_ENV: process.env.NODE_ENV || 'production',
|
|
||||||
PLUGIN_NAME: name,
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useRef } from 'react';
|
|||||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
|
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
||||||
|
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
||||||
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
import type { PendingPermissionRequest, SessionNavigationOptions } 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';
|
||||||
@@ -285,6 +287,9 @@ export function useChatRealtimeHandlers({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showCompletionTitleIndicator();
|
||||||
|
void playChatCompletionSound();
|
||||||
|
|
||||||
const actualSessionId =
|
const actualSessionId =
|
||||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
||||||
? msg.actualSessionId
|
? msg.actualSessionId
|
||||||
|
|||||||
@@ -383,12 +383,47 @@ export function useChatSessionState({
|
|||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||||
|
|
||||||
// Initial scroll to bottom
|
// Initial scroll to bottom — robust to lazy content reflow.
|
||||||
|
// The previous implementation fired one scrollToBottom() at +200ms and
|
||||||
|
// cleared the pending flag. When markdown blocks, code highlighting, or
|
||||||
|
// images finished rendering after that window, scrollHeight grew but
|
||||||
|
// nothing re-anchored the viewport, leaving the chat tab visually
|
||||||
|
// "scrolled way up" with the latest assistant message off-screen.
|
||||||
|
//
|
||||||
|
// This version re-scrolls every animation frame while scrollHeight is
|
||||||
|
// still growing, capped at ~1s (60 frames) or 3 consecutive stable
|
||||||
|
// frames. Cancels cleanly on session change via the pending flag.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
||||||
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
||||||
pendingInitialScrollRef.current = false;
|
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
|
||||||
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
|
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
let frame = 0;
|
||||||
|
let lastHeight = 0;
|
||||||
|
let stableCount = 0;
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return;
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
if (container.scrollHeight === lastHeight) {
|
||||||
|
stableCount++;
|
||||||
|
} else {
|
||||||
|
stableCount = 0;
|
||||||
|
lastHeight = container.scrollHeight;
|
||||||
|
}
|
||||||
|
frame++;
|
||||||
|
if (stableCount < 3 && frame < 60) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
pendingInitialScrollRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||||
|
|
||||||
// Main session loading effect — store-based
|
// Main session loading effect — store-based
|
||||||
|
|||||||
@@ -393,7 +393,8 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slashPattern = /^\/(\S*)$/;
|
// Match / at start of input OR after whitespace, capturing the /word up to cursor.
|
||||||
|
const slashPattern = /(?:^|\s)(\/\S*)$/;
|
||||||
const match = textBeforeCursor.match(slashPattern);
|
const match = textBeforeCursor.match(slashPattern);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -401,8 +402,9 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slashPos = 0;
|
// Compute actual position of / in the full input string.
|
||||||
const query = match[1];
|
const slashPos = match.index! + (match[0].length - match[1].length);
|
||||||
|
const query = match[1].slice(1); // strip leading /
|
||||||
|
|
||||||
setSlashPosition(slashPos);
|
setSlashPosition(slashPos);
|
||||||
setShowCommandMenu(true);
|
setShowCommandMenu(true);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function EditorSidebar({
|
|||||||
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`flex h-full min-w-0 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
<div ref={containerRef} className={`flex h-full min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
||||||
{!editorExpanded && (
|
{!editorExpanded && (
|
||||||
<div
|
<div
|
||||||
ref={resizeHandleRef}
|
ref={resizeHandleRef}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-start
|
|||||||
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
|
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
|
||||||
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
|
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
|
||||||
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
|
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
|
||||||
|
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
|
||||||
|
|
||||||
type PluginRecommendation = {
|
type PluginRecommendation = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,6 +73,14 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
|||||||
icon: Clock,
|
icon: Clock,
|
||||||
source: 'unofficial',
|
source: 'unofficial',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'prism',
|
||||||
|
translationKey: 'prismCloudCLI',
|
||||||
|
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
|
||||||
|
installedNames: ['prism'],
|
||||||
|
icon: Activity,
|
||||||
|
source: 'unofficial'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
function repoSlug(repoUrl: string) {
|
function repoSlug(repoUrl: string) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
|
||||||
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODE_EDITOR_SETTINGS,
|
DEFAULT_CODE_EDITOR_SETTINGS,
|
||||||
@@ -107,6 +109,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: true,
|
inApp: true,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -115,6 +118,25 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeNotificationPreferences = (
|
||||||
|
preferences?: Partial<NotificationPreferencesState> | null,
|
||||||
|
): NotificationPreferencesState => {
|
||||||
|
const defaults = createDefaultNotificationPreferences();
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: {
|
||||||
|
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
||||||
|
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
||||||
|
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
actionRequired: preferences?.events?.actionRequired ?? defaults.events.actionRequired,
|
||||||
|
stop: preferences?.events?.stop ?? defaults.events.stop,
|
||||||
|
error: preferences?.events?.error ?? defaults.events.error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
|
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
|
||||||
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||||
const closeTimerRef = useRef<number | null>(null);
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
@@ -186,7 +208,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
if (notificationResponse.ok) {
|
if (notificationResponse.ok) {
|
||||||
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
||||||
if (notificationData.success && notificationData.preferences) {
|
if (notificationData.success && notificationData.preferences) {
|
||||||
setNotificationPreferences(notificationData.preferences);
|
setNotificationPreferences(normalizeNotificationPreferences(notificationData.preferences));
|
||||||
} else {
|
} else {
|
||||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
}
|
}
|
||||||
@@ -301,6 +323,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
void refreshProviderAuthStatuses();
|
void refreshProviderAuthStatuses();
|
||||||
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotificationSoundEnabled(notificationPreferences.channels.sound);
|
||||||
|
}, [notificationPreferences.channels.sound]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
||||||
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { LLMProvider } from '../../../types/app';
|
import type { LLMProvider } from '../../../types/app';
|
||||||
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export type NotificationPreferencesState = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
sound: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
|
import { Bell, BellOff, BellRing, Loader2, Play, Volume2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '../../../../shared/view/ui';
|
||||||
|
import { playChatCompletionSound } from '../../../../utils/notificationSound';
|
||||||
import type { NotificationPreferencesState } from '../../types/types';
|
import type { NotificationPreferencesState } from '../../types/types';
|
||||||
|
|
||||||
type NotificationsSettingsTabProps = {
|
type NotificationsSettingsTabProps = {
|
||||||
@@ -82,6 +85,54 @@ export default function NotificationsSettingsTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="h-4 w-4 text-blue-600" />
|
||||||
|
<h4 className="font-medium text-foreground">
|
||||||
|
{t('notifications.sound.title', { defaultValue: 'Sound' })}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('notifications.sound.description', {
|
||||||
|
defaultValue: 'Play a short tone when a chat run finishes.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex shrink-0 items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationPreferences.channels.sound}
|
||||||
|
onChange={(event) =>
|
||||||
|
onNotificationPreferencesChange({
|
||||||
|
...notificationPreferences,
|
||||||
|
channels: {
|
||||||
|
...notificationPreferences.channels,
|
||||||
|
sound: event.target.checked,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{t('notifications.sound.enabled', { defaultValue: 'Enabled' })}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void playChatCompletionSound({ force: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
{t('notifications.sound.test', { defaultValue: 'Test sound' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -94,9 +94,35 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & Token",
|
"apiTokens": "API & Token",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
|
"notifications": "Benachrichtigungen",
|
||||||
"plugins": "Plugins",
|
"plugins": "Plugins",
|
||||||
"about": "Info"
|
"about": "Info"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Benachrichtigungen",
|
||||||
|
"description": "Lege fest, welche Benachrichtigungen du erhältst.",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web-Push-Benachrichtigungen",
|
||||||
|
"enable": "Push-Benachrichtigungen aktivieren",
|
||||||
|
"disable": "Push-Benachrichtigungen deaktivieren",
|
||||||
|
"enabled": "Push-Benachrichtigungen sind aktiviert",
|
||||||
|
"loading": "Wird aktualisiert...",
|
||||||
|
"unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
||||||
|
"denied": "Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browsereinstellungen."
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Ton",
|
||||||
|
"description": "Spielt einen kurzen Ton ab, wenn ein Chat-Lauf abgeschlossen ist.",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"test": "Ton testen"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Ereignistypen",
|
||||||
|
"actionRequired": "Aktion erforderlich",
|
||||||
|
"stop": "Lauf gestoppt",
|
||||||
|
"error": "Lauf fehlgeschlagen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
"label": "Darkmode",
|
"label": "Darkmode",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "Push notifications are not supported in this browser.",
|
"unsupported": "Push notifications are not supported in this browser.",
|
||||||
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Sound",
|
||||||
|
"description": "Play a short tone when a chat run finishes.",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"test": "Test sound"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Event Types",
|
"title": "Event Types",
|
||||||
"actionRequired": "Action required",
|
"actionRequired": "Action required",
|
||||||
@@ -502,6 +508,12 @@
|
|||||||
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
|
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
|
||||||
"install": "Install"
|
"install": "Install"
|
||||||
},
|
},
|
||||||
|
"prismCloudCLI": {
|
||||||
|
"name": "PRISM CloudCLI",
|
||||||
|
"badge": "unofficial",
|
||||||
|
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
|
||||||
|
"install": "Install"
|
||||||
|
},
|
||||||
"morePlugins": "More",
|
"morePlugins": "More",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
||||||
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Suono",
|
||||||
|
"description": "Riproduci un breve tono quando termina un'esecuzione della chat.",
|
||||||
|
"enabled": "Attivato",
|
||||||
|
"test": "Prova suono"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Tipi di evento",
|
"title": "Tipi di evento",
|
||||||
"actionRequired": "Azione richiesta",
|
"actionRequired": "Azione richiesta",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||||
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "サウンド",
|
||||||
|
"description": "チャット実行が完了したときに短い音を再生します。",
|
||||||
|
"enabled": "有効",
|
||||||
|
"test": "サウンドをテスト"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "イベント種別",
|
"title": "イベント種別",
|
||||||
"actionRequired": "対応が必要",
|
"actionRequired": "対応が必要",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||||
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "소리",
|
||||||
|
"description": "채팅 실행이 완료되면 짧은 알림음을 재생합니다.",
|
||||||
|
"enabled": "사용",
|
||||||
|
"test": "소리 테스트"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "이벤트 유형",
|
"title": "이벤트 유형",
|
||||||
"actionRequired": "작업 필요",
|
"actionRequired": "작업 필요",
|
||||||
|
|||||||
@@ -94,9 +94,35 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API и токены",
|
"apiTokens": "API и токены",
|
||||||
"tasks": "Задачи",
|
"tasks": "Задачи",
|
||||||
|
"notifications": "Уведомления",
|
||||||
"plugins": "Плагины",
|
"plugins": "Плагины",
|
||||||
"about": "О программе"
|
"about": "О программе"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Уведомления",
|
||||||
|
"description": "Управляйте тем, какие события уведомлений вы получаете.",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web Push уведомления",
|
||||||
|
"enable": "Включить Push уведомления",
|
||||||
|
"disable": "Отключить Push уведомления",
|
||||||
|
"enabled": "Push уведомления включены",
|
||||||
|
"loading": "Обновление...",
|
||||||
|
"unsupported": "Push уведомления не поддерживаются в этом браузере.",
|
||||||
|
"denied": "Push уведомления заблокированы. Разрешите их в настройках браузера."
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Звук",
|
||||||
|
"description": "Воспроизводить короткий сигнал при завершении запуска чата.",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"test": "Проверить звук"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Типы событий",
|
||||||
|
"actionRequired": "Требуется действие",
|
||||||
|
"stop": "Запуск остановлен",
|
||||||
|
"error": "Запуск завершился с ошибкой"
|
||||||
|
}
|
||||||
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
"label": "Темная тема",
|
"label": "Темная тема",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
|
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
|
||||||
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
|
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Ses",
|
||||||
|
"description": "Sohbet çalışması tamamlandığında kısa bir ton çal.",
|
||||||
|
"enabled": "Etkin",
|
||||||
|
"test": "Sesi test et"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Etkinlik Türleri",
|
"title": "Etkinlik Türleri",
|
||||||
"actionRequired": "Aksiyon gerekli",
|
"actionRequired": "Aksiyon gerekli",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "此浏览器不支持推送通知。",
|
"unsupported": "此浏览器不支持推送通知。",
|
||||||
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "声音",
|
||||||
|
"description": "聊天运行完成时播放短提示音。",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"test": "测试声音"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "事件类型",
|
"title": "事件类型",
|
||||||
"actionRequired": "需要处理",
|
"actionRequired": "需要处理",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "此瀏覽器不支援推播通知。",
|
"unsupported": "此瀏覽器不支援推播通知。",
|
||||||
"denied": "推播通知已被封鎖,請在瀏覽器設定中允許。"
|
"denied": "推播通知已被封鎖,請在瀏覽器設定中允許。"
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "聲音",
|
||||||
|
"description": "聊天執行完成時播放短提示音。",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"test": "測試聲音"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "事件類型",
|
"title": "事件類型",
|
||||||
"actionRequired": "需要處理",
|
"actionRequired": "需要處理",
|
||||||
|
|||||||
83
src/utils/notificationSound.ts
Normal file
83
src/utils/notificationSound.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const NOTIFICATION_SOUND_ENABLED_STORAGE_KEY = 'notificationSoundEnabled';
|
||||||
|
const AudioContextConstructor =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
|
||||||
|
export const isNotificationSoundEnabled = (): boolean => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return localStorage.getItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY) !== 'false';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setNotificationSoundEnabled = (enabled: boolean): void => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY, String(enabled));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAudioContext = (): AudioContext | null => {
|
||||||
|
if (!AudioContextConstructor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioContext) {
|
||||||
|
audioContext = new AudioContextConstructor();
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
const playTone = (
|
||||||
|
context: AudioContext,
|
||||||
|
frequency: number,
|
||||||
|
startsAt: number,
|
||||||
|
duration: number,
|
||||||
|
peakVolume: number,
|
||||||
|
): void => {
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
const gain = context.createGain();
|
||||||
|
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, startsAt);
|
||||||
|
|
||||||
|
// Shape the volume so the synthesized tone starts and stops cleanly.
|
||||||
|
gain.gain.setValueAtTime(0.0001, startsAt);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(peakVolume, startsAt + 0.015);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + duration);
|
||||||
|
|
||||||
|
oscillator.connect(gain);
|
||||||
|
gain.connect(context.destination);
|
||||||
|
oscillator.start(startsAt);
|
||||||
|
oscillator.stop(startsAt + duration + 0.02);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playChatCompletionSound = async ({ force = false } = {}): Promise<void> => {
|
||||||
|
if (!force && !isNotificationSoundEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = getAudioContext();
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (context.state === 'suspended') {
|
||||||
|
await context.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = context.currentTime;
|
||||||
|
playTone(context, 740, now, 0.12, 0.075);
|
||||||
|
playTone(context, 988, now + 0.11, 0.16, 0.06);
|
||||||
|
} catch (error) {
|
||||||
|
// Browsers may block audio until the page receives a user gesture.
|
||||||
|
console.warn('Unable to play notification sound:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
112
src/utils/pageTitleNotification.ts
Normal file
112
src/utils/pageTitleNotification.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
const COMPLETION_TITLE_INDICATOR = '[Done]';
|
||||||
|
const TITLE_INDICATOR_CLEAR_DELAY_MS = 2000;
|
||||||
|
|
||||||
|
let clearTimer: number | null = null;
|
||||||
|
let returnListenersAttached = false;
|
||||||
|
|
||||||
|
const getIndicatorPrefix = () => `${COMPLETION_TITLE_INDICATOR} `;
|
||||||
|
|
||||||
|
const stripIndicator = (title: string): string => {
|
||||||
|
const prefix = getIndicatorPrefix();
|
||||||
|
return title.startsWith(prefix) ? title.slice(prefix.length) : title;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageIsActive = (): boolean => (
|
||||||
|
document.visibilityState === 'visible' && document.hasFocus()
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeReturnListeners = (): void => {
|
||||||
|
if (!returnListenersAttached || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('visibilitychange', handleUserReturn);
|
||||||
|
window.removeEventListener('focus', handleUserReturn, true);
|
||||||
|
window.removeEventListener('click', handleUserReturn, true);
|
||||||
|
returnListenersAttached = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTitleIndicator = (): void => {
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
clearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeReturnListeners();
|
||||||
|
removePageInactiveListener();
|
||||||
|
|
||||||
|
if (document.title.startsWith(getIndicatorPrefix())) {
|
||||||
|
document.title = stripIndicator(document.title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePageInactiveListener = (): void => {
|
||||||
|
document.removeEventListener('visibilitychange', handlePageInactive);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleClear = (): void => {
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimer = window.setTimeout(() => {
|
||||||
|
clearTitleIndicator();
|
||||||
|
}, TITLE_INDICATOR_CLEAR_DELAY_MS);
|
||||||
|
|
||||||
|
removePageInactiveListener();
|
||||||
|
document.addEventListener('visibilitychange', handlePageInactive, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleUserReturn(): void {
|
||||||
|
if (!pageIsActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background completions keep the marker indefinitely. A tab click normally
|
||||||
|
// surfaces as visibility/focus, while an in-page click is a useful fallback.
|
||||||
|
scheduleClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageInactive(): void {
|
||||||
|
if (document.visibilityState !== 'hidden') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
clearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!returnListenersAttached) {
|
||||||
|
document.addEventListener('visibilitychange', handleUserReturn);
|
||||||
|
window.addEventListener('focus', handleUserReturn, true);
|
||||||
|
window.addEventListener('click', handleUserReturn, true);
|
||||||
|
returnListenersAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showCompletionTitleIndicator = (): void => {
|
||||||
|
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTitle = stripIndicator(document.title || 'CloudCLI UI');
|
||||||
|
document.title = `${getIndicatorPrefix()}${baseTitle}`;
|
||||||
|
|
||||||
|
if (pageIsActive()) {
|
||||||
|
scheduleClear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
clearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!returnListenersAttached) {
|
||||||
|
document.addEventListener('visibilitychange', handleUserReturn);
|
||||||
|
window.addEventListener('focus', handleUserReturn, true);
|
||||||
|
window.addEventListener('click', handleUserReturn, true);
|
||||||
|
returnListenersAttached = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user