mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-10 15:55:53 +08:00
Compare commits
1 Commits
v1.34.0
...
fix/codex-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0299beccb9 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -3,33 +3,6 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
|
|
||||||
|
|
||||||
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
|
|
||||||
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
|
|
||||||
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
|
|
||||||
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
|
|
||||||
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
|
|
||||||
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
|
|
||||||
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
|
|
||||||
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
|
|
||||||
|
|
||||||
### Maintenance
|
|
||||||
|
|
||||||
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
|
|
||||||
|
|
||||||
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
|
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -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 200m;
|
client_max_body_size 100m;
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.34.0",
|
"version": "1.33.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.34.0",
|
"version": "1.33.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.34.0",
|
"version": "1.33.2",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist-server/server/index.js",
|
"main": "dist-server/server/index.js",
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ export const CLAUDE_MODELS = {
|
|||||||
description:
|
description:
|
||||||
"Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
"Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "fable",
|
|
||||||
label: "Fable",
|
|
||||||
description:
|
|
||||||
"Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "sonnet",
|
value: "sonnet",
|
||||||
label: "Sonnet",
|
label: "Sonnet",
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||||
|
|
||||||
// Map model (default to sonnet)
|
// Map model (default to sonnet)
|
||||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
|
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||||
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||||
// Model logged at query start below
|
// Model logged at query start below
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,6 @@ const __dirname = getModuleDir(import.meta.url);
|
|||||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
const APP_ROOT = findAppRoot(__dirname);
|
||||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||||
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);
|
||||||
|
|
||||||
@@ -900,27 +897,27 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
|
fileSize: 50 * 1024 * 1024, // 50MB limit
|
||||||
files: MAX_FILE_UPLOAD_COUNT
|
files: 20 // Max 20 files at once
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use multer middleware
|
// Use multer middleware
|
||||||
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
|
uploadMiddleware.array('files', 20)(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 ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
|
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
||||||
}
|
}
|
||||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||||
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
|
return res.status(400).json({ error: 'Too many files. Maximum is 20 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, requestedFileCount: requestedFileCountRaw } = req.body;
|
const { targetPath, relativePaths } = req.body;
|
||||||
|
|
||||||
// Parse relative paths if provided (for folder uploads)
|
// Parse relative paths if provided (for folder uploads)
|
||||||
let filePaths = [];
|
let filePaths = [];
|
||||||
@@ -944,11 +941,6 @@ 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) {
|
||||||
@@ -1027,10 +1019,8 @@ 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} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
|
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading files:', error);
|
console.error('Error uploading files:', error);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ type NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
sound: boolean;
|
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
@@ -23,7 +22,6 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: false,
|
inApp: false,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
sound: true,
|
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -39,7 +37,6 @@ 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,
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
|||||||
label: 'Default (recommended)',
|
label: 'Default (recommended)',
|
||||||
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: 'fable',
|
|
||||||
label: 'Fable',
|
|
||||||
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "sonnet",
|
value: "sonnet",
|
||||||
label: "Sonnet",
|
label: "Sonnet",
|
||||||
|
|||||||
@@ -279,6 +279,16 @@ 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) {
|
||||||
@@ -324,6 +334,10 @@ 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
|
||||||
@@ -354,6 +368,8 @@ 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,
|
||||||
|
|||||||
@@ -646,7 +646,7 @@ class ResponseCollector {
|
|||||||
*
|
*
|
||||||
* @param {string} model - (Optional) Model identifier for providers.
|
* @param {string} model - (Optional) Model identifier for providers.
|
||||||
*
|
*
|
||||||
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
|
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
|
||||||
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
|
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
|
||||||
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
|
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
|
||||||
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
|
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
import { spawn } from 'cross-spawn';
|
|
||||||
|
|
||||||
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
||||||
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
||||||
|
|||||||
@@ -7,41 +7,6 @@ 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> }
|
||||||
@@ -61,9 +26,15 @@ 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: buildPluginEnv(name),
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
HOME: process.env.HOME,
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'production',
|
||||||
|
PLUGIN_NAME: name,
|
||||||
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ 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';
|
||||||
@@ -100,6 +98,7 @@ 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;
|
||||||
@@ -153,6 +152,17 @@ 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);
|
||||||
@@ -182,6 +192,10 @@ 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 || '';
|
||||||
@@ -260,6 +274,10 @@ 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);
|
||||||
@@ -287,9 +305,6 @@ 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
|
||||||
@@ -318,6 +333,10 @@ 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);
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ 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);
|
||||||
@@ -383,47 +385,12 @@ export function useChatSessionState({
|
|||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||||
|
|
||||||
// Initial scroll to bottom — robust to lazy content reflow.
|
// Initial scroll to bottom
|
||||||
// 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; }
|
||||||
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;
|
pendingInitialScrollRef.current = false;
|
||||||
}
|
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
|
||||||
};
|
|
||||||
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
|
||||||
@@ -728,9 +695,17 @@ 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 && !isLoading) {
|
if (shouldBeProcessing && (!wasProcessing || activeViewSessionChanged) && !isLoading) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setCanAbortSession(true);
|
setCanAbortSession(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,8 +393,7 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match / at start of input OR after whitespace, capturing the /word up to cursor.
|
const slashPattern = /^\/(\S*)$/;
|
||||||
const slashPattern = /(?:^|\s)(\/\S*)$/;
|
|
||||||
const match = textBeforeCursor.match(slashPattern);
|
const match = textBeforeCursor.match(slashPattern);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -402,9 +401,8 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute actual position of / in the full input string.
|
const slashPos = 0;
|
||||||
const slashPos = match.index! + (match[0].length - match[1].length);
|
const query = match[1];
|
||||||
const query = match[1].slice(1); // strip leading /
|
|
||||||
|
|
||||||
setSlashPosition(slashPos);
|
setSlashPosition(slashPos);
|
||||||
setShowCommandMenu(true);
|
setShowCommandMenu(true);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function EditorSidebar({
|
|||||||
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`flex h-full min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
<div ref={containerRef} className={`flex h-full min-w-0 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
||||||
{!editorExpanded && (
|
{!editorExpanded && (
|
||||||
<div
|
<div
|
||||||
ref={resizeHandleRef}
|
ref={resizeHandleRef}
|
||||||
|
|||||||
@@ -6,14 +6,6 @@ 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',
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useState, useRef } 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 {
|
import { api } from '../../../utils/api';
|
||||||
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;
|
||||||
@@ -15,141 +8,6 @@ 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[] = [];
|
||||||
@@ -199,26 +57,56 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
|
|||||||
return files;
|
return files;
|
||||||
};
|
};
|
||||||
|
|
||||||
const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
|
export const useFileTreeUpload = ({
|
||||||
|
selectedProject,
|
||||||
|
onRefresh,
|
||||||
|
showToast,
|
||||||
|
}: UseFileTreeUploadOptions) => {
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const treeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Only set isDragOver to false if we're leaving the entire tree
|
||||||
|
if (treeRef.current && !treeRef.current.contains(e.relatedTarget as Node)) {
|
||||||
|
setIsDragOver(false);
|
||||||
|
setDropTarget(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
const targetPath = dropTarget || '';
|
||||||
|
setOperationLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
|
|
||||||
// Use DataTransferItemList for folder support
|
// Use DataTransferItemList for folder support
|
||||||
const { items } = dataTransfer;
|
const items = e.dataTransfer.items;
|
||||||
if (items) {
|
if (items) {
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
if (item.kind !== 'file') {
|
if (item.kind === 'file') {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||||
if (!entry) {
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file) {
|
|
||||||
files.push(file);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
if (entry.isFile) {
|
if (entry.isFile) {
|
||||||
const file = await new Promise<File>((resolve, reject) => {
|
const file = await new Promise<File>((resolve, reject) => {
|
||||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||||
@@ -230,207 +118,73 @@ const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
|
|||||||
files.push(...dirFiles);
|
files.push(...dirFiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return files;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// Fallback for browsers that don't support webkitGetAsEntry
|
// Fallback for browsers that don't support webkitGetAsEntry
|
||||||
for (const file of Array.from(dataTransfer.files)) {
|
const fileList = e.dataTransfer.files;
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
files.push(file);
|
files.push(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
return files;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useFileTreeUpload = ({
|
|
||||||
selectedProject,
|
|
||||||
onRefresh,
|
|
||||||
showToast,
|
|
||||||
}: UseFileTreeUploadOptions) => {
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
|
||||||
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
|
|
||||||
const treeRef = useRef<HTMLDivElement>(null);
|
|
||||||
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
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) {
|
if (files.length === 0) {
|
||||||
|
setOperationLoading(false);
|
||||||
setDropTarget(null);
|
setDropTarget(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined;
|
const formData = new FormData();
|
||||||
|
formData.append('targetPath', targetPath);
|
||||||
|
|
||||||
if (!selectedProject) {
|
// Store relative paths separately since FormData strips path info from File.name
|
||||||
const message = 'Select a project before uploading files.';
|
const relativePaths: string[] = [];
|
||||||
showToast(message, 'error');
|
files.forEach((file) => {
|
||||||
setUploadError(message, files.length, targetPath, fileName);
|
// Create a new file with just the filename (without path) for FormData
|
||||||
return;
|
// but store the relative path separately
|
||||||
}
|
const cleanFile = new File([file], file.name.split('/').pop()!, {
|
||||||
|
type: file.type,
|
||||||
const validationError = validateFilesForUpload(files);
|
lastModified: file.lastModified
|
||||||
if (validationError) {
|
});
|
||||||
showToast(validationError, 'error');
|
formData.append('files', cleanFile);
|
||||||
setUploadError(validationError, files.length, targetPath, fileName);
|
relativePaths.push(file.name); // Keep the full relative path
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearProgressTimer();
|
|
||||||
setOperationLoading(true);
|
|
||||||
setUploadProgress({
|
|
||||||
status: 'uploading',
|
|
||||||
progress: 0,
|
|
||||||
fileCount: files.length,
|
|
||||||
fileName,
|
|
||||||
targetPath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let latestProgress = 0;
|
// Send relative paths as a JSON array
|
||||||
|
formData.append('relativePaths', JSON.stringify(relativePaths));
|
||||||
|
|
||||||
try {
|
const response = await api.post(
|
||||||
const response = await uploadFormDataWithProgress(
|
// File upload endpoint is keyed by DB projectId post-migration.
|
||||||
selectedProject.projectId,
|
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
|
||||||
buildUploadFormData(files, targetPath),
|
formData
|
||||||
(progress) => {
|
|
||||||
latestProgress = progress;
|
|
||||||
setUploadProgress((current) =>
|
|
||||||
current && current.status === 'uploading'
|
|
||||||
? { ...current, progress }
|
|
||||||
: current,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadedCount =
|
if (!response.ok) {
|
||||||
typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length;
|
const data = await response.json();
|
||||||
const requestedFileCount =
|
throw new Error(data.error || 'Upload failed');
|
||||||
typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length;
|
}
|
||||||
|
|
||||||
setUploadProgress({
|
showToast(
|
||||||
status: 'complete',
|
`Uploaded ${files.length} file(s)`,
|
||||||
progress: 100,
|
'success'
|
||||||
fileCount: requestedFileCount,
|
);
|
||||||
uploadedCount,
|
|
||||||
fileName,
|
|
||||||
targetPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success');
|
|
||||||
scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS);
|
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
|
||||||
console.error('Upload error:', err);
|
console.error('Upload error:', err);
|
||||||
showToast(message, 'error');
|
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
|
||||||
setUploadError(message, files.length, targetPath, fileName, latestProgress);
|
|
||||||
} finally {
|
} finally {
|
||||||
setOperationLoading(false);
|
setOperationLoading(false);
|
||||||
setDropTarget(null);
|
setDropTarget(null);
|
||||||
}
|
}
|
||||||
},
|
}, [dropTarget, selectedProject, onRefresh, showToast]);
|
||||||
[
|
|
||||||
clearProgressTimer,
|
|
||||||
onRefresh,
|
|
||||||
scheduleProgressClear,
|
|
||||||
selectedProject,
|
|
||||||
setUploadError,
|
|
||||||
showToast,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFileSelect = useCallback(
|
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
|
||||||
async (fileList: FileList | File[]) => {
|
|
||||||
await uploadFiles(Array.from(fileList), '');
|
|
||||||
},
|
|
||||||
[uploadFiles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragEnter = useCallback((e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragOver(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
// Only set isDragOver to false if we're leaving the entire tree
|
|
||||||
if (treeRef.current && !treeRef.current.contains(e.relatedTarget as Node)) {
|
|
||||||
setIsDragOver(false);
|
|
||||||
setDropTarget(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
async (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
const targetPath = dropTarget || '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const files = await collectDroppedFiles(e.dataTransfer);
|
|
||||||
await uploadFiles(files, targetPath);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Could not read dropped files';
|
|
||||||
console.error('Upload error:', err);
|
|
||||||
showToast(message, 'error');
|
|
||||||
setUploadError(message, 0, targetPath);
|
|
||||||
setDropTarget(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dropTarget, setUploadError, showToast, uploadFiles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
|
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
@@ -440,9 +194,7 @@ export const useFileTreeUpload = ({
|
|||||||
isDragOver,
|
isDragOver,
|
||||||
dropTarget,
|
dropTarget,
|
||||||
operationLoading,
|
operationLoading,
|
||||||
uploadProgress,
|
|
||||||
treeRef,
|
treeRef,
|
||||||
handleFileSelect,
|
|
||||||
handleDragEnter,
|
handleDragEnter,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragLeave,
|
handleDragLeave,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
@@ -14,12 +13,10 @@ 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';
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +66,6 @@ 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(() => {
|
||||||
@@ -150,19 +146,14 @@ 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={operationLoading}
|
operationLoading={operations.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">
|
||||||
@@ -193,7 +184,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={operationLoading}
|
disabled={operations.operationLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -222,7 +213,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={operationLoading}
|
operationLoading={operations.operationLoading}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -260,17 +251,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={operationLoading}
|
disabled={operations.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={operationLoading}
|
disabled={operations.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"
|
||||||
>
|
>
|
||||||
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('fileTree.delete.confirm', 'Delete')}
|
{t('fileTree.delete.confirm', 'Delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-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 = {
|
||||||
@@ -16,14 +12,11 @@ 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({
|
||||||
@@ -33,24 +26,12 @@ 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">
|
||||||
@@ -59,50 +40,6 @@ 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"
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ 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;
|
||||||
@@ -73,14 +72,6 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
|||||||
icon: Clock,
|
icon: Clock,
|
||||||
source: 'unofficial',
|
source: 'unofficial',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'prism',
|
|
||||||
translationKey: 'prismCloudCLI',
|
|
||||||
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
|
|
||||||
installedNames: ['prism'],
|
|
||||||
icon: Activity,
|
|
||||||
source: 'unofficial'
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function repoSlug(repoUrl: string) {
|
function repoSlug(repoUrl: string) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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,
|
||||||
@@ -109,7 +107,6 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: true,
|
inApp: true,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
sound: true,
|
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -118,25 +115,6 @@ 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);
|
||||||
@@ -208,7 +186,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(normalizeNotificationPreferences(notificationData.preferences));
|
setNotificationPreferences(notificationData.preferences);
|
||||||
} else {
|
} else {
|
||||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
}
|
}
|
||||||
@@ -323,10 +301,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
void refreshProviderAuthStatuses();
|
void refreshProviderAuthStatuses();
|
||||||
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNotificationSoundEnabled(notificationPreferences.channels.sound);
|
|
||||||
}, [notificationPreferences.channels.sound]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
||||||
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ export type NotificationPreferencesState = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
sound: boolean;
|
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Bell, BellOff, BellRing, Loader2, Play, Volume2 } from 'lucide-react';
|
import { Bell, BellOff, BellRing, Loader2 } 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 = {
|
||||||
@@ -85,54 +82,6 @@ export default function NotificationsSettingsTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Volume2 className="h-4 w-4 text-blue-600" />
|
|
||||||
<h4 className="font-medium text-foreground">
|
|
||||||
{t('notifications.sound.title', { defaultValue: 'Sound' })}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('notifications.sound.description', {
|
|
||||||
defaultValue: 'Play a short tone when a chat run finishes.',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex shrink-0 items-center gap-2 text-sm text-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={notificationPreferences.channels.sound}
|
|
||||||
onChange={(event) =>
|
|
||||||
onNotificationPreferencesChange({
|
|
||||||
...notificationPreferences,
|
|
||||||
channels: {
|
|
||||||
...notificationPreferences.channels,
|
|
||||||
sound: event.target.checked,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-4 w-4"
|
|
||||||
/>
|
|
||||||
{t('notifications.sound.enabled', { defaultValue: 'Enabled' })}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
void playChatCompletionSound({ force: true });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
{t('notifications.sound.test', { defaultValue: 'Test sound' })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -94,35 +94,9 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & Token",
|
"apiTokens": "API & Token",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"notifications": "Benachrichtigungen",
|
|
||||||
"plugins": "Plugins",
|
"plugins": "Plugins",
|
||||||
"about": "Info"
|
"about": "Info"
|
||||||
},
|
},
|
||||||
"notifications": {
|
|
||||||
"title": "Benachrichtigungen",
|
|
||||||
"description": "Lege fest, welche Benachrichtigungen du erhältst.",
|
|
||||||
"webPush": {
|
|
||||||
"title": "Web-Push-Benachrichtigungen",
|
|
||||||
"enable": "Push-Benachrichtigungen aktivieren",
|
|
||||||
"disable": "Push-Benachrichtigungen deaktivieren",
|
|
||||||
"enabled": "Push-Benachrichtigungen sind aktiviert",
|
|
||||||
"loading": "Wird aktualisiert...",
|
|
||||||
"unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
|
||||||
"denied": "Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browsereinstellungen."
|
|
||||||
},
|
|
||||||
"sound": {
|
|
||||||
"title": "Ton",
|
|
||||||
"description": "Spielt einen kurzen Ton ab, wenn ein Chat-Lauf abgeschlossen ist.",
|
|
||||||
"enabled": "Aktiviert",
|
|
||||||
"test": "Ton testen"
|
|
||||||
},
|
|
||||||
"events": {
|
|
||||||
"title": "Ereignistypen",
|
|
||||||
"actionRequired": "Aktion erforderlich",
|
|
||||||
"stop": "Lauf gestoppt",
|
|
||||||
"error": "Lauf fehlgeschlagen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
"label": "Darkmode",
|
"label": "Darkmode",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"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",
|
||||||
@@ -508,12 +502,6 @@
|
|||||||
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
|
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
|
||||||
"install": "Install"
|
"install": "Install"
|
||||||
},
|
},
|
||||||
"prismCloudCLI": {
|
|
||||||
"name": "PRISM CloudCLI",
|
|
||||||
"badge": "unofficial",
|
|
||||||
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
|
|
||||||
"install": "Install"
|
|
||||||
},
|
|
||||||
"morePlugins": "More",
|
"morePlugins": "More",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
||||||
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "Suono",
|
|
||||||
"description": "Riproduci un breve tono quando termina un'esecuzione della chat.",
|
|
||||||
"enabled": "Attivato",
|
|
||||||
"test": "Prova suono"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Tipi di evento",
|
"title": "Tipi di evento",
|
||||||
"actionRequired": "Azione richiesta",
|
"actionRequired": "Azione richiesta",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||||
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "サウンド",
|
|
||||||
"description": "チャット実行が完了したときに短い音を再生します。",
|
|
||||||
"enabled": "有効",
|
|
||||||
"test": "サウンドをテスト"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "イベント種別",
|
"title": "イベント種別",
|
||||||
"actionRequired": "対応が必要",
|
"actionRequired": "対応が必要",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||||
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "소리",
|
|
||||||
"description": "채팅 실행이 완료되면 짧은 알림음을 재생합니다.",
|
|
||||||
"enabled": "사용",
|
|
||||||
"test": "소리 테스트"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "이벤트 유형",
|
"title": "이벤트 유형",
|
||||||
"actionRequired": "작업 필요",
|
"actionRequired": "작업 필요",
|
||||||
|
|||||||
@@ -94,35 +94,9 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API и токены",
|
"apiTokens": "API и токены",
|
||||||
"tasks": "Задачи",
|
"tasks": "Задачи",
|
||||||
"notifications": "Уведомления",
|
|
||||||
"plugins": "Плагины",
|
"plugins": "Плагины",
|
||||||
"about": "О программе"
|
"about": "О программе"
|
||||||
},
|
},
|
||||||
"notifications": {
|
|
||||||
"title": "Уведомления",
|
|
||||||
"description": "Управляйте тем, какие события уведомлений вы получаете.",
|
|
||||||
"webPush": {
|
|
||||||
"title": "Web Push уведомления",
|
|
||||||
"enable": "Включить Push уведомления",
|
|
||||||
"disable": "Отключить Push уведомления",
|
|
||||||
"enabled": "Push уведомления включены",
|
|
||||||
"loading": "Обновление...",
|
|
||||||
"unsupported": "Push уведомления не поддерживаются в этом браузере.",
|
|
||||||
"denied": "Push уведомления заблокированы. Разрешите их в настройках браузера."
|
|
||||||
},
|
|
||||||
"sound": {
|
|
||||||
"title": "Звук",
|
|
||||||
"description": "Воспроизводить короткий сигнал при завершении запуска чата.",
|
|
||||||
"enabled": "Включено",
|
|
||||||
"test": "Проверить звук"
|
|
||||||
},
|
|
||||||
"events": {
|
|
||||||
"title": "Типы событий",
|
|
||||||
"actionRequired": "Требуется действие",
|
|
||||||
"stop": "Запуск остановлен",
|
|
||||||
"error": "Запуск завершился с ошибкой"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
"label": "Темная тема",
|
"label": "Темная тема",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
|
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
|
||||||
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
|
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "Ses",
|
|
||||||
"description": "Sohbet çalışması tamamlandığında kısa bir ton çal.",
|
|
||||||
"enabled": "Etkin",
|
|
||||||
"test": "Sesi test et"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Etkinlik Türleri",
|
"title": "Etkinlik Türleri",
|
||||||
"actionRequired": "Aksiyon gerekli",
|
"actionRequired": "Aksiyon gerekli",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "此浏览器不支持推送通知。",
|
"unsupported": "此浏览器不支持推送通知。",
|
||||||
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "声音",
|
|
||||||
"description": "聊天运行完成时播放短提示音。",
|
|
||||||
"enabled": "已启用",
|
|
||||||
"test": "测试声音"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "事件类型",
|
"title": "事件类型",
|
||||||
"actionRequired": "需要处理",
|
"actionRequired": "需要处理",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "此瀏覽器不支援推播通知。",
|
"unsupported": "此瀏覽器不支援推播通知。",
|
||||||
"denied": "推播通知已被封鎖,請在瀏覽器設定中允許。"
|
"denied": "推播通知已被封鎖,請在瀏覽器設定中允許。"
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "聲音",
|
|
||||||
"description": "聊天執行完成時播放短提示音。",
|
|
||||||
"enabled": "已啟用",
|
|
||||||
"test": "測試聲音"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "事件類型",
|
"title": "事件類型",
|
||||||
"actionRequired": "需要處理",
|
"actionRequired": "需要處理",
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
const COMPLETION_TITLE_INDICATOR = '[Done]';
|
|
||||||
const TITLE_INDICATOR_CLEAR_DELAY_MS = 2000;
|
|
||||||
|
|
||||||
let clearTimer: number | null = null;
|
|
||||||
let returnListenersAttached = false;
|
|
||||||
|
|
||||||
const getIndicatorPrefix = () => `${COMPLETION_TITLE_INDICATOR} `;
|
|
||||||
|
|
||||||
const stripIndicator = (title: string): string => {
|
|
||||||
const prefix = getIndicatorPrefix();
|
|
||||||
return title.startsWith(prefix) ? title.slice(prefix.length) : title;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pageIsActive = (): boolean => (
|
|
||||||
document.visibilityState === 'visible' && document.hasFocus()
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeReturnListeners = (): void => {
|
|
||||||
if (!returnListenersAttached || typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.removeEventListener('visibilitychange', handleUserReturn);
|
|
||||||
window.removeEventListener('focus', handleUserReturn, true);
|
|
||||||
window.removeEventListener('click', handleUserReturn, true);
|
|
||||||
returnListenersAttached = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearTitleIndicator = (): void => {
|
|
||||||
if (clearTimer !== null) {
|
|
||||||
window.clearTimeout(clearTimer);
|
|
||||||
clearTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeReturnListeners();
|
|
||||||
removePageInactiveListener();
|
|
||||||
|
|
||||||
if (document.title.startsWith(getIndicatorPrefix())) {
|
|
||||||
document.title = stripIndicator(document.title);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePageInactiveListener = (): void => {
|
|
||||||
document.removeEventListener('visibilitychange', handlePageInactive);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleClear = (): void => {
|
|
||||||
if (clearTimer !== null) {
|
|
||||||
window.clearTimeout(clearTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimer = window.setTimeout(() => {
|
|
||||||
clearTitleIndicator();
|
|
||||||
}, TITLE_INDICATOR_CLEAR_DELAY_MS);
|
|
||||||
|
|
||||||
removePageInactiveListener();
|
|
||||||
document.addEventListener('visibilitychange', handlePageInactive, { once: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleUserReturn(): void {
|
|
||||||
if (!pageIsActive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background completions keep the marker indefinitely. A tab click normally
|
|
||||||
// surfaces as visibility/focus, while an in-page click is a useful fallback.
|
|
||||||
scheduleClear();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageInactive(): void {
|
|
||||||
if (document.visibilityState !== 'hidden') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clearTimer !== null) {
|
|
||||||
window.clearTimeout(clearTimer);
|
|
||||||
clearTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!returnListenersAttached) {
|
|
||||||
document.addEventListener('visibilitychange', handleUserReturn);
|
|
||||||
window.addEventListener('focus', handleUserReturn, true);
|
|
||||||
window.addEventListener('click', handleUserReturn, true);
|
|
||||||
returnListenersAttached = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const showCompletionTitleIndicator = (): void => {
|
|
||||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseTitle = stripIndicator(document.title || 'CloudCLI UI');
|
|
||||||
document.title = `${getIndicatorPrefix()}${baseTitle}`;
|
|
||||||
|
|
||||||
if (pageIsActive()) {
|
|
||||||
scheduleClear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clearTimer !== null) {
|
|
||||||
window.clearTimeout(clearTimer);
|
|
||||||
clearTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!returnListenersAttached) {
|
|
||||||
document.addEventListener('visibilitychange', handleUserReturn);
|
|
||||||
window.addEventListener('focus', handleUserReturn, true);
|
|
||||||
window.addEventListener('click', handleUserReturn, true);
|
|
||||||
returnListenersAttached = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user