Compare commits

..

15 Commits

Author SHA1 Message Date
viper151
b6a45b3183 chore(release): v1.34.0 2026-06-09 20:34:48 +00:00
Simos Mikelatos
ce327b6fa9 feat: adding Fable 5 in claude code 2026-06-09 20:33:00 +00:00
viper151
276639099b chore(release): v1.33.3 2026-06-09 16:18:59 +00:00
Simos Mikelatos
f4f88318c2 Merge pull request #853 from siteboon/feature/chat-completion-notifications
feat: signal when chat runs complete
2026-06-09 18:14:53 +02:00
Simos Mikelatos
029d159592 Merge branch 'main' into feature/chat-completion-notifications 2026-06-09 18:13:42 +02:00
Simos Mikelatos
7c9ec8fa12 Merge pull request #859 from jakeefr/fix/editor-toolbar-offscreen-796
fix: keep editor toolbar in view on long unwrapped lines
2026-06-09 18:10:37 +02:00
Simos Mikelatos
1b4d4b7278 Merge branch 'main' into fix/editor-toolbar-offscreen-796 2026-06-09 18:10:28 +02:00
Simos Mikelatos
b1a0afe9e0 Merge pull request #856 from bourgois/fix/chat-initial-scroll-reanchor
fix(chat): re-anchor initial scroll across lazy content reflow
2026-06-09 18:06:17 +02:00
Simos Mikelatos
88eb2009bb Merge branch 'main' into fix/chat-initial-scroll-reanchor 2026-06-09 18:05:41 +02:00
Simos Mikelatos
602e6ad4ac fix: address notification review feedback 2026-06-09 16:04:15 +00:00
Simos Mikelatos
4a2453fe32 Merge pull request #848 from siteboon/chore/add-prism-plugin
chore: add prism plugin
2026-06-09 17:46:56 +02:00
Simos Mikelatos
23210bc40e Merge branch 'main' into feature/chat-completion-notifications 2026-06-09 17:39:56 +02:00
Jake
beae8c6513 fix: keep editor toolbar in view on long unwrapped lines 2026-06-09 10:38:27 -05:00
ShockStruck
33a4e72ca4 fix(chat): re-anchor initial scroll across lazy content reflow
The previous initial-scroll behavior fired one scrollToBottom() at
+200ms after the session load and cleared the pending flag. When
markdown, syntax highlighting, or images finished rendering after
that window, scrollHeight grew but nothing re-anchored the viewport.
The chat tab appeared "scrolled way up" with the latest assistant
message off-screen until the user manually scrolled or sent a new
message.

This replaces the setTimeout with a requestAnimationFrame loop that
re-scrolls every frame while scrollHeight is still growing, capped
at ~1s (60 frames) or 3 consecutive stable frames. The loop cancels
cleanly on session change via the existing pendingInitialScrollRef
flag, and the cleanup function cancels any in-flight rAF on unmount.

No behavior change for sessions whose content layout is already stable
at the first frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 15:39:26 +02:00
Haileyesus
d70dc077bf feat: signal when chat runs complete
Users can miss chat completions while the app is in the background.

They can also miss completions when their attention is elsewhere.

Add opt-out sound notifications and a temporary title marker.

This makes completion noticeable without external audio assets or persistent browser notifications.
2026-06-09 13:51:51 +03:00
25 changed files with 460 additions and 11 deletions

View File

@@ -3,6 +3,33 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
### New Features
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
### New Features
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
### Bug Fixes
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
### Maintenance
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08) ## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
### New Features ### New Features

4
package-lock.json generated
View File

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

View File

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

View File

@@ -14,6 +14,12 @@ export const CLAUDE_MODELS = {
description: description:
"Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok", "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
}, },
{
value: "fable",
label: "Fable",
description:
"Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus",
},
{ {
value: "sonnet", value: "sonnet",
label: "Sonnet", label: "Sonnet",

View File

@@ -204,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.disallowedTools = settings.disallowedTools || []; sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet) // Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT; sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
// Model logged at query start below // Model logged at query start below

View File

@@ -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,

View File

@@ -20,6 +20,11 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
label: 'Default (recommended)', label: 'Default (recommended)',
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok', description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
}, },
{
value: 'fable',
label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
},
{ {
value: "sonnet", value: "sonnet",
label: "Sonnet", label: "Sonnet",

View File

@@ -646,7 +646,7 @@ class ResponseCollector {
* *
* @param {string} model - (Optional) Model identifier for providers. * @param {string} model - (Optional) Model identifier for providers.
* *
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]' * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5', * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high', * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max', * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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));

View File

@@ -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;

View File

@@ -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">

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -110,6 +110,12 @@
"unsupported": "このブラウザではプッシュ通知がサポートされていません。", "unsupported": "このブラウザではプッシュ通知がサポートされていません。",
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。" "denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
}, },
"sound": {
"title": "サウンド",
"description": "チャット実行が完了したときに短い音を再生します。",
"enabled": "有効",
"test": "サウンドをテスト"
},
"events": { "events": {
"title": "イベント種別", "title": "イベント種別",
"actionRequired": "対応が必要", "actionRequired": "対応が必要",

View File

@@ -110,6 +110,12 @@
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.", "unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요." "denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
}, },
"sound": {
"title": "소리",
"description": "채팅 실행이 완료되면 짧은 알림음을 재생합니다.",
"enabled": "사용",
"test": "소리 테스트"
},
"events": { "events": {
"title": "이벤트 유형", "title": "이벤트 유형",
"actionRequired": "작업 필요", "actionRequired": "작업 필요",

View File

@@ -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": "Темная тема",

View File

@@ -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",

View File

@@ -110,6 +110,12 @@
"unsupported": "此浏览器不支持推送通知。", "unsupported": "此浏览器不支持推送通知。",
"denied": "推送通知已被阻止,请在浏览器设置中允许。" "denied": "推送通知已被阻止,请在浏览器设置中允许。"
}, },
"sound": {
"title": "声音",
"description": "聊天运行完成时播放短提示音。",
"enabled": "已启用",
"test": "测试声音"
},
"events": { "events": {
"title": "事件类型", "title": "事件类型",
"actionRequired": "需要处理", "actionRequired": "需要处理",

View File

@@ -110,6 +110,12 @@
"unsupported": "此瀏覽器不支援推播通知。", "unsupported": "此瀏覽器不支援推播通知。",
"denied": "推播通知已被封鎖,請在瀏覽器設定中允許。" "denied": "推播通知已被封鎖,請在瀏覽器設定中允許。"
}, },
"sound": {
"title": "聲音",
"description": "聊天執行完成時播放短提示音。",
"enabled": "已啟用",
"test": "測試聲音"
},
"events": { "events": {
"title": "事件類型", "title": "事件類型",
"actionRequired": "需要處理", "actionRequired": "需要處理",

View 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);
}
};

View 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;
}
};