Compare commits

..

21 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
Simos Mikelatos
231ed04002 Merge branch 'main' into feature/file-tree-upload-ux 2026-06-09 14:25:57 +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
Haileyesus
c235b05e1d feat: add file tree upload progress
Users need a visible upload path from the explorer itself, not only drag and
 drop behavior with no progress feedback. Routing picker and drop uploads
 through one XHR-backed hook keeps progress, validation, refresh, and success
 counts consistent for every upload source.

The 200MB limit is mirrored in the client, multer, and nginx template so large
 uploads fail predictably instead of being blocked by whichever layer sees the
 request first. The server also returns explicit requested and uploaded counts
 so partial or multi-file batches can render accurate status text.
2026-06-08 14:52:09 +03:00
30 changed files with 1011 additions and 175 deletions

View File

@@ -72,7 +72,7 @@ http {
set $cloudcli_upstream http://127.0.0.1:3001; set $cloudcli_upstream http://127.0.0.1:3001;
# Allow larger file uploads through the code editor/project file APIs. # Allow larger file uploads through the code editor/project file APIs.
client_max_body_size 100m; client_max_body_size 200m;
# Redirect /ai to /ai/ so relative browser URL resolution is stable. # Redirect /ai to /ai/ so relative browser URL resolution is stable.
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath. # [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.

View File

@@ -84,6 +84,9 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts. // Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname); const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
console.log('SERVER_PORT from env:', process.env.SERVER_PORT); console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
@@ -897,27 +900,27 @@ const uploadFilesHandler = async (req, res) => {
} }
}), }),
limits: { limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
files: 20 // Max 20 files at once files: MAX_FILE_UPLOAD_COUNT
} }
}); });
// Use multer middleware // Use multer middleware
uploadMiddleware.array('files', 20)(req, res, async (err) => { uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
if (err) { if (err) {
console.error('Multer error:', err); console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' }); return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
} }
if (err.code === 'LIMIT_FILE_COUNT') { if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' }); return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
} }
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
} }
try { try {
const { projectId } = req.params; const { projectId } = req.params;
const { targetPath, relativePaths } = req.body; const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
// Parse relative paths if provided (for folder uploads) // Parse relative paths if provided (for folder uploads)
let filePaths = []; let filePaths = [];
@@ -941,6 +944,11 @@ const uploadFilesHandler = async (req, res) => {
return res.status(400).json({ error: 'No files provided' }); return res.status(400).json({ error: 'No files provided' });
} }
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
? parsedRequestedFileCount
: req.files.length;
// Resolve the project directory through the DB using the new projectId. // Resolve the project directory through the DB using the new projectId.
const projectRoot = await projectsDb.getProjectPathById(projectId); const projectRoot = await projectsDb.getProjectPathById(projectId);
if (!projectRoot) { if (!projectRoot) {
@@ -1019,8 +1027,10 @@ const uploadFilesHandler = async (req, res) => {
res.json({ res.json({
success: true, success: true,
files: uploadedFiles, files: uploadedFiles,
uploadedCount: uploadedFiles.length,
requestedFileCount,
targetPath: resolvedTargetDir, targetPath: resolvedTargetDir,
message: `Uploaded ${uploadedFiles.length} file(s) successfully` message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
}); });
} catch (error) { } catch (error) {
console.error('Error uploading files:', error); console.error('Error uploading files:', error);

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

@@ -279,16 +279,6 @@ export async function queryCodex(command, options = {}, ws) {
startedAt: new Date().toISOString() startedAt: new Date().toISOString()
}); });
}; };
const markSessionFinished = (id) => {
if (!id) {
return;
}
const session = activeCodexSessions.get(id);
if (session && session.status !== 'aborted') {
session.status = 'completed';
}
};
// Existing sessions can be tracked immediately; new sessions are tracked after thread.started. // Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
if (capturedSessionId) { if (capturedSessionId) {
@@ -334,10 +324,6 @@ export async function queryCodex(command, options = {}, ws) {
continue; continue;
} }
if (event.type === 'turn.completed' || event.type === 'turn.failed') {
markSessionFinished(capturedSessionId || sessionId);
}
const transformed = transformCodexEvent(event); const transformed = transformCodexEvent(event);
// Normalize the transformed event into NormalizedMessage(s) via adapter // Normalize the transformed event into NormalizedMessage(s) via adapter
@@ -368,8 +354,6 @@ export async function queryCodex(command, options = {}, ws) {
// Send completion event // Send completion event
if (!terminalFailure) { if (!terminalFailure) {
markSessionFinished(capturedSessionId || sessionId);
sendMessage(ws, createNormalizedMessage({ sendMessage(ws, createNormalizedMessage({
kind: 'complete', kind: 'complete',
actualSessionId: capturedSessionId || thread.id || sessionId || null, actualSessionId: capturedSessionId || thread.id || sessionId || null,

View File

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

View File

@@ -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'],
}); });

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';
@@ -98,7 +100,6 @@ export function useChatRealtimeHandlers({
}: UseChatRealtimeHandlersArgs) { }: UseChatRealtimeHandlersArgs) {
const paletteOps = usePaletteOps(); const paletteOps = usePaletteOps();
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null); const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
const terminalSessionIdsRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (!latestMessage) return; if (!latestMessage) return;
@@ -152,17 +153,6 @@ export function useChatRealtimeHandlers({
const isCurrentSession = const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (msg.isProcessing && terminalSessionIdsRef.current.has(statusSessionId)) {
onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
}
return;
}
if (msg.isProcessing) { if (msg.isProcessing) {
onSessionActive?.(statusSessionId); onSessionActive?.(statusSessionId);
onSessionProcessing?.(statusSessionId); onSessionProcessing?.(statusSessionId);
@@ -192,10 +182,6 @@ export function useChatRealtimeHandlers({
const sid = msg.sessionId || activeViewSessionId; const sid = msg.sessionId || activeViewSessionId;
if (sid && msg.kind === 'session_created') {
terminalSessionIdsRef.current.delete(sid);
}
// --- Streaming: buffer for performance --- // --- Streaming: buffer for performance ---
if (msg.kind === 'stream_delta') { if (msg.kind === 'stream_delta') {
const text = msg.content || ''; const text = msg.content || '';
@@ -274,10 +260,6 @@ export function useChatRealtimeHandlers({
} }
case 'complete': { case 'complete': {
if (sid) {
terminalSessionIdsRef.current.add(sid);
}
// Flush any remaining streaming state // Flush any remaining streaming state
if (streamTimerRef.current) { if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current); clearTimeout(streamTimerRef.current);
@@ -305,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
@@ -333,10 +318,6 @@ export function useChatRealtimeHandlers({
} }
case 'error': { case 'error': {
if (sid) {
terminalSessionIdsRef.current.add(sid);
}
setIsLoading(false); setIsLoading(false);
setCanAbortSession(false); setCanAbortSession(false);
setClaudeStatus(null); setClaudeStatus(null);

View File

@@ -131,8 +131,6 @@ export function useChatSessionState({
const pendingInitialScrollRef = useRef(true); const pendingInitialScrollRef = useRef(true);
const messagesOffsetRef = useRef(0); const messagesOffsetRef = useRef(0);
const scrollPositionRef = useRef({ height: 0, top: 0 }); const scrollPositionRef = useRef({ height: 0, top: 0 });
const previousProcessingSessionsRef = useRef<Set<string> | null>(null);
const previousProcessingSessionViewIdRef = useRef<string | null>(null);
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastLoadedSessionKeyRef = useRef<string | null>(null); const lastLoadedSessionKeyRef = useRef<string | null>(null);
@@ -385,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
@@ -695,17 +728,9 @@ export function useChatSessionState({
useEffect(() => { useEffect(() => {
const activeViewSessionId = selectedSession?.id || currentSessionId; const activeViewSessionId = selectedSession?.id || currentSessionId;
const previousProcessingSessions = previousProcessingSessionsRef.current;
const previousProcessingSessionViewId = previousProcessingSessionViewIdRef.current;
previousProcessingSessionsRef.current = processingSessions ?? null;
previousProcessingSessionViewIdRef.current = activeViewSessionId ?? null;
if (!activeViewSessionId || !processingSessions) return; if (!activeViewSessionId || !processingSessions) return;
const activeViewSessionChanged = previousProcessingSessionViewId !== activeViewSessionId;
const wasProcessing = previousProcessingSessions?.has(activeViewSessionId) ?? false;
const shouldBeProcessing = processingSessions.has(activeViewSessionId); const shouldBeProcessing = processingSessions.has(activeViewSessionId);
if (shouldBeProcessing && (!wasProcessing || activeViewSessionChanged) && !isLoading) { if (shouldBeProcessing && !isLoading) {
setIsLoading(true); setIsLoading(true);
setCanAbortSession(true); setCanAbortSession(true);
} }

View File

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

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

@@ -6,6 +6,14 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed']; export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
export const MAX_FILE_UPLOAD_SIZE_MB = 200;
export const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
export const MAX_FILE_UPLOAD_SIZE_LABEL = `${MAX_FILE_UPLOAD_SIZE_MB}MB`;
export const MAX_FILE_UPLOAD_COUNT = 20;
export const IMAGE_FILE_EXTENSIONS = new Set([ export const IMAGE_FILE_EXTENSIONS = new Set([
'png', 'png',
'jpg', 'jpg',

View File

@@ -1,6 +1,13 @@
import { useCallback, useState, useRef } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import { api } from '../../../utils/api'; import {
MAX_FILE_UPLOAD_COUNT,
MAX_FILE_UPLOAD_SIZE_BYTES,
MAX_FILE_UPLOAD_SIZE_LABEL,
} from '../constants/constants';
type UseFileTreeUploadOptions = { type UseFileTreeUploadOptions = {
selectedProject: Project | null; selectedProject: Project | null;
@@ -8,6 +15,141 @@ type UseFileTreeUploadOptions = {
showToast: (message: string, type: 'success' | 'error') => void; showToast: (message: string, type: 'success' | 'error') => void;
}; };
export type FileTreeUploadProgressState = {
status: 'uploading' | 'complete' | 'error';
progress: number;
fileCount: number;
uploadedCount?: number;
fileName?: string;
targetPath?: string;
error?: string;
};
type UploadResponse = {
error?: string;
message?: string;
files?: unknown[];
uploadedCount?: number;
requestedFileCount?: number;
};
const COMPLETE_PROGRESS_CLEAR_DELAY_MS = 1400;
const ERROR_PROGRESS_CLEAR_DELAY_MS = 3200;
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
const getRelativePath = (file: File) => {
const fileWithRelativePath = file as File & { webkitRelativePath?: string };
return fileWithRelativePath.webkitRelativePath || file.name;
};
const getFileDisplayName = (file: File) => {
const relativePath = getRelativePath(file);
return relativePath.split(/[\\/]/).pop() || file.name;
};
const validateFilesForUpload = (files: File[]): string | null => {
if (files.length > MAX_FILE_UPLOAD_COUNT) {
return `You can upload up to ${MAX_FILE_UPLOAD_COUNT} files at once.`;
}
const oversizedFile = files.find((file) => file.size > MAX_FILE_UPLOAD_SIZE_BYTES);
if (oversizedFile) {
return `${getFileDisplayName(oversizedFile)} is larger than ${MAX_FILE_UPLOAD_SIZE_LABEL}.`;
}
return null;
};
const parseUploadResponse = (xhr: XMLHttpRequest): UploadResponse => {
if (!xhr.responseText) {
return {};
}
try {
return JSON.parse(xhr.responseText) as UploadResponse;
} catch {
return {};
}
};
const formatUploadSuccessMessage = (uploadedCount: number, requestedFileCount: number) => {
if (uploadedCount !== requestedFileCount) {
return `Uploaded ${uploadedCount} of ${requestedFileCount} ${pluralizeFiles(requestedFileCount)}`;
}
return `Uploaded ${uploadedCount} ${pluralizeFiles(uploadedCount)} successfully`;
};
const buildUploadFormData = (files: File[], targetPath: string) => {
const formData = new FormData();
const relativePaths: string[] = [];
formData.append('targetPath', targetPath);
formData.append('requestedFileCount', String(files.length));
files.forEach((file) => {
const relativePath = getRelativePath(file);
const cleanFile = new File([file], relativePath.split(/[\\/]/).pop() || file.name, {
type: file.type,
lastModified: file.lastModified,
});
formData.append('files', cleanFile);
relativePaths.push(relativePath);
});
formData.append('relativePaths', JSON.stringify(relativePaths));
return formData;
};
const uploadFormDataWithProgress = (
projectId: string,
formData: FormData,
onProgress: (progress: number) => void,
) =>
new Promise<UploadResponse>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`);
const token = localStorage.getItem('auth-token');
if (!IS_PLATFORM && token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) {
return;
}
// Keep 100% for the server response so the UI can distinguish transfer
// completion from the final write/refresh step.
onProgress(Math.min(99, Math.round((event.loaded / event.total) * 100)));
};
xhr.onload = () => {
const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token');
if (refreshedToken) {
localStorage.setItem('auth-token', refreshedToken);
}
const payload = parseUploadResponse(xhr);
if (xhr.status >= 200 && xhr.status < 300) {
resolve(payload);
return;
}
reject(new Error(payload.error || payload.message || `Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('Upload failed. Check your connection and try again.'));
xhr.onabort = () => reject(new Error('Upload canceled.'));
xhr.send(formData);
});
// Helper function to read all files from a directory entry recursively // Helper function to read all files from a directory entry recursively
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => { const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
const files: File[] = []; const files: File[] = [];
@@ -57,6 +199,48 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
return files; return files;
}; };
const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
const files: File[] = [];
// Use DataTransferItemList for folder support
const { items } = dataTransfer;
if (items) {
for (const item of Array.from(items)) {
if (item.kind !== 'file') {
continue;
}
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (!entry) {
const file = item.getAsFile();
if (file) {
files.push(file);
}
continue;
}
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject);
});
files.push(file);
} else if (entry.isDirectory) {
// Pass the directory name as basePath so files include the folder path
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
files.push(...dirFiles);
}
}
return files;
}
// Fallback for browsers that don't support webkitGetAsEntry
for (const file of Array.from(dataTransfer.files)) {
files.push(file);
}
return files;
};
export const useFileTreeUpload = ({ export const useFileTreeUpload = ({
selectedProject, selectedProject,
onRefresh, onRefresh,
@@ -65,20 +249,150 @@ export const useFileTreeUpload = ({
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [dropTarget, setDropTarget] = useState<string | null>(null); const [dropTarget, setDropTarget] = useState<string | null>(null);
const [operationLoading, setOperationLoading] = useState(false); const [operationLoading, setOperationLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
const treeRef = useRef<HTMLDivElement>(null); const treeRef = useRef<HTMLDivElement>(null);
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => { const clearProgressTimer = useCallback(() => {
if (clearProgressTimerRef.current) {
clearTimeout(clearProgressTimerRef.current);
clearProgressTimerRef.current = null;
}
}, []);
const scheduleProgressClear = useCallback(
(delay: number) => {
clearProgressTimer();
clearProgressTimerRef.current = setTimeout(() => {
setUploadProgress(null);
clearProgressTimerRef.current = null;
}, delay);
},
[clearProgressTimer],
);
useEffect(() => clearProgressTimer, [clearProgressTimer]);
const setUploadError = useCallback(
(message: string, fileCount: number, targetPath = '', fileName?: string, progress = 0) => {
setUploadProgress({
status: 'error',
progress,
fileCount,
fileName,
targetPath,
error: message,
});
scheduleProgressClear(ERROR_PROGRESS_CLEAR_DELAY_MS);
},
[scheduleProgressClear],
);
const uploadFiles = useCallback(
async (files: File[], targetPath = '') => {
if (files.length === 0) {
setDropTarget(null);
return;
}
const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined;
if (!selectedProject) {
const message = 'Select a project before uploading files.';
showToast(message, 'error');
setUploadError(message, files.length, targetPath, fileName);
return;
}
const validationError = validateFilesForUpload(files);
if (validationError) {
showToast(validationError, 'error');
setUploadError(validationError, files.length, targetPath, fileName);
return;
}
clearProgressTimer();
setOperationLoading(true);
setUploadProgress({
status: 'uploading',
progress: 0,
fileCount: files.length,
fileName,
targetPath,
});
let latestProgress = 0;
try {
const response = await uploadFormDataWithProgress(
selectedProject.projectId,
buildUploadFormData(files, targetPath),
(progress) => {
latestProgress = progress;
setUploadProgress((current) =>
current && current.status === 'uploading'
? { ...current, progress }
: current,
);
},
);
const uploadedCount =
typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length;
const requestedFileCount =
typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length;
setUploadProgress({
status: 'complete',
progress: 100,
fileCount: requestedFileCount,
uploadedCount,
fileName,
targetPath,
});
showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success');
scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS);
onRefresh();
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
console.error('Upload error:', err);
showToast(message, 'error');
setUploadError(message, files.length, targetPath, fileName, latestProgress);
} finally {
setOperationLoading(false);
setDropTarget(null);
}
},
[
clearProgressTimer,
onRefresh,
scheduleProgressClear,
selectedProject,
setUploadError,
showToast,
],
);
const handleFileSelect = useCallback(
async (fileList: FileList | File[]) => {
await uploadFiles(Array.from(fileList), '');
},
[uploadFiles],
);
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragOver(true); setIsDragOver(true);
}, []); }, []);
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}, []); }, []);
const handleDragLeave = useCallback((e: React.DragEvent) => { const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Only set isDragOver to false if we're leaving the entire tree // Only set isDragOver to false if we're leaving the entire tree
@@ -88,103 +402,35 @@ export const useFileTreeUpload = ({
} }
}, []); }, []);
const handleDrop = useCallback(async (e: React.DragEvent) => { const handleDrop = useCallback(
e.preventDefault(); async (e: DragEvent) => {
e.stopPropagation(); e.preventDefault();
setIsDragOver(false); e.stopPropagation();
setIsDragOver(false);
const targetPath = dropTarget || ''; const targetPath = dropTarget || '';
setOperationLoading(true);
try { try {
const files: File[] = []; const files = await collectDroppedFiles(e.dataTransfer);
await uploadFiles(files, targetPath);
// Use DataTransferItemList for folder support } catch (err) {
const items = e.dataTransfer.items; const message = err instanceof Error ? err.message : 'Could not read dropped files';
if (items) { console.error('Upload error:', err);
for (const item of Array.from(items)) { showToast(message, 'error');
if (item.kind === 'file') { setUploadError(message, 0, targetPath);
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject);
});
files.push(file);
} else if (entry.isDirectory) {
// Pass the directory name as basePath so files include the folder path
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
files.push(...dirFiles);
}
}
}
}
} else {
// Fallback for browsers that don't support webkitGetAsEntry
const fileList = e.dataTransfer.files;
for (const file of Array.from(fileList)) {
files.push(file);
}
}
if (files.length === 0) {
setOperationLoading(false);
setDropTarget(null); setDropTarget(null);
return;
} }
},
[dropTarget, setUploadError, showToast, uploadFiles],
);
const formData = new FormData(); const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
formData.append('targetPath', targetPath);
// Store relative paths separately since FormData strips path info from File.name
const relativePaths: string[] = [];
files.forEach((file) => {
// Create a new file with just the filename (without path) for FormData
// but store the relative path separately
const cleanFile = new File([file], file.name.split('/').pop()!, {
type: file.type,
lastModified: file.lastModified
});
formData.append('files', cleanFile);
relativePaths.push(file.name); // Keep the full relative path
});
// Send relative paths as a JSON array
formData.append('relativePaths', JSON.stringify(relativePaths));
const response = await api.post(
// File upload endpoint is keyed by DB projectId post-migration.
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
formData
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
showToast(
`Uploaded ${files.length} file(s)`,
'success'
);
onRefresh();
} catch (err) {
console.error('Upload error:', err);
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
} finally {
setOperationLoading(false);
setDropTarget(null);
}
}, [dropTarget, selectedProject, onRefresh, showToast]);
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDropTarget(itemPath); setDropTarget(itemPath);
}, []); }, []);
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => { const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDropTarget(itemPath); setDropTarget(itemPath);
@@ -194,7 +440,9 @@ export const useFileTreeUpload = ({
isDragOver, isDragOver,
dropTarget, dropTarget,
operationLoading, operationLoading,
uploadProgress,
treeRef, treeRef,
handleFileSelect,
handleDragEnter, handleDragEnter,
handleDragOver, handleDragOver,
handleDragLeave, handleDragLeave,

View File

@@ -1,6 +1,7 @@
import { useCallback, useState, useEffect, useRef } from 'react'; import { useCallback, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react'; import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons'; import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
import { useExpandedDirectories } from '../hooks/useExpandedDirectories'; import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
@@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils'; import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
import { Project } from '../../../types/app'; import { Project } from '../../../types/app';
import { ScrollArea, Input } from '../../../shared/view/ui'; import { ScrollArea, Input } from '../../../shared/view/ui';
import FileTreeBody from './FileTreeBody'; import FileTreeBody from './FileTreeBody';
import FileTreeDetailedColumns from './FileTreeDetailedColumns'; import FileTreeDetailedColumns from './FileTreeDetailedColumns';
import FileTreeHeader from './FileTreeHeader'; import FileTreeHeader from './FileTreeHeader';
import FileTreeLoadingState from './FileTreeLoadingState'; import FileTreeLoadingState from './FileTreeLoadingState';
import FileTreeUploadProgress from './FileTreeUploadProgress';
import ImageViewer from './ImageViewer'; import ImageViewer from './ImageViewer';
@@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
onRefresh: refreshFiles, onRefresh: refreshFiles,
showToast, showToast,
}); });
const operationLoading = operations.operationLoading || upload.operationLoading;
// Focus input when creating new item // Focus input when creating new item
useEffect(() => { useEffect(() => {
@@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
onViewModeChange={changeViewMode} onViewModeChange={changeViewMode}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
onUploadFiles={upload.handleFileSelect}
onNewFile={() => operations.handleStartCreate('', 'file')} onNewFile={() => operations.handleStartCreate('', 'file')}
onNewFolder={() => operations.handleStartCreate('', 'directory')} onNewFolder={() => operations.handleStartCreate('', 'directory')}
onRefresh={refreshFiles} onRefresh={refreshFiles}
onCollapseAll={collapseAll} onCollapseAll={collapseAll}
loading={loading} loading={loading}
operationLoading={operations.operationLoading} operationLoading={operationLoading}
isUploading={upload.uploadProgress?.status === 'uploading'}
uploadProgress={upload.uploadProgress?.progress ?? null}
/> />
<FileTreeUploadProgress upload={upload.uploadProgress} />
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />} {viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
<ScrollArea className="flex-1 px-2 py-1"> <ScrollArea className="flex-1 px-2 py-1">
@@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
}, 100); }, 100);
}} }}
className="h-6 flex-1 text-sm" className="h-6 flex-1 text-sm"
disabled={operations.operationLoading} disabled={operationLoading}
/> />
</div> </div>
)} )}
@@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
handleConfirmRename={operations.handleConfirmRename} handleConfirmRename={operations.handleConfirmRename}
handleCancelRename={operations.handleCancelRename} handleCancelRename={operations.handleCancelRename}
renameInputRef={renameInputRef} renameInputRef={renameInputRef}
operationLoading={operations.operationLoading} operationLoading={operationLoading}
/> />
</ScrollArea> </ScrollArea>
@@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
onClick={operations.handleCancelDelete} onClick={operations.handleCancelDelete}
disabled={operations.operationLoading} disabled={operationLoading}
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent" className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
> >
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</button> </button>
<button <button
onClick={operations.handleConfirmDelete} onClick={operations.handleConfirmDelete}
disabled={operations.operationLoading} disabled={operationLoading}
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50" className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
> >
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />} {operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{t('fileTree.delete.confirm', 'Delete')} {t('fileTree.delete.confirm', 'Delete')}
</button> </button>
</div> </div>

View File

@@ -1,7 +1,11 @@
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react'; import { useRef } from 'react';
import type { ChangeEvent } from 'react';
import { ChevronDown, Eye, FileText, FolderPlus, List, Loader2, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../shared/view/ui'; import { Button, Input } from '../../../shared/view/ui';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants';
import type { FileTreeViewMode } from '../types/types'; import type { FileTreeViewMode } from '../types/types';
type FileTreeHeaderProps = { type FileTreeHeaderProps = {
@@ -12,11 +16,14 @@ type FileTreeHeaderProps = {
// Toolbar actions // Toolbar actions
onNewFile?: () => void; onNewFile?: () => void;
onNewFolder?: () => void; onNewFolder?: () => void;
onUploadFiles?: (files: FileList) => void;
onRefresh?: () => void; onRefresh?: () => void;
onCollapseAll?: () => void; onCollapseAll?: () => void;
// Loading state // Loading state
loading?: boolean; loading?: boolean;
operationLoading?: boolean; operationLoading?: boolean;
isUploading?: boolean;
uploadProgress?: number | null;
}; };
export default function FileTreeHeader({ export default function FileTreeHeader({
@@ -26,12 +33,24 @@ export default function FileTreeHeader({
onSearchQueryChange, onSearchQueryChange,
onNewFile, onNewFile,
onNewFolder, onNewFolder,
onUploadFiles,
onRefresh, onRefresh,
onCollapseAll, onCollapseAll,
loading, loading,
operationLoading, operationLoading,
isUploading,
uploadProgress,
}: FileTreeHeaderProps) { }: FileTreeHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uploadInputRef = useRef<HTMLInputElement>(null);
const handleUploadInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const { files } = event.target;
if (files && files.length > 0) {
onUploadFiles?.(files);
}
event.target.value = '';
};
return ( return (
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3"> <div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
@@ -40,6 +59,50 @@ export default function FileTreeHeader({
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3> <h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{/* Action buttons */} {/* Action buttons */}
{onUploadFiles && (
<>
<input
ref={uploadInputRef}
type="file"
multiple
className="hidden"
onChange={handleUploadInputChange}
tabIndex={-1}
aria-hidden="true"
/>
<Button
variant="ghost"
size="sm"
className="relative h-7 w-7 p-0"
onClick={() => uploadInputRef.current?.click()}
title={
isUploading
? t('fileTree.uploadingFiles', 'Uploading files')
: t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
size: MAX_FILE_UPLOAD_SIZE_LABEL,
})
}
aria-label={t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
size: MAX_FILE_UPLOAD_SIZE_LABEL,
})}
disabled={operationLoading}
>
{isUploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{isUploading && typeof uploadProgress === 'number' && (
<span className="absolute bottom-0.5 left-1/2 h-0.5 w-4 -translate-x-1/2 overflow-hidden rounded-full bg-primary/20">
<span
className="block h-full rounded-full bg-primary transition-[width] duration-150"
style={{ width: `${uploadProgress}%` }}
/>
</span>
)}
</Button>
</>
)}
{onNewFile && ( {onNewFile && (
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -0,0 +1,90 @@
import { AlertCircle, CheckCircle2, Upload } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import type { FileTreeUploadProgressState } from '../hooks/useFileTreeUpload';
type FileTreeUploadProgressProps = {
upload: FileTreeUploadProgressState | null;
};
const clampProgress = (progress: number) => Math.min(100, Math.max(0, progress));
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
export default function FileTreeUploadProgress({ upload }: FileTreeUploadProgressProps) {
const { t } = useTranslation();
if (!upload) {
return null;
}
const progress = clampProgress(upload.progress);
const isUploading = upload.status === 'uploading';
const isComplete = upload.status === 'complete';
const isError = upload.status === 'error';
const fileSummary =
upload.fileCount === 1 && upload.fileName
? upload.fileName
: `${upload.fileCount} ${pluralizeFiles(upload.fileCount)}`;
const title = isUploading
? t('fileTree.uploadingFiles', 'Uploading files')
: isComplete
? t('fileTree.uploadComplete', 'Upload complete')
: t('fileTree.uploadFailed', 'Upload failed');
const detail = isError
? upload.error
: isComplete && typeof upload.uploadedCount === 'number'
? t('fileTree.uploadedCount', 'Uploaded {{uploaded}} of {{total}} {{label}}', {
uploaded: upload.uploadedCount,
total: upload.fileCount,
label: pluralizeFiles(upload.fileCount),
})
: fileSummary;
const Icon = isError ? AlertCircle : isComplete ? CheckCircle2 : Upload;
return (
<div
className={cn(
'border-b px-3 py-2 transition-colors',
isError
? 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
: isComplete
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: 'border-primary/20 bg-primary/10 text-foreground',
)}
>
<div className="flex min-h-[36px] items-center gap-2">
<div
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md',
isError ? 'bg-red-500/15' : isComplete ? 'bg-emerald-500/15' : 'bg-primary/15',
)}
>
<Icon className={cn('h-3.5 w-3.5', isUploading && 'animate-pulse')} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-xs font-medium">{title}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{isUploading ? `${progress}%` : isComplete ? t('common.done', 'Done') : t('common.failed', 'Failed')}
</span>
</div>
<div className="mt-1 truncate text-[11px] text-muted-foreground">{detail}</div>
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-background/80">
<div
className={cn(
'h-full rounded-full transition-[width] duration-200',
isError ? 'bg-red-500' : isComplete ? 'bg-emerald-500' : 'bg-primary',
)}
style={{ width: `${isError ? Math.max(progress, 8) : progress}%` }}
/>
</div>
</div>
);
}

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 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) {

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

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