Compare commits

..

19 Commits

Author SHA1 Message Date
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
f439a8a3d5 Merge branch 'main' into chore/add-prism-plugin 2026-06-09 17:46:47 +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
szmidtpiotr
f7c0024fe1 fix: slash command suggestions trigger at any / in input, not only at start (#843)
Previously the regex ^\/(\S*)$ only matched when the entire text before
the cursor was a bare /command. Typing a slash mid-sentence (e.g.
"please run /he") produced no suggestions.

Changed pattern to (?:^|\s)(\/\S*)$  which matches / at the start of
input or after any whitespace. Also compute slashPos from match.index
instead of hardcoding 0, so insertCommandIntoInput replaces the correct
slice of the input when the command is mid-sentence.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:56:31 +03:00
Haileyesus
ca8fd0ee23 fix: align prism plugin name and id with manifest.json 2026-06-09 15:44:42 +03:00
Simos Mikelatos
b7e6bca2e3 Merge pull request #851 from jakeefr/fix/windows-plugin-env
Pass Windows-essential env vars to plugin subprocesses
2026-06-09 14:41:27 +02:00
Simos Mikelatos
84c166c4cb Merge pull request #847 from siteboon/feature/file-tree-upload-ux
feat: add file tree upload progress
2026-06-09 14:26:31 +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
Jakob Michael Werner
1faa1a6a00 Pass Windows-essential env vars to plugin subprocesses
Plugin servers are started with a deliberately minimal env (PATH, HOME,
NODE_ENV, PLUGIN_NAME). On Windows that drops system variables that child
processes need to bootstrap. The one that bit me: without APPDATA, CPython
cannot find the per-user site-packages, so a plugin that shells out to a
pip install --user CLI launches the tool but it dies with ModuleNotFoundError.
SystemRoot, PATHEXT and TEMP cause similar failures for other tools.

On win32, pass through a small allowlist of non-secret system variables
(SystemRoot, windir, SystemDrive, USERPROFILE, APPDATA, LOCALAPPDATA, TEMP,
TMP, PATHEXT) when they are set. No change off Windows, and no host secrets
are exposed.
2026-06-08 17:13:10 -05:00
Haileyesus
3cd89956ba fix: update naming convention 2026-06-08 16:10:24 +03:00
Haileyesus
01dbe2a8bf chore: add prism plugin 2026-06-08 15:55:40 +03:00
22 changed files with 475 additions and 17 deletions

View File

@@ -10,6 +10,7 @@ type NotificationPreferences = {
channels: {
inApp: boolean;
webPush: boolean;
sound: boolean;
};
events: {
actionRequired: boolean;
@@ -22,6 +23,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
channels: {
inApp: false,
webPush: false,
sound: true,
},
events: {
actionRequired: true,
@@ -37,6 +39,7 @@ function normalizeNotificationPreferences(value: unknown): NotificationPreferenc
channels: {
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true,
sound: source.channels?.sound !== false,
},
events: {
actionRequired: source.events?.actionRequired !== false,

View File

@@ -1,7 +1,8 @@
import fs from 'fs';
import path from 'path';
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_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');

View File

@@ -7,6 +7,41 @@ const runningPlugins = new Map();
// Map<pluginName, Promise<port>> — in-flight start operations
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.
* 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);
// Restricted env — only essentials, no host secrets
const pluginProcess = spawn('node', [serverPath], {
cwd: pluginDir,
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV || 'production',
PLUGIN_NAME: name,
},
env: buildPluginEnv(name),
stdio: ['ignore', 'pipe', 'pipe'],
});

View File

@@ -2,6 +2,8 @@ import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
@@ -285,6 +287,9 @@ export function useChatRealtimeHandlers({
break;
}
showCompletionTitleIndicator();
void playChatCompletionSound();
const actualSessionId =
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
? msg.actualSessionId

View File

@@ -383,12 +383,47 @@ export function useChatSessionState({
setIsUserScrolledUp(false);
}, [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(() => {
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
pendingInitialScrollRef.current = false;
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
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]);
// Main session loading effect — store-based

View File

@@ -393,7 +393,8 @@ export function useSlashCommands({
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);
if (!match) {
@@ -401,8 +402,9 @@ export function useSlashCommands({
return;
}
const slashPos = 0;
const query = match[1];
// Compute actual position of / in the full input string.
const slashPos = match.index! + (match[0].length - match[1].length);
const query = match[1].slice(1); // strip leading /
setSlashPosition(slashPos);
setShowCommandMenu(true);

View File

@@ -102,7 +102,7 @@ export default function EditorSidebar({
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
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 && (
<div
ref={resizeHandleRef}

View File

@@ -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 SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
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 = {
id: string;
@@ -72,6 +73,14 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
icon: Clock,
source: 'unofficial',
},
{
id: 'prism',
translationKey: 'prismCloudCLI',
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
installedNames: ['prism'],
icon: Activity,
source: 'unofficial'
}
];
function repoSlug(repoUrl: string) {

View File

@@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../../utils/api';
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
import {
DEFAULT_CODE_EDITOR_SETTINGS,
@@ -107,6 +109,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
channels: {
inApp: true,
webPush: false,
sound: true,
},
events: {
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) {
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
const closeTimerRef = useRef<number | null>(null);
@@ -186,7 +208,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
if (notificationResponse.ok) {
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
if (notificationData.success && notificationData.preferences) {
setNotificationPreferences(notificationData.preferences);
setNotificationPreferences(normalizeNotificationPreferences(notificationData.preferences));
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
@@ -301,6 +323,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
void refreshProviderAuthStatuses();
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
useEffect(() => {
setNotificationSoundEnabled(notificationPreferences.channels.sound);
}, [notificationPreferences.channels.sound]);
useEffect(() => {
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));

View File

@@ -1,4 +1,5 @@
import type { Dispatch, SetStateAction } from 'react';
import type { LLMProvider } from '../../../types/app';
import type { ProviderAuthStatus } from '../../provider-auth/types';
@@ -29,6 +30,7 @@ export type NotificationPreferencesState = {
channels: {
inApp: boolean;
webPush: boolean;
sound: boolean;
};
events: {
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 { Button } from '../../../../shared/view/ui';
import { playChatCompletionSound } from '../../../../utils/notificationSound';
import type { NotificationPreferencesState } from '../../types/types';
type NotificationsSettingsTabProps = {
@@ -82,6 +85,54 @@ export default function NotificationsSettingsTab({
)}
</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">
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
<div className="space-y-3">

View File

@@ -94,9 +94,35 @@
"git": "Git",
"apiTokens": "API & Token",
"tasks": "Aufgaben",
"notifications": "Benachrichtigungen",
"plugins": "Plugins",
"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": {
"darkMode": {
"label": "Darkmode",

View File

@@ -110,6 +110,12 @@
"unsupported": "Push notifications are not supported in this browser.",
"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": {
"title": "Event Types",
"actionRequired": "Action required",
@@ -502,6 +508,12 @@
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
"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",
"enable": "Enable",
"disable": "Disable",

View File

@@ -110,6 +110,12 @@
"unsupported": "Le notifiche push non sono supportate in questo 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": {
"title": "Tipi di evento",
"actionRequired": "Azione richiesta",

View File

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

View File

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

View File

@@ -94,9 +94,35 @@
"git": "Git",
"apiTokens": "API и токены",
"tasks": "Задачи",
"notifications": "Уведомления",
"plugins": "Плагины",
"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": {
"darkMode": {
"label": "Темная тема",

View File

@@ -110,6 +110,12 @@
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
"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": {
"title": "Etkinlik Türleri",
"actionRequired": "Aksiyon gerekli",

View File

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

View File

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