mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 10:33:00 +08:00
Compare commits
86 Commits
cloudcli-l
...
camoufox-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bcb9436a1 | ||
|
|
19e4bf9d80 | ||
|
|
a34249497e | ||
|
|
7eb7348d50 | ||
|
|
44aecbab68 | ||
|
|
18e98a780d | ||
|
|
2ebe64f218 | ||
|
|
0907d873f6 | ||
|
|
b6cf33308d | ||
|
|
ec437072ad | ||
|
|
54f4d8aa36 | ||
|
|
261690935f | ||
|
|
46ba8e56b4 | ||
|
|
a0899a252e | ||
|
|
fff89e6132 | ||
|
|
3bc2c777a3 | ||
|
|
63f3c3941d | ||
|
|
e6c6f89dda | ||
|
|
8adcdaa0e5 | ||
|
|
6f712269e8 | ||
|
|
52244404a3 | ||
|
|
8ad18f8587 | ||
|
|
fe116a7138 | ||
|
|
490e66ebdb | ||
|
|
81eb966904 | ||
|
|
0d68dc2cd0 | ||
|
|
0610cc8333 | ||
|
|
9457651fdd | ||
|
|
8c31ebcc63 | ||
|
|
bb630ef739 | ||
|
|
1c05fe0905 | ||
|
|
077baee5f2 | ||
|
|
f150fa6b09 | ||
|
|
9f8cee8919 | ||
|
|
bb323fc566 | ||
|
|
5ef40be2d3 | ||
|
|
cf4b28273e | ||
|
|
f4c68942a5 | ||
|
|
4d70a2588c | ||
|
|
218e8e2e38 | ||
|
|
53c3c4c27a | ||
|
|
901c6fc956 | ||
|
|
278fe4f7b1 | ||
|
|
d7f4d4c342 | ||
|
|
d1930fecdb | ||
|
|
1726705459 | ||
|
|
a35200f340 | ||
|
|
06c9745489 | ||
|
|
0dd22db2bb | ||
|
|
e7aa72c41e | ||
|
|
9f24f80f33 | ||
|
|
25ab273b05 | ||
|
|
5be100ea1b | ||
|
|
2af3d38afe | ||
|
|
531833bc87 | ||
|
|
b2333e7d93 | ||
|
|
f75ae385dd | ||
|
|
7786763dd1 | ||
|
|
1dbf545fd9 | ||
|
|
ac37213269 | ||
|
|
65fdc38f2e | ||
|
|
6c2652aee6 | ||
|
|
bf50d29c20 | ||
|
|
ffc0cd7501 | ||
|
|
59194d1502 | ||
|
|
7e6028b113 | ||
|
|
9881e5e366 | ||
|
|
496a895e8a | ||
|
|
086df034b4 | ||
|
|
fc71fc7d2b | ||
|
|
a0d56429a7 | ||
|
|
6af4afe6f2 | ||
|
|
7aeca52669 | ||
|
|
56532af33a | ||
|
|
9438a365f2 | ||
|
|
e5c6e5e596 | ||
|
|
0426522406 | ||
|
|
6e7e2ff4c1 | ||
|
|
e6263dbd1f | ||
|
|
260070bae0 | ||
|
|
daac6e3fd3 | ||
|
|
861cfecbaa | ||
|
|
a182765e10 | ||
|
|
828d1a2302 | ||
|
|
d427004bd7 | ||
|
|
243e6cecd5 |
19
README.md
19
README.md
@@ -74,12 +74,6 @@ The fastest way to get started — no local setup required. Get a fully managed,
|
|||||||
|
|
||||||
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
||||||
|
|
||||||
### Desktop App
|
|
||||||
|
|
||||||
Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page.
|
|
||||||
|
|
||||||
Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
|
||||||
|
|
||||||
### Self-Hosted (Open source)
|
### Self-Hosted (Open source)
|
||||||
|
|
||||||
#### npm
|
#### npm
|
||||||
@@ -111,6 +105,16 @@ npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
|||||||
|
|
||||||
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
|
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
|
||||||
|
|
||||||
|
### Desktop Companion App
|
||||||
|
|
||||||
|
CloudCLI Desktop is an optional native companion for CloudCLI Cloud and Local CloudCLI. It ships from this repository's GitHub Releases and keeps CloudCLI available from your menu bar or tray.
|
||||||
|
|
||||||
|
- **[macOS](https://cloudcli.ai/download/macos)**
|
||||||
|
- **[Windows](https://cloudcli.ai/download/windows)**
|
||||||
|
- **[Download page](https://cloudcli.ai/download)** · **[GitHub Releases and checksums](https://github.com/siteboon/claudecodeui/releases)**
|
||||||
|
|
||||||
|
Use it to open CloudCLI Cloud environments, switch between local and remote workspaces, and copy mobile/browser URLs. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,7 +129,8 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
|
|||||||
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
|
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
|
||||||
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
||||||
| **Machine needs to stay on** | Yes | Yes | No |
|
| **Machine needs to stay on** | Yes | Yes | No |
|
||||||
| **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
|
| **Mobile access** | Any browser on your network | Any browser on your network | Any device |
|
||||||
|
| **Desktop companion** | Optional. Choose Local CloudCLI | Optional. Choose Local CloudCLI | Optional. Opens cloud environments |
|
||||||
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||||
| **File explorer and Git** | Yes | Yes | Yes |
|
| **File explorer and Git** | Yes | Yes | Yes |
|
||||||
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
||||||
|
|||||||
12
index.html
12
index.html
@@ -4,9 +4,17 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<title>CloudCLI UI</title>
|
<title>CloudCLI UI</title>
|
||||||
|
|
||||||
|
<!-- Fonts: Encode Sans (UI) + Merriweather (chat) -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Encode+Sans:wght@400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400;1,700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const sessionIdSchema = {
|
|||||||
const tools: ToolDefinition[] = [
|
const tools: ToolDefinition[] = [
|
||||||
{
|
{
|
||||||
name: 'browser_create_session',
|
name: 'browser_create_session',
|
||||||
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
|
description: 'Create a Browser session that the agent can control. Provide profileName to use a specific persistent profile; when omitted, the configured persistent profile is used only if session persistence is enabled, otherwise a temporary session is created.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ import providerRoutes from './modules/providers/provider.routes.js';
|
|||||||
import voiceRoutes from './voice-proxy.js';
|
import voiceRoutes from './voice-proxy.js';
|
||||||
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
||||||
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
|
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
|
||||||
import { browserUseService } from './modules/browser-use/browser-use.service.js';
|
import { browserUseService, VIEWER_COOKIE_NAME } from './modules/browser-use/index.js';
|
||||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||||
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
||||||
import { configureWebPush } from './services/vapid-keys.js';
|
import { configureWebPush } from './services/vapid-keys.js';
|
||||||
@@ -147,6 +147,8 @@ const wss = createWebSocketServer(server, {
|
|||||||
shouldAutoOpenUrlFromOutput,
|
shouldAutoOpenUrlFromOutput,
|
||||||
},
|
},
|
||||||
getPluginPort,
|
getPluginPort,
|
||||||
|
browserUseViewer: (ws, pathname) => browserUseService.handleViewerWebSocket(ws, pathname),
|
||||||
|
authenticateBrowserUseViewer: authenticateBrowserUseViewerPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Make WebSocket server available to routes
|
// Make WebSocket server available to routes
|
||||||
@@ -214,11 +216,42 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
|
|||||||
// Plugins API Routes (protected)
|
// Plugins API Routes (protected)
|
||||||
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||||
|
|
||||||
|
function readCookieValue(header, name) {
|
||||||
|
if (!header) return null;
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const cookie = String(header).split(';').map((part) => part.trim()).find((part) => part.startsWith(prefix));
|
||||||
|
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticateBrowserUseViewerPath(pathname, token) {
|
||||||
|
const parts = String(pathname || '').split('/');
|
||||||
|
const sessionId = parts[4];
|
||||||
|
if (parts[1] !== 'api' || parts[2] !== 'browser-use' || parts[3] !== 'sessions' || parts[5] !== 'viewer' || parts[6] !== 'websockify') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return browserUseService.validateViewerToken(decodeURIComponent(sessionId), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticateBrowserUse(req, res, next) {
|
||||||
|
const match = /^\/sessions\/([^/]+)\/viewer(?:\/|$)/.exec(req.path || '');
|
||||||
|
if (match) {
|
||||||
|
const sessionId = decodeURIComponent(match[1]);
|
||||||
|
const token = typeof req.query.viewerToken === 'string'
|
||||||
|
? req.query.viewerToken
|
||||||
|
: readCookieValue(req.headers.cookie, VIEWER_COOKIE_NAME);
|
||||||
|
if (browserUseService.validateViewerToken(sessionId, token)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(401).json({ error: 'Browser viewer access requires a valid session token.' });
|
||||||
|
}
|
||||||
|
return authenticateToken(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
// Browser MCP bridge API (local token protected)
|
// Browser MCP bridge API (local token protected)
|
||||||
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
|
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
|
||||||
|
|
||||||
// Browser API Routes (protected)
|
// Browser API Routes (protected)
|
||||||
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
|
app.use('/api/browser-use', authenticateBrowserUse, browserUseRoutes);
|
||||||
|
|
||||||
// Unified provider MCP routes (protected)
|
// Unified provider MCP routes (protected)
|
||||||
app.use('/api/providers', authenticateToken, providerRoutes);
|
app.use('/api/providers', authenticateToken, providerRoutes);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||||
|
import { VIEWER_COOKIE_NAME, VIEWER_TOKEN_TTL_MS } from '@/modules/browser-use/browser-use.viewer.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -8,6 +9,45 @@ function readParam(value: string | string[] | undefined): string {
|
|||||||
return Array.isArray(value) ? value[0] || '' : value || '';
|
return Array.isArray(value) ? value[0] || '' : value || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAFE_VIEWER_ROOT_FILES = new Set(['vnc.html', 'favicon.ico', 'manifest.json']);
|
||||||
|
const SAFE_VIEWER_ROOT_DIRS = new Set(['app', 'core', 'vendor', 'assets', 'images', 'utils']);
|
||||||
|
|
||||||
|
function isSafeViewerPath(viewerPath: string): boolean {
|
||||||
|
if (!viewerPath || viewerPath.startsWith('/') || viewerPath.includes('..') || viewerPath.includes('\\')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9._~/-]*$/.test(viewerPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SAFE_VIEWER_ROOT_FILES.has(viewerPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rootDir] = viewerPath.split('/');
|
||||||
|
return Boolean(rootDir && SAFE_VIEWER_ROOT_DIRS.has(rootDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSecureRequest(req: express.Request): boolean {
|
||||||
|
const forwardedProto = String(req.headers['x-forwarded-proto'] || '')
|
||||||
|
.split(',')[0]
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return req.secure || forwardedProto === 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readQueryString(originalUrl: string): string {
|
||||||
|
const queryIndex = originalUrl.indexOf('?');
|
||||||
|
if (queryIndex < 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(originalUrl.slice(queryIndex + 1));
|
||||||
|
params.delete('viewerToken');
|
||||||
|
const nextQuery = params.toString();
|
||||||
|
return nextQuery ? `?${nextQuery}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/status', async (_req, res) => {
|
router.get('/status', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({ success: true, data: await browserUseService.getStatus() });
|
res.json({ success: true, data: await browserUseService.getStatus() });
|
||||||
@@ -62,13 +102,60 @@ router.get('/sessions', async (_req, res) => {
|
|||||||
try {
|
try {
|
||||||
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
|
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(401).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
|
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/sessions/:sessionId/viewer/*', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessionId = readParam(req.params.sessionId);
|
||||||
|
const originalPath = req.originalUrl.split('?')[0] || '';
|
||||||
|
const viewerMarker = `/sessions/${sessionId}/viewer/`;
|
||||||
|
const markerIndex = originalPath.indexOf(viewerMarker);
|
||||||
|
const rawViewerPath = markerIndex >= 0 ? originalPath.slice(markerIndex + viewerMarker.length) : 'vnc.html';
|
||||||
|
const viewerPath = decodeURIComponent(rawViewerPath).replace(/^\/+/, '') || 'vnc.html';
|
||||||
|
if (!isSafeViewerPath(viewerPath)) {
|
||||||
|
res.status(400).json({ success: false, error: 'Invalid Browser viewer path.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerToken = readParam(req.query.viewerToken as string | string[] | undefined);
|
||||||
|
if (viewerPath === 'vnc.html' && browserUseService.validateViewerToken(sessionId, viewerToken)) {
|
||||||
|
res.cookie(VIEWER_COOKIE_NAME, viewerToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isSecureRequest(req),
|
||||||
|
maxAge: VIEWER_TOKEN_TTL_MS,
|
||||||
|
path: '/api/browser-use/sessions/' + encodeURIComponent(sessionId) + '/viewer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const target = browserUseService.getViewerProxyTarget(sessionId);
|
||||||
|
const query = readQueryString(req.originalUrl);
|
||||||
|
const upstream = await fetch(`http://127.0.0.1:${target.websockifyPort}/${viewerPath}${query}`, {
|
||||||
|
headers: {
|
||||||
|
accept: String(req.headers.accept || '*/*'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const contentType = upstream.headers.get('content-type');
|
||||||
|
if (contentType) {
|
||||||
|
res.setHeader('content-type', contentType);
|
||||||
|
}
|
||||||
|
const cacheControl = viewerPath === 'vnc.html' ? 'no-store' : 'public, max-age=3600';
|
||||||
|
res.setHeader('cache-control', cacheControl);
|
||||||
|
res.status(upstream.status);
|
||||||
|
const body = Buffer.from(await upstream.arrayBuffer());
|
||||||
|
res.send(body);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Browser viewer is not available.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/sessions/:sessionId/stop', async (req, res) => {
|
router.post('/sessions/:sessionId/stop', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
|
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
|
||||||
|
|||||||
@@ -1,128 +1,86 @@
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { randomBytes, randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { spawn } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { appConfigDb } from '@/modules/database/index.js';
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
import { providerMcpService } from '@/modules/providers/index.js';
|
import { providerMcpService } from '@/modules/providers/index.js';
|
||||||
import { getModuleDir } from '@/utils/runtime-paths.js';
|
import { getModuleDir } from '@/utils/runtime-paths.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getOrCreateMcpToken,
|
||||||
|
getProfilePath,
|
||||||
|
normalizeBrowserBackend,
|
||||||
|
PROFILE_ROOT,
|
||||||
|
readSettings,
|
||||||
|
resolveSessionProfileName,
|
||||||
|
useVisibleCamoufoxBackend,
|
||||||
|
writeSettings,
|
||||||
|
} from './browser-use.settings.js';
|
||||||
|
import type {
|
||||||
|
BrowserUseSession,
|
||||||
|
BrowserUseSettings,
|
||||||
|
PublicBrowserUseSession,
|
||||||
|
RuntimeHandle,
|
||||||
|
RuntimeProbe,
|
||||||
|
RuntimeReadiness,
|
||||||
|
} from './browser-use.types.js';
|
||||||
|
import { getViewerUrl, handleViewerWebSocket, VIEWER_TOKEN_TTL_MS } from './browser-use.viewer.js';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const __dirname = getModuleDir(import.meta.url);
|
const __dirname = getModuleDir(import.meta.url);
|
||||||
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||||
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
|
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
|
||||||
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
|
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
|
||||||
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
|
|
||||||
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
|
|
||||||
|
|
||||||
type BrowserUseRuntime = 'cloud' | 'local';
|
|
||||||
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
|
|
||||||
|
|
||||||
type BrowserUseSession = {
|
|
||||||
id: string;
|
|
||||||
ownerId: string;
|
|
||||||
createdBy: 'agent';
|
|
||||||
runtime: BrowserUseRuntime;
|
|
||||||
status: BrowserUseSessionStatus;
|
|
||||||
url: string | null;
|
|
||||||
title: string | null;
|
|
||||||
screenshotDataUrl: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
lastAction: string | null;
|
|
||||||
message: string | null;
|
|
||||||
profileName: string | null;
|
|
||||||
viewport: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null;
|
|
||||||
cursor: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
actor: 'agent';
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
|
|
||||||
|
|
||||||
type RuntimeHandle = {
|
|
||||||
browser?: any;
|
|
||||||
context?: any;
|
|
||||||
page?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BrowserUseSettings = {
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RuntimeReadiness = {
|
|
||||||
playwright: any | null;
|
|
||||||
playwrightInstalled: boolean;
|
|
||||||
chromiumInstalled: boolean;
|
|
||||||
chromiumExecutablePath: string | null;
|
|
||||||
installInProgress: boolean;
|
|
||||||
installMessage: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
|
|
||||||
|
|
||||||
const sessions = new Map<string, BrowserUseSession>();
|
const sessions = new Map<string, BrowserUseSession>();
|
||||||
const handles = new Map<string, RuntimeHandle>();
|
const handles = new Map<string, RuntimeHandle>();
|
||||||
|
const reservedDisplays = new Set<string>();
|
||||||
|
const viewerTokens = new Map<string, { token: string; expiresAt: number }>();
|
||||||
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
||||||
let lastInstallMessage: string | null = null;
|
let lastInstallMessage: string | null = null;
|
||||||
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
|
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: BrowserUseSettings = {
|
|
||||||
enabled: false,
|
|
||||||
};
|
|
||||||
const AGENT_OWNER_ID = 'agent';
|
const AGENT_OWNER_ID = 'agent';
|
||||||
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
|
|
||||||
const MCP_SERVER_NAME = 'cloudcli-browser';
|
const MCP_SERVER_NAME = 'cloudcli-browser';
|
||||||
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
|
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
|
||||||
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
|
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
|
||||||
|
const VISIBLE_BROWSER_ENABLED = process.env.CLOUDCLI_BROWSER_USE_VISIBLE !== 'false';
|
||||||
|
const RUNTIME_ROOT = process.env.CLOUDCLI_BROWSER_USE_RUNTIME_ROOT || '/opt/claudecodeui/.runtime-browser';
|
||||||
|
const NOVNC_ROOT = process.env.CLOUDCLI_BROWSER_USE_NOVNC_ROOT || path.join(RUNTIME_ROOT, 'novnc');
|
||||||
|
const X11VNC_BIN = process.env.CLOUDCLI_BROWSER_USE_X11VNC_BIN || path.join(RUNTIME_ROOT, 'rootfs/usr/bin/x11vnc');
|
||||||
|
const X11VNC_LIB_DIR = process.env.CLOUDCLI_BROWSER_USE_X11VNC_LIB_DIR || path.join(RUNTIME_ROOT, 'rootfs/usr/lib/x86_64-linux-gnu');
|
||||||
|
const X11VNC_EXTRA_LIB_DIR = process.env.CLOUDCLI_BROWSER_USE_X11VNC_EXTRA_LIB_DIR || path.join(RUNTIME_ROOT, 'rootfs/lib/x86_64-linux-gnu');
|
||||||
|
const LOG_RUNTIME_PROCESS_OUTPUT = process.env.CLOUDCLI_BROWSER_USE_RUNTIME_LOGS === 'true';
|
||||||
|
|
||||||
function getRuntime(): BrowserUseRuntime {
|
function getRuntime(): 'cloud' | 'local' {
|
||||||
return IS_PLATFORM ? 'cloud' : 'local';
|
return IS_PLATFORM ? 'cloud' : 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSettings(): BrowserUseSettings {
|
function getCamoufoxExecutablePath(): string | null {
|
||||||
|
const configured = process.env.CLOUDCLI_BROWSER_USE_CAMOUFOX_EXECUTABLE;
|
||||||
|
if (configured && fs.existsSync(configured)) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
|
const output = execFileSync(path.join(os.homedir(), '.local/bin/camoufox'), ['path'], {
|
||||||
if (!raw) {
|
encoding: 'utf8',
|
||||||
return DEFAULT_SETTINGS;
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
}
|
}).trim();
|
||||||
|
const executablePath = fs.statSync(output).isDirectory()
|
||||||
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
|
? path.join(output, 'camoufox')
|
||||||
return {
|
: output;
|
||||||
enabled: parsed.enabled === true,
|
return fs.existsSync(executablePath) ? executablePath : null;
|
||||||
};
|
} catch {
|
||||||
} catch (error: any) {
|
return null;
|
||||||
console.warn('[Browser] Failed to read settings:', error?.message || error);
|
|
||||||
return DEFAULT_SETTINGS;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
|
|
||||||
const normalized = {
|
|
||||||
enabled: settings.enabled === true,
|
|
||||||
};
|
|
||||||
|
|
||||||
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateMcpToken(): string {
|
|
||||||
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const token = randomBytes(32).toString('hex');
|
|
||||||
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
|
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
|
||||||
if (!settings.enabled) {
|
if (!settings.enabled) {
|
||||||
return 'Browser is disabled in settings.';
|
return 'Browser is disabled in settings.';
|
||||||
@@ -132,6 +90,26 @@ function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadine
|
|||||||
return 'Install Playwright and Chromium to use browser sessions.';
|
return 'Install Playwright and Chromium to use browser sessions.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.browserBackend === 'camoufox-vnc' && !getCamoufoxExecutablePath()) {
|
||||||
|
return 'Camoufox is selected, but Camoufox is not installed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useVisibleCamoufoxBackend(settings)) {
|
||||||
|
if (!VISIBLE_BROWSER_ENABLED) {
|
||||||
|
return 'Camoufox is selected, but visible browser sessions are disabled.';
|
||||||
|
}
|
||||||
|
if (!getCamoufoxExecutablePath()) {
|
||||||
|
return 'Camoufox is selected, but Camoufox is not installed.';
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(X11VNC_BIN)) {
|
||||||
|
return 'Camoufox is selected, but x11vnc is missing.';
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'))) {
|
||||||
|
return 'Camoufox is selected, but noVNC is missing.';
|
||||||
|
}
|
||||||
|
return readiness.installMessage || 'Camoufox runtime is not ready.';
|
||||||
|
}
|
||||||
|
|
||||||
if (!readiness.chromiumInstalled) {
|
if (!readiness.chromiumInstalled) {
|
||||||
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
|
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
|
||||||
}
|
}
|
||||||
@@ -176,24 +154,6 @@ async function removeMcpServerFromAllProviders(name: string) {
|
|||||||
return results.map((result) => ({ ...result, name }));
|
return results.map((result) => ({ ...result, name }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProfileName(profileName?: string | null): string | null {
|
|
||||||
const normalized = String(profileName || '').trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized.slice(0, 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProfilePath(profileName: string): string {
|
|
||||||
const safeName = profileName
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9._-]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 80) || 'default';
|
|
||||||
return path.join(PROFILE_ROOT, safeName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeRuntime(): RuntimeProbe {
|
function probeRuntime(): RuntimeProbe {
|
||||||
const playwright = getPlaywright();
|
const playwright = getPlaywright();
|
||||||
const readiness: RuntimeProbe = {
|
const readiness: RuntimeProbe = {
|
||||||
@@ -238,6 +198,175 @@ function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadines
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findAvailablePort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.on('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
server.close(() => {
|
||||||
|
if (typeof address === 'object' && address?.port) {
|
||||||
|
resolve(address.port);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to reserve a browser runtime port.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRuntimeProcessAlive(child: ReturnType<typeof spawn>): boolean {
|
||||||
|
return child.exitCode === null && child.signalCode === null && !child.killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRuntimeProcessesAlive(processes: Array<ReturnType<typeof spawn>>, label: string) {
|
||||||
|
const exited = processes.find((child) => !isRuntimeProcessAlive(child));
|
||||||
|
if (exited) {
|
||||||
|
throw new Error(`${label} exited before the Browser viewer runtime was ready.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isPortListening(port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||||
|
let settled = false;
|
||||||
|
const finish = (listening: boolean) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve(listening);
|
||||||
|
};
|
||||||
|
socket.setTimeout(250);
|
||||||
|
socket.once('connect', () => finish(true));
|
||||||
|
socket.once('timeout', () => finish(false));
|
||||||
|
socket.once('error', () => finish(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRuntimePort(
|
||||||
|
port: number,
|
||||||
|
label: string,
|
||||||
|
processes: Array<ReturnType<typeof spawn>>,
|
||||||
|
timeoutMs = 5_000,
|
||||||
|
) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
assertRuntimeProcessesAlive(processes, label);
|
||||||
|
if (await isPortListening(port)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
assertRuntimeProcessesAlive(processes, label);
|
||||||
|
throw new Error(`${label} did not start listening on 127.0.0.1:${port}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function killRuntimeProcesses(processes?: Array<ReturnType<typeof spawn>>) {
|
||||||
|
processes?.forEach((child) => child.kill('SIGTERM'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reserveDisplay(): string {
|
||||||
|
for (let index = 90; index < 140; index += 1) {
|
||||||
|
const display = `:${index}`;
|
||||||
|
if (!reservedDisplays.has(display)) {
|
||||||
|
reservedDisplays.add(display);
|
||||||
|
return display;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No browser display slots are available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnRuntimeProcess(command: string, args: string[], options: { env?: NodeJS.ProcessEnv } = {}) {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
env: { ...process.env, ...options.env },
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
});
|
||||||
|
child.stderr?.on('data', (chunk) => {
|
||||||
|
if (!LOG_RUNTIME_PROCESS_OUTPUT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = String(chunk).trim();
|
||||||
|
if (text) {
|
||||||
|
console.warn(`[Browser runtime] ${path.basename(command)}: ${text}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.warn(`[Browser runtime] ${path.basename(command)} failed:`, error.message);
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startVisibleRuntime(): Promise<NonNullable<RuntimeHandle['viewer']> & { processes: Array<ReturnType<typeof spawn>> }> {
|
||||||
|
const display = reserveDisplay();
|
||||||
|
const vncPort = await findAvailablePort();
|
||||||
|
const websockifyPort = await findAvailablePort();
|
||||||
|
const processes: Array<ReturnType<typeof spawn>> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
processes.push(spawnRuntimeProcess('Xvfb', [
|
||||||
|
display,
|
||||||
|
'-screen',
|
||||||
|
'0',
|
||||||
|
'1440x900x24',
|
||||||
|
'-ac',
|
||||||
|
'-nolisten',
|
||||||
|
'tcp',
|
||||||
|
]));
|
||||||
|
await delay(700);
|
||||||
|
assertRuntimeProcessesAlive(processes, 'Xvfb');
|
||||||
|
|
||||||
|
if (!fs.existsSync(X11VNC_BIN)) {
|
||||||
|
throw new Error(`x11vnc is missing at ${X11VNC_BIN}.`);
|
||||||
|
}
|
||||||
|
processes.push(spawnRuntimeProcess(X11VNC_BIN, [
|
||||||
|
'-display',
|
||||||
|
display,
|
||||||
|
'-localhost',
|
||||||
|
'-forever',
|
||||||
|
'-shared',
|
||||||
|
'-rfbport',
|
||||||
|
String(vncPort),
|
||||||
|
'-nopw',
|
||||||
|
'-quiet',
|
||||||
|
], {
|
||||||
|
env: {
|
||||||
|
LD_LIBRARY_PATH: `${X11VNC_LIB_DIR}:${X11VNC_EXTRA_LIB_DIR}:${process.env.LD_LIBRARY_PATH || ''}`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
await waitForRuntimePort(vncPort, 'x11vnc', processes);
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'))) {
|
||||||
|
throw new Error(`noVNC is missing at ${NOVNC_ROOT}.`);
|
||||||
|
}
|
||||||
|
processes.push(spawnRuntimeProcess(path.join(os.homedir(), '.local/bin/websockify'), [
|
||||||
|
'--web',
|
||||||
|
NOVNC_ROOT,
|
||||||
|
`127.0.0.1:${websockifyPort}`,
|
||||||
|
`127.0.0.1:${vncPort}`,
|
||||||
|
]));
|
||||||
|
await waitForRuntimePort(websockifyPort, 'websockify', processes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
display,
|
||||||
|
vncPort,
|
||||||
|
websockifyPort,
|
||||||
|
noVncRoot: NOVNC_ROOT,
|
||||||
|
processes,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
killRuntimeProcesses(processes);
|
||||||
|
reservedDisplays.delete(display);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
|
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
|
||||||
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
|
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
|
||||||
10,
|
10,
|
||||||
@@ -350,6 +479,45 @@ function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
|
|||||||
return publicFields;
|
return publicFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionViewer(sessionId: string): RuntimeHandle['viewer'] | null {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session || session.ownerId !== AGENT_OWNER_ID || session.status !== 'ready') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return handles.get(sessionId)?.viewer || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createViewerToken(sessionId: string): string {
|
||||||
|
const token = randomUUID();
|
||||||
|
viewerTokens.set(sessionId, {
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + VIEWER_TOKEN_TTL_MS,
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteViewerToken(sessionId: string) {
|
||||||
|
viewerTokens.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateViewerTokenForSession(sessionId: string, token: string | null | undefined): boolean {
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
const viewer = session?.ownerId === AGENT_OWNER_ID && session.status === 'ready'
|
||||||
|
? handles.get(sessionId)?.viewer || null
|
||||||
|
: null;
|
||||||
|
const stored = viewerTokens.get(sessionId);
|
||||||
|
if (!viewer || !stored || stored.token !== token || stored.expiresAt < Date.now()) {
|
||||||
|
if (stored?.expiresAt && stored.expiresAt < Date.now()) {
|
||||||
|
viewerTokens.delete(sessionId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function ownerSessions(ownerId: string): BrowserUseSession[] {
|
function ownerSessions(ownerId: string): BrowserUseSession[] {
|
||||||
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
|
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
|
||||||
}
|
}
|
||||||
@@ -357,8 +525,13 @@ function ownerSessions(ownerId: string): BrowserUseSession[] {
|
|||||||
async function closeHandle(sessionId: string): Promise<void> {
|
async function closeHandle(sessionId: string): Promise<void> {
|
||||||
const handle = handles.get(sessionId);
|
const handle = handles.get(sessionId);
|
||||||
handles.delete(sessionId);
|
handles.delete(sessionId);
|
||||||
|
deleteViewerToken(sessionId);
|
||||||
await handle?.context?.close?.().catch(() => undefined);
|
await handle?.context?.close?.().catch(() => undefined);
|
||||||
await handle?.browser?.close().catch(() => undefined);
|
await handle?.browser?.close().catch(() => undefined);
|
||||||
|
killRuntimeProcesses(handle?.processes);
|
||||||
|
if (handle?.viewer?.display) {
|
||||||
|
reservedDisplays.delete(handle.viewer.display);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
||||||
@@ -424,6 +597,11 @@ export const browserUseService = {
|
|||||||
const current = readSettings();
|
const current = readSettings();
|
||||||
const nextSettings = {
|
const nextSettings = {
|
||||||
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
|
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
|
||||||
|
persistSessions: typeof settings.persistSessions === 'boolean' ? settings.persistSessions : current.persistSessions,
|
||||||
|
defaultProfileName: typeof settings.defaultProfileName === 'string'
|
||||||
|
? settings.defaultProfileName
|
||||||
|
: current.defaultProfileName,
|
||||||
|
browserBackend: settings.browserBackend ? normalizeBrowserBackend(settings.browserBackend) : current.browserBackend,
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = writeSettings(nextSettings);
|
const next = writeSettings(nextSettings);
|
||||||
@@ -439,14 +617,28 @@ export const browserUseService = {
|
|||||||
async getStatus() {
|
async getStatus() {
|
||||||
const settings = readSettings();
|
const settings = readSettings();
|
||||||
const readiness = getRuntimeReadiness();
|
const readiness = getRuntimeReadiness();
|
||||||
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
|
const useVisibleBackend = useVisibleCamoufoxBackend(settings);
|
||||||
|
const visibleCamoufoxReady = useVisibleBackend
|
||||||
|
&& VISIBLE_BROWSER_ENABLED
|
||||||
|
&& readiness.playwrightInstalled
|
||||||
|
&& Boolean(getCamoufoxExecutablePath())
|
||||||
|
&& fs.existsSync(X11VNC_BIN)
|
||||||
|
&& fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'));
|
||||||
|
const available = settings.enabled
|
||||||
|
&& readiness.playwrightInstalled
|
||||||
|
&& (useVisibleBackend ? visibleCamoufoxReady : readiness.chromiumInstalled);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: settings.enabled,
|
enabled: settings.enabled,
|
||||||
runtime: getRuntime(),
|
runtime: getRuntime(),
|
||||||
|
backend: useVisibleBackend ? 'camoufox-vnc' : 'playwright',
|
||||||
|
browserBackend: settings.browserBackend,
|
||||||
available,
|
available,
|
||||||
playwrightInstalled: readiness.playwrightInstalled,
|
playwrightInstalled: readiness.playwrightInstalled,
|
||||||
chromiumInstalled: readiness.chromiumInstalled,
|
chromiumInstalled: readiness.chromiumInstalled,
|
||||||
|
camoufoxInstalled: Boolean(getCamoufoxExecutablePath()),
|
||||||
|
noVncInstalled: fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html')),
|
||||||
|
x11vncInstalled: fs.existsSync(X11VNC_BIN),
|
||||||
installInProgress: readiness.installInProgress,
|
installInProgress: readiness.installInProgress,
|
||||||
sessionCount: sessions.size,
|
sessionCount: sessions.size,
|
||||||
message: available
|
message: available
|
||||||
@@ -505,7 +697,7 @@ export const browserUseService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await expireStaleSessions();
|
await expireStaleSessions();
|
||||||
const profileName = normalizeProfileName(options?.profileName);
|
const profileName = resolveSessionProfileName(settings, options?.profileName);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const session: BrowserUseSession = {
|
const session: BrowserUseSession = {
|
||||||
@@ -521,6 +713,9 @@ export const browserUseService = {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastAction: 'create',
|
lastAction: 'create',
|
||||||
message: null,
|
message: null,
|
||||||
|
backend: useVisibleCamoufoxBackend(settings) ? 'camoufox-vnc' : 'playwright',
|
||||||
|
viewerUrl: null,
|
||||||
|
viewerEmbedUrl: null,
|
||||||
profileName,
|
profileName,
|
||||||
viewport: { width: 1440, height: 900 },
|
viewport: { width: 1440, height: 900 },
|
||||||
cursor: null,
|
cursor: null,
|
||||||
@@ -532,7 +727,13 @@ export const browserUseService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const readiness = getRuntimeReadiness();
|
const readiness = getRuntimeReadiness();
|
||||||
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
|
const useVisibleBackend = useVisibleCamoufoxBackend(settings);
|
||||||
|
const visibleCamoufoxReady = useVisibleBackend
|
||||||
|
&& VISIBLE_BROWSER_ENABLED
|
||||||
|
&& Boolean(getCamoufoxExecutablePath())
|
||||||
|
&& fs.existsSync(X11VNC_BIN)
|
||||||
|
&& fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'));
|
||||||
|
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.playwright || (useVisibleBackend ? !visibleCamoufoxReady : !readiness.chromiumInstalled)) {
|
||||||
session.message = getSetupMessage(settings, readiness);
|
session.message = getSetupMessage(settings, readiness);
|
||||||
sessions.set(session.id, session);
|
sessions.set(session.id, session);
|
||||||
return publicSession(session);
|
return publicSession(session);
|
||||||
@@ -541,31 +742,73 @@ export const browserUseService = {
|
|||||||
let browser: any | undefined;
|
let browser: any | undefined;
|
||||||
let context: any | undefined;
|
let context: any | undefined;
|
||||||
let page: any;
|
let page: any;
|
||||||
const launchOptions = {
|
let viewer: RuntimeHandle['viewer'];
|
||||||
headless: true,
|
let processes: RuntimeHandle['processes'];
|
||||||
|
const launchOptions: Record<string, unknown> = {
|
||||||
|
headless: !useVisibleBackend,
|
||||||
args: ['--disable-dev-shm-usage'],
|
args: ['--disable-dev-shm-usage'],
|
||||||
};
|
};
|
||||||
const contextOptions = {
|
const contextOptions = useVisibleBackend
|
||||||
viewport: { width: 1440, height: 900 },
|
? { viewport: null }
|
||||||
serviceWorkers: 'block',
|
: {
|
||||||
};
|
viewport: { width: 1440, height: 900 },
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
if (profileName) {
|
try {
|
||||||
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
|
if (useVisibleBackend) {
|
||||||
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
|
const camoufoxExecutable = getCamoufoxExecutablePath();
|
||||||
...launchOptions,
|
if (!camoufoxExecutable) {
|
||||||
...contextOptions,
|
throw new Error('Camoufox is not installed.');
|
||||||
});
|
}
|
||||||
page = context.pages()[0] || await context.newPage();
|
const runtime = await startVisibleRuntime();
|
||||||
} else {
|
viewer = {
|
||||||
browser = await readiness.playwright.chromium.launch(launchOptions);
|
display: runtime.display,
|
||||||
context = await browser.newContext(contextOptions);
|
vncPort: runtime.vncPort,
|
||||||
page = await context.newPage();
|
websockifyPort: runtime.websockifyPort,
|
||||||
|
noVncRoot: runtime.noVncRoot,
|
||||||
|
};
|
||||||
|
processes = runtime.processes;
|
||||||
|
launchOptions.executablePath = camoufoxExecutable;
|
||||||
|
launchOptions.env = {
|
||||||
|
...process.env,
|
||||||
|
DISPLAY: runtime.display,
|
||||||
|
LD_LIBRARY_PATH: `${X11VNC_LIB_DIR}:${X11VNC_EXTRA_LIB_DIR}:${process.env.LD_LIBRARY_PATH || ''}`,
|
||||||
|
};
|
||||||
|
launchOptions.args = [];
|
||||||
|
session.backend = 'camoufox-vnc';
|
||||||
|
const viewerToken = createViewerToken(session.id);
|
||||||
|
session.viewerUrl = getViewerUrl(session.id, viewerToken);
|
||||||
|
session.viewerEmbedUrl = session.viewerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileName) {
|
||||||
|
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
|
||||||
|
const browserType = useVisibleBackend ? readiness.playwright.firefox : readiness.playwright.chromium;
|
||||||
|
context = await browserType.launchPersistentContext(getProfilePath(profileName), {
|
||||||
|
...launchOptions,
|
||||||
|
...contextOptions,
|
||||||
|
});
|
||||||
|
page = context.pages()[0] || await context.newPage();
|
||||||
|
} else {
|
||||||
|
const browserType = useVisibleBackend ? readiness.playwright.firefox : readiness.playwright.chromium;
|
||||||
|
browser = await browserType.launch(launchOptions);
|
||||||
|
context = await browser.newContext(contextOptions);
|
||||||
|
page = await context.newPage();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await context?.close?.().catch(() => undefined);
|
||||||
|
await browser?.close?.().catch(() => undefined);
|
||||||
|
killRuntimeProcesses(processes);
|
||||||
|
if (viewer?.display) {
|
||||||
|
reservedDisplays.delete(viewer.display);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
session.status = 'ready';
|
session.status = 'ready';
|
||||||
session.message = 'Browser session is ready.';
|
session.message = 'Browser session is ready.';
|
||||||
sessions.set(session.id, session);
|
sessions.set(session.id, session);
|
||||||
handles.set(session.id, { browser, context, page });
|
handles.set(session.id, { browser, context, page, processes, viewer });
|
||||||
await captureSession(session, page);
|
await captureSession(session, page);
|
||||||
return publicSession(session);
|
return publicSession(session);
|
||||||
},
|
},
|
||||||
@@ -812,6 +1055,25 @@ export const browserUseService = {
|
|||||||
return { deleted: true, sessionId };
|
return { deleted: true, sessionId };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getViewerProxyTarget(sessionId: string) {
|
||||||
|
const viewer = getSessionViewer(sessionId);
|
||||||
|
if (!viewer) {
|
||||||
|
throw new Error('Browser viewer is not available for this session.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
websockifyPort: viewer.websockifyPort,
|
||||||
|
noVncRoot: viewer.noVncRoot,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
validateViewerToken(sessionId: string, token: string | null | undefined) {
|
||||||
|
return validateViewerTokenForSession(sessionId, token);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleViewerWebSocket(clientWs: WebSocket, pathname: string) {
|
||||||
|
handleViewerWebSocket(clientWs, pathname, getSessionViewer);
|
||||||
|
},
|
||||||
|
|
||||||
async agentStopSession(sessionId: string) {
|
async agentStopSession(sessionId: string) {
|
||||||
await this.getAgentSession(sessionId);
|
await this.getAgentSession(sessionId);
|
||||||
return this.stopSession(sessionId);
|
return this.stopSession(sessionId);
|
||||||
|
|||||||
147
server/modules/browser-use/browser-use.settings.ts
Normal file
147
server/modules/browser-use/browser-use.settings.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { appConfigDb } from '@/modules/database/index.js';
|
||||||
|
|
||||||
|
import type { BrowserUseBackend, BrowserUseSettings } from './browser-use.types.js';
|
||||||
|
|
||||||
|
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||||
|
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
|
||||||
|
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
|
||||||
|
const MAX_PROFILE_NAME_LENGTH = 80;
|
||||||
|
|
||||||
|
export const DEFAULT_BROWSER_USE_SETTINGS: BrowserUseSettings = {
|
||||||
|
enabled: false,
|
||||||
|
persistSessions: false,
|
||||||
|
defaultProfileName: 'default',
|
||||||
|
browserBackend: IS_PLATFORM ? 'camoufox-vnc' : 'playwright',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROFILE_ROOT = process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT
|
||||||
|
|| path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
|
||||||
|
|
||||||
|
export function normalizeBrowserBackend(value: unknown): BrowserUseBackend {
|
||||||
|
return value === 'playwright' || value === 'camoufox-vnc'
|
||||||
|
? value
|
||||||
|
: DEFAULT_BROWSER_USE_SETTINGS.browserBackend;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimEdgeDashes(value: string): string {
|
||||||
|
let start = 0;
|
||||||
|
let end = value.length;
|
||||||
|
while (start < end && value[start] === '-') {
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
while (end > start && value[end - 1] === '-') {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
return value.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProfileName(profileName?: string | null): string | null {
|
||||||
|
const sanitized = trimEdgeDashes(String(profileName || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-'));
|
||||||
|
const normalized = sanitized
|
||||||
|
.slice(0, MAX_PROFILE_NAME_LENGTH)
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /[a-z0-9]/.test(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDefaultProfileName(profileName?: string | null): string {
|
||||||
|
return normalizeProfileName(profileName) || DEFAULT_BROWSER_USE_SETTINGS.defaultProfileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSessionProfileName(settings: BrowserUseSettings, profileName?: string | null): string | null {
|
||||||
|
const requestedProfileName = normalizeProfileName(profileName);
|
||||||
|
if (String(profileName || '').trim() && !requestedProfileName) {
|
||||||
|
throw new Error('Browser profile name must include at least one letter or number.');
|
||||||
|
}
|
||||||
|
if (requestedProfileName) {
|
||||||
|
validateRequestedProfileName(profileName, requestedProfileName);
|
||||||
|
return requestedProfileName;
|
||||||
|
}
|
||||||
|
return settings.persistSessions ? normalizeDefaultProfileName(settings.defaultProfileName) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfilePath(profileName: string): string {
|
||||||
|
return path.join(PROFILE_ROOT, normalizeDefaultProfileName(profileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRequestedProfileName(profileName: string | null | undefined, normalizedProfileName: string): void {
|
||||||
|
const requestedProfileName = String(profileName || '').trim();
|
||||||
|
const existingProfileName = findExistingProfileName(normalizedProfileName);
|
||||||
|
if (
|
||||||
|
existingProfileName
|
||||||
|
&& (requestedProfileName !== normalizedProfileName || existingProfileName !== normalizedProfileName)
|
||||||
|
) {
|
||||||
|
throw new Error(`Browser profile "${requestedProfileName}" resolves to existing profile "${existingProfileName}". Use "${normalizedProfileName}" instead.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExistingProfileName(normalizedProfileName: string): string | null {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(PROFILE_ROOT)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(PROFILE_ROOT, { withFileTypes: true });
|
||||||
|
const match = entries.find((entry) => entry.isDirectory() && normalizeProfileName(entry.name) === normalizedProfileName);
|
||||||
|
return match?.name || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVisibleCamoufoxBackend(settings: BrowserUseSettings): boolean {
|
||||||
|
return settings.browserBackend === 'camoufox-vnc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSettings(): BrowserUseSettings {
|
||||||
|
try {
|
||||||
|
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return DEFAULT_BROWSER_USE_SETTINGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
|
||||||
|
return {
|
||||||
|
enabled: parsed.enabled === true,
|
||||||
|
persistSessions: parsed.persistSessions === true,
|
||||||
|
defaultProfileName: normalizeDefaultProfileName(parsed.defaultProfileName),
|
||||||
|
browserBackend: normalizeBrowserBackend(parsed.browserBackend),
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn('[Browser] Failed to read settings:', error?.message || error);
|
||||||
|
return DEFAULT_BROWSER_USE_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
|
||||||
|
const normalized = {
|
||||||
|
enabled: settings.enabled === true,
|
||||||
|
persistSessions: settings.persistSessions === true,
|
||||||
|
defaultProfileName: normalizeDefaultProfileName(settings.defaultProfileName),
|
||||||
|
browserBackend: normalizeBrowserBackend(settings.browserBackend),
|
||||||
|
};
|
||||||
|
|
||||||
|
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrCreateMcpToken(): string {
|
||||||
|
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
66
server/modules/browser-use/browser-use.types.ts
Normal file
66
server/modules/browser-use/browser-use.types.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
export type BrowserUseRuntime = 'cloud' | 'local';
|
||||||
|
export type BrowserUseBackend = 'playwright' | 'camoufox-vnc';
|
||||||
|
export type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
|
||||||
|
|
||||||
|
export type BrowserUseSession = {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
createdBy: 'agent';
|
||||||
|
runtime: BrowserUseRuntime;
|
||||||
|
status: BrowserUseSessionStatus;
|
||||||
|
url: string | null;
|
||||||
|
title: string | null;
|
||||||
|
screenshotDataUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastAction: string | null;
|
||||||
|
message: string | null;
|
||||||
|
backend: BrowserUseBackend;
|
||||||
|
viewerUrl: string | null;
|
||||||
|
viewerEmbedUrl: string | null;
|
||||||
|
profileName: string | null;
|
||||||
|
viewport: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
|
cursor: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
actor: 'agent';
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
|
||||||
|
|
||||||
|
export type RuntimeHandle = {
|
||||||
|
browser?: any;
|
||||||
|
context?: any;
|
||||||
|
page?: any;
|
||||||
|
processes?: Array<ReturnType<typeof spawn>>;
|
||||||
|
viewer?: {
|
||||||
|
display: string;
|
||||||
|
vncPort: number;
|
||||||
|
websockifyPort: number;
|
||||||
|
noVncRoot: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserUseSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
persistSessions: boolean;
|
||||||
|
defaultProfileName: string;
|
||||||
|
browserBackend: BrowserUseBackend;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeReadiness = {
|
||||||
|
playwright: any | null;
|
||||||
|
playwrightInstalled: boolean;
|
||||||
|
chromiumInstalled: boolean;
|
||||||
|
chromiumExecutablePath: string | null;
|
||||||
|
installInProgress: boolean;
|
||||||
|
installMessage: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
|
||||||
76
server/modules/browser-use/browser-use.viewer.ts
Normal file
76
server/modules/browser-use/browser-use.viewer.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
import type { RuntimeHandle } from './browser-use.types.js';
|
||||||
|
|
||||||
|
type BrowserUseViewer = NonNullable<RuntimeHandle['viewer']>;
|
||||||
|
|
||||||
|
export const VIEWER_COOKIE_NAME = 'browser_use_viewer_token';
|
||||||
|
const DEFAULT_VIEWER_TOKEN_TTL_MS = 30 * 60 * 1000;
|
||||||
|
const parsedViewerTokenTtlMs = Number.parseInt(
|
||||||
|
process.env.CLOUDCLI_BROWSER_USE_VIEWER_TOKEN_TTL_MS || String(DEFAULT_VIEWER_TOKEN_TTL_MS),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
export const VIEWER_TOKEN_TTL_MS =
|
||||||
|
Number.isFinite(parsedViewerTokenTtlMs) && parsedViewerTokenTtlMs > 0
|
||||||
|
? parsedViewerTokenTtlMs
|
||||||
|
: DEFAULT_VIEWER_TOKEN_TTL_MS;
|
||||||
|
|
||||||
|
export function getViewerUrl(sessionId: string, viewerToken?: string): string {
|
||||||
|
const basePath = `/api/browser-use/sessions/${encodeURIComponent(sessionId)}/viewer`;
|
||||||
|
const websockifyPath = viewerToken
|
||||||
|
? `${basePath}/websockify?viewerToken=${encodeURIComponent(viewerToken)}`
|
||||||
|
: `${basePath}/websockify`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
autoconnect: '1',
|
||||||
|
resize: 'scale',
|
||||||
|
reconnect: '1',
|
||||||
|
path: websockifyPath,
|
||||||
|
});
|
||||||
|
if (viewerToken) {
|
||||||
|
params.set('viewerToken', viewerToken);
|
||||||
|
}
|
||||||
|
return `${basePath}/vnc.html?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleViewerWebSocket(
|
||||||
|
clientWs: WebSocket,
|
||||||
|
pathname: string,
|
||||||
|
getSessionViewer: (sessionId: string) => BrowserUseViewer | null | undefined,
|
||||||
|
) {
|
||||||
|
const match = /^\/api\/browser-use\/sessions\/([^/]+)\/viewer\/websockify\/?$/.exec(pathname);
|
||||||
|
const sessionId = match ? decodeURIComponent(match[1]) : '';
|
||||||
|
const viewer = sessionId ? getSessionViewer(sessionId) : null;
|
||||||
|
if (!viewer) {
|
||||||
|
clientWs.close(4404, 'Browser viewer not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = new WebSocket(`ws://127.0.0.1:${viewer.websockifyPort}`);
|
||||||
|
upstream.on('open', () => {
|
||||||
|
clientWs.on('message', (data) => {
|
||||||
|
if (upstream.readyState === WebSocket.OPEN) {
|
||||||
|
upstream.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstream.on('message', (data) => {
|
||||||
|
if (clientWs.readyState === WebSocket.OPEN) {
|
||||||
|
clientWs.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
upstream.on('close', (code, reason) => {
|
||||||
|
if (clientWs.readyState === WebSocket.OPEN) {
|
||||||
|
clientWs.close(code, reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstream.on('error', () => {
|
||||||
|
if (clientWs.readyState === WebSocket.OPEN) {
|
||||||
|
clientWs.close(4502, 'Browser viewer upstream error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clientWs.on('close', () => {
|
||||||
|
if (upstream.readyState === WebSocket.OPEN) {
|
||||||
|
upstream.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
2
server/modules/browser-use/index.ts
Normal file
2
server/modules/browser-use/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { browserUseService } from './browser-use.service.js';
|
||||||
|
export { VIEWER_COOKIE_NAME } from './browser-use.viewer.js';
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const originalProfileRoot = process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT;
|
||||||
|
const testProfileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-use-profiles-'));
|
||||||
|
process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT = testProfileRoot;
|
||||||
|
|
||||||
|
const {
|
||||||
|
getProfilePath,
|
||||||
|
normalizeDefaultProfileName,
|
||||||
|
normalizeProfileName,
|
||||||
|
PROFILE_ROOT,
|
||||||
|
resolveSessionProfileName,
|
||||||
|
} = await import('@/modules/browser-use/browser-use.settings.js');
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
if (originalProfileRoot === undefined) {
|
||||||
|
delete process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT = originalProfileRoot;
|
||||||
|
}
|
||||||
|
fs.rmSync(testProfileRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser profile names are canonicalized before storage and path resolution', () => {
|
||||||
|
assert.equal(normalizeProfileName(' Work Profile!! '), 'work-profile');
|
||||||
|
assert.equal(normalizeProfileName(`${'-'.repeat(100)}Work Profile`), 'work-profile');
|
||||||
|
assert.equal(normalizeDefaultProfileName(' Work Profile!! '), 'work-profile');
|
||||||
|
assert.equal(
|
||||||
|
getProfilePath(' Work Profile!! '),
|
||||||
|
`${PROFILE_ROOT}/work-profile`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveSessionProfileName({
|
||||||
|
enabled: true,
|
||||||
|
persistSessions: true,
|
||||||
|
defaultProfileName: ' Work Profile!! ',
|
||||||
|
browserBackend: 'playwright',
|
||||||
|
}),
|
||||||
|
'work-profile',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser profile aliases are rejected when the normalized profile already exists', () => {
|
||||||
|
const profileName = `alias-test-${Date.now()}`;
|
||||||
|
fs.mkdirSync(getProfilePath(profileName), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() => resolveSessionProfileName({
|
||||||
|
enabled: true,
|
||||||
|
persistSessions: false,
|
||||||
|
defaultProfileName: 'default',
|
||||||
|
browserBackend: 'playwright',
|
||||||
|
}, profileName.toUpperCase()),
|
||||||
|
/resolves to existing profile/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveSessionProfileName({
|
||||||
|
enabled: true,
|
||||||
|
persistSessions: false,
|
||||||
|
defaultProfileName: 'default',
|
||||||
|
browserBackend: 'playwright',
|
||||||
|
}, profileName),
|
||||||
|
profileName,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(getProfilePath(profileName), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Server as HttpServer } from 'node:http';
|
import type { Server as HttpServer } from 'node:http';
|
||||||
|
|
||||||
import { WebSocketServer, type VerifyClientCallbackSync } from 'ws';
|
import { WebSocket, WebSocketServer, type VerifyClientCallbackSync } from 'ws';
|
||||||
|
|
||||||
import { handleChatConnection } from '@/modules/websocket/services/chat-websocket.service.js';
|
import { handleChatConnection } from '@/modules/websocket/services/chat-websocket.service.js';
|
||||||
|
import { VIEWER_COOKIE_NAME } from '@/modules/browser-use/index.js';
|
||||||
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
|
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
|
||||||
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
||||||
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
||||||
@@ -14,8 +15,21 @@ type WebSocketServerDependencies = {
|
|||||||
chat: Parameters<typeof handleChatConnection>[2];
|
chat: Parameters<typeof handleChatConnection>[2];
|
||||||
shell: Parameters<typeof handleShellConnection>[1];
|
shell: Parameters<typeof handleShellConnection>[1];
|
||||||
getPluginPort: Parameters<typeof handlePluginWsProxy>[2];
|
getPluginPort: Parameters<typeof handlePluginWsProxy>[2];
|
||||||
|
browserUseViewer?: (ws: WebSocket, pathname: string) => void;
|
||||||
|
authenticateBrowserUseViewer?: (pathname: string, token: string | null) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function readCookieValue(header: unknown, name: string): string | null {
|
||||||
|
if (!header) return null;
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const cookie = String(header).split(';').map((part) => part.trim()).find((part) => part.startsWith(prefix));
|
||||||
|
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBrowserUseViewerToken(url: URL, headers: Record<string, unknown>): string | null {
|
||||||
|
return url.searchParams.get('viewerToken') || readCookieValue(headers.cookie, VIEWER_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates and wires the server-wide websocket gateway used for chat, shell, and
|
* Creates and wires the server-wide websocket gateway used for chat, shell, and
|
||||||
* plugin proxy routes.
|
* plugin proxy routes.
|
||||||
@@ -28,7 +42,17 @@ export function createWebSocketServer(
|
|||||||
server,
|
server,
|
||||||
verifyClient: ((
|
verifyClient: ((
|
||||||
info: Parameters<VerifyClientCallbackSync<AuthenticatedWebSocketRequest>>[0]
|
info: Parameters<VerifyClientCallbackSync<AuthenticatedWebSocketRequest>>[0]
|
||||||
) => verifyWebSocketClient(info, dependencies.verifyClient)),
|
) => {
|
||||||
|
const requestUrl = new URL(info.req.url ?? '/', 'http://localhost');
|
||||||
|
if (
|
||||||
|
requestUrl.pathname.startsWith('/api/browser-use/sessions/')
|
||||||
|
&& requestUrl.pathname.endsWith('/viewer/websockify')
|
||||||
|
) {
|
||||||
|
const token = getBrowserUseViewerToken(requestUrl, info.req.headers as Record<string, unknown>);
|
||||||
|
return Boolean(dependencies.authenticateBrowserUseViewer?.(requestUrl.pathname, token));
|
||||||
|
}
|
||||||
|
return verifyWebSocketClient(info, dependencies.verifyClient);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
wss.on('connection', (ws, request) => {
|
wss.on('connection', (ws, request) => {
|
||||||
@@ -74,6 +98,11 @@ export function createWebSocketServer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith('/api/browser-use/sessions/') && pathname.endsWith('/viewer/websockify')) {
|
||||||
|
dependencies.browserUseViewer?.(ws, pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[WARN] Unknown WebSocket path:', pathname);
|
console.log('[WARN] Unknown WebSocket path:', pathname);
|
||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
type AuthErrorAlertProps = {
|
type AuthErrorAlertProps = {
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
};
|
};
|
||||||
@@ -8,8 +10,12 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20">
|
<div
|
||||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
|
role="alert"
|
||||||
|
className="flex items-start gap-2.5 rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-destructive"
|
||||||
|
>
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<p className="text-sm leading-relaxed">{errorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
type AuthInputFieldProps = {
|
type AuthInputFieldProps = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -8,13 +12,14 @@ type AuthInputFieldProps = {
|
|||||||
type?: 'text' | 'password' | 'email';
|
type?: 'text' | 'password' | 'email';
|
||||||
name?: string;
|
name?: string;
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
|
icon?: ComponentType<{ className?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A labelled input field for authentication forms.
|
* A labelled input field for authentication forms.
|
||||||
* Renders a `<label>` / `<input>` pair and forwards browser autofill hints
|
* Renders a `<label>` / `<input>` pair and forwards browser autofill hints
|
||||||
* (`name`, `autoComplete`) so that password managers can identify and fill
|
* (`name`, `autoComplete`) so that password managers can identify and fill
|
||||||
* the field correctly.
|
* the field correctly. Password fields gain a show/hide visibility toggle.
|
||||||
*/
|
*/
|
||||||
export default function AuthInputField({
|
export default function AuthInputField({
|
||||||
id,
|
id,
|
||||||
@@ -26,24 +31,48 @@ export default function AuthInputField({
|
|||||||
type = 'text',
|
type = 'text',
|
||||||
name,
|
name,
|
||||||
autoComplete,
|
autoComplete,
|
||||||
|
icon: Icon,
|
||||||
}: AuthInputFieldProps) {
|
}: AuthInputFieldProps) {
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
const isPasswordField = type === 'password';
|
||||||
|
const resolvedType = isPasswordField && isPasswordVisible ? 'text' : type;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={id} className="mb-1 block text-sm font-medium text-foreground">
|
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="group relative">
|
||||||
id={id}
|
{Icon && (
|
||||||
type={type}
|
<Icon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
||||||
name={name ?? id}
|
)}
|
||||||
autoComplete={autoComplete}
|
<input
|
||||||
value={value}
|
id={id}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
type={resolvedType}
|
||||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
name={name ?? id}
|
||||||
placeholder={placeholder}
|
autoComplete={autoComplete}
|
||||||
required
|
value={value}
|
||||||
disabled={isDisabled}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
/>
|
className={`w-full rounded-xl border border-border bg-background/60 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||||
|
Icon ? 'pl-10' : 'pl-3.5'
|
||||||
|
} ${isPasswordField ? 'pr-11' : 'pr-3.5'}`}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
{isPasswordField && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPasswordVisible((previous) => !previous)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||||
|
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,37 @@
|
|||||||
import { MessageSquare } from 'lucide-react';
|
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../constants/branding';
|
||||||
|
|
||||||
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
|
const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
|
||||||
|
|
||||||
export default function AuthLoadingScreen() {
|
export default function AuthLoadingScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
||||||
<div className="text-center">
|
<div aria-hidden className="pointer-events-none absolute inset-0">
|
||||||
<div className="mb-4 flex justify-center">
|
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
|
</div>
|
||||||
<MessageSquare className="h-8 w-8 text-primary-foreground" />
|
|
||||||
|
<div className="relative text-center" role="status" aria-live="polite">
|
||||||
|
<div className="mb-5 flex justify-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
|
||||||
|
<img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
|
<h1
|
||||||
|
className="mb-4 text-2xl font-bold tracking-tight text-foreground"
|
||||||
<div className="flex items-center justify-center space-x-2">
|
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
|
||||||
|
>
|
||||||
|
CloudCLI
|
||||||
|
</h1>
|
||||||
|
<p className="sr-only">Loading authentication state…</p>
|
||||||
|
<div aria-hidden className="flex items-center justify-center gap-2">
|
||||||
{loadingDotAnimationDelays.map((delay) => (
|
{loadingDotAnimationDelays.map((delay) => (
|
||||||
<div
|
<div
|
||||||
key={delay}
|
key={delay}
|
||||||
className="h-2 w-2 animate-bounce rounded-full bg-blue-500"
|
className="h-2 w-2 animate-bounce rounded-full bg-primary"
|
||||||
style={{ animationDelay: delay }}
|
style={{ animationDelay: delay }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-muted-foreground">Loading...</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { MessageSquare } from 'lucide-react';
|
|
||||||
import { IS_PLATFORM } from '../../../constants/config';
|
import { IS_PLATFORM } from '../../../constants/config';
|
||||||
|
|
||||||
type AuthScreenLayoutProps = {
|
type AuthScreenLayoutProps = {
|
||||||
@@ -18,29 +17,38 @@ export default function AuthScreenLayout({
|
|||||||
logo,
|
logo,
|
||||||
}: AuthScreenLayoutProps) {
|
}: AuthScreenLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="relative h-screen overflow-y-auto bg-background">
|
||||||
<div className="w-full max-w-md">
|
{/* Ambient, on-brand backdrop that gives the screen depth without
|
||||||
<div className="space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg">
|
competing with the card content. Fixed so it stays put while the
|
||||||
|
form scrolls on short viewports. */}
|
||||||
|
<div aria-hidden className="pointer-events-none fixed inset-0">
|
||||||
|
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-32 -left-24 h-[26rem] w-[26rem] rounded-full bg-primary/5 blur-3xl" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(hsl(var(--foreground)/0.04)_1px,transparent_1px)] [background-size:22px_22px] opacity-60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto flex min-h-full w-full max-w-md items-center justify-center p-4 py-8">
|
||||||
|
<div className="w-full rounded-2xl border border-border/70 bg-card/90 p-8 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl sm:p-10">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4 flex justify-center">
|
<div className="mb-5 flex justify-center">
|
||||||
{logo ?? (
|
{logo ?? (
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
|
||||||
<MessageSquare className="h-8 w-8 text-primary-foreground" />
|
<img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
<h1 className="font-serif text-3xl font-bold tracking-tight text-foreground">{title}</h1>
|
||||||
<p className="mt-2 text-muted-foreground">{description}</p>
|
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
<div className="mt-8">{children}</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="mt-6 border-t border-border/60 pt-5 text-center">
|
||||||
<p className="text-sm text-muted-foreground">{footerText}</p>
|
<p className="text-xs leading-relaxed text-muted-foreground">{footerText}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!IS_PLATFORM && (
|
{!IS_PLATFORM && (
|
||||||
<div className="flex items-center justify-center gap-1.5 pt-2">
|
<div className="mt-4 flex items-center justify-center gap-1.5">
|
||||||
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Loader2, Lock, User } from 'lucide-react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import AuthErrorAlert from './AuthErrorAlert';
|
import AuthErrorAlert from './AuthErrorAlert';
|
||||||
import AuthInputField from './AuthInputField';
|
import AuthInputField from './AuthInputField';
|
||||||
@@ -69,6 +70,7 @@ export default function LoginForm() {
|
|||||||
placeholder={t('login.placeholders.username')}
|
placeholder={t('login.placeholders.username')}
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
icon={User}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -80,6 +82,7 @@ export default function LoginForm() {
|
|||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
icon={Lock}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthErrorAlert errorMessage={errorMessage} />
|
<AuthErrorAlert errorMessage={errorMessage} />
|
||||||
@@ -87,9 +90,16 @@ export default function LoginForm() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isSubmitting ? t('login.loading') : t('login.submit')}
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{t('login.loading')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('login.submit')
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthScreenLayout>
|
</AuthScreenLayout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
|
import { Loader2, Lock, ShieldCheck, User } from 'lucide-react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import AuthErrorAlert from './AuthErrorAlert';
|
import AuthErrorAlert from './AuthErrorAlert';
|
||||||
import AuthInputField from './AuthInputField';
|
import AuthInputField from './AuthInputField';
|
||||||
@@ -85,7 +86,6 @@ export default function SetupForm() {
|
|||||||
title="Welcome to CloudCLI"
|
title="Welcome to CloudCLI"
|
||||||
description="Set up your account to get started"
|
description="Set up your account to get started"
|
||||||
footerText="This is a single-user system. Only one account can be created."
|
footerText="This is a single-user system. Only one account can be created."
|
||||||
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}
|
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -94,9 +94,10 @@ export default function SetupForm() {
|
|||||||
label="Username"
|
label="Username"
|
||||||
value={formState.username}
|
value={formState.username}
|
||||||
onChange={(value) => updateField('username', value)}
|
onChange={(value) => updateField('username', value)}
|
||||||
placeholder="Enter your username"
|
placeholder="Choose a username"
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
icon={User}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -105,10 +106,11 @@ export default function SetupForm() {
|
|||||||
label="Password"
|
label="Password"
|
||||||
value={formState.password}
|
value={formState.password}
|
||||||
onChange={(value) => updateField('password', value)}
|
onChange={(value) => updateField('password', value)}
|
||||||
placeholder="Enter your password"
|
placeholder="Create a password"
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
icon={Lock}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -117,20 +119,33 @@ export default function SetupForm() {
|
|||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
value={formState.confirmPassword}
|
value={formState.confirmPassword}
|
||||||
onChange={(value) => updateField('confirmPassword', value)}
|
onChange={(value) => updateField('confirmPassword', value)}
|
||||||
placeholder="Confirm your password"
|
placeholder="Re-enter your password"
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
icon={ShieldCheck}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
At least 3 characters for username, 6 for password.
|
||||||
|
</p>
|
||||||
|
|
||||||
<AuthErrorAlert errorMessage={errorMessage} />
|
<AuthErrorAlert errorMessage={errorMessage} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Setting up...' : 'Create Account'}
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Setting up...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Account'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthScreenLayout>
|
</AuthScreenLayout>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Clock3,
|
Clock3,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
|
MousePointer2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings,
|
Settings,
|
||||||
Square,
|
Square,
|
||||||
@@ -19,9 +20,14 @@ import { Badge, Button } from '../../../shared/view/ui';
|
|||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import type { SettingsMainTab } from '../../settings/types/types';
|
import type { SettingsMainTab } from '../../settings/types/types';
|
||||||
|
|
||||||
|
const BROWSER_USE_GUIDE_URL = 'https://cloudcli.ai/docs/browser-use';
|
||||||
|
const BROWSER_USE_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
type BrowserUseStatus = {
|
type BrowserUseStatus = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
backend: 'playwright' | 'camoufox-vnc';
|
||||||
|
browserBackend: 'playwright' | 'camoufox-vnc';
|
||||||
playwrightInstalled: boolean;
|
playwrightInstalled: boolean;
|
||||||
chromiumInstalled: boolean;
|
chromiumInstalled: boolean;
|
||||||
installInProgress: boolean;
|
installInProgress: boolean;
|
||||||
@@ -39,6 +45,9 @@ type BrowserUseSession = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastAction: string | null;
|
lastAction: string | null;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
|
backend?: 'playwright' | 'camoufox-vnc';
|
||||||
|
viewerUrl?: string | null;
|
||||||
|
viewerEmbedUrl?: string | null;
|
||||||
createdBy: 'agent';
|
createdBy: 'agent';
|
||||||
profileName: string | null;
|
profileName: string | null;
|
||||||
viewport: {
|
viewport: {
|
||||||
@@ -54,17 +63,48 @@ type BrowserUseSession = {
|
|||||||
|
|
||||||
type BrowserUsePanelProps = {
|
type BrowserUsePanelProps = {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
projectId?: string | null;
|
||||||
onShowSettings?: (tab?: SettingsMainTab) => void;
|
onShowSettings?: (tab?: SettingsMainTab) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BrowserUsePanelCacheEntry = {
|
||||||
|
status: BrowserUseStatus | null;
|
||||||
|
sessions: BrowserUseSession[];
|
||||||
|
selectedSessionId: string | null;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const browserUsePanelCache = new Map<string, BrowserUsePanelCacheEntry>();
|
||||||
|
|
||||||
async function readJson<T>(response: Response): Promise<T> {
|
async function readJson<T>(response: Response): Promise<T> {
|
||||||
const data = await response.json();
|
const text = await response.text();
|
||||||
|
let data: any = {};
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(response.ok ? 'Received an invalid Browser response.' : `Browser request failed (${response.status}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!response.ok || data.success === false) {
|
if (!response.ok || data.success === false) {
|
||||||
throw new Error(data.error || data.details || `Request failed (${response.status})`);
|
throw new Error(data.error || data.details || `Request failed (${response.status})`);
|
||||||
}
|
}
|
||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchBrowserPanelData() {
|
||||||
|
const [statusResponse, sessionsResponse] = await Promise.all([
|
||||||
|
authenticatedFetch('/api/browser-use/status'),
|
||||||
|
authenticatedFetch('/api/browser-use/sessions'),
|
||||||
|
]);
|
||||||
|
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
|
||||||
|
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
|
||||||
|
return {
|
||||||
|
status: statusData.data,
|
||||||
|
sessions: [...sessionsData.data.sessions].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelativeTime(value: string | null): string {
|
function formatRelativeTime(value: string | null): string {
|
||||||
if (!value) return 'Never';
|
if (!value) return 'Never';
|
||||||
|
|
||||||
@@ -119,20 +159,42 @@ function getStatusDot(status: BrowserUseSession['status']): string {
|
|||||||
return 'bg-border';
|
return 'bg-border';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEngineLabel(backend?: BrowserUseStatus['backend'] | BrowserUseSession['backend']): string {
|
||||||
|
return backend === 'camoufox-vnc' ? 'Visible browser' : 'Playwright';
|
||||||
|
}
|
||||||
|
|
||||||
const PROMPTS = [
|
const PROMPTS = [
|
||||||
'Use Browser to inspect the checkout flow and report any broken UI states.',
|
'Use Browser to inspect the checkout flow and report any broken UI states.',
|
||||||
'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
|
'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUsePanelProps) {
|
function getBrowserUseCacheKey(projectId?: string | null): string {
|
||||||
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
|
return projectId ? `browser-use:project:${projectId}` : 'browser-use:global';
|
||||||
const [sessions, setSessions] = useState<BrowserUseSession[]>([]);
|
}
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
|
||||||
|
function getFreshCacheEntry(cacheKey: string): BrowserUsePanelCacheEntry | null {
|
||||||
|
const entry = browserUsePanelCache.get(cacheKey);
|
||||||
|
if (!entry || Date.now() - entry.updatedAt > BROWSER_USE_CACHE_TTL_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BrowserUsePanel({ isVisible, projectId, onShowSettings }: BrowserUsePanelProps) {
|
||||||
|
const cacheKey = getBrowserUseCacheKey(projectId);
|
||||||
|
const initialCacheEntry = getFreshCacheEntry(cacheKey);
|
||||||
|
const [status, setStatus] = useState<BrowserUseStatus | null>(() => initialCacheEntry?.status ?? null);
|
||||||
|
const [sessions, setSessions] = useState<BrowserUseSession[]>(() => initialCacheEntry?.sessions ?? []);
|
||||||
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(() => (
|
||||||
|
initialCacheEntry?.selectedSessionId || initialCacheEntry?.sessions[0]?.id || null
|
||||||
|
));
|
||||||
|
const [hasLoadedOnce, setHasLoadedOnce] = useState(Boolean(initialCacheEntry));
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const activeLoadIdRef = useRef(0);
|
||||||
|
|
||||||
const selectedSession = useMemo(
|
const selectedSession = useMemo(
|
||||||
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
|
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
|
||||||
@@ -140,8 +202,12 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
);
|
);
|
||||||
|
|
||||||
const activeSessions = sessions.filter((session) => session.status === 'ready');
|
const activeSessions = sessions.filter((session) => session.status === 'ready');
|
||||||
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
|
const isInitialLoading = isRefreshing && !hasLoadedOnce && sessions.length === 0;
|
||||||
const runtimeLabel = !status?.enabled
|
const isBackgroundRefreshing = isRefreshing && !isInitialLoading;
|
||||||
|
const needsBrowserBinaries = Boolean(status?.enabled && !status.available);
|
||||||
|
const runtimeLabel = isInitialLoading
|
||||||
|
? 'Loading'
|
||||||
|
: !status?.enabled
|
||||||
? 'Disabled'
|
? 'Disabled'
|
||||||
: status.available
|
: status.available
|
||||||
? 'Ready'
|
? 'Ready'
|
||||||
@@ -157,29 +223,72 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
const loadId = activeLoadIdRef.current + 1;
|
||||||
|
activeLoadIdRef.current = loadId;
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const [statusResponse, sessionsResponse] = await Promise.all([
|
let nextData: Awaited<ReturnType<typeof fetchBrowserPanelData>>;
|
||||||
authenticatedFetch('/api/browser-use/status'),
|
try {
|
||||||
authenticatedFetch('/api/browser-use/sessions'),
|
nextData = await fetchBrowserPanelData();
|
||||||
]);
|
} catch (error) {
|
||||||
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
|
if (loadId !== activeLoadIdRef.current) {
|
||||||
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
|
return;
|
||||||
const nextSessions = sessionsData.data.sessions;
|
}
|
||||||
setStatus(statusData.data);
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
nextData = await fetchBrowserPanelData();
|
||||||
|
}
|
||||||
|
if (activeLoadIdRef.current !== loadId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextSessions = nextData.sessions;
|
||||||
|
setStatus(nextData.status);
|
||||||
setSessions(nextSessions);
|
setSessions(nextSessions);
|
||||||
setSelectedSessionId((current) => (
|
setHasLoadedOnce(true);
|
||||||
current && nextSessions.some((session) => session.id === current)
|
let nextSelectedSessionId: string | null = null;
|
||||||
|
setSelectedSessionId((current) => {
|
||||||
|
nextSelectedSessionId = current && nextSessions.some((session) => session.id === current)
|
||||||
? current
|
? current
|
||||||
: nextSessions[0]?.id || null
|
: nextSessions[0]?.id || null;
|
||||||
));
|
return nextSelectedSessionId;
|
||||||
|
});
|
||||||
|
browserUsePanelCache.set(cacheKey, {
|
||||||
|
status: nextData.status,
|
||||||
|
sessions: nextSessions,
|
||||||
|
selectedSessionId: nextSelectedSessionId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (activeLoadIdRef.current !== loadId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHasLoadedOnce(true);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load Browser');
|
setError(err instanceof Error ? err.message : 'Failed to load Browser');
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
if (activeLoadIdRef.current === loadId) {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [cacheKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cachedEntry = browserUsePanelCache.get(cacheKey);
|
||||||
|
if (!cachedEntry) return;
|
||||||
|
browserUsePanelCache.set(cacheKey, {
|
||||||
|
...cachedEntry,
|
||||||
|
selectedSessionId,
|
||||||
|
});
|
||||||
|
}, [cacheKey, selectedSessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cachedEntry = getFreshCacheEntry(cacheKey);
|
||||||
|
setStatus(cachedEntry?.status ?? null);
|
||||||
|
setSessions(cachedEntry?.sessions ?? []);
|
||||||
|
setSelectedSessionId(cachedEntry?.selectedSessionId || cachedEntry?.sessions[0]?.id || null);
|
||||||
|
setHasLoadedOnce(Boolean(cachedEntry));
|
||||||
|
setError(null);
|
||||||
|
activeLoadIdRef.current += 1;
|
||||||
|
}, [cacheKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return;
|
if (!isVisible) return;
|
||||||
@@ -253,6 +362,10 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
<span>{formatRelativeTime(session.updatedAt)}</span>
|
<span>{formatRelativeTime(session.updatedAt)}</span>
|
||||||
<span className="truncate">- {formatAction(session.lastAction)}</span>
|
<span className="truncate">- {formatAction(session.lastAction)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5 pl-3.5 text-[10px] text-muted-foreground">
|
||||||
|
<span className="rounded border border-border/70 bg-background/70 px-1.5 py-0.5">{getEngineLabel(session.backend)}</span>
|
||||||
|
<span className="rounded border border-border/70 bg-background/70 px-1.5 py-0.5">{session.profileName || 'Temporary'}</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -270,9 +383,18 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
|
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
|
||||||
{status?.enabled
|
{status?.enabled
|
||||||
? 'Agent browser sessions appear here while an AI task is using Browser.'
|
? 'When an agent opens a browser, you can watch the latest screenshot, take control in a new tab, or end the running session.'
|
||||||
: 'Enable Browser in settings to let agents open monitored browser sessions.'}
|
: 'Enable Browser to let agents open websites, test flows, capture screenshots, and debug UI from a real page.'}
|
||||||
</p>
|
</p>
|
||||||
|
<a
|
||||||
|
href={BROWSER_USE_GUIDE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Read the Browser guide
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -312,10 +434,19 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderLoadingState = () => (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
|
||||||
|
<div className="flex items-center gap-3 rounded-md border border-border bg-card/40 px-4 py-3 text-sm text-muted-foreground shadow-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
Loading browser sessions...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const renderBrowserSurface = (fullscreen = false) => (
|
const renderBrowserSurface = (fullscreen = false) => (
|
||||||
<div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
|
<div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
|
||||||
{selectedSession?.screenshotDataUrl ? (
|
{selectedSession?.screenshotDataUrl ? (
|
||||||
<div className="relative inline-block max-h-full">
|
<div className="group relative inline-block max-h-full">
|
||||||
<img
|
<img
|
||||||
src={selectedSession.screenshotDataUrl}
|
src={selectedSession.screenshotDataUrl}
|
||||||
alt="Browser session screenshot"
|
alt="Browser session screenshot"
|
||||||
@@ -329,6 +460,18 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
|
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedSession?.viewerEmbedUrl && selectedSession.status === 'ready' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.open(selectedSession.viewerUrl || selectedSession.viewerEmbedUrl || '', '_blank', 'noopener,noreferrer')}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition focus-visible:bg-black/30 focus-visible:opacity-100 focus-visible:outline-none group-hover:bg-black/30 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-black/80 px-3 py-2 text-sm font-medium text-white shadow-lg">
|
||||||
|
<MousePointer2 className="h-4 w-4" />
|
||||||
|
Take control
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-6 text-center">
|
<div className="px-6 text-center">
|
||||||
@@ -350,10 +493,29 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
|
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
|
||||||
{runtimeLabel}
|
{runtimeLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-border bg-background text-[10px] text-muted-foreground">
|
||||||
|
{getEngineLabel(status?.backend)}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">Monitor browser sessions opened by AI agents.</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">Watch and manage browser sessions agents use to test real websites.</p>
|
||||||
|
{isBackgroundRefreshing && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||||
|
Refreshing sessions...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => window.open(BROWSER_USE_GUIDE_URL, '_blank', 'noopener,noreferrer')}
|
||||||
|
title="Open Browser guide"
|
||||||
|
aria-label="Open Browser guide"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
{onShowSettings && (
|
{onShowSettings && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -425,7 +587,7 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
renderEmptyState()
|
isInitialLoading ? renderLoadingState() : renderEmptyState()
|
||||||
) : (
|
) : (
|
||||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
|
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
|
||||||
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
|
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
|
||||||
@@ -441,14 +603,32 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
|
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5 text-[10px] text-muted-foreground">
|
||||||
|
<span className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5">{getEngineLabel(selectedSession?.backend || status?.backend)}</span>
|
||||||
|
<span className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5">Profile: {selectedSession?.profileName || 'Temporary'}</span>
|
||||||
|
<span className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5">Updated {formatRelativeTime(selectedSession?.updatedAt || null)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden text-xs text-muted-foreground md:block">
|
<div className="hidden text-xs text-muted-foreground md:block">
|
||||||
{formatAction(selectedSession?.lastAction || null)}
|
{formatAction(selectedSession?.lastAction || null)}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedSession?.viewerUrl && selectedSession.status === 'ready' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => window.open(selectedSession.viewerUrl || '', '_blank', 'noopener,noreferrer')}
|
||||||
|
title="Open live browser control in a new tab"
|
||||||
|
aria-label="Open live browser control in a new tab"
|
||||||
|
>
|
||||||
|
<MousePointer2 className="h-4 w-4" />
|
||||||
|
Take control
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
|
||||||
<Expand className="h-4 w-4" />
|
<Expand className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="Stop session" aria-label="Stop session">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="End session" aria-label="End session">
|
||||||
<Square className="h-4 w-4" />
|
<Square className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
|
||||||
@@ -475,6 +655,11 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
{sessions.length > 0 ? (
|
{sessions.length > 0 ? (
|
||||||
<div className="space-y-2">{sessions.map(renderSessionItem)}</div>
|
<div className="space-y-2">{sessions.map(renderSessionItem)}</div>
|
||||||
|
) : isInitialLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Loading sessions...
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
|
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
|
||||||
No agent browser sessions.
|
No agent browser sessions.
|
||||||
@@ -505,7 +690,7 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
|
|||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
|
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
|
||||||
<Square className="h-4 w-4" />
|
<Square className="h-4 w-4" />
|
||||||
Stop
|
End
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
|
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface UseChatSessionStateArgs {
|
|||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
autoScrollToBottom?: boolean;
|
|
||||||
externalMessageUpdate?: number;
|
externalMessageUpdate?: number;
|
||||||
newSessionTrigger?: number;
|
newSessionTrigger?: number;
|
||||||
processingSessions?: SessionActivityMap;
|
processingSessions?: SessionActivityMap;
|
||||||
@@ -96,7 +95,6 @@ export function useChatSessionState({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
autoScrollToBottom,
|
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
@@ -121,6 +119,7 @@ export function useChatSessionState({
|
|||||||
const [viewHiddenCount, setViewHiddenCount] = useState(0);
|
const [viewHiddenCount, setViewHiddenCount] = useState(0);
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wasNearTopRef = useRef(false);
|
||||||
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
|
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
|
||||||
const searchScrollActiveRef = useRef(false);
|
const searchScrollActiveRef = useRef(false);
|
||||||
const isLoadingSessionRef = useRef(false);
|
const isLoadingSessionRef = useRef(false);
|
||||||
@@ -185,6 +184,7 @@ export function useChatSessionState({
|
|||||||
setShowLoadAllOverlay(false);
|
setShowLoadAllOverlay(false);
|
||||||
setViewHiddenCount(0);
|
setViewHiddenCount(0);
|
||||||
setSearchTarget(null);
|
setSearchTarget(null);
|
||||||
|
wasNearTopRef.current = false;
|
||||||
searchScrollActiveRef.current = false;
|
searchScrollActiveRef.current = false;
|
||||||
topLoadLockRef.current = false;
|
topLoadLockRef.current = false;
|
||||||
pendingScrollRestoreRef.current = null;
|
pendingScrollRestoreRef.current = null;
|
||||||
@@ -336,12 +336,34 @@ export function useChatSessionState({
|
|||||||
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
||||||
limit: MESSAGES_PER_PAGE,
|
limit: MESSAGES_PER_PAGE,
|
||||||
});
|
});
|
||||||
if (!slot || slot.serverMessages.length === 0) return false;
|
if (!slot) return false;
|
||||||
|
if (slot.serverMessages.length === 0) {
|
||||||
|
if (!slot.hasMore) {
|
||||||
|
setHasMoreMessages(false);
|
||||||
|
allMessagesLoadedRef.current = true;
|
||||||
|
setAllMessagesLoaded(true);
|
||||||
|
if (loadAllOverlayTimerRef.current) {
|
||||||
|
clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
loadAllOverlayTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setShowLoadAllOverlay(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
|
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
|
||||||
setHasMoreMessages(slot.hasMore);
|
setHasMoreMessages(slot.hasMore);
|
||||||
setTotalMessages(slot.total);
|
setTotalMessages(slot.total);
|
||||||
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
|
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
|
||||||
|
if (!slot.hasMore) {
|
||||||
|
allMessagesLoadedRef.current = true;
|
||||||
|
setAllMessagesLoaded(true);
|
||||||
|
if (loadAllOverlayTimerRef.current) {
|
||||||
|
clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
loadAllOverlayTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setShowLoadAllOverlay(false);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingMoreRef.current = false;
|
isLoadingMoreRef.current = false;
|
||||||
@@ -357,8 +379,25 @@ export function useChatSessionState({
|
|||||||
const nearBottom = isNearBottom();
|
const nearBottom = isNearBottom();
|
||||||
setIsUserScrolledUp(!nearBottom);
|
setIsUserScrolledUp(!nearBottom);
|
||||||
|
|
||||||
|
const scrolledNearTop = container.scrollTop < 100;
|
||||||
|
|
||||||
|
// "Load all" prompt: appear (with fade-in) when the user reaches the top
|
||||||
|
if (scrolledNearTop && hasMoreMessages && !allMessagesLoadedRef.current) {
|
||||||
|
if (!wasNearTopRef.current) {
|
||||||
|
wasNearTopRef.current = true;
|
||||||
|
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
|
||||||
|
setShowLoadAllOverlay(true);
|
||||||
|
loadAllOverlayTimerRef.current = setTimeout(() => {
|
||||||
|
setShowLoadAllOverlay(false);
|
||||||
|
loadAllOverlayTimerRef.current = null;
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
} else if (!scrolledNearTop) {
|
||||||
|
wasNearTopRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!allMessagesLoadedRef.current) {
|
if (!allMessagesLoadedRef.current) {
|
||||||
const scrolledNearTop = container.scrollTop < 100;
|
|
||||||
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
|
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
|
||||||
if (topLoadLockRef.current) {
|
if (topLoadLockRef.current) {
|
||||||
if (container.scrollTop > 20) topLoadLockRef.current = false;
|
if (container.scrollTop > 20) topLoadLockRef.current = false;
|
||||||
@@ -367,7 +406,7 @@ export function useChatSessionState({
|
|||||||
const didLoad = await loadOlderMessages(container);
|
const didLoad = await loadOlderMessages(container);
|
||||||
if (didLoad) topLoadLockRef.current = true;
|
if (didLoad) topLoadLockRef.current = true;
|
||||||
}
|
}
|
||||||
}, [isNearBottom, loadOlderMessages]);
|
}, [hasMoreMessages, isNearBottom, loadOlderMessages]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
|
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
|
||||||
@@ -386,6 +425,7 @@ export function useChatSessionState({
|
|||||||
}
|
}
|
||||||
topLoadLockRef.current = false;
|
topLoadLockRef.current = false;
|
||||||
pendingScrollRestoreRef.current = null;
|
pendingScrollRestoreRef.current = null;
|
||||||
|
wasNearTopRef.current = false;
|
||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||||
|
|
||||||
@@ -492,6 +532,7 @@ export function useChatSessionState({
|
|||||||
setLoadAllJustFinished(false);
|
setLoadAllJustFinished(false);
|
||||||
setShowLoadAllOverlay(false);
|
setShowLoadAllOverlay(false);
|
||||||
setViewHiddenCount(0);
|
setViewHiddenCount(0);
|
||||||
|
wasNearTopRef.current = false;
|
||||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
|
|
||||||
@@ -546,7 +587,7 @@ export function useChatSessionState({
|
|||||||
if (!isProcessing) {
|
if (!isProcessing) {
|
||||||
await sessionStore.refreshFromServer(selectedSession.id);
|
await sessionStore.refreshFromServer(selectedSession.id);
|
||||||
|
|
||||||
if (Boolean(autoScrollToBottom) && isNearBottom()) {
|
if (isNearBottom()) {
|
||||||
setTimeout(() => scrollToBottom(), 200);
|
setTimeout(() => scrollToBottom(), 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,7 +598,6 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
reloadExternalMessages();
|
reloadExternalMessages();
|
||||||
}, [
|
}, [
|
||||||
autoScrollToBottom,
|
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
isNearBottom,
|
isNearBottom,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
@@ -689,10 +729,9 @@ export function useChatSessionState({
|
|||||||
}, [chatMessages, visibleMessageCount]);
|
}, [chatMessages, visibleMessageCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoScrollToBottom && scrollContainerRef.current) {
|
const container = scrollContainerRef.current;
|
||||||
const container = scrollContainerRef.current;
|
if (!container) return;
|
||||||
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
|
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -700,8 +739,8 @@ export function useChatSessionState({
|
|||||||
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
|
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
|
||||||
if (searchScrollActiveRef.current) return;
|
if (searchScrollActiveRef.current) return;
|
||||||
|
|
||||||
if (autoScrollToBottom) {
|
if (!isUserScrolledUp) {
|
||||||
if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50);
|
setTimeout(() => scrollToBottom(), 50);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,7 +750,7 @@ export function useChatSessionState({
|
|||||||
const newHeight = container.scrollHeight;
|
const newHeight = container.scrollHeight;
|
||||||
const heightDiff = newHeight - prevHeight;
|
const heightDiff = newHeight - prevHeight;
|
||||||
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
|
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
|
||||||
}, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
}, [chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
@@ -720,23 +759,8 @@ export function useChatSessionState({
|
|||||||
return () => container.removeEventListener('scroll', handleScroll);
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
// "Load all" overlay
|
// "Load all" overlay visibility is driven by scroll-to-top in handleScroll;
|
||||||
const prevLoadingRef = useRef(false);
|
// timers are cleared on session change via the reset effect above.
|
||||||
useEffect(() => {
|
|
||||||
const wasLoading = prevLoadingRef.current;
|
|
||||||
prevLoadingRef.current = isLoadingMoreMessages;
|
|
||||||
|
|
||||||
if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) {
|
|
||||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
|
||||||
setShowLoadAllOverlay(true);
|
|
||||||
loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000);
|
|
||||||
}
|
|
||||||
if (!hasMoreMessages && !isLoadingMoreMessages) {
|
|
||||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
|
||||||
setShowLoadAllOverlay(false);
|
|
||||||
}
|
|
||||||
return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); };
|
|
||||||
}, [isLoadingMoreMessages, hasMoreMessages]);
|
|
||||||
|
|
||||||
const loadAllMessages = useCallback(async () => {
|
const loadAllMessages = useCallback(async () => {
|
||||||
if (!selectedSession || !selectedProject) return;
|
if (!selectedSession || !selectedProject) return;
|
||||||
@@ -746,6 +770,10 @@ export function useChatSessionState({
|
|||||||
isLoadingMoreRef.current = true;
|
isLoadingMoreRef.current = true;
|
||||||
setIsLoadingAllMessages(true);
|
setIsLoadingAllMessages(true);
|
||||||
setShowLoadAllOverlay(true);
|
setShowLoadAllOverlay(true);
|
||||||
|
if (loadAllOverlayTimerRef.current) {
|
||||||
|
clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
loadAllOverlayTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
const previousScrollHeight = container ? container.scrollHeight : 0;
|
const previousScrollHeight = container ? container.scrollHeight : 0;
|
||||||
@@ -772,7 +800,11 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
setLoadAllJustFinished(true);
|
setLoadAllJustFinished(true);
|
||||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);
|
loadAllFinishedTimerRef.current = setTimeout(() => {
|
||||||
|
setLoadAllJustFinished(false);
|
||||||
|
setShowLoadAllOverlay(false);
|
||||||
|
loadAllFinishedTimerRef.current = null;
|
||||||
|
}, 2500);
|
||||||
} else {
|
} else {
|
||||||
allMessagesLoadedRef.current = false;
|
allMessagesLoadedRef.current = false;
|
||||||
setShowLoadAllOverlay(false);
|
setShowLoadAllOverlay(false);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ interface ToolRendererProps {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||||
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
|
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
rawToolInput?: string;
|
rawToolInput?: string;
|
||||||
isSubagentContainer?: boolean;
|
isSubagentContainer?: boolean;
|
||||||
@@ -80,7 +79,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
createDiff,
|
createDiff,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
autoExpandTools = false,
|
|
||||||
showRawParameters = false,
|
showRawParameters = false,
|
||||||
rawToolInput,
|
rawToolInput,
|
||||||
isSubagentContainer,
|
isSubagentContainer,
|
||||||
@@ -151,8 +149,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
output={output}
|
output={output}
|
||||||
isError={Boolean(toolResult?.isError)}
|
isError={Boolean(toolResult?.isError)}
|
||||||
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
||||||
// Commands stay collapsed by default (even consecutive ones); only
|
// Commands stay collapsed by default; only failures auto-expand so they
|
||||||
// failures auto-expand so they remain visible.
|
// remain visible.
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -199,7 +197,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
<PlanDisplay
|
<PlanDisplay
|
||||||
title={title}
|
title={title}
|
||||||
content={contentProps.content || ''}
|
content={contentProps.content || ''}
|
||||||
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
|
defaultOpen={displayConfig.defaultOpen ?? false}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
showRawParameters={mode === 'input' && showRawParameters}
|
showRawParameters={mode === 'input' && showRawParameters}
|
||||||
rawContent={rawToolInput}
|
rawContent={rawToolInput}
|
||||||
@@ -216,7 +214,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
|
|
||||||
const defaultOpen = displayConfig.defaultOpen !== undefined
|
const defaultOpen = displayConfig.defaultOpen !== undefined
|
||||||
? displayConfig.defaultOpen
|
? displayConfig.defaultOpen
|
||||||
: autoExpandTools;
|
: false;
|
||||||
|
|
||||||
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
||||||
selectedProject,
|
selectedProject,
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
|
|||||||
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
||||||
: 'dark:hover:bg-gray-750/50 border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
|
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
@@ -277,7 +277,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
|
|||||||
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
||||||
isOtherOn
|
isOtherOn
|
||||||
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
||||||
: 'dark:hover:bg-gray-750/50 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
|
: 'border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
|
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
|
||||||
|
|||||||
@@ -126,10 +126,8 @@ export interface ChatInterfaceProps {
|
|||||||
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
autoScrollToBottom?: boolean;
|
|
||||||
sendByCtrlEnter?: boolean;
|
sendByCtrlEnter?: boolean;
|
||||||
externalMessageUpdate?: number;
|
externalMessageUpdate?: number;
|
||||||
newSessionTrigger?: number;
|
newSessionTrigger?: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ChatMessage } from '../types/types';
|
import type { ChatMessage } from '../types/types';
|
||||||
|
|
||||||
export const TOOL_GROUP_THRESHOLD = 3;
|
export const TOOL_GROUP_THRESHOLD = 2;
|
||||||
|
|
||||||
export interface ToolGroupItem {
|
export interface ToolGroupItem {
|
||||||
_isGroup: true;
|
_isGroup: true;
|
||||||
@@ -19,7 +19,17 @@ function isGroupableToolMessage(message: ChatMessage): message is ChatMessage &
|
|||||||
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
|
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] {
|
// Messages that render nothing (e.g. reasoning hidden when showThinking is off)
|
||||||
|
// shouldn't split an otherwise-continuous run of the same tool — providers like
|
||||||
|
// Codex interleave hidden reasoning between consecutive tool calls.
|
||||||
|
function rendersNothing(message: ChatMessage, showThinking: boolean): boolean {
|
||||||
|
return Boolean(message.isThinking && !showThinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupConsecutiveTools(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
showThinking: boolean = true,
|
||||||
|
): MessageListItem[] {
|
||||||
const items: MessageListItem[] = [];
|
const items: MessageListItem[] = [];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
@@ -35,13 +45,22 @@ export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[
|
|||||||
const run: ChatMessage[] = [message];
|
const run: ChatMessage[] = [message];
|
||||||
let nextIndex = index + 1;
|
let nextIndex = index + 1;
|
||||||
|
|
||||||
while (
|
while (nextIndex < messages.length) {
|
||||||
nextIndex < messages.length &&
|
const candidate = messages[nextIndex];
|
||||||
isGroupableToolMessage(messages[nextIndex]) &&
|
|
||||||
messages[nextIndex].toolName === message.toolName
|
// Skip invisible interleaved messages so they don't break the run.
|
||||||
) {
|
if (rendersNothing(candidate, showThinking)) {
|
||||||
run.push(messages[nextIndex]);
|
nextIndex += 1;
|
||||||
nextIndex += 1;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroupableToolMessage(candidate) && candidate.toolName === message.toolName) {
|
||||||
|
run.push(candidate);
|
||||||
|
nextIndex += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (run.length >= TOOL_GROUP_THRESHOLD) {
|
if (run.length >= TOOL_GROUP_THRESHOLD) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowDownIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||||
@@ -30,10 +31,8 @@ function ChatInterface({
|
|||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onSessionEstablished,
|
onSessionEstablished,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
autoExpandTools,
|
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
showThinking,
|
showThinking,
|
||||||
autoScrollToBottom,
|
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
@@ -124,7 +123,6 @@ function ChatInterface({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
autoScrollToBottom,
|
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
@@ -185,7 +183,7 @@ function ChatInterface({
|
|||||||
handlePermissionDecision,
|
handlePermissionDecision,
|
||||||
handleGrantToolPermission,
|
handleGrantToolPermission,
|
||||||
handleInputFocusChange,
|
handleInputFocusChange,
|
||||||
isInputFocused: _isInputFocused,
|
isInputFocused,
|
||||||
commandModalPayload,
|
commandModalPayload,
|
||||||
closeCommandModal,
|
closeCommandModal,
|
||||||
showCostModal,
|
showCostModal,
|
||||||
@@ -356,13 +354,27 @@ function ChatInterface({
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
onGrantToolPermission={handleGrantToolPermission}
|
onGrantToolPermission={handleGrantToolPermission}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatComposer
|
<div className="relative flex-shrink-0">
|
||||||
|
{isUserScrolledUp && chatMessages.length > 0 && (
|
||||||
|
<div className="pointer-events-none absolute -top-11 left-0 right-0 z-20 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={scrollToBottomAndReset}
|
||||||
|
aria-label={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
||||||
|
className="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
|
||||||
|
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="h-4 w-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChatComposer
|
||||||
pendingPermissionRequests={pendingPermissionRequests}
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
handleGrantToolPermission={handleGrantToolPermission}
|
handleGrantToolPermission={handleGrantToolPermission}
|
||||||
@@ -377,9 +389,6 @@ function ChatInterface({
|
|||||||
onToggleCommandMenu={handleToggleCommandMenu}
|
onToggleCommandMenu={handleToggleCommandMenu}
|
||||||
hasInput={Boolean(input.trim())}
|
hasInput={Boolean(input.trim())}
|
||||||
onClearInput={handleClearInput}
|
onClearInput={handleClearInput}
|
||||||
isUserScrolledUp={isUserScrolledUp}
|
|
||||||
hasMessages={chatMessages.length > 0}
|
|
||||||
onScrollToBottom={scrollToBottomAndReset}
|
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isDragActive={isDragActive}
|
isDragActive={isDragActive}
|
||||||
attachedImages={attachedImages}
|
attachedImages={attachedImages}
|
||||||
@@ -414,6 +423,7 @@ function ChatInterface({
|
|||||||
onTextareaPaste={handlePaste}
|
onTextareaPaste={handlePaste}
|
||||||
onTextareaScrollSync={syncInputOverlayScroll}
|
onTextareaScrollSync={syncInputOverlayScroll}
|
||||||
onTextareaInput={handleTextareaInput}
|
onTextareaInput={handleTextareaInput}
|
||||||
|
isInputFocused={isInputFocused}
|
||||||
onInputFocusChange={handleInputFocusChange}
|
onInputFocusChange={handleInputFocusChange}
|
||||||
placeholder={t('input.placeholder', {
|
placeholder={t('input.placeholder', {
|
||||||
provider:
|
provider:
|
||||||
@@ -430,6 +440,7 @@ function ChatInterface({
|
|||||||
isTextareaExpanded={isTextareaExpanded}
|
isTextareaExpanded={isTextareaExpanded}
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<QuickSettingsPanel />
|
<QuickSettingsPanel />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
|||||||
type ActivityIndicatorProps = {
|
type ActivityIndicatorProps = {
|
||||||
activity: SessionActivity | null;
|
activity: SessionActivity | null;
|
||||||
onAbort?: () => void;
|
onAbort?: () => void;
|
||||||
|
isInputFocused?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_KEYS = [
|
const ACTION_KEYS = [
|
||||||
@@ -18,6 +19,7 @@ const ACTION_KEYS = [
|
|||||||
'claudeStatus.actions.reasoning',
|
'claudeStatus.actions.reasoning',
|
||||||
];
|
];
|
||||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||||
|
const EXIT_ANIMATION_MS = 220;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal response-in-progress indicator, in the spirit of the inline status
|
* Minimal response-in-progress indicator, in the spirit of the inline status
|
||||||
@@ -26,11 +28,31 @@ const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working',
|
|||||||
* session has an entry in the processing map; it disappears the instant that
|
* session has an entry in the processing map; it disappears the instant that
|
||||||
* entry is removed.
|
* entry is removed.
|
||||||
*/
|
*/
|
||||||
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const startedAt = activity?.startedAt ?? null;
|
const [renderedActivity, setRenderedActivity] = useState<SessionActivity | null>(activity);
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
const startedAt = renderedActivity?.startedAt ?? null;
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activity) {
|
||||||
|
setRenderedActivity(activity);
|
||||||
|
setIsExiting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renderedActivity) return;
|
||||||
|
|
||||||
|
setIsExiting(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setRenderedActivity(null);
|
||||||
|
setIsExiting(false);
|
||||||
|
}, EXIT_ANIMATION_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [activity, renderedActivity]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (startedAt === null) return;
|
if (startedAt === null) return;
|
||||||
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
||||||
@@ -39,10 +61,10 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [startedAt]);
|
}, [startedAt]);
|
||||||
|
|
||||||
if (!activity) return null;
|
if (!renderedActivity) return null;
|
||||||
|
|
||||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||||
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
const label = (renderedActivity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
||||||
.replace(/\.+$/, '');
|
.replace(/\.+$/, '');
|
||||||
|
|
||||||
const minutes = Math.floor(elapsedSeconds / 60);
|
const minutes = Math.floor(elapsedSeconds / 60);
|
||||||
@@ -50,19 +72,31 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
|
|||||||
const elapsedLabel = minutes < 1
|
const elapsedLabel = minutes < 1
|
||||||
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
||||||
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
||||||
|
const tabSurfaceClassName = [
|
||||||
|
'chat-activity-tab inline-flex h-8 items-center rounded-b-none rounded-t-lg border border-b-0 bg-card px-3 text-xs transition-all duration-200',
|
||||||
|
isInputFocused
|
||||||
|
? 'border-primary/30 shadow-[0_-1px_2px_hsl(var(--foreground)/0.08),1px_0_2px_hsl(var(--foreground)/0.06),-1px_0_2px_hsl(var(--foreground)/0.06)]'
|
||||||
|
: 'border-border/50 shadow-[0_-1px_1px_hsl(var(--foreground)/0.04),1px_0_1px_hsl(var(--foreground)/0.03),-1px_0_1px_hsl(var(--foreground)/0.03)]',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in mb-2 w-full duration-300">
|
<div
|
||||||
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
|
className={`pointer-events-none bg-transparent ${
|
||||||
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
isExiting ? 'chat-activity-exit' : 'chat-activity-enter'
|
||||||
<Shimmer className="text-xs font-medium">{`${label}…`}</Shimmer>
|
}`}
|
||||||
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
>
|
||||||
|
<div className="flex items-end justify-between gap-2">
|
||||||
|
<div className={`${tabSurfaceClassName} gap-2`}>
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||||
|
<Shimmer className="font-medium">{`${label}…`}</Shimmer>
|
||||||
|
<span className="tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{activity.canInterrupt && onAbort && (
|
{renderedActivity.canInterrupt && onAbort && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAbort}
|
onClick={onAbort}
|
||||||
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
className={`${tabSurfaceClassName} pointer-events-auto gap-1.5 text-muted-foreground hover:bg-card hover:text-destructive`}
|
||||||
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
||||||
>
|
>
|
||||||
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
RefObject,
|
RefObject,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
|
import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
||||||
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
||||||
@@ -68,9 +68,6 @@ interface ChatComposerProps {
|
|||||||
onToggleCommandMenu: () => void;
|
onToggleCommandMenu: () => void;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
onClearInput: () => void;
|
onClearInput: () => void;
|
||||||
isUserScrolledUp: boolean;
|
|
||||||
hasMessages: boolean;
|
|
||||||
onScrollToBottom: () => void;
|
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
|
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
|
||||||
isDragActive: boolean;
|
isDragActive: boolean;
|
||||||
attachedImages: File[];
|
attachedImages: File[];
|
||||||
@@ -101,6 +98,7 @@ interface ChatComposerProps {
|
|||||||
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
|
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
|
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
|
||||||
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||||
|
isInputFocused?: boolean;
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
onInputFocusChange?: (focused: boolean) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
isTextareaExpanded: boolean;
|
isTextareaExpanded: boolean;
|
||||||
@@ -122,9 +120,6 @@ export default function ChatComposer({
|
|||||||
onToggleCommandMenu,
|
onToggleCommandMenu,
|
||||||
hasInput,
|
hasInput,
|
||||||
onClearInput,
|
onClearInput,
|
||||||
isUserScrolledUp,
|
|
||||||
hasMessages,
|
|
||||||
onScrollToBottom,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isDragActive,
|
isDragActive,
|
||||||
attachedImages,
|
attachedImages,
|
||||||
@@ -155,6 +150,7 @@ export default function ChatComposer({
|
|||||||
onTextareaPaste,
|
onTextareaPaste,
|
||||||
onTextareaScrollSync,
|
onTextareaScrollSync,
|
||||||
onTextareaInput,
|
onTextareaInput,
|
||||||
|
isInputFocused = false,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
isTextareaExpanded,
|
isTextareaExpanded,
|
||||||
@@ -201,15 +197,18 @@ export default function ChatComposer({
|
|||||||
|
|
||||||
// Hide the thinking/status bar while any permission request is pending
|
// Hide the thinking/status bar while any permission request is pending
|
||||||
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
||||||
|
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-composer-shell flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
<div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
|
||||||
{!hasPendingPermissions && (
|
{!hasPendingPermissions && (
|
||||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 w-[calc(100%-1rem)] max-w-[54.25rem] -translate-x-1/2 translate-y-px bg-transparent sm:w-[calc(100%-2rem)]">
|
||||||
|
<ActivityIndicator activity={activity} onAbort={onAbortSession} isInputFocused={isInputFocused} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingPermissionRequests.length > 0 && (
|
{pendingPermissionRequests.length > 0 && (
|
||||||
<div className="mx-auto mb-3 max-w-4xl">
|
<div className="mx-auto mb-3 max-w-[54.25rem]">
|
||||||
<PermissionRequestsBanner
|
<PermissionRequestsBanner
|
||||||
pendingPermissionRequests={pendingPermissionRequests}
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
@@ -218,19 +217,7 @@ export default function ChatComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
|
{!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
|
||||||
{isUserScrolledUp && hasMessages && (
|
|
||||||
<div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onScrollToBottom}
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
|
|
||||||
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
|
||||||
>
|
|
||||||
<ArrowDownIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showFileDropdown && filteredFiles.length > 0 && (
|
{showFileDropdown && filteredFiles.length > 0 && (
|
||||||
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
||||||
{filteredFiles.map((file, index) => (
|
{filteredFiles.map((file, index) => (
|
||||||
@@ -271,7 +258,10 @@ export default function ChatComposer({
|
|||||||
<PromptInput
|
<PromptInput
|
||||||
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
|
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
|
||||||
status={isLoading ? 'streaming' : 'ready'}
|
status={isLoading ? 'streaming' : 'ready'}
|
||||||
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
|
className={[
|
||||||
|
isTextareaExpanded ? 'chat-input-expanded' : '',
|
||||||
|
hasActivityIndicator ? 'rounded-t-none' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
>
|
>
|
||||||
{isDragActive && (
|
{isDragActive && (
|
||||||
@@ -349,7 +339,7 @@ export default function ChatComposer({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onModeSwitch}
|
onClick={onModeSwitch}
|
||||||
className={`rounded-lg border p-2 text-xs font-medium transition-all duration-200 sm:px-2.5 sm:py-1 ${
|
className={`inline-flex h-8 items-center rounded-lg border px-2 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
|
||||||
permissionMode === 'default'
|
permissionMode === 'default'
|
||||||
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
|
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
|
||||||
: permissionMode === 'acceptEdits'
|
: permissionMode === 'acceptEdits'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { ChatMessage } from '../../types/types';
|
import type { ChatMessage } from '../../types/types';
|
||||||
@@ -15,6 +15,7 @@ import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping
|
|||||||
import MessageComponent from './MessageComponent';
|
import MessageComponent from './MessageComponent';
|
||||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||||
import ToolGroupContainer from './ToolGroupContainer';
|
import ToolGroupContainer from './ToolGroupContainer';
|
||||||
|
import LoadAllMessagesOverlay from './LoadAllMessagesOverlay';
|
||||||
|
|
||||||
interface ChatMessagesPaneProps {
|
interface ChatMessagesPaneProps {
|
||||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||||
@@ -61,7 +62,6 @@ interface ChatMessagesPaneProps {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject: Project;
|
selectedProject: Project;
|
||||||
@@ -111,48 +111,59 @@ function ChatMessagesPane({
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
onGrantToolPermission,
|
onGrantToolPermission,
|
||||||
autoExpandTools,
|
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
showThinking,
|
showThinking,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
}: ChatMessagesPaneProps) {
|
}: ChatMessagesPaneProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
const groupedVisibleMessages = useMemo(
|
||||||
const allocatedKeysRef = useRef<Set<string>>(new Set());
|
() => groupConsecutiveTools(visibleMessages, Boolean(showThinking)),
|
||||||
const generatedMessageKeyCounterRef = useRef(0);
|
[visibleMessages, showThinking],
|
||||||
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
|
);
|
||||||
|
|
||||||
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
|
// Stable, deterministic keys for the messages rendered this pass.
|
||||||
const getMessageKey = useCallback((message: ChatMessage) => {
|
//
|
||||||
const existingKey = messageKeyMapRef.current.get(message);
|
// `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store
|
||||||
if (existingKey) {
|
// update, so caching keys by object identity (or via a cross-render allocation
|
||||||
return existingKey;
|
// Set) minted a brand-new key for the *same* logical message on each prepend —
|
||||||
|
// remounting the whole list, which disconnects the scroll-restore anchor and
|
||||||
|
// reflows heights, jumping the viewport to the bottom. Deriving keys purely
|
||||||
|
// from this render's ordered messages (intrinsic key, disambiguated by
|
||||||
|
// occurrence index on collision) yields the same key for the same message
|
||||||
|
// order, so React preserves existing DOM nodes and component state on prepend.
|
||||||
|
const messageKeyMap = useMemo(() => {
|
||||||
|
const keys = new WeakMap<ChatMessage, string>();
|
||||||
|
const occurrences = new Map<string, number>();
|
||||||
|
const assign = (message: ChatMessage) => {
|
||||||
|
const intrinsicKey = getIntrinsicMessageKey(message) ?? 'message-generated';
|
||||||
|
const seen = occurrences.get(intrinsicKey) ?? 0;
|
||||||
|
occurrences.set(intrinsicKey, seen + 1);
|
||||||
|
keys.set(message, seen === 0 ? intrinsicKey : `${intrinsicKey}__${seen}`);
|
||||||
|
};
|
||||||
|
for (const item of groupedVisibleMessages) {
|
||||||
|
if (isToolGroupItem(item)) {
|
||||||
|
item.messages.forEach(assign);
|
||||||
|
} else {
|
||||||
|
assign(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return keys;
|
||||||
|
}, [groupedVisibleMessages]);
|
||||||
|
|
||||||
const intrinsicKey = getIntrinsicMessageKey(message);
|
const getMessageKey = useCallback(
|
||||||
let candidateKey = intrinsicKey;
|
(message: ChatMessage) =>
|
||||||
|
messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
|
||||||
if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {
|
[messageKeyMap],
|
||||||
do {
|
);
|
||||||
generatedMessageKeyCounterRef.current += 1;
|
|
||||||
candidateKey = intrinsicKey
|
|
||||||
? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
|
|
||||||
: `message-generated-${generatedMessageKeyCounterRef.current}`;
|
|
||||||
} while (allocatedKeysRef.current.has(candidateKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
allocatedKeysRef.current.add(candidateKey);
|
|
||||||
messageKeyMapRef.current.set(message, candidateKey);
|
|
||||||
return candidateKey;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
className="chat-messages-pane relative min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
className="chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3 sm:py-4"
|
||||||
>
|
>
|
||||||
|
<div className="mx-auto w-full max-w-[54.25rem] space-y-3 px-4 sm:space-y-4">
|
||||||
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
||||||
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
@@ -208,35 +219,13 @@ function ChatMessagesPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Floating "Load all messages" overlay */}
|
<LoadAllMessagesOverlay
|
||||||
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
|
showLoadAllOverlay={showLoadAllOverlay}
|
||||||
<div className="pointer-events-none sticky top-2 z-20 flex justify-center">
|
isLoadingAllMessages={isLoadingAllMessages}
|
||||||
{loadAllJustFinished ? (
|
loadAllJustFinished={loadAllJustFinished}
|
||||||
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
|
totalMessages={totalMessages}
|
||||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onLoadAllMessages={loadAllMessages}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
/>
|
||||||
</svg>
|
|
||||||
<span>{t('session.messages.allLoaded')}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
|
|
||||||
onClick={loadAllMessages}
|
|
||||||
disabled={isLoadingAllMessages}
|
|
||||||
>
|
|
||||||
{isLoadingAllMessages && (
|
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{isLoadingAllMessages
|
|
||||||
? t('session.messages.loadingAll')
|
|
||||||
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legacy message count indicator (for non-paginated view) */}
|
{/* Legacy message count indicator (for non-paginated view) */}
|
||||||
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
||||||
@@ -273,7 +262,6 @@ function ChatMessagesPane({
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
onGrantToolPermission={onGrantToolPermission}
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
@@ -294,7 +282,6 @@ function ChatMessagesPane({
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
onGrantToolPermission={onGrantToolPermission}
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
@@ -305,6 +292,7 @@ function ChatMessagesPane({
|
|||||||
})()}
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import type { CSSProperties } from 'react';
|
import { createPortal } from 'react-dom';
|
||||||
|
import type { CSSProperties, ReactElement } from 'react';
|
||||||
import {
|
import {
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -77,6 +78,7 @@ const namespaceAccentClasses: Record<string, string> = {
|
|||||||
|
|
||||||
const MENU_EDGE_GAP = 16;
|
const MENU_EDGE_GAP = 16;
|
||||||
const MENU_MAX_HEIGHT = 360;
|
const MENU_MAX_HEIGHT = 360;
|
||||||
|
const MENU_MIN_HEIGHT = 160;
|
||||||
|
|
||||||
const getCommandKey = (command: CommandMenuCommand) =>
|
const getCommandKey = (command: CommandMenuCommand) =>
|
||||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||||
@@ -92,8 +94,9 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return { position: 'fixed', top: '16px', left: '16px' };
|
return { position: 'fixed', top: '16px', left: '16px' };
|
||||||
}
|
}
|
||||||
|
const maxAnchorBottom = Math.max(MENU_EDGE_GAP, window.innerHeight - MENU_EDGE_GAP - MENU_MIN_HEIGHT);
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
|
||||||
return {
|
return {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: `${anchorBottom}px`,
|
bottom: `${anchorBottom}px`,
|
||||||
@@ -104,7 +107,7 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
|
|||||||
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
|
||||||
const clampedLeft = Math.max(
|
const clampedLeft = Math.max(
|
||||||
MENU_EDGE_GAP,
|
MENU_EDGE_GAP,
|
||||||
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
|
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
|
||||||
@@ -216,12 +219,14 @@ export default function CommandMenu({
|
|||||||
: ['builtin', 'skill', 'project', 'user', 'other'];
|
: ['builtin', 'skill', 'project', 'user', 'other'];
|
||||||
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
||||||
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
||||||
|
const renderInPortal = (node: ReactElement) =>
|
||||||
|
typeof document === 'undefined' ? node : createPortal(node, document.body);
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
return (
|
return renderInPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="command-menu command-menu-empty border border-gray-200 bg-white/95 text-sm text-gray-500 dark:border-gray-700/80 dark:bg-gray-900/95 dark:text-gray-400"
|
className="command-menu command-menu-empty border border-border bg-popover/95 text-sm text-muted-foreground"
|
||||||
style={{
|
style={{
|
||||||
...menuBaseStyle,
|
...menuBaseStyle,
|
||||||
...menuPosition,
|
...menuPosition,
|
||||||
@@ -237,20 +242,20 @@ export default function CommandMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return renderInPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Available commands"
|
aria-label="Available commands"
|
||||||
className="command-menu border border-gray-200/90 bg-white/95 text-gray-900 dark:border-slate-700/80 dark:bg-slate-950/95 dark:text-slate-100"
|
className="command-menu border border-border bg-popover/95 text-popover-foreground"
|
||||||
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
|
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
|
||||||
>
|
>
|
||||||
{orderedNamespaces.map((namespace) => (
|
{orderedNamespaces.map((namespace) => (
|
||||||
<div key={namespace} className="command-group">
|
<div key={namespace} className="command-group">
|
||||||
{orderedNamespaces.length > 1 && (
|
{orderedNamespaces.length > 1 && (
|
||||||
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400">
|
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<span>{namespaceLabels[namespace] || namespace}</span>
|
<span>{namespaceLabels[namespace] || namespace}</span>
|
||||||
<span className="rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
<span className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
{(groupedCommands[namespace] || []).length}
|
{(groupedCommands[namespace] || []).length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,15 +273,15 @@ export default function CommandMenu({
|
|||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
|
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
|
? 'border-primary/30 bg-primary/10 shadow-sm'
|
||||||
: 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80'
|
: 'border-transparent bg-transparent hover:border-border hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
||||||
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
|
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-sky-500 dark:bg-cyan-300" />
|
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
|
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
|
||||||
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
|
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
|
||||||
@@ -284,20 +289,20 @@ export default function CommandMenu({
|
|||||||
<div className="min-w-0 flex-1 pr-1">
|
<div className="min-w-0 flex-1 pr-1">
|
||||||
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
||||||
<span
|
<span
|
||||||
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50"
|
className="min-w-0 truncate font-mono text-[13px] font-semibold text-foreground"
|
||||||
title={command.name}
|
title={command.name}
|
||||||
>
|
>
|
||||||
{command.name}
|
{command.name}
|
||||||
</span>
|
</span>
|
||||||
{command.metadata?.type && (
|
{command.metadata?.type && (
|
||||||
<span className="command-metadata-badge shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
|
<span className="command-metadata-badge shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
|
||||||
{command.metadata.type}
|
{command.metadata.type}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{command.description && (
|
{command.description && (
|
||||||
<div
|
<div
|
||||||
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400"
|
className="truncate whitespace-nowrap text-[12px] leading-4 text-muted-foreground"
|
||||||
title={command.description}
|
title={command.description}
|
||||||
>
|
>
|
||||||
{command.description}
|
{command.description}
|
||||||
@@ -305,7 +310,7 @@ export default function CommandMenu({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-sky-200 bg-white text-sky-600 shadow-sm dark:border-cyan-400/30 dark:bg-slate-950 dark:text-cyan-200">
|
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-primary/30 bg-card text-primary shadow-sm">
|
||||||
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
|
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -565,46 +565,41 @@ export default function CommandResultModal({
|
|||||||
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 ${
|
className={`flex shrink-0 items-start justify-between gap-3 border-b border-border bg-popover ${
|
||||||
isModelsModal ? 'px-4 pb-3 pt-3 sm:px-5 sm:pb-4 sm:pt-4' : 'px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5'
|
isModelsModal ? 'px-4 py-3 sm:px-5 sm:py-4' : 'px-4 py-4 sm:px-6 sm:py-5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" />
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-1/2 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_58%)]" />
|
<div
|
||||||
|
className={`flex shrink-0 items-center justify-center rounded-xl border border-border bg-muted text-foreground ${
|
||||||
<div className="relative flex items-start justify-between gap-3">
|
isModelsModal ? 'h-9 w-9' : 'h-10 w-10'
|
||||||
<div className="flex min-w-0 items-start gap-3 sm:items-center">
|
}`}
|
||||||
<div
|
|
||||||
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
|
|
||||||
isModelsModal ? 'p-2.5' : 'p-3'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[12px] font-bold uppercase tracking-[0.22em] text-primary">
|
|
||||||
{activeMeta?.eyebrow}
|
|
||||||
</p>
|
|
||||||
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-xl sm:text-2xl' : 'text-xl sm:text-2xl'}`}>
|
|
||||||
{activeMeta?.title}
|
|
||||||
</p>
|
|
||||||
<p className={`mt-1 max-w-2xl ${isModelsModal ? 'text-sm leading-5 text-foreground/75' : 'text-sm leading-5 text-muted-foreground'}`}>
|
|
||||||
{activeMeta?.subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onClose}
|
|
||||||
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:bg-background/70 hover:text-foreground"
|
|
||||||
aria-label="Close command result modal"
|
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
|
||||||
</Button>
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{activeMeta?.eyebrow}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||||
|
{activeMeta?.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 max-w-2xl text-sm leading-5 text-muted-foreground">
|
||||||
|
{activeMeta?.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8 shrink-0 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Close command result modal"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const loadAllOverlayAnimationStyle = `
|
||||||
|
@keyframes loadAllOverlayAutoFade {
|
||||||
|
0%, 80% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.load-all-overlay-auto-fade {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface LoadAllMessagesOverlayProps {
|
||||||
|
showLoadAllOverlay: boolean;
|
||||||
|
isLoadingAllMessages: boolean;
|
||||||
|
loadAllJustFinished: boolean;
|
||||||
|
totalMessages: number;
|
||||||
|
onLoadAllMessages: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadAllMessagesOverlay({
|
||||||
|
showLoadAllOverlay,
|
||||||
|
isLoadingAllMessages,
|
||||||
|
loadAllJustFinished,
|
||||||
|
totalMessages,
|
||||||
|
onLoadAllMessages,
|
||||||
|
}: LoadAllMessagesOverlayProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
|
if (!showLoadAllOverlay && !isLoadingAllMessages && !loadAllJustFinished) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none sticky top-2 z-20 flex justify-center ${!isLoadingAllMessages ? 'load-all-overlay-auto-fade' : ''}`}
|
||||||
|
style={!isLoadingAllMessages ? { animation: 'loadAllOverlayAutoFade 2500ms ease forwards' } : undefined}
|
||||||
|
>
|
||||||
|
<style>{loadAllOverlayAnimationStyle}</style>
|
||||||
|
{loadAllJustFinished ? (
|
||||||
|
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
|
||||||
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span>{t('session.messages.allLoaded')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||||
|
onClick={onLoadAllMessages}
|
||||||
|
disabled={isLoadingAllMessages}
|
||||||
|
>
|
||||||
|
{isLoadingAllMessages && (
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{isLoadingAllMessages
|
||||||
|
? t('session.messages.loadingAll')
|
||||||
|
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import remarkGfm from 'remark-gfm';
|
|||||||
import remarkMath from 'remark-math';
|
import remarkMath from 'remark-math';
|
||||||
import rehypeKatex from 'rehype-katex';
|
import rehypeKatex from 'rehype-katex';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
||||||
|
import { useTheme } from '../../../../contexts/ThemeContext';
|
||||||
|
|
||||||
type MarkdownProps = {
|
type MarkdownProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -59,6 +60,7 @@ type CodeBlockProps = {
|
|||||||
|
|
||||||
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
|
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
const { isDarkMode } = useTheme();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||||
const looksMultiline = /[\r\n]/.test(raw);
|
const looksMultiline = /[\r\n]/.test(raw);
|
||||||
@@ -96,7 +98,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100"
|
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted focus:opacity-100 active:opacity-100 group-hover:opacity-100"
|
||||||
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||||
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||||
>
|
>
|
||||||
@@ -132,17 +134,20 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
|||||||
|
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={language}
|
language={language}
|
||||||
style={oneDark}
|
style={isDarkMode ? oneDark : oneLight}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.75rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||||
|
// ChatGPT-style soft grey block in light mode; keep oneDark's own bg in dark.
|
||||||
|
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
|
||||||
}}
|
}}
|
||||||
codeTagProps={{
|
codeTagProps={{
|
||||||
style: {
|
style: {
|
||||||
fontFamily:
|
fontFamily:
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||||
|
...(isDarkMode ? {} : { background: 'transparent' }),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -154,6 +159,10 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
|||||||
|
|
||||||
const markdownComponents = {
|
const markdownComponents = {
|
||||||
code: CodeBlock,
|
code: CodeBlock,
|
||||||
|
// CodeBlock renders its own syntax-highlighted <pre>; this passthrough stops
|
||||||
|
// react-markdown (and Tailwind Typography) from wrapping it in a second,
|
||||||
|
// dark-themed <pre> shell that would frame the block.
|
||||||
|
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||||
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
@@ -30,7 +30,6 @@ type MessageComponentProps = {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
@@ -45,7 +44,7 @@ type InteractiveOption = {
|
|||||||
|
|
||||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||||
|
|
||||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
((prevMessage.type === 'assistant') ||
|
((prevMessage.type === 'assistant') ||
|
||||||
@@ -53,7 +52,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
(prevMessage.type === 'tool') ||
|
(prevMessage.type === 'tool') ||
|
||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const userCopyContent = String(message.content || '');
|
const userCopyContent = String(message.content || '');
|
||||||
const formattedMessageContent = useMemo(
|
const formattedMessageContent = useMemo(
|
||||||
() => formatUsageLimitText(String(message.content || '')),
|
() => formatUsageLimitText(String(message.content || '')),
|
||||||
@@ -72,32 +70,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
!message.isThinking;
|
!message.isThinking;
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const node = messageRef.current;
|
|
||||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting && !isExpanded) {
|
|
||||||
setIsExpanded(true);
|
|
||||||
const details = node.querySelectorAll<HTMLDetailsElement>('details');
|
|
||||||
details.forEach((detail) => {
|
|
||||||
detail.open = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(node);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.unobserve(node);
|
|
||||||
};
|
|
||||||
}, [autoExpandTools, isExpanded, message.isToolUse]);
|
|
||||||
|
|
||||||
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
|
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
|
||||||
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
|
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
|
||||||
|
|
||||||
@@ -115,7 +87,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
/* User message bubble on the right */
|
/* User message bubble on the right */
|
||||||
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||||
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
||||||
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
{message.images && message.images.length > 0 && (
|
{message.images && message.images.length > 0 && (
|
||||||
@@ -166,7 +138,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
🔧
|
🔧
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-foreground">
|
||||||
<SessionProviderLogo provider={provider} className="h-full w-full" />
|
<SessionProviderLogo provider={provider} className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -194,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm max-w-none font-serif dark:prose-invert">
|
||||||
{String(message.displayText || '')}
|
{String(message.displayText || '')}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +182,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
createDiff={createDiff}
|
createDiff={createDiff}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
|
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
|
||||||
isSubagentContainer={message.isSubagentContainer}
|
isSubagentContainer={message.isSubagentContainer}
|
||||||
@@ -233,7 +204,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
|
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative text-sm text-red-900 dark:text-red-100">
|
<div className="relative text-sm text-red-900 dark:text-red-100">
|
||||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-red max-w-none font-serif dark:prose-invert">
|
||||||
{String(message.toolResult.content || '')}
|
{String(message.toolResult.content || '')}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,7 +221,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
createDiff={createDiff}
|
createDiff={createDiff}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -342,7 +312,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
<Reasoning defaultOpen={false}>
|
<Reasoning defaultOpen={false}>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
|
||||||
{message.content}
|
{message.content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
<div className="mt-3 flex items-center text-[11px]">
|
<div className="mt-3 flex items-center text-[11px]">
|
||||||
@@ -377,15 +347,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2">
|
<div className="my-2">
|
||||||
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{t('json.response')}</span>
|
<span className="font-medium">{t('json.response')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900">
|
<div className="overflow-hidden rounded-lg border border-border bg-muted">
|
||||||
<pre className="overflow-x-auto p-4">
|
<pre className="overflow-x-auto p-4">
|
||||||
<code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200">
|
<code className="block whitespace-pre font-mono text-sm text-foreground">
|
||||||
{formatted}
|
{formatted}
|
||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -399,7 +369,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
|
|
||||||
// Normal rendering for non-JSON content
|
// Normal rendering for non-JSON content
|
||||||
return message.type === 'assistant' ? (
|
return message.type === 'assistant' ? (
|
||||||
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
|
||||||
{content}
|
{content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
|
|
||||||
@@ -49,9 +51,32 @@ const MessageCopyControl = ({
|
|||||||
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
|
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// The dropdown is rendered in a portal so it escapes the chat message's
|
||||||
|
// `contain: paint` box (which would otherwise clip it). Anchor it to the
|
||||||
|
// trigger, flipping above when there isn't room below.
|
||||||
|
const openDropdown = () => {
|
||||||
|
const rect = triggerRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) {
|
||||||
|
const ESTIMATED_MENU_HEIGHT = 84;
|
||||||
|
const openUp = rect.bottom + ESTIMATED_MENU_HEIGHT + 8 > window.innerHeight;
|
||||||
|
setMenuStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
right: Math.max(8, window.innerWidth - rect.right),
|
||||||
|
zIndex: 1000,
|
||||||
|
...(openUp
|
||||||
|
? { bottom: window.innerHeight - rect.top + 4 }
|
||||||
|
: { top: rect.bottom + 4 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsDropdownOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const copyFormatOptions: CopyFormatOption[] = useMemo(
|
const copyFormatOptions: CopyFormatOption[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -83,18 +108,28 @@ const MessageCopyControl = ({
|
|||||||
}, [defaultFormat]);
|
}, [defaultFormat]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Close the dropdown when clicking anywhere outside this control.
|
if (!isDropdownOpen) return;
|
||||||
|
|
||||||
|
// Close when clicking outside both the control and the portaled menu.
|
||||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||||
if (!isDropdownOpen) return;
|
|
||||||
const target = event.target as Node;
|
const target = event.target as Node;
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
|
if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
||||||
setIsDropdownOpen(false);
|
return;
|
||||||
}
|
}
|
||||||
|
setIsDropdownOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The menu is fixed-positioned; close it if the page scrolls so it can't
|
||||||
|
// detach from the trigger.
|
||||||
|
const closeOnScroll = () => setIsDropdownOpen(false);
|
||||||
|
|
||||||
window.addEventListener('mousedown', closeOnOutsideClick);
|
window.addEventListener('mousedown', closeOnOutsideClick);
|
||||||
|
window.addEventListener('scroll', closeOnScroll, true);
|
||||||
|
window.addEventListener('resize', closeOnScroll);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousedown', closeOnOutsideClick);
|
window.removeEventListener('mousedown', closeOnOutsideClick);
|
||||||
|
window.removeEventListener('scroll', closeOnScroll, true);
|
||||||
|
window.removeEventListener('resize', closeOnScroll);
|
||||||
};
|
};
|
||||||
}, [isDropdownOpen]);
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
@@ -170,8 +205,9 @@ const MessageCopyControl = ({
|
|||||||
{canSelectCopyFormat && (
|
{canSelectCopyFormat && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
|
||||||
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
||||||
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
@@ -186,8 +222,12 @@ const MessageCopyControl = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isDropdownOpen && (
|
{isDropdownOpen && createPortal(
|
||||||
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
style={menuStyle}
|
||||||
|
className="min-w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||||
|
>
|
||||||
{copyFormatOptions.map((option) => {
|
{copyFormatOptions.map((option) => {
|
||||||
const isSelected = option.format === selectedFormat;
|
const isSelected = option.format === selectedFormat;
|
||||||
return (
|
return (
|
||||||
@@ -196,15 +236,16 @@ const MessageCopyControl = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleFormatChange(option.format)}
|
onClick={() => handleFormatChange(option.format)}
|
||||||
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
||||||
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
|
? 'bg-accent text-foreground'
|
||||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
|
: 'text-foreground hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="block text-xs font-medium">{option.label}</span>
|
<span className="block text-xs font-medium">{option.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
if (!selectedSession && !currentSessionId) {
|
if (!selectedSession && !currentSessionId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center px-4">
|
<div className="flex h-full items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-[34.25rem]">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||||
{t("providerSelection.title")}
|
{t("providerSelection.title")}
|
||||||
@@ -352,7 +352,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
if (selectedSession) {
|
if (selectedSession) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="max-w-md px-6 text-center">
|
<div className="max-w-[34.25rem] px-6 text-center">
|
||||||
<p className="mb-1.5 text-lg font-semibold text-foreground">
|
<p className="mb-1.5 text-lg font-semibold text-foreground">
|
||||||
{t("session.continue.title")}
|
{t("session.continue.title")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
|
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
|
||||||
title={`${usedTokens.toLocaleString()} tokens used`}
|
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||||
aria-label="Show token usage"
|
aria-label="Show token usage"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface ToolGroupContainerProps {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
@@ -66,7 +65,6 @@ export default function ToolGroupContainer({
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
onGrantToolPermission,
|
onGrantToolPermission,
|
||||||
autoExpandTools,
|
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
showThinking,
|
showThinking,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
@@ -133,7 +131,6 @@ export default function ToolGroupContainer({
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
onGrantToolPermission={onGrantToolPermission}
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export const CODE_EDITOR_STORAGE_KEYS = {
|
export const CODE_EDITOR_STORAGE_KEYS = {
|
||||||
theme: 'codeEditorTheme',
|
|
||||||
wordWrap: 'codeEditorWordWrap',
|
wordWrap: 'codeEditorWordWrap',
|
||||||
showMinimap: 'codeEditorShowMinimap',
|
showMinimap: 'codeEditorShowMinimap',
|
||||||
lineNumbers: 'codeEditorLineNumbers',
|
lineNumbers: 'codeEditorLineNumbers',
|
||||||
@@ -7,7 +6,6 @@ export const CODE_EDITOR_STORAGE_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const CODE_EDITOR_DEFAULTS = {
|
export const CODE_EDITOR_DEFAULTS = {
|
||||||
isDarkMode: true,
|
|
||||||
wordWrap: false,
|
wordWrap: false,
|
||||||
minimapEnabled: true,
|
minimapEnabled: true,
|
||||||
showLineNumbers: true,
|
showLineNumbers: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { api } from '../../../utils/api';
|
import { api } from '../../../utils/api';
|
||||||
import type { CodeEditorFile } from '../types/types';
|
import type { CodeEditorFile } from '../types/types';
|
||||||
import { isBinaryFile } from '../utils/binaryFile';
|
import { isBinaryFile } from '../utils/binaryFile';
|
||||||
|
import { getPreviewKind } from '../utils/previewableFile';
|
||||||
|
|
||||||
type UseCodeEditorDocumentParams = {
|
type UseCodeEditorDocumentParams = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [isBinary, setIsBinary] = useState(false);
|
const [isBinary, setIsBinary] = useState(false);
|
||||||
|
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
|
||||||
|
// editor shows an inline preview instead of the generic binary placeholder.
|
||||||
|
const previewKind = getPreviewKind(file.name);
|
||||||
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
||||||
// the fallback to `projectPath` preserves older callers that didn't yet
|
// the fallback to `projectPath` preserves older callers that didn't yet
|
||||||
// propagate the identifier.
|
// propagate the identifier.
|
||||||
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setIsBinary(false);
|
setIsBinary(false);
|
||||||
|
|
||||||
|
// Natively previewable media (image/pdf/audio/video) is rendered by
|
||||||
|
// CodeEditorMediaPreview, so there is nothing to read as text here.
|
||||||
|
// Clear any buffer left over from a previously opened text file so a
|
||||||
|
// stray save can't write stale content over the binary file.
|
||||||
|
if (getPreviewKind(file.name)) {
|
||||||
|
setContent('');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file is binary by extension
|
// Check if file is binary by extension
|
||||||
if (isBinaryFile(file.name)) {
|
if (isBinaryFile(file.name)) {
|
||||||
|
setContent('');
|
||||||
setIsBinary(true);
|
setIsBinary(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
// Preview-only and binary files have no editable text buffer; never write
|
||||||
|
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
|
||||||
|
if (previewKind || isBinaryFile(fileName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
|
||||||
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [content, filePath, fileProjectId]);
|
}, [content, filePath, fileProjectId, previewKind, fileName]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
|
previewKind,
|
||||||
|
fileProjectId,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,15 +5,6 @@ import {
|
|||||||
CODE_EDITOR_STORAGE_KEYS,
|
CODE_EDITOR_STORAGE_KEYS,
|
||||||
} from '../constants/settings';
|
} from '../constants/settings';
|
||||||
|
|
||||||
const readTheme = () => {
|
|
||||||
const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
|
|
||||||
if (!savedTheme) {
|
|
||||||
return CODE_EDITOR_DEFAULTS.isDarkMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return savedTheme === 'dark';
|
|
||||||
};
|
|
||||||
|
|
||||||
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
|
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
|
||||||
const value = localStorage.getItem(storageKey);
|
const value = localStorage.getItem(storageKey);
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
@@ -33,7 +24,6 @@ const readFontSize = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useCodeEditorSettings = () => {
|
export const useCodeEditorSettings = () => {
|
||||||
const [isDarkMode, setIsDarkMode] = useState(readTheme);
|
|
||||||
const [wordWrap, setWordWrap] = useState(readWordWrap);
|
const [wordWrap, setWordWrap] = useState(readWordWrap);
|
||||||
const [minimapEnabled, setMinimapEnabled] = useState(() => (
|
const [minimapEnabled, setMinimapEnabled] = useState(() => (
|
||||||
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
|
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
|
||||||
@@ -43,18 +33,13 @@ export const useCodeEditorSettings = () => {
|
|||||||
));
|
));
|
||||||
const [fontSize, setFontSize] = useState(readFontSize);
|
const [fontSize, setFontSize] = useState(readFontSize);
|
||||||
|
|
||||||
// Keep legacy behavior where the editor writes theme and wrap settings directly.
|
// Keep legacy behavior where the editor writes wrap settings directly.
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
|
|
||||||
}, [isDarkMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
|
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
|
||||||
}, [wordWrap]);
|
}, [wordWrap]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refreshFromStorage = () => {
|
const refreshFromStorage = () => {
|
||||||
setIsDarkMode(readTheme());
|
|
||||||
setWordWrap(readWordWrap());
|
setWordWrap(readWordWrap());
|
||||||
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
|
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
|
||||||
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
|
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
|
||||||
@@ -71,8 +56,6 @@ export const useCodeEditorSettings = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDarkMode,
|
|
||||||
setIsDarkMode,
|
|
||||||
wordWrap,
|
wordWrap,
|
||||||
setWordWrap,
|
setWordWrap,
|
||||||
minimapEnabled,
|
minimapEnabled,
|
||||||
|
|||||||
63
src/components/code-editor/utils/previewableFile.ts
Normal file
63
src/components/code-editor/utils/previewableFile.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Some binary files can't be edited as text, but the browser can still render
|
||||||
|
// them natively (images, PDFs, audio, video). For those we show an inline
|
||||||
|
// preview instead of the generic "binary file" placeholder. Anything not listed
|
||||||
|
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
|
||||||
|
|
||||||
|
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
|
||||||
|
|
||||||
|
// Single source of truth: every extension the browser can preview, mapped to the
|
||||||
|
// MIME type we apply when the server response has a missing/generic Content-Type.
|
||||||
|
// The preview kind is derived from the MIME type so the two never drift apart.
|
||||||
|
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
|
||||||
|
// absent and keep the binary fallback.
|
||||||
|
const EXTENSION_MIME: Record<string, string> = {
|
||||||
|
// Images
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
webp: 'image/webp',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
avif: 'image/avif',
|
||||||
|
apng: 'image/apng',
|
||||||
|
// PDF
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
// Video
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
ogv: 'video/ogg',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
m4v: 'video/x-m4v',
|
||||||
|
// Audio
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
opus: 'audio/opus',
|
||||||
|
oga: 'audio/ogg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
weba: 'audio/webm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
const kindForMime = (mime: string): PreviewKind | null => {
|
||||||
|
if (mime === 'application/pdf') return 'pdf';
|
||||||
|
if (mime.startsWith('image/')) return 'image';
|
||||||
|
if (mime.startsWith('video/')) return 'video';
|
||||||
|
if (mime.startsWith('audio/')) return 'audio';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPreviewKind = (filename: string): PreviewKind | null => {
|
||||||
|
const mime = EXTENSION_MIME[extensionOf(filename)];
|
||||||
|
return mime ? kindForMime(mime) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// MIME type to fall back to when the server returns no/generic Content-Type.
|
||||||
|
// Returns undefined for non-previewable extensions.
|
||||||
|
export const getPreviewMimeType = (filename: string): string | undefined =>
|
||||||
|
EXTENSION_MIME[extensionOf(filename)];
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
import { unifiedMergeView } from '@codemirror/merge';
|
import { unifiedMergeView } from '@codemirror/merge';
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
||||||
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
||||||
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
|
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
|
||||||
@@ -11,11 +13,13 @@ import type { CodeEditorFile } from '../types/types';
|
|||||||
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
||||||
import { getEditorStyles } from '../utils/editorStyles';
|
import { getEditorStyles } from '../utils/editorStyles';
|
||||||
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
||||||
|
|
||||||
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||||
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
||||||
|
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
|
||||||
|
|
||||||
type CodeEditorProps = {
|
type CodeEditorProps = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -42,8 +46,10 @@ export default function CodeEditor({
|
|||||||
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
|
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
|
||||||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||||||
|
|
||||||
|
// The code editor follows the app-wide theme; it has no theme of its own.
|
||||||
|
const { isDarkMode } = useTheme();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDarkMode,
|
|
||||||
wordWrap,
|
wordWrap,
|
||||||
minimapEnabled,
|
minimapEnabled,
|
||||||
showLineNumbers,
|
showLineNumbers,
|
||||||
@@ -58,6 +64,8 @@ export default function CodeEditor({
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
|
previewKind,
|
||||||
|
fileProjectId,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
} = useCodeEditorDocument({
|
} = useCodeEditorDocument({
|
||||||
@@ -70,6 +78,29 @@ export default function CodeEditor({
|
|||||||
return extension === 'md' || extension === 'markdown';
|
return extension === 'md' || extension === 'markdown';
|
||||||
}, [file.name]);
|
}, [file.name]);
|
||||||
|
|
||||||
|
const isHtmlPreviewFile = useMemo(() => {
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
return extension === 'html' || extension === 'htm';
|
||||||
|
}, [file.name]);
|
||||||
|
|
||||||
|
const openHtmlPreview = useCallback(() => {
|
||||||
|
const previewWindow = window.open('', '_blank');
|
||||||
|
if (!previewWindow) return;
|
||||||
|
|
||||||
|
previewWindow.opener = null;
|
||||||
|
previewWindow.document.title = file.name;
|
||||||
|
previewWindow.document.body.style.margin = '0';
|
||||||
|
|
||||||
|
const iframe = previewWindow.document.createElement('iframe');
|
||||||
|
iframe.title = file.name;
|
||||||
|
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
|
||||||
|
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
|
||||||
|
|
||||||
|
iframe.srcdoc = content;
|
||||||
|
|
||||||
|
previewWindow.document.body.appendChild(iframe);
|
||||||
|
}, [content, file.name]);
|
||||||
|
|
||||||
const minimapExtension = useMemo(
|
const minimapExtension = useMemo(
|
||||||
() => (
|
() => (
|
||||||
createMinimapExtension({
|
createMinimapExtension({
|
||||||
@@ -162,6 +193,30 @@ export default function CodeEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Natively previewable media (image/pdf/audio/video) is rendered inline
|
||||||
|
// instead of showing the generic "cannot be displayed" placeholder.
|
||||||
|
if (previewKind) {
|
||||||
|
return (
|
||||||
|
<CodeEditorMediaPreview
|
||||||
|
file={file}
|
||||||
|
kind={previewKind}
|
||||||
|
projectId={fileProjectId}
|
||||||
|
isSidebar={isSidebar}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onClose={onClose}
|
||||||
|
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
||||||
|
labels={{
|
||||||
|
loading: t('filePreview.loading', 'Loading preview...'),
|
||||||
|
error: t('filePreview.error', 'Unable to display this file.'),
|
||||||
|
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
|
||||||
|
fullscreen: t('actions.fullscreen', 'Fullscreen'),
|
||||||
|
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
|
||||||
|
close: t('actions.close', 'Close'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Binary file display
|
// Binary file display
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
return (
|
return (
|
||||||
@@ -197,10 +252,12 @@ export default function CodeEditor({
|
|||||||
isSidebar={isSidebar}
|
isSidebar={isSidebar}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
isMarkdownFile={isMarkdownFile}
|
isMarkdownFile={isMarkdownFile}
|
||||||
|
isHtmlPreviewFile={isHtmlPreviewFile}
|
||||||
markdownPreview={markdownPreview}
|
markdownPreview={markdownPreview}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
saveSuccess={saveSuccess}
|
saveSuccess={saveSuccess}
|
||||||
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
||||||
|
onOpenHtmlPreview={openHtmlPreview}
|
||||||
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -210,6 +267,7 @@ export default function CodeEditor({
|
|||||||
showingChanges: t('header.showingChanges'),
|
showingChanges: t('header.showingChanges'),
|
||||||
editMarkdown: t('actions.editMarkdown'),
|
editMarkdown: t('actions.editMarkdown'),
|
||||||
previewMarkdown: t('actions.previewMarkdown'),
|
previewMarkdown: t('actions.previewMarkdown'),
|
||||||
|
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
|
||||||
settings: t('toolbar.settings'),
|
settings: t('toolbar.settings'),
|
||||||
download: t('actions.download'),
|
download: t('actions.download'),
|
||||||
save: t('actions.save'),
|
save: t('actions.save'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
||||||
|
|
||||||
import type { CodeEditorFile } from '../../types/types';
|
import type { CodeEditorFile } from '../../types/types';
|
||||||
|
|
||||||
type CodeEditorHeaderProps = {
|
type CodeEditorHeaderProps = {
|
||||||
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
|
|||||||
isSidebar: boolean;
|
isSidebar: boolean;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
isMarkdownFile: boolean;
|
isMarkdownFile: boolean;
|
||||||
|
isHtmlPreviewFile: boolean;
|
||||||
markdownPreview: boolean;
|
markdownPreview: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
saveSuccess: boolean;
|
saveSuccess: boolean;
|
||||||
onToggleMarkdownPreview: () => void;
|
onToggleMarkdownPreview: () => void;
|
||||||
|
onOpenHtmlPreview: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
|
|||||||
showingChanges: string;
|
showingChanges: string;
|
||||||
editMarkdown: string;
|
editMarkdown: string;
|
||||||
previewMarkdown: string;
|
previewMarkdown: string;
|
||||||
|
previewHtml: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
download: string;
|
download: string;
|
||||||
save: string;
|
save: string;
|
||||||
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
|
|||||||
isSidebar,
|
isSidebar,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isMarkdownFile,
|
isMarkdownFile,
|
||||||
|
isHtmlPreviewFile,
|
||||||
markdownPreview,
|
markdownPreview,
|
||||||
saving,
|
saving,
|
||||||
saveSuccess,
|
saveSuccess,
|
||||||
onToggleMarkdownPreview,
|
onToggleMarkdownPreview,
|
||||||
|
onOpenHtmlPreview,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onDownload,
|
onDownload,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isHtmlPreviewFile && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenHtmlPreview}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
title={labels.previewHtml}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { authenticatedFetch } from '../../../../utils/api';
|
||||||
|
import type { CodeEditorFile } from '../../types/types';
|
||||||
|
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
|
||||||
|
|
||||||
|
type CodeEditorMediaPreviewProps = {
|
||||||
|
file: CodeEditorFile;
|
||||||
|
kind: PreviewKind;
|
||||||
|
// DB projectId used to build the raw-content URL; falls back to projectPath
|
||||||
|
// for older callers, mirroring useCodeEditorDocument.
|
||||||
|
projectId?: string;
|
||||||
|
isSidebar: boolean;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
labels: {
|
||||||
|
loading: string;
|
||||||
|
error: string;
|
||||||
|
openInNewTab: string;
|
||||||
|
fullscreen: string;
|
||||||
|
exitFullscreen: string;
|
||||||
|
close: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
|
||||||
|
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
|
||||||
|
const PDF_HEADER_SCAN_BYTES = 1024;
|
||||||
|
|
||||||
|
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
|
||||||
|
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
|
||||||
|
// PDFs must contain the "%PDF-" marker at the very start of the file.
|
||||||
|
return new TextDecoder('latin1').decode(header).includes('%PDF-');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CodeEditorMediaPreview({
|
||||||
|
file,
|
||||||
|
kind,
|
||||||
|
projectId,
|
||||||
|
isSidebar,
|
||||||
|
isFullscreen,
|
||||||
|
onClose,
|
||||||
|
onToggleFullscreen,
|
||||||
|
labels,
|
||||||
|
}: CodeEditorMediaPreviewProps) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
// Identifies which file the current `url` was loaded for. Rendering is gated on
|
||||||
|
// this so a blob from a previously-opened file can never show under the new
|
||||||
|
// file (the editor reuses this component instance across files).
|
||||||
|
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||||
|
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
setUrl(null);
|
||||||
|
setLoadedKey(null);
|
||||||
|
setError(labels.error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const loadMedia = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setUrl(null);
|
||||||
|
|
||||||
|
// The content endpoint requires the auth header, so we fetch the bytes
|
||||||
|
// ourselves and hand the media element a blob URL instead of a bare src.
|
||||||
|
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
|
||||||
|
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||||
|
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Pick the MIME type to expose to the browser. Preserve a valid
|
||||||
|
// Content-Type from the server, but supply an extension-specific
|
||||||
|
// default when it is missing or generic (application/octet-stream),
|
||||||
|
// otherwise formats like webm/ogg/flac/svg won't render.
|
||||||
|
const fallbackMime = getPreviewMimeType(file.name);
|
||||||
|
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
|
||||||
|
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
|
||||||
|
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
|
||||||
|
|
||||||
|
if (kind === 'pdf') {
|
||||||
|
// The PDF renders in a same-origin <iframe>, so verify the bytes are
|
||||||
|
// really a PDF and pin the type to application/pdf. That forces the
|
||||||
|
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
|
||||||
|
// executing scripts in the app's origin.
|
||||||
|
if (!(await looksLikePdf(blob))) {
|
||||||
|
throw new Error('File is not a valid PDF');
|
||||||
|
}
|
||||||
|
outType = 'application/pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
|
||||||
|
objectUrl = URL.createObjectURL(typed);
|
||||||
|
|
||||||
|
// The cleanup may have already run (deps changed during an await), in
|
||||||
|
// which case it revoked nothing because objectUrl was still null. Don't
|
||||||
|
// publish a URL the cleanup will never revoke — drop it ourselves.
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrl(objectUrl);
|
||||||
|
setLoadedKey(sourceKey);
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
if (loadError instanceof Error && loadError.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Error loading preview:', loadError);
|
||||||
|
setError(labels.error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMedia();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
|
||||||
|
|
||||||
|
// Only expose the blob once it matches the file currently being shown, so a
|
||||||
|
// stale URL from the previous file is never rendered during a switch.
|
||||||
|
const currentUrl = url && loadedKey === sourceKey ? url : null;
|
||||||
|
|
||||||
|
// SVGs render safely inline via <img> (scripts don't execute there), but the
|
||||||
|
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
|
||||||
|
// app's origin, so a user-controlled SVG with an embedded <script> would run
|
||||||
|
// as same-origin script. Withhold the new-tab action for SVGs.
|
||||||
|
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
|
||||||
|
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
|
||||||
|
|
||||||
|
const renderMedia = () => {
|
||||||
|
if (!currentUrl) return null;
|
||||||
|
switch (kind) {
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={currentUrl}
|
||||||
|
alt={file.name}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'pdf':
|
||||||
|
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
|
||||||
|
// load inside a sandboxed frame (any `sandbox` value yields a broken
|
||||||
|
// viewer). Script execution is instead prevented upstream by validating
|
||||||
|
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
|
||||||
|
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
|
||||||
|
{labels.error}
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
case 'audio':
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
|
||||||
|
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
|
||||||
|
<audio src={currentUrl} controls className="w-full">
|
||||||
|
{labels.error}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewBody = (
|
||||||
|
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-muted-foreground">{labels.loading}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && currentUrl && renderMedia()}
|
||||||
|
|
||||||
|
{!loading && !currentUrl && (
|
||||||
|
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
|
||||||
|
<p className="text-sm">{error || labels.error}</p>
|
||||||
|
<p className="break-all text-xs">{file.path}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerActions = (
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
{canOpenInNewTab && currentUrl && (
|
||||||
|
<a
|
||||||
|
href={currentUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={labels.openInNewTab}
|
||||||
|
title={labels.openInNewTab}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{!isSidebar && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||||
|
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={labels.close}
|
||||||
|
title={labels.close}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
|
||||||
|
</div>
|
||||||
|
{headerActions}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSidebar) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col bg-background">
|
||||||
|
{header}
|
||||||
|
{previewBody}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerClassName = isFullscreen
|
||||||
|
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
|
||||||
|
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
|
||||||
|
|
||||||
|
const innerClassName = isFullscreen
|
||||||
|
? 'bg-background flex flex-col w-full h-full'
|
||||||
|
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClassName}>
|
||||||
|
<div className={innerClassName}>
|
||||||
|
{header}
|
||||||
|
{previewBody}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { oneDark as prismOneDark, oneLight as prismOneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
||||||
|
import { useTheme } from '../../../../../contexts/ThemeContext';
|
||||||
|
|
||||||
type MarkdownCodeBlockProps = {
|
type MarkdownCodeBlockProps = {
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
@@ -16,6 +17,7 @@ export default function MarkdownCodeBlock({
|
|||||||
node: _node,
|
node: _node,
|
||||||
...props
|
...props
|
||||||
}: MarkdownCodeBlockProps) {
|
}: MarkdownCodeBlockProps) {
|
||||||
|
const { isDarkMode } = useTheme();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
|
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||||
const looksMultiline = /[\r\n]/.test(rawContent);
|
const looksMultiline = /[\r\n]/.test(rawContent);
|
||||||
@@ -50,20 +52,22 @@ export default function MarkdownCodeBlock({
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 group-hover:opacity-100"
|
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={language}
|
language={language}
|
||||||
style={prismOneDark}
|
style={isDarkMode ? prismOneDark : prismOneLight}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.75rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||||
|
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
|
||||||
}}
|
}}
|
||||||
|
codeTagProps={{ style: isDarkMode ? {} : { background: 'transparent' } }}
|
||||||
>
|
>
|
||||||
{rawContent}
|
{rawContent}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ type MarkdownPreviewProps = {
|
|||||||
|
|
||||||
const markdownPreviewComponents: Components = {
|
const markdownPreviewComponents: Components = {
|
||||||
code: MarkdownCodeBlock,
|
code: MarkdownCodeBlock,
|
||||||
|
// MarkdownCodeBlock renders its own highlighted <pre>; passthrough prevents a
|
||||||
|
// second Typography-styled <pre> shell from framing it.
|
||||||
|
pre: ({ children }) => <>{children}</>,
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export default function GitPanelHeader({
|
|||||||
<button
|
<button
|
||||||
onClick={requestPublishConfirmation}
|
onClick={requestPublishConfirmation}
|
||||||
disabled={anyPending}
|
disabled={anyPending}
|
||||||
className="flex items-center gap-1 rounded-lg bg-purple-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-purple-700 disabled:opacity-50"
|
className="flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
title={`Publish "${currentBranch}" to ${remoteName}`}
|
title={`Publish "${currentBranch}" to ${remoteName}`}
|
||||||
>
|
>
|
||||||
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function MainContent({
|
|||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
}: MainContentProps) {
|
}: MainContentProps) {
|
||||||
const { preferences } = useUiPreferences();
|
const { preferences } = useUiPreferences();
|
||||||
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
|
const { showRawParameters, showThinking, sendByCtrlEnter } = preferences;
|
||||||
|
|
||||||
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
|
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
|
||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
|
||||||
@@ -170,10 +170,8 @@ function MainContent({
|
|||||||
onNavigateToSession={onNavigateToSession}
|
onNavigateToSession={onNavigateToSession}
|
||||||
onSessionEstablished={onSessionEstablished}
|
onSessionEstablished={onSessionEstablished}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
autoScrollToBottom={autoScrollToBottom}
|
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
externalMessageUpdate={externalMessageUpdate}
|
externalMessageUpdate={externalMessageUpdate}
|
||||||
newSessionTrigger={newSessionTrigger}
|
newSessionTrigger={newSessionTrigger}
|
||||||
@@ -209,7 +207,11 @@ function MainContent({
|
|||||||
|
|
||||||
{shouldShowBrowserTab && activeTab === 'browser' && (
|
{shouldShowBrowserTab && activeTab === 'browser' && (
|
||||||
<div className="h-full overflow-hidden">
|
<div className="h-full overflow-hidden">
|
||||||
<BrowserUsePanel isVisible={activeTab === 'browser'} onShowSettings={onShowSettings} />
|
<BrowserUsePanel
|
||||||
|
isVisible={activeTab === 'browser'}
|
||||||
|
projectId={selectedProject.projectId}
|
||||||
|
onShowSettings={onShowSettings}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -70,10 +70,18 @@ export default function MainContentTitle({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{activeTab === 'chat' && selectedSession ? (
|
{activeTab === 'chat' && selectedSession ? (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground">
|
<h2 title={getSessionTitle(selectedSession)} className="truncate text-sm font-semibold leading-tight text-foreground">
|
||||||
{getSessionTitle(selectedSession)}
|
{getSessionTitle(selectedSession)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
<div className="flex min-w-0 items-center gap-2 text-[11px] leading-tight text-muted-foreground">
|
||||||
|
<span className="min-w-0 truncate">{selectedProject.displayName}</span>
|
||||||
|
<span
|
||||||
|
className="hidden min-w-0 max-w-[45%] flex-shrink truncate border-l border-border/60 pl-2 font-mono text-[10px] sm:block"
|
||||||
|
title={selectedSession.id}
|
||||||
|
>
|
||||||
|
{selectedSession.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : showChatNewSession ? (
|
) : showChatNewSession ? (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
|
|||||||
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
|
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
|
||||||
|
|
||||||
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
||||||
claude: 'bg-purple-600 text-white hover:bg-purple-700',
|
claude: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
|
cursor: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
codex: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
|
gemini: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
opencode: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Server className="h-5 w-5 text-purple-500" />
|
<Server className="h-5 w-5 text-primary" />
|
||||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FolderOpen, Globe, X } from 'lucide-react';
|
import { FolderOpen, Globe, X } from 'lucide-react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Input } from '../../../../shared/view/ui';
|
import { Button, Input } from '../../../../shared/view/ui';
|
||||||
@@ -119,8 +120,8 @@ export default function McpServerFormModal({
|
|||||||
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
||||||
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
|
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4">
|
||||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
|
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
|
||||||
<div className="flex items-center justify-between border-b border-border p-4">
|
<div className="flex items-center justify-between border-b border-border p-4">
|
||||||
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
|
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
|
||||||
@@ -418,7 +419,7 @@ export default function McpServerFormModal({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !canSubmit}
|
disabled={isSubmitting || !canSubmit}
|
||||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? t('mcpForm.actions.saving')
|
? t('mcpForm.actions.saving')
|
||||||
@@ -429,6 +430,7 @@ export default function McpServerFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,11 +148,18 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="relative h-screen overflow-y-auto bg-background">
|
||||||
<div className="w-full max-w-2xl">
|
<div aria-hidden className="pointer-events-none fixed inset-0">
|
||||||
|
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-32 -left-24 h-[26rem] w-[26rem] rounded-full bg-primary/5 blur-3xl" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(hsl(var(--foreground)/0.04)_1px,transparent_1px)] [background-size:22px_22px] opacity-60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto flex min-h-full w-full max-w-2xl items-center justify-center p-4">
|
||||||
|
<div className="w-full py-6">
|
||||||
<OnboardingStepProgress currentStep={currentStep} />
|
<OnboardingStepProgress currentStep={currentStep} />
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-card p-8 shadow-lg">
|
<div className="rounded-2xl border border-border/70 bg-card/90 p-6 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl">
|
||||||
{currentStep === 0 ? (
|
{currentStep === 0 ? (
|
||||||
<GitConfigurationStep
|
<GitConfigurationStep
|
||||||
gitName={gitName}
|
gitName={gitName}
|
||||||
@@ -168,13 +175,16 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="mt-6 rounded-lg border border-red-300 bg-red-100 p-4 dark:border-red-800 dark:bg-red-900/20">
|
<div
|
||||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
|
role="alert"
|
||||||
</div>
|
className="mt-5 rounded-xl border border-destructive/30 bg-destructive/10 p-3.5"
|
||||||
)}
|
>
|
||||||
|
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-between border-t border-border pt-6">
|
<div className="mt-6 flex items-center justify-between border-t border-border pt-5">
|
||||||
<button
|
<button
|
||||||
onClick={handlePreviousStep}
|
onClick={handlePreviousStep}
|
||||||
disabled={currentStep === 0 || isSubmitting}
|
disabled={currentStep === 0 || isSubmitting}
|
||||||
@@ -189,7 +199,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleNextStep}
|
onClick={handleNextStep}
|
||||||
disabled={!isCurrentStepValid || isSubmitting}
|
disabled={!isCurrentStepValid || isSubmitting}
|
||||||
className="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
|
className="flex items-center gap-2 rounded-xl bg-primary px-6 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
@@ -207,7 +217,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleFinish}
|
onClick={handleFinish}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-green-700 disabled:cursor-not-allowed disabled:bg-green-400"
|
className="flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-2.5 font-medium text-white shadow-lg shadow-emerald-600/25 transition-all duration-200 hover:bg-emerald-700 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
@@ -225,6 +235,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -31,26 +31,26 @@ export default function AgentConnectionCard({
|
|||||||
: status.error || 'Not connected';
|
: status.error || 'Not connected';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border p-4 transition-colors ${containerClassName}`}>
|
<div className={`rounded-xl border px-3 py-2.5 transition-colors ${containerClassName}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${iconContainerClassName}`}>
|
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full ${iconContainerClassName}`}>
|
||||||
<SessionProviderLogo provider={provider} className="h-5 w-5" />
|
<SessionProviderLogo provider={provider} className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 font-medium text-foreground">
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
{title}
|
{title}
|
||||||
{status.authenticated && <Check className="h-4 w-4 text-green-500" />}
|
{status.authenticated && <Check className="h-3.5 w-3.5 flex-shrink-0 text-emerald-500" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{statusText}</div>
|
<div className="truncate text-xs text-muted-foreground" title={statusText}>{statusText}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!status.authenticated && !status.loading && (
|
{!status.authenticated && !status.loading && (
|
||||||
<button
|
<button
|
||||||
onClick={onLogin}
|
onClick={onLogin}
|
||||||
className={`${loginButtonClassName} rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors`}
|
className={`${loginButtonClassName} flex-shrink-0 rounded-lg px-4 py-1.5 text-sm font-medium text-white transition-colors`}
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,15 +51,15 @@ export default function AgentConnectionsStep({
|
|||||||
onOpenProviderLogin,
|
onOpenProviderLogin,
|
||||||
}: AgentConnectionsStepProps) {
|
}: AgentConnectionsStepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div className="mb-6 text-center">
|
<div className="text-center">
|
||||||
<h2 className="mb-2 text-2xl font-bold text-foreground">Connect Your AI Agents</h2>
|
<h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Connect Your AI Agents</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||||
Login to one or more AI coding assistants. All are optional.
|
Login to one or more AI coding assistants. All are optional.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="-mr-1 max-h-[38vh] space-y-2 overflow-y-auto pr-1">
|
||||||
{providerCards.map((providerCard) => (
|
{providerCards.map((providerCard) => (
|
||||||
<AgentConnectionCard
|
<AgentConnectionCard
|
||||||
key={providerCard.provider}
|
key={providerCard.provider}
|
||||||
@@ -74,9 +74,7 @@ export default function AgentConnectionsStep({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 text-center text-sm text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">You can configure these later in Settings.</p>
|
||||||
<p>You can configure these later in Settings.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ export default function GitConfigurationStep({
|
|||||||
onGitEmailChange,
|
onGitEmailChange,
|
||||||
}: GitConfigurationStepProps) {
|
}: GitConfigurationStepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<div className="mb-8 text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-inset ring-primary/20">
|
||||||
<GitBranch className="h-8 w-8 text-blue-600 dark:text-blue-400" />
|
<GitBranch className="h-7 w-7 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-2 text-2xl font-bold text-foreground">Git Configuration</h2>
|
<h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Git Configuration</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||||
Configure your git identity to ensure proper attribution for commits.
|
Configure your git identity to ensure proper attribution for commits.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +38,7 @@ export default function GitConfigurationStep({
|
|||||||
id="gitName"
|
id="gitName"
|
||||||
value={gitName}
|
value={gitName}
|
||||||
onChange={(event) => onGitNameChange(event.target.value)}
|
onChange={(event) => onGitNameChange(event.target.value)}
|
||||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full rounded-xl border border-border bg-background/60 px-4 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
required
|
required
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
@@ -56,7 +56,7 @@ export default function GitConfigurationStep({
|
|||||||
id="gitEmail"
|
id="gitEmail"
|
||||||
value={gitEmail}
|
value={gitEmail}
|
||||||
onChange={(event) => onGitEmailChange(event.target.value)}
|
onChange={(event) => onGitEmailChange(event.target.value)}
|
||||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full rounded-xl border border-border bg-background/60 px-4 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
placeholder="john@example.com"
|
placeholder="john@example.com"
|
||||||
required
|
required
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const onboardingSteps = [
|
|||||||
|
|
||||||
export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {
|
export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{onboardingSteps.map((step, index) => {
|
{onboardingSteps.map((step, index) => {
|
||||||
const isCompleted = index < currentStep;
|
const isCompleted = index < currentStep;
|
||||||
@@ -22,18 +22,18 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
|
|||||||
<div key={step.title} className="contents">
|
<div key={step.title} className="contents">
|
||||||
<div className="flex flex-1 flex-col items-center">
|
<div className="flex flex-1 flex-col items-center">
|
||||||
<div
|
<div
|
||||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-colors duration-200 ${
|
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||||
isCompleted
|
isCompleted
|
||||||
? 'border-green-500 bg-green-500 text-white'
|
? 'border-emerald-500 bg-emerald-500 text-white shadow-lg shadow-emerald-500/25'
|
||||||
: isActive
|
: isActive
|
||||||
? 'border-blue-600 bg-blue-600 text-white'
|
? 'border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/25'
|
||||||
: 'border-border bg-background text-muted-foreground'
|
: 'border-border bg-card text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isCompleted ? <Check className="h-6 w-6" /> : <Icon className="h-6 w-6" />}
|
{isCompleted ? <Check className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 text-center">
|
<div className="mt-1.5 text-center">
|
||||||
<p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>
|
<p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||||
{step.title}
|
{step.title}
|
||||||
</p>
|
</p>
|
||||||
@@ -42,7 +42,7 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{index < onboardingSteps.length - 1 && (
|
{index < onboardingSteps.length - 1 && (
|
||||||
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-green-500' : 'bg-border'}`} />
|
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-emerald-500' : 'bg-border'}`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
ArrowDown,
|
|
||||||
Brain,
|
Brain,
|
||||||
Eye,
|
Eye,
|
||||||
Languages,
|
Languages,
|
||||||
Maximize2,
|
|
||||||
Mic,
|
Mic,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import type { PreferenceToggleItem } from './types';
|
import type { PreferenceToggleItem } from './types';
|
||||||
|
|
||||||
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
|
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
|
||||||
@@ -16,7 +15,7 @@ export const HANDLE_POSITION_MAX = 90;
|
|||||||
export const DRAG_THRESHOLD_PX = 5;
|
export const DRAG_THRESHOLD_PX = 5;
|
||||||
|
|
||||||
export const SETTING_ROW_CLASS =
|
export const SETTING_ROW_CLASS =
|
||||||
'flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600';
|
'flex items-center justify-between p-3 rounded-lg bg-muted/60 hover:bg-accent transition-colors border border-transparent hover:border-border';
|
||||||
|
|
||||||
export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;
|
export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;
|
||||||
|
|
||||||
@@ -24,11 +23,6 @@ export const CHECKBOX_CLASS =
|
|||||||
'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600';
|
'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600';
|
||||||
|
|
||||||
export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
|
export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
|
||||||
{
|
|
||||||
key: 'autoExpandTools',
|
|
||||||
labelKey: 'quickSettings.autoExpandTools',
|
|
||||||
icon: Maximize2,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'showRawParameters',
|
key: 'showRawParameters',
|
||||||
labelKey: 'quickSettings.showRawParameters',
|
labelKey: 'quickSettings.showRawParameters',
|
||||||
@@ -41,14 +35,6 @@ export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const VIEW_OPTION_TOGGLES: PreferenceToggleItem[] = [
|
|
||||||
{
|
|
||||||
key: 'autoScrollToBottom',
|
|
||||||
labelKey: 'quickSettings.autoScrollToBottom',
|
|
||||||
icon: ArrowDown,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
|
export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
|
||||||
{
|
{
|
||||||
key: 'sendByCtrlEnter',
|
key: 'sendByCtrlEnter',
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import type { CSSProperties } from 'react';
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
export type PreferenceToggleKey =
|
export type PreferenceToggleKey =
|
||||||
| 'autoExpandTools'
|
|
||||||
| 'showRawParameters'
|
| 'showRawParameters'
|
||||||
| 'showThinking'
|
| 'showThinking'
|
||||||
| 'autoScrollToBottom'
|
|
||||||
| 'sendByCtrlEnter'
|
| 'sendByCtrlEnter'
|
||||||
| 'voiceEnabled';
|
| 'voiceEnabled';
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Moon, Sun } from 'lucide-react';
|
import { Moon, Sun } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { DarkModeToggle } from '../../../shared/view/ui';
|
import { DarkModeToggle } from '../../../shared/view/ui';
|
||||||
import LanguageSelector from '../../../shared/view/ui/LanguageSelector';
|
import LanguageSelector from '../../../shared/view/ui/LanguageSelector';
|
||||||
import {
|
import {
|
||||||
INPUT_SETTING_TOGGLES,
|
INPUT_SETTING_TOGGLES,
|
||||||
SETTING_ROW_CLASS,
|
SETTING_ROW_CLASS,
|
||||||
TOOL_DISPLAY_TOGGLES,
|
TOOL_DISPLAY_TOGGLES,
|
||||||
VIEW_OPTION_TOGGLES,
|
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import type {
|
import type {
|
||||||
PreferenceToggleItem,
|
PreferenceToggleItem,
|
||||||
PreferenceToggleKey,
|
PreferenceToggleKey,
|
||||||
QuickSettingsPreferences,
|
QuickSettingsPreferences,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import QuickSettingsSection from './QuickSettingsSection';
|
import QuickSettingsSection from './QuickSettingsSection';
|
||||||
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
|
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
|
||||||
|
|
||||||
@@ -48,11 +49,11 @@ export default function QuickSettingsContent({
|
|||||||
<div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
|
<div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
|
||||||
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
|
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
|
||||||
<div className={SETTING_ROW_CLASS}>
|
<div className={SETTING_ROW_CLASS}>
|
||||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
<span className="flex items-center gap-2 text-sm text-foreground">
|
||||||
{isDarkMode ? (
|
{isDarkMode ? (
|
||||||
<Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Moon className="h-4 w-4 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
{t('quickSettings.darkMode')}
|
{t('quickSettings.darkMode')}
|
||||||
</span>
|
</span>
|
||||||
@@ -65,13 +66,9 @@ export default function QuickSettingsContent({
|
|||||||
{renderToggleRows(TOOL_DISPLAY_TOGGLES)}
|
{renderToggleRows(TOOL_DISPLAY_TOGGLES)}
|
||||||
</QuickSettingsSection>
|
</QuickSettingsSection>
|
||||||
|
|
||||||
<QuickSettingsSection title={t('quickSettings.sections.viewOptions')}>
|
|
||||||
{renderToggleRows(VIEW_OPTION_TOGGLES)}
|
|
||||||
</QuickSettingsSection>
|
|
||||||
|
|
||||||
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
|
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
|
||||||
{renderToggleRows(inputSettingToggles)}
|
{renderToggleRows(inputSettingToggles)}
|
||||||
<p className="ml-3 text-xs text-gray-500 dark:text-gray-400">
|
<p className="ml-3 text-xs text-muted-foreground">
|
||||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||||
</p>
|
</p>
|
||||||
</QuickSettingsSection>
|
</QuickSettingsSection>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ export default function QuickSettingsPanelHeader() {
|
|||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900">
|
<div className="border-b border-border bg-muted/40 p-4">
|
||||||
<h3 className="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||||
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
<Settings2 className="h-5 w-5 text-muted-foreground" />
|
||||||
{t('quickSettings.title')}
|
{t('quickSettings.title')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||||
|
|
||||||
import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
|
import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
|
||||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { useQuickSettingsDrag } from '../hooks/useQuickSettingsDrag';
|
import { useQuickSettingsDrag } from '../hooks/useQuickSettingsDrag';
|
||||||
import type { PreferenceToggleKey, QuickSettingsPreferences } from '../types';
|
import type { PreferenceToggleKey, QuickSettingsPreferences } from '../types';
|
||||||
|
|
||||||
import QuickSettingsContent from './QuickSettingsContent';
|
import QuickSettingsContent from './QuickSettingsContent';
|
||||||
import QuickSettingsHandle from './QuickSettingsHandle';
|
import QuickSettingsHandle from './QuickSettingsHandle';
|
||||||
import QuickSettingsPanelHeader from './QuickSettingsPanelHeader';
|
import QuickSettingsPanelHeader from './QuickSettingsPanelHeader';
|
||||||
@@ -22,15 +24,11 @@ export default function QuickSettingsPanelView() {
|
|||||||
} = useQuickSettingsDrag({ isMobile });
|
} = useQuickSettingsDrag({ isMobile });
|
||||||
|
|
||||||
const quickSettingsPreferences = useMemo<QuickSettingsPreferences>(() => ({
|
const quickSettingsPreferences = useMemo<QuickSettingsPreferences>(() => ({
|
||||||
autoExpandTools: preferences.autoExpandTools,
|
|
||||||
showRawParameters: preferences.showRawParameters,
|
showRawParameters: preferences.showRawParameters,
|
||||||
showThinking: preferences.showThinking,
|
showThinking: preferences.showThinking,
|
||||||
autoScrollToBottom: preferences.autoScrollToBottom,
|
|
||||||
sendByCtrlEnter: preferences.sendByCtrlEnter,
|
sendByCtrlEnter: preferences.sendByCtrlEnter,
|
||||||
voiceEnabled: preferences.voiceEnabled,
|
voiceEnabled: preferences.voiceEnabled,
|
||||||
}), [
|
}), [
|
||||||
preferences.autoExpandTools,
|
|
||||||
preferences.autoScrollToBottom,
|
|
||||||
preferences.sendByCtrlEnter,
|
preferences.sendByCtrlEnter,
|
||||||
preferences.showRawParameters,
|
preferences.showRawParameters,
|
||||||
preferences.showThinking,
|
preferences.showThinking,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function QuickSettingsSection({
|
|||||||
}: QuickSettingsSectionProps) {
|
}: QuickSettingsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${className}`}>
|
<div className={`space-y-2 ${className}`}>
|
||||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{title}
|
{title}
|
||||||
</h4>
|
</h4>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ function QuickSettingsToggleRow({
|
|||||||
}: QuickSettingsToggleRowProps) {
|
}: QuickSettingsToggleRowProps) {
|
||||||
return (
|
return (
|
||||||
<label className={TOGGLE_ROW_CLASS}>
|
<label className={TOGGLE_ROW_CLASS}>
|
||||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
<span className="flex items-center gap-2 text-sm text-foreground">
|
||||||
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp
|
|||||||
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
||||||
export const DEFAULT_SAVE_STATUS = null;
|
export const DEFAULT_SAVE_STATUS = null;
|
||||||
export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
|
export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
|
||||||
theme: 'dark',
|
|
||||||
wordWrap: false,
|
wordWrap: false,
|
||||||
showMinimap: true,
|
showMinimap: true,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ const toCodexPermissionMode = (value: unknown): CodexPermissionMode => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const readCodeEditorSettings = (): CodeEditorSettingsState => ({
|
const readCodeEditorSettings = (): CodeEditorSettingsState => ({
|
||||||
theme: localStorage.getItem('codeEditorTheme') === 'light' ? 'light' : 'dark',
|
|
||||||
wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',
|
wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',
|
||||||
showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',
|
showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',
|
||||||
lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',
|
lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',
|
||||||
@@ -330,7 +329,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
}, [notificationPreferences.channels.sound]);
|
}, [notificationPreferences.channels.sound]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
|
||||||
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
||||||
localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));
|
localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));
|
||||||
localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));
|
localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export type CursorPermissionsState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CodeEditorSettingsState = {
|
export type CodeEditorSettingsState = {
|
||||||
theme: 'dark' | 'light';
|
|
||||||
wordWrap: boolean;
|
wordWrap: boolean;
|
||||||
showMinimap: boolean;
|
showMinimap: boolean;
|
||||||
lineNumbers: boolean;
|
lineNumbers: boolean;
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
projectSortOrder={projectSortOrder}
|
projectSortOrder={projectSortOrder}
|
||||||
onProjectSortOrderChange={setProjectSortOrder}
|
onProjectSortOrderChange={setProjectSortOrder}
|
||||||
codeEditorSettings={codeEditorSettings}
|
codeEditorSettings={codeEditorSettings}
|
||||||
onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}
|
|
||||||
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
|
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
|
||||||
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
|
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
|
||||||
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
|
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ExternalLink, MessageSquare, Star } from 'lucide-react';
|
import { Cloud, ExternalLink, MessageSquare, Star, Users } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../../constants/branding';
|
||||||
import { IS_PLATFORM } from '../../../../constants/config';
|
import { IS_PLATFORM } from '../../../../constants/config';
|
||||||
import { useVersionCheck } from '../../../../hooks/useVersionCheck';
|
import { useVersionCheck } from '../../../../hooks/useVersionCheck';
|
||||||
import PremiumFeatureCard from '../PremiumFeatureCard';
|
import PremiumFeatureCard from '../PremiumFeatureCard';
|
||||||
import { Cloud, Users } from 'lucide-react';
|
|
||||||
|
|
||||||
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
|
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
|
||||||
const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
|
const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
|
||||||
@@ -40,7 +41,12 @@ export default function AboutTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-base font-semibold text-foreground">CloudCLI</span>
|
<span
|
||||||
|
className="text-base font-semibold text-foreground"
|
||||||
|
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
|
||||||
|
>
|
||||||
|
CloudCLI
|
||||||
|
</span>
|
||||||
<a
|
<a
|
||||||
href={releasesUrl}
|
href={releasesUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ type AppearanceSettingsTabProps = {
|
|||||||
projectSortOrder: ProjectSortOrder;
|
projectSortOrder: ProjectSortOrder;
|
||||||
onProjectSortOrderChange: (value: ProjectSortOrder) => void;
|
onProjectSortOrderChange: (value: ProjectSortOrder) => void;
|
||||||
codeEditorSettings: CodeEditorSettingsState;
|
codeEditorSettings: CodeEditorSettingsState;
|
||||||
onCodeEditorThemeChange: (value: 'dark' | 'light') => void;
|
|
||||||
onCodeEditorWordWrapChange: (value: boolean) => void;
|
onCodeEditorWordWrapChange: (value: boolean) => void;
|
||||||
onCodeEditorShowMinimapChange: (value: boolean) => void;
|
onCodeEditorShowMinimapChange: (value: boolean) => void;
|
||||||
onCodeEditorLineNumbersChange: (value: boolean) => void;
|
onCodeEditorLineNumbersChange: (value: boolean) => void;
|
||||||
@@ -22,7 +21,6 @@ export default function AppearanceSettingsTab({
|
|||||||
projectSortOrder,
|
projectSortOrder,
|
||||||
onProjectSortOrderChange,
|
onProjectSortOrderChange,
|
||||||
codeEditorSettings,
|
codeEditorSettings,
|
||||||
onCodeEditorThemeChange,
|
|
||||||
onCodeEditorWordWrapChange,
|
onCodeEditorWordWrapChange,
|
||||||
onCodeEditorShowMinimapChange,
|
onCodeEditorShowMinimapChange,
|
||||||
onCodeEditorLineNumbersChange,
|
onCodeEditorLineNumbersChange,
|
||||||
@@ -69,17 +67,6 @@ export default function AppearanceSettingsTab({
|
|||||||
|
|
||||||
<SettingsSection title={t('appearanceSettings.codeEditor.title')}>
|
<SettingsSection title={t('appearanceSettings.codeEditor.title')}>
|
||||||
<SettingsCard divided>
|
<SettingsCard divided>
|
||||||
<SettingsRow
|
|
||||||
label={t('appearanceSettings.codeEditor.theme.label')}
|
|
||||||
description={t('appearanceSettings.codeEditor.theme.description')}
|
|
||||||
>
|
|
||||||
<DarkModeToggle
|
|
||||||
checked={codeEditorSettings.theme === 'dark'}
|
|
||||||
onToggle={(enabled) => onCodeEditorThemeChange(enabled ? 'dark' : 'light')}
|
|
||||||
ariaLabel={t('appearanceSettings.codeEditor.theme.label')}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label={t('appearanceSettings.codeEditor.wordWrap.label')}
|
label={t('appearanceSettings.codeEditor.wordWrap.label')}
|
||||||
description={t('appearanceSettings.codeEditor.wordWrap.description')}
|
description={t('appearanceSettings.codeEditor.wordWrap.description')}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ExternalLink, Star, MessageSquare } from 'lucide-react';
|
import { ExternalLink, Star, MessageSquare } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../../../../constants/branding';
|
||||||
import { IS_PLATFORM } from '../../../../../../constants/config';
|
import { IS_PLATFORM } from '../../../../../../constants/config';
|
||||||
import type { ReleaseInfo } from '../../../../../../types/sharedTypes';
|
import type { ReleaseInfo } from '../../../../../../types/sharedTypes';
|
||||||
|
|
||||||
@@ -51,7 +53,12 @@ export default function VersionInfoSection({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-foreground">CloudCLI</span>
|
<span
|
||||||
|
className="text-sm font-semibold text-foreground"
|
||||||
|
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
|
||||||
|
>
|
||||||
|
CloudCLI
|
||||||
|
</span>
|
||||||
<a
|
<a
|
||||||
href={releasesUrl}
|
href={releasesUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Download, Loader2 } from 'lucide-react';
|
import { Download, ExternalLink, Eye, Loader2, Zap } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '../../../../../shared/view/ui';
|
import { Button, Input } from '../../../../../shared/view/ui';
|
||||||
import { authenticatedFetch } from '../../../../../utils/api';
|
import { authenticatedFetch } from '../../../../../utils/api';
|
||||||
import SettingsCard from '../../SettingsCard';
|
import SettingsCard from '../../SettingsCard';
|
||||||
import SettingsRow from '../../SettingsRow';
|
import SettingsRow from '../../SettingsRow';
|
||||||
import SettingsSection from '../../SettingsSection';
|
import SettingsSection from '../../SettingsSection';
|
||||||
import SettingsToggle from '../../SettingsToggle';
|
import SettingsToggle from '../../SettingsToggle';
|
||||||
|
|
||||||
|
const BROWSER_USE_GUIDE_URL = 'https://cloudcli.ai/docs/browser-use';
|
||||||
|
|
||||||
type BrowserUseSettings = {
|
type BrowserUseSettings = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
persistSessions: boolean;
|
||||||
|
defaultProfileName: string;
|
||||||
|
browserBackend: 'playwright' | 'camoufox-vnc';
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrowserUseStatus = {
|
type BrowserUseStatus = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
backend: 'playwright' | 'camoufox-vnc';
|
||||||
|
browserBackend: 'playwright' | 'camoufox-vnc';
|
||||||
playwrightInstalled: boolean;
|
playwrightInstalled: boolean;
|
||||||
chromiumInstalled: boolean;
|
chromiumInstalled: boolean;
|
||||||
|
camoufoxInstalled: boolean;
|
||||||
|
noVncInstalled: boolean;
|
||||||
|
x11vncInstalled: boolean;
|
||||||
installInProgress: boolean;
|
installInProgress: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
@@ -32,16 +42,20 @@ async function readJson<T>(response: Response): Promise<T> {
|
|||||||
export default function BrowserUseSettingsTab() {
|
export default function BrowserUseSettingsTab() {
|
||||||
const [settings, setSettings] = useState<BrowserUseSettings | null>(null);
|
const [settings, setSettings] = useState<BrowserUseSettings | null>(null);
|
||||||
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
|
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
|
||||||
|
const [hasLoadedSettings, setHasLoadedSettings] = useState(false);
|
||||||
const [isSettingsLoading, setIsSettingsLoading] = useState(true);
|
const [isSettingsLoading, setIsSettingsLoading] = useState(true);
|
||||||
const [isStatusLoading, setIsStatusLoading] = useState(true);
|
const [isStatusLoading, setIsStatusLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [profileNameDraft, setProfileNameDraft] = useState('default');
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
const settingsResponse = await authenticatedFetch('/api/browser-use/settings');
|
const settingsResponse = await authenticatedFetch('/api/browser-use/settings');
|
||||||
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
|
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
|
||||||
setSettings(settingsData.data.settings);
|
setSettings(settingsData.data.settings);
|
||||||
|
setHasLoadedSettings(true);
|
||||||
|
setProfileNameDraft(settingsData.data.settings.defaultProfileName || 'default');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadStatus = useCallback(async () => {
|
const loadStatus = useCallback(async () => {
|
||||||
@@ -52,6 +66,7 @@ export default function BrowserUseSettingsTab() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setHasLoadedSettings(false);
|
||||||
setIsSettingsLoading(true);
|
setIsSettingsLoading(true);
|
||||||
setIsStatusLoading(true);
|
setIsStatusLoading(true);
|
||||||
|
|
||||||
@@ -74,6 +89,7 @@ export default function BrowserUseSettingsTab() {
|
|||||||
});
|
});
|
||||||
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
|
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
|
||||||
setSettings(data.data.settings);
|
setSettings(data.data.settings);
|
||||||
|
setHasLoadedSettings(true);
|
||||||
window.dispatchEvent(new Event('browserUseSettingsChanged'));
|
window.dispatchEvent(new Event('browserUseSettingsChanged'));
|
||||||
setIsStatusLoading(true);
|
setIsStatusLoading(true);
|
||||||
await loadStatus();
|
await loadStatus();
|
||||||
@@ -101,8 +117,21 @@ export default function BrowserUseSettingsTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveProfileName = async () => {
|
||||||
|
const nextName = profileNameDraft.trim() || 'default';
|
||||||
|
setProfileNameDraft(nextName);
|
||||||
|
if (nextName === settings?.defaultProfileName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateSettings({ defaultProfileName: nextName });
|
||||||
|
};
|
||||||
|
|
||||||
const browserEnabled = settings?.enabled === true;
|
const browserEnabled = settings?.enabled === true;
|
||||||
const needsBrowserBinaries = Boolean(browserEnabled && status && (!status.playwrightInstalled || !status.chromiumInstalled));
|
const browserDisabled = hasLoadedSettings && settings?.enabled === false;
|
||||||
|
const persistSessions = settings?.persistSessions === true;
|
||||||
|
const selectedBackend = settings?.browserBackend || 'playwright';
|
||||||
|
const effectiveBackend = status?.backend || 'playwright';
|
||||||
|
const needsBrowserBinaries = Boolean(browserEnabled && status && !status.available);
|
||||||
const runtimeLabel = (installed?: boolean) => {
|
const runtimeLabel = (installed?: boolean) => {
|
||||||
if (isStatusLoading && !status) {
|
if (isStatusLoading && !status) {
|
||||||
return 'checking...';
|
return 'checking...';
|
||||||
@@ -114,33 +143,165 @@ export default function BrowserUseSettingsTab() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Browser"
|
title="Browser"
|
||||||
description="Allow agents to create guarded Playwright browser sessions that you can monitor from the Browser tab."
|
description="Give coding agents a working browser so they can open websites, test flows, capture screenshots, and help debug what users actually see."
|
||||||
>
|
>
|
||||||
<SettingsCard divided>
|
<SettingsCard divided>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Enable Browser"
|
label="Give Agents Browser Access"
|
||||||
description="Registers Browser for supported agents. Agents can create browser sessions; you can watch, stop, and delete them."
|
description="Let agents use a browser during coding tasks while you can watch live sessions, open them in a tab, and stop them at any time."
|
||||||
>
|
>
|
||||||
{isSettingsLoading && !settings ? (
|
{isSettingsLoading && !hasLoadedSettings ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
) : (
|
) : hasLoadedSettings ? (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
checked={browserEnabled}
|
checked={browserEnabled}
|
||||||
onChange={(value) => void updateSettings({ enabled: value })}
|
onChange={(value) => void updateSettings({ enabled: value })}
|
||||||
ariaLabel="Enable Browser"
|
ariaLabel="Give Agents Browser Access"
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">Unavailable</span>
|
||||||
)}
|
)}
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
|
{browserDisabled && (
|
||||||
|
<div className="px-4 py-4">
|
||||||
|
<a
|
||||||
|
href={BROWSER_USE_GUIDE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Read the Browser guide
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-4 py-4">
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{browserEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3 px-4 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground">Browser Engine</div>
|
||||||
|
<div className="mt-0.5 text-sm text-muted-foreground">
|
||||||
|
Pick the kind of browser experience agents should use for new sessions.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{([
|
||||||
|
{
|
||||||
|
value: 'playwright' as const,
|
||||||
|
label: 'Playwright',
|
||||||
|
description: 'Best for quick checks, screenshots, and automated page interaction when no manual login is needed.',
|
||||||
|
icon: Zap,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'camoufox-vnc' as const,
|
||||||
|
label: 'Camoufox + noVNC',
|
||||||
|
description: 'Best when a person may need to log in, approve a step, or watch the browser session live.',
|
||||||
|
icon: Eye,
|
||||||
|
},
|
||||||
|
]).map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const selected = selectedBackend === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void updateSettings({ browserBackend: option.value })}
|
||||||
|
disabled={isSaving || isSettingsLoading}
|
||||||
|
className={[
|
||||||
|
'group flex min-h-[88px] items-start gap-3 rounded-lg border px-3 py-3 text-left transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||||
|
selected
|
||||||
|
? 'border-primary bg-primary/5 text-foreground shadow-sm'
|
||||||
|
: 'border-border bg-background hover:border-foreground/20 hover:bg-muted/40',
|
||||||
|
(isSaving || isSettingsLoading) ? 'cursor-not-allowed opacity-60' : '',
|
||||||
|
].join(' ')}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
<span className={[
|
||||||
|
'mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border',
|
||||||
|
selected ? 'border-primary/30 bg-primary/10 text-primary' : 'border-border bg-muted/40 text-muted-foreground',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium">{option.label}</span>
|
||||||
|
<span className="mt-1 block text-xs leading-relaxed text-muted-foreground">{option.description}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
label="Remember Browser Logins"
|
||||||
|
description="Keep cookies and site storage in a named profile so agents can reuse signed-in sessions instead of starting from scratch."
|
||||||
|
>
|
||||||
|
{isSettingsLoading && !settings ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<SettingsToggle
|
||||||
|
checked={persistSessions}
|
||||||
|
onChange={(value) => void updateSettings({ persistSessions: value })}
|
||||||
|
ariaLabel="Remember Browser Logins"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
{persistSessions && (
|
||||||
|
<SettingsRow
|
||||||
|
label="Default Browser Profile"
|
||||||
|
description="New browser sessions use this profile by default, so saved logins stay tied to a predictable workspace."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={profileNameDraft}
|
||||||
|
onChange={(event) => setProfileNameDraft(event.target.value)}
|
||||||
|
onBlur={() => void saveProfileName()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSaving || isSettingsLoading}
|
||||||
|
className="w-40"
|
||||||
|
aria-label="Default Browser Profile"
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{browserEnabled && (
|
||||||
<div className="space-y-4 px-4 py-4">
|
<div className="space-y-4 px-4 py-4">
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="rounded-md border border-border px-2 py-1">
|
||||||
|
Backend: {effectiveBackend === 'camoufox-vnc' ? 'Camoufox + noVNC' : 'Playwright'}
|
||||||
|
</span>
|
||||||
<span className="rounded-md border border-border px-2 py-1">
|
<span className="rounded-md border border-border px-2 py-1">
|
||||||
Playwright: {runtimeLabel(status?.playwrightInstalled)}
|
Playwright: {runtimeLabel(status?.playwrightInstalled)}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-md border border-border px-2 py-1">
|
<span className="rounded-md border border-border px-2 py-1">
|
||||||
Chromium: {runtimeLabel(status?.chromiumInstalled)}
|
Chromium: {runtimeLabel(status?.chromiumInstalled)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="rounded-md border border-border px-2 py-1">
|
||||||
|
Camoufox: {runtimeLabel(status?.camoufoxInstalled)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md border border-border px-2 py-1">
|
||||||
|
noVNC: {runtimeLabel(status?.noVncInstalled)}
|
||||||
|
</span>
|
||||||
<span className="rounded-md border border-border px-2 py-1">
|
<span className="rounded-md border border-border px-2 py-1">
|
||||||
Status: {isStatusLoading && !status ? 'checking...' : status?.available ? 'ready' : browserEnabled ? 'setup required' : 'disabled'}
|
Status: {isStatusLoading && !status ? 'checking...' : status?.available ? 'ready' : browserEnabled ? 'setup required' : 'disabled'}
|
||||||
</span>
|
</span>
|
||||||
@@ -172,12 +333,17 @@ export default function BrowserUseSettingsTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
<a
|
||||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
|
href={BROWSER_USE_GUIDE_URL}
|
||||||
{error}
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
)}
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Read the Browser guide
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ITerminalOptions } from '@xterm/xterm';
|
import type { ITerminalOptions } from '@xterm/xterm';
|
||||||
|
|
||||||
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
|
|
||||||
export const SHELL_RESTART_DELAY_MS = 200;
|
export const SHELL_RESTART_DELAY_MS = 200;
|
||||||
export const TERMINAL_INIT_DELAY_MS = 100;
|
export const TERMINAL_INIT_DELAY_MS = 100;
|
||||||
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type UseShellConnectionOptions = {
|
|||||||
autoConnect: boolean;
|
autoConnect: boolean;
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
clearTerminalScreen: () => void;
|
clearTerminalScreen: () => void;
|
||||||
setAuthUrl: (nextAuthUrl: string) => void;
|
|
||||||
onOutputRef?: MutableRefObject<(() => void) | null>;
|
onOutputRef?: MutableRefObject<(() => void) | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ export function useShellConnection({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
setAuthUrl,
|
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
}: UseShellConnectionOptions): UseShellConnectionResult {
|
}: UseShellConnectionOptions): UseShellConnectionResult {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@@ -100,14 +98,8 @@ export function useShellConnection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'auth_url' || message.type === 'url_open') {
|
|
||||||
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
|
|
||||||
if (nextAuthUrl) {
|
|
||||||
setAuthUrl(nextAuthUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],
|
[handleProcessCompletion, onOutputRef, terminalRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(
|
const connectWebSocket = useCallback(
|
||||||
@@ -133,7 +125,6 @@ export function useShellConnection({
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
setAuthUrl('');
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const currentTerminal = terminalRef.current;
|
const currentTerminal = terminalRef.current;
|
||||||
@@ -196,7 +187,6 @@ export function useShellConnection({
|
|||||||
isPlainShellRef,
|
isPlainShellRef,
|
||||||
selectedProjectRef,
|
selectedProjectRef,
|
||||||
selectedSessionRef,
|
selectedSessionRef,
|
||||||
setAuthUrl,
|
|
||||||
terminalRef,
|
terminalRef,
|
||||||
wsRef,
|
wsRef,
|
||||||
],
|
],
|
||||||
@@ -225,8 +215,7 @@ export function useShellConnection({
|
|||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
forceRestartOnInitRef.current = false;
|
forceRestartOnInitRef.current = false;
|
||||||
setAuthUrl('');
|
}, [clearTerminalScreen, closeSocket]);
|
||||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { FitAddon } from '@xterm/addon-fit';
|
import type { FitAddon } from '@xterm/addon-fit';
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
|
||||||
import { useShellConnection } from './useShellConnection';
|
import { useShellConnection } from './useShellConnection';
|
||||||
import { useShellTerminal } from './useShellTerminal';
|
import { useShellTerminal } from './useShellTerminal';
|
||||||
|
|
||||||
@@ -22,15 +23,11 @@ export function useShellRuntime({
|
|||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
const [authUrl, setAuthUrl] = useState('');
|
|
||||||
const [authUrlVersion, setAuthUrlVersion] = useState(0);
|
|
||||||
|
|
||||||
const selectedProjectRef = useRef(selectedProject);
|
const selectedProjectRef = useRef(selectedProject);
|
||||||
const selectedSessionRef = useRef(selectedSession);
|
const selectedSessionRef = useRef(selectedSession);
|
||||||
const initialCommandRef = useRef(initialCommand);
|
const initialCommandRef = useRef(initialCommand);
|
||||||
const isPlainShellRef = useRef(isPlainShell);
|
const isPlainShellRef = useRef(isPlainShell);
|
||||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||||
const authUrlRef = useRef('');
|
|
||||||
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
||||||
|
|
||||||
// Keep mutable values in refs so websocket handlers always read current data.
|
// Keep mutable values in refs so websocket handlers always read current data.
|
||||||
@@ -42,12 +39,6 @@ export function useShellRuntime({
|
|||||||
onProcessCompleteRef.current = onProcessComplete;
|
onProcessCompleteRef.current = onProcessComplete;
|
||||||
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
||||||
|
|
||||||
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
|
|
||||||
authUrlRef.current = nextAuthUrl;
|
|
||||||
setAuthUrl(nextAuthUrl);
|
|
||||||
setAuthUrlVersion((previous) => previous + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeSocket = useCallback(() => {
|
const closeSocket = useCallback(() => {
|
||||||
const activeSocket = wsRef.current;
|
const activeSocket = wsRef.current;
|
||||||
if (!activeSocket) {
|
if (!activeSocket) {
|
||||||
@@ -64,32 +55,6 @@ export function useShellRuntime({
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
|
||||||
if (!url) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const popup = window.open(url, '_blank');
|
|
||||||
if (popup) {
|
|
||||||
try {
|
|
||||||
popup.opener = null;
|
|
||||||
} catch {
|
|
||||||
// Ignore cross-origin restrictions when trying to null opener.
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
|
|
||||||
if (!url) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return copyTextToClipboard(url);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
@@ -98,10 +63,6 @@ export function useShellRuntime({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
authUrlRef,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,7 +79,6 @@ export function useShellRuntime({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
setAuthUrl: setCurrentAuthUrl,
|
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,11 +116,7 @@ export function useShellRuntime({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
import { WebglAddon } from '@xterm/addon-webgl';
|
import { WebglAddon } from '@xterm/addon-webgl';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
import {
|
import {
|
||||||
CODEX_DEVICE_AUTH_URL,
|
|
||||||
TERMINAL_INIT_DELAY_MS,
|
TERMINAL_INIT_DELAY_MS,
|
||||||
TERMINAL_OPTIONS,
|
TERMINAL_OPTIONS,
|
||||||
TERMINAL_RESIZE_DELAY_MS,
|
TERMINAL_RESIZE_DELAY_MS,
|
||||||
} from '../constants/constants';
|
} from '../constants/constants';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
import {
|
||||||
import { isCodexLoginCommand } from '../utils/auth';
|
installMobileTerminalSelection,
|
||||||
|
type MobileTerminalSelectionManager,
|
||||||
|
} from '../utils/mobileTerminalSelection';
|
||||||
import { sendSocketMessage } from '../utils/socket';
|
import { sendSocketMessage } from '../utils/socket';
|
||||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||||
|
|
||||||
@@ -24,10 +27,6 @@ type UseShellTerminalOptions = {
|
|||||||
selectedProject: Project | null | undefined;
|
selectedProject: Project | null | undefined;
|
||||||
minimal: boolean;
|
minimal: boolean;
|
||||||
isRestarting: boolean;
|
isRestarting: boolean;
|
||||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
|
||||||
isPlainShellRef: MutableRefObject<boolean>;
|
|
||||||
authUrlRef: MutableRefObject<string>;
|
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,14 +44,11 @@ export function useShellTerminal({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
authUrlRef,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
}: UseShellTerminalOptions): UseShellTerminalResult {
|
}: UseShellTerminalOptions): UseShellTerminalResult {
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const resizeTimeoutRef = useRef<number | null>(null);
|
const resizeTimeoutRef = useRef<number | null>(null);
|
||||||
|
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
|
||||||
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
||||||
const hasSelectedProject = Boolean(selectedProject);
|
const hasSelectedProject = Boolean(selectedProject);
|
||||||
|
|
||||||
@@ -70,6 +66,11 @@ export function useShellTerminal({
|
|||||||
}, [terminalRef]);
|
}, [terminalRef]);
|
||||||
|
|
||||||
const disposeTerminal = useCallback(() => {
|
const disposeTerminal = useCallback(() => {
|
||||||
|
if (mobileSelectionRef.current) {
|
||||||
|
mobileSelectionRef.current.dispose();
|
||||||
|
mobileSelectionRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
terminalRef.current.dispose();
|
terminalRef.current.dispose();
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
@@ -80,7 +81,8 @@ export function useShellTerminal({
|
|||||||
}, [fitAddonRef, terminalRef]);
|
}, [fitAddonRef, terminalRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
|
const terminalContainer = terminalContainerRef.current;
|
||||||
|
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +104,28 @@ export function useShellTerminal({
|
|||||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTerminal.open(terminalContainerRef.current);
|
nextTerminal.open(terminalContainer);
|
||||||
|
mobileSelectionRef.current = installMobileTerminalSelection(
|
||||||
|
nextTerminal,
|
||||||
|
terminalContainer,
|
||||||
|
{
|
||||||
|
onFontSizeChange: (fontSize) => {
|
||||||
|
nextTerminal.options.fontSize = fontSize;
|
||||||
|
|
||||||
|
const currentFitAddon = fitAddonRef.current;
|
||||||
|
if (currentFitAddon) {
|
||||||
|
currentFitAddon.fit();
|
||||||
|
sendSocketMessage(wsRef.current, {
|
||||||
|
type: 'resize',
|
||||||
|
cols: nextTerminal.cols,
|
||||||
|
rows: nextTerminal.rows,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nextTerminal.refresh(0, nextTerminal.rows - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const copyTerminalSelection = async () => {
|
const copyTerminalSelection = async () => {
|
||||||
const selection = nextTerminal.getSelection();
|
const selection = nextTerminal.getSelection();
|
||||||
@@ -133,29 +156,9 @@ export function useShellTerminal({
|
|||||||
void copyTextToClipboard(selection);
|
void copyTextToClipboard(selection);
|
||||||
};
|
};
|
||||||
|
|
||||||
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
|
terminalContainer.addEventListener('copy', handleTerminalCopy);
|
||||||
|
|
||||||
nextTerminal.attachCustomKeyEventHandler((event) => {
|
nextTerminal.attachCustomKeyEventHandler((event) => {
|
||||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
|
||||||
? CODEX_DEVICE_AUTH_URL
|
|
||||||
: authUrlRef.current;
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.type === 'keydown' &&
|
|
||||||
minimal &&
|
|
||||||
isPlainShellRef.current &&
|
|
||||||
activeAuthUrl &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
event.key?.toLowerCase() === 'c'
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
void copyAuthUrlToClipboard(activeAuthUrl);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type === 'keydown' &&
|
event.type === 'keydown' &&
|
||||||
(event.ctrlKey || event.metaKey) &&
|
(event.ctrlKey || event.metaKey) &&
|
||||||
@@ -240,10 +243,10 @@ export function useShellTerminal({
|
|||||||
}, TERMINAL_RESIZE_DELAY_MS);
|
}, TERMINAL_RESIZE_DELAY_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(terminalContainerRef.current);
|
resizeObserver.observe(terminalContainer);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
terminalContainer.removeEventListener('copy', handleTerminalCopy);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
if (resizeTimeoutRef.current !== null) {
|
if (resizeTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(resizeTimeoutRef.current);
|
window.clearTimeout(resizeTimeoutRef.current);
|
||||||
@@ -254,16 +257,12 @@ export function useShellTerminal({
|
|||||||
disposeTerminal();
|
disposeTerminal();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
authUrlRef,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
disposeTerminal,
|
disposeTerminal,
|
||||||
fitAddonRef,
|
fitAddonRef,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
isRestarting,
|
isRestarting,
|
||||||
minimal,
|
|
||||||
hasSelectedProject,
|
hasSelectedProject,
|
||||||
|
minimal,
|
||||||
selectedProjectKey,
|
selectedProjectKey,
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm';
|
|||||||
|
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
|
|
||||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
|
||||||
|
|
||||||
export type ShellInitMessage = {
|
export type ShellInitMessage = {
|
||||||
type: 'init';
|
type: 'init';
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -54,7 +52,6 @@ export type ShellSharedRefs = {
|
|||||||
wsRef: MutableRefObject<WebSocket | null>;
|
wsRef: MutableRefObject<WebSocket | null>;
|
||||||
terminalRef: MutableRefObject<Terminal | null>;
|
terminalRef: MutableRefObject<Terminal | null>;
|
||||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||||
authUrlRef: MutableRefObject<string>;
|
|
||||||
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
||||||
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
||||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||||
@@ -69,10 +66,6 @@ export type UseShellRuntimeResult = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
authUrl: string;
|
|
||||||
authUrlVersion: number;
|
|
||||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
import type { ProjectSession } from '../../../types/app';
|
import type { ProjectSession } from '../../../types/app';
|
||||||
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
|
|
||||||
|
|
||||||
export function isCodexLoginCommand(command: string | null | undefined): boolean {
|
|
||||||
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
|
|
||||||
if (isCodexLoginCommand(command)) {
|
|
||||||
return CODEX_DEVICE_AUTH_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return authUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
|
|||||||
return session.__provider === 'cursor'
|
return session.__provider === 'cursor'
|
||||||
? session.name || 'Untitled Session'
|
? session.name || 'Untitled Session'
|
||||||
: session.summary || 'New Session';
|
: session.summary || 'New Session';
|
||||||
}
|
}
|
||||||
|
|||||||
1068
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
1068
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,12 +59,8 @@ export default function Shell({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
} = useShellRuntime({
|
} = useShellRuntime({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -243,15 +239,7 @@ export default function Shell({
|
|||||||
if (minimal) {
|
if (minimal) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ShellMinimalView
|
<ShellMinimalView terminalContainerRef={terminalContainerRef} />
|
||||||
terminalContainerRef={terminalContainerRef}
|
|
||||||
authUrl={authUrl}
|
|
||||||
authUrlVersion={authUrlVersion}
|
|
||||||
initialCommand={initialCommand}
|
|
||||||
isConnected={isConnected}
|
|
||||||
openAuthUrlInBrowser={openAuthUrlInBrowser}
|
|
||||||
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
|
|
||||||
/>
|
|
||||||
<TerminalShortcutsPanel
|
<TerminalShortcutsPanel
|
||||||
wsRef={wsRef}
|
wsRef={wsRef}
|
||||||
terminalRef={terminalRef}
|
terminalRef={terminalRef}
|
||||||
@@ -322,7 +310,7 @@ export default function Shell({
|
|||||||
|
|
||||||
{cliPromptOptions && isConnected && (
|
{cliPromptOptions && isConnected && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 bottom-0 z-10 border-t border-gray-700/80 bg-gray-800/95 px-3 py-2 backdrop-blur-sm"
|
className="absolute inset-x-0 bottom-0 z-10 border-t border-gray-700/80 bg-gray-800/95 px-3 py-2 backdrop-blur-sm md:hidden"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
@@ -1,45 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import type { AuthCopyStatus } from '../../types/types';
|
|
||||||
import { resolveAuthUrlForDisplay } from '../../utils/auth';
|
|
||||||
|
|
||||||
type ShellMinimalViewProps = {
|
type ShellMinimalViewProps = {
|
||||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
terminalContainerRef: RefObject<HTMLDivElement>;
|
||||||
authUrl: string;
|
|
||||||
authUrlVersion: number;
|
|
||||||
initialCommand: string | null | undefined;
|
|
||||||
isConnected: boolean;
|
|
||||||
openAuthUrlInBrowser: (url: string) => boolean;
|
|
||||||
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ShellMinimalView({
|
export default function ShellMinimalView({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
initialCommand,
|
|
||||||
isConnected,
|
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
}: ShellMinimalViewProps) {
|
}: ShellMinimalViewProps) {
|
||||||
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
|
|
||||||
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
|
|
||||||
|
|
||||||
const displayAuthUrl = useMemo(
|
|
||||||
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
|
|
||||||
[authUrl, initialCommand],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
|
|
||||||
useEffect(() => {
|
|
||||||
setAuthUrlCopyStatus('idle');
|
|
||||||
setIsAuthPanelHidden(false);
|
|
||||||
}, [authUrlVersion, displayAuthUrl, isConnected]);
|
|
||||||
|
|
||||||
const hasAuthUrl = Boolean(displayAuthUrl);
|
|
||||||
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
|
|
||||||
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full bg-gray-900">
|
<div className="relative h-full w-full bg-gray-900">
|
||||||
<div
|
<div
|
||||||
@@ -47,67 +14,6 @@ export default function ShellMinimalView({
|
|||||||
className="h-full w-full focus:outline-none"
|
className="h-full w-full focus:outline-none"
|
||||||
style={{ outline: 'none' }}
|
style={{ outline: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showMobileAuthPanel && (
|
|
||||||
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAuthPanelHidden(true)}
|
|
||||||
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
Hide
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayAuthUrl}
|
|
||||||
readOnly
|
|
||||||
onClick={(event) => event.currentTarget.select()}
|
|
||||||
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
aria-label="Authentication URL"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
openAuthUrlInBrowser(displayAuthUrl);
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Open URL
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
|
|
||||||
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showMobileAuthPanelToggle && (
|
|
||||||
<div className="absolute bottom-14 right-3 z-20 md:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAuthPanelHidden(false)}
|
|
||||||
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Show login URL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const MOBILE_KEYS: Shortcut[] = [
|
|||||||
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
|
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
|
||||||
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
|
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
|
||||||
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
|
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
|
||||||
|
{ type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ARROW_ICONS = {
|
const ARROW_ICONS = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Activity, Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw,
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { Button, Input, Tooltip } from '../../../../shared/view/ui';
|
import { Button, Input, Tooltip } from '../../../../shared/view/ui';
|
||||||
|
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../../constants/branding';
|
||||||
import { IS_PLATFORM } from '../../../../constants/config';
|
import { IS_PLATFORM } from '../../../../constants/config';
|
||||||
import { cn } from '../../../../lib/utils';
|
import { cn } from '../../../../lib/utils';
|
||||||
import type { SidebarSearchMode } from '../../types/types';
|
import type { SidebarSearchMode } from '../../types/types';
|
||||||
@@ -67,7 +68,12 @@ export default function SidebarHeader({
|
|||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="truncate text-sm font-normal tracking-tight text-foreground">{t('app.title')}</h1>
|
<h1
|
||||||
|
className="truncate text-sm font-bold tracking-tight text-foreground"
|
||||||
|
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
|
||||||
|
>
|
||||||
|
{t('app.title')}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export default function SidebarSessionItem({
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
|
<div className="min-w-0 flex-1 truncate text-sm font-normal text-foreground">{sessionView.sessionName}</div>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<span className="ml-auto flex-shrink-0">
|
<span className="ml-auto flex-shrink-0">
|
||||||
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||||
@@ -226,7 +226,7 @@ export default function SidebarSessionItem({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
|
<div className="min-w-0 flex-1 truncate text-sm font-normal text-foreground">{sessionView.sessionName}</div>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
2
src/constants/branding.ts
Normal file
2
src/constants/branding.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const CLOUDCLI_WORDMARK_FONT_FAMILY =
|
||||||
|
'ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji';
|
||||||
@@ -41,7 +41,7 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
|
|
||||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (themeColorMeta) {
|
if (themeColorMeta) {
|
||||||
themeColorMeta.setAttribute('content', '#0c1117'); // Dark background color (hsl(222.2 84% 4.9%))
|
themeColorMeta.setAttribute('content', '#141414'); // Dark background color (hsl(0 0% 8%))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
@@ -55,7 +55,7 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
|
|
||||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (themeColorMeta) {
|
if (themeColorMeta) {
|
||||||
themeColorMeta.setAttribute('content', '#ffffff'); // Light background color
|
themeColorMeta.setAttribute('content', '#f6f4ef'); // Light background color (warm cream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isDarkMode]);
|
}, [isDarkMode]);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useEffect, useReducer, useRef } from 'react';
|
import { useEffect, useReducer, useRef } from 'react';
|
||||||
|
|
||||||
type UiPreferences = {
|
type UiPreferences = {
|
||||||
autoExpandTools: boolean;
|
|
||||||
showRawParameters: boolean;
|
showRawParameters: boolean;
|
||||||
showThinking: boolean;
|
showThinking: boolean;
|
||||||
autoScrollToBottom: boolean;
|
|
||||||
sendByCtrlEnter: boolean;
|
sendByCtrlEnter: boolean;
|
||||||
sidebarVisible: boolean;
|
sidebarVisible: boolean;
|
||||||
voiceEnabled: boolean;
|
voiceEnabled: boolean;
|
||||||
@@ -34,10 +32,8 @@ type UiPreferencesAction =
|
|||||||
| ResetPreferencesAction;
|
| ResetPreferencesAction;
|
||||||
|
|
||||||
const DEFAULTS: UiPreferences = {
|
const DEFAULTS: UiPreferences = {
|
||||||
autoExpandTools: false,
|
|
||||||
showRawParameters: false,
|
showRawParameters: false,
|
||||||
showThinking: true,
|
showThinking: true,
|
||||||
autoScrollToBottom: true,
|
|
||||||
sendByCtrlEnter: false,
|
sendByCtrlEnter: false,
|
||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
voiceEnabled: false,
|
voiceEnabled: false,
|
||||||
|
|||||||
@@ -54,14 +54,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "Darstellung",
|
"appearance": "Darstellung",
|
||||||
"toolDisplay": "Werkzeuganzeige",
|
"toolDisplay": "Werkzeuganzeige",
|
||||||
"viewOptions": "Anzeigeoptionen",
|
|
||||||
"inputSettings": "Eingabeeinstellungen"
|
"inputSettings": "Eingabeeinstellungen"
|
||||||
},
|
},
|
||||||
"darkMode": "Darkmode",
|
"darkMode": "Darkmode",
|
||||||
"autoExpandTools": "Werkzeuge automatisch erweitern",
|
|
||||||
"showRawParameters": "Rohe Parameter anzeigen",
|
"showRawParameters": "Rohe Parameter anzeigen",
|
||||||
"showThinking": "Denken anzeigen",
|
"showThinking": "Denken anzeigen",
|
||||||
"autoScrollToBottom": "Automatisch nach unten scrollen",
|
|
||||||
"sendByCtrlEnter": "Mit Strg+Enter senden",
|
"sendByCtrlEnter": "Mit Strg+Enter senden",
|
||||||
"sendByCtrlEnterDescription": "Wenn aktiviert, sendet Strg+Enter die Nachricht anstelle von Enter. Dies ist nützlich für IME-Benutzer:innen, um versehentliches Senden zu vermeiden.",
|
"sendByCtrlEnterDescription": "Wenn aktiviert, sendet Strg+Enter die Nachricht anstelle von Enter. Dies ist nützlich für IME-Benutzer:innen, um versehentliches Senden zu vermeiden.",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
|
|||||||
@@ -32,5 +32,10 @@
|
|||||||
"binaryFile": {
|
"binaryFile": {
|
||||||
"title": "Binary File",
|
"title": "Binary File",
|
||||||
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
|
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
|
||||||
|
},
|
||||||
|
"filePreview": {
|
||||||
|
"loading": "Loading preview...",
|
||||||
|
"error": "Unable to display this file.",
|
||||||
|
"openInNewTab": "Open in new tab"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,14 +70,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"toolDisplay": "Tool Display",
|
"toolDisplay": "Tool Display",
|
||||||
"viewOptions": "View Options",
|
|
||||||
"inputSettings": "Input Settings"
|
"inputSettings": "Input Settings"
|
||||||
},
|
},
|
||||||
"darkMode": "Dark Mode",
|
"darkMode": "Dark Mode",
|
||||||
"autoExpandTools": "Auto-expand tools",
|
|
||||||
"showRawParameters": "Show raw parameters",
|
"showRawParameters": "Show raw parameters",
|
||||||
"showThinking": "Show thinking",
|
"showThinking": "Show thinking",
|
||||||
"autoScrollToBottom": "Auto-scroll to bottom",
|
|
||||||
"sendByCtrlEnter": "Send by Ctrl+Enter",
|
"sendByCtrlEnter": "Send by Ctrl+Enter",
|
||||||
"voiceEnabled": "Voice (mic + read aloud)",
|
"voiceEnabled": "Voice (mic + read aloud)",
|
||||||
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",
|
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",
|
||||||
|
|||||||
@@ -54,14 +54,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "Apparence",
|
"appearance": "Apparence",
|
||||||
"toolDisplay": "Affichage des outils",
|
"toolDisplay": "Affichage des outils",
|
||||||
"viewOptions": "Options d'affichage",
|
|
||||||
"inputSettings": "Paramètres de saisie"
|
"inputSettings": "Paramètres de saisie"
|
||||||
},
|
},
|
||||||
"darkMode": "Mode sombre",
|
"darkMode": "Mode sombre",
|
||||||
"autoExpandTools": "Développer automatiquement les outils",
|
|
||||||
"showRawParameters": "Afficher les paramètres bruts",
|
"showRawParameters": "Afficher les paramètres bruts",
|
||||||
"showThinking": "Afficher la réflexion",
|
"showThinking": "Afficher la réflexion",
|
||||||
"autoScrollToBottom": "Défilement automatique vers le bas",
|
|
||||||
"sendByCtrlEnter": "Envoyer avec Ctrl+Entrée",
|
"sendByCtrlEnter": "Envoyer avec Ctrl+Entrée",
|
||||||
"sendByCtrlEnterDescription": "Lorsqu'activé, appuyer sur Ctrl+Entrée envoie le message au lieu de simplement Entrée. Utile pour les utilisateurs IME pour éviter les envois accidentels.",
|
"sendByCtrlEnterDescription": "Lorsqu'activé, appuyer sur Ctrl+Entrée envoie le message au lieu de simplement Entrée. Utile pour les utilisateurs IME pour éviter les envois accidentels.",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
|
|||||||
@@ -54,14 +54,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "Aspetto",
|
"appearance": "Aspetto",
|
||||||
"toolDisplay": "Visualizzazione strumenti",
|
"toolDisplay": "Visualizzazione strumenti",
|
||||||
"viewOptions": "Opzioni visualizzazione",
|
|
||||||
"inputSettings": "Impostazioni input"
|
"inputSettings": "Impostazioni input"
|
||||||
},
|
},
|
||||||
"darkMode": "Modalità scura",
|
"darkMode": "Modalità scura",
|
||||||
"autoExpandTools": "Espandi strumenti automaticamente",
|
|
||||||
"showRawParameters": "Mostra parametri grezzi",
|
"showRawParameters": "Mostra parametri grezzi",
|
||||||
"showThinking": "Mostra ragionamento",
|
"showThinking": "Mostra ragionamento",
|
||||||
"autoScrollToBottom": "Scorrimento automatico in basso",
|
|
||||||
"sendByCtrlEnter": "Invia con Ctrl+Invio",
|
"sendByCtrlEnter": "Invia con Ctrl+Invio",
|
||||||
"sendByCtrlEnterDescription": "Se abilitato, premere Ctrl+Invio invierà il messaggio invece di Invio. Utile per gli utenti IME per evitare invii accidentali.",
|
"sendByCtrlEnterDescription": "Se abilitato, premere Ctrl+Invio invierà il messaggio invece di Invio. Utile per gli utenti IME per evitare invii accidentali.",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
|
|||||||
@@ -54,14 +54,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "外観",
|
"appearance": "外観",
|
||||||
"toolDisplay": "ツール表示",
|
"toolDisplay": "ツール表示",
|
||||||
"viewOptions": "表示オプション",
|
|
||||||
"inputSettings": "入力設定"
|
"inputSettings": "入力設定"
|
||||||
},
|
},
|
||||||
"darkMode": "ダークモード",
|
"darkMode": "ダークモード",
|
||||||
"autoExpandTools": "ツールを自動展開",
|
|
||||||
"showRawParameters": "生パラメータを表示",
|
"showRawParameters": "生パラメータを表示",
|
||||||
"showThinking": "思考を表示",
|
"showThinking": "思考を表示",
|
||||||
"autoScrollToBottom": "自動スクロール",
|
|
||||||
"sendByCtrlEnter": "Ctrl+Enterで送信",
|
"sendByCtrlEnter": "Ctrl+Enterで送信",
|
||||||
"sendByCtrlEnterDescription": "有効にすると、Enterではなく Ctrl+Enter でメッセージを送信します。IMEユーザーの誤送信防止に便利です。",
|
"sendByCtrlEnterDescription": "有効にすると、Enterではなく Ctrl+Enter でメッセージを送信します。IMEユーザーの誤送信防止に便利です。",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
|
|||||||
@@ -54,14 +54,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "외관",
|
"appearance": "외관",
|
||||||
"toolDisplay": "도구 표시",
|
"toolDisplay": "도구 표시",
|
||||||
"viewOptions": "보기 옵션",
|
|
||||||
"inputSettings": "입력 설정"
|
"inputSettings": "입력 설정"
|
||||||
},
|
},
|
||||||
"darkMode": "다크 모드",
|
"darkMode": "다크 모드",
|
||||||
"autoExpandTools": "도구 자동 펼치기",
|
|
||||||
"showRawParameters": "Raw 파라미터 표시",
|
"showRawParameters": "Raw 파라미터 표시",
|
||||||
"showThinking": "생각 과정 표시",
|
"showThinking": "생각 과정 표시",
|
||||||
"autoScrollToBottom": "자동 스크롤",
|
|
||||||
"sendByCtrlEnter": "Ctrl+Enter로 전송",
|
"sendByCtrlEnter": "Ctrl+Enter로 전송",
|
||||||
"sendByCtrlEnterDescription": "활성화하면 Enter 대신 Ctrl+Enter로 메시지를 전송합니다. IME 사용자가 실수로 전송하는 것을 방지하는 데 유용합니다.",
|
"sendByCtrlEnterDescription": "활성화하면 Enter 대신 Ctrl+Enter로 메시지를 전송합니다. IME 사용자가 실수로 전송하는 것을 방지하는 데 유용합니다.",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
|
|||||||
@@ -54,14 +54,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "Внешний вид",
|
"appearance": "Внешний вид",
|
||||||
"toolDisplay": "Отображение инструментов",
|
"toolDisplay": "Отображение инструментов",
|
||||||
"viewOptions": "Параметры просмотра",
|
|
||||||
"inputSettings": "Настройки ввода"
|
"inputSettings": "Настройки ввода"
|
||||||
},
|
},
|
||||||
"darkMode": "Темная тема",
|
"darkMode": "Темная тема",
|
||||||
"autoExpandTools": "Автоматически разворачивать инструменты",
|
|
||||||
"showRawParameters": "Показывать сырые параметры",
|
"showRawParameters": "Показывать сырые параметры",
|
||||||
"showThinking": "Показывать размышления",
|
"showThinking": "Показывать размышления",
|
||||||
"autoScrollToBottom": "Автопрокрутка вниз",
|
|
||||||
"sendByCtrlEnter": "Отправка по Ctrl+Enter",
|
"sendByCtrlEnter": "Отправка по Ctrl+Enter",
|
||||||
"sendByCtrlEnterDescription": "Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.",
|
"sendByCtrlEnterDescription": "Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
|
|||||||
@@ -54,14 +54,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "Görünüm",
|
"appearance": "Görünüm",
|
||||||
"toolDisplay": "Araç Gösterimi",
|
"toolDisplay": "Araç Gösterimi",
|
||||||
"viewOptions": "Görünüm Seçenekleri",
|
|
||||||
"inputSettings": "Girdi Ayarları"
|
"inputSettings": "Girdi Ayarları"
|
||||||
},
|
},
|
||||||
"darkMode": "Koyu Mod",
|
"darkMode": "Koyu Mod",
|
||||||
"autoExpandTools": "Araçları otomatik genişlet",
|
|
||||||
"showRawParameters": "Ham parametreleri göster",
|
"showRawParameters": "Ham parametreleri göster",
|
||||||
"showThinking": "Düşünmeyi göster",
|
"showThinking": "Düşünmeyi göster",
|
||||||
"autoScrollToBottom": "Otomatik en alta kaydır",
|
|
||||||
"sendByCtrlEnter": "Ctrl+Enter ile gönder",
|
"sendByCtrlEnter": "Ctrl+Enter ile gönder",
|
||||||
"sendByCtrlEnterDescription": "Etkinleştirildiğinde, Ctrl+Enter'a basmak yalnız Enter yerine mesajı gönderir. IME (girdi metot düzenleyici) kullananlar için yanlışlıkla göndermeyi önler.",
|
"sendByCtrlEnterDescription": "Etkinleştirildiğinde, Ctrl+Enter'a basmak yalnız Enter yerine mesajı gönderir. IME (girdi metot düzenleyici) kullananlar için yanlışlıkla göndermeyi önler.",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
|
|||||||
@@ -54,14 +54,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "外观",
|
"appearance": "外观",
|
||||||
"toolDisplay": "工具显示",
|
"toolDisplay": "工具显示",
|
||||||
"viewOptions": "视图选项",
|
|
||||||
"inputSettings": "输入设置"
|
"inputSettings": "输入设置"
|
||||||
},
|
},
|
||||||
"darkMode": "深色模式",
|
"darkMode": "深色模式",
|
||||||
"autoExpandTools": "自动展开工具",
|
|
||||||
"showRawParameters": "显示原始参数",
|
"showRawParameters": "显示原始参数",
|
||||||
"showThinking": "显示思考过程",
|
"showThinking": "显示思考过程",
|
||||||
"autoScrollToBottom": "自动滚动到底部",
|
|
||||||
"sendByCtrlEnter": "使用 Ctrl+Enter 发送",
|
"sendByCtrlEnter": "使用 Ctrl+Enter 发送",
|
||||||
"sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。",
|
"sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
|
|||||||
@@ -54,14 +54,11 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"appearance": "外觀",
|
"appearance": "外觀",
|
||||||
"toolDisplay": "工具顯示",
|
"toolDisplay": "工具顯示",
|
||||||
"viewOptions": "檢視選項",
|
|
||||||
"inputSettings": "輸入設定"
|
"inputSettings": "輸入設定"
|
||||||
},
|
},
|
||||||
"darkMode": "深色模式",
|
"darkMode": "深色模式",
|
||||||
"autoExpandTools": "自動展開工具",
|
|
||||||
"showRawParameters": "顯示原始參數",
|
"showRawParameters": "顯示原始參數",
|
||||||
"showThinking": "顯示思考過程",
|
"showThinking": "顯示思考過程",
|
||||||
"autoScrollToBottom": "自動捲動到底部",
|
|
||||||
"sendByCtrlEnter": "使用 Ctrl+Enter 傳送",
|
"sendByCtrlEnter": "使用 Ctrl+Enter 傳送",
|
||||||
"sendByCtrlEnterDescription": "啟用後,按 Ctrl+Enter 傳送訊息,而不是僅按 Enter。這對於使用輸入法的使用者可以避免意外傳送。",
|
"sendByCtrlEnterDescription": "啟用後,按 Ctrl+Enter 傳送訊息,而不是僅按 Enter。這對於使用輸入法的使用者可以避免意外傳送。",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
|
|||||||
148
src/index.css
148
src/index.css
@@ -23,37 +23,37 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 44 22% 96%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 36 25% 4%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 36 25% 4%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 36 25% 4%;
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--primary: 221.2 83.2% 53.3%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 44 15% 91%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 36 15% 18%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 44 15% 91%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 40 5% 44%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 44 15% 91%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 36 15% 18%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 44 14% 87%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 44 14% 87%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring: 221.2 83.2% 53.3%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
/* Nav design tokens */
|
/* Nav design tokens */
|
||||||
--nav-glass-bg: 0 0% 100% / 0.7;
|
--nav-glass-bg: 44 22% 96% / 0.7;
|
||||||
--nav-glass-blur: 20px;
|
--nav-glass-blur: 20px;
|
||||||
--nav-glass-saturate: 1.8;
|
--nav-glass-saturate: 1.8;
|
||||||
--nav-tab-glow: 221.2 83.2% 53.3% / 0.18;
|
--nav-tab-glow: 221.2 83.2% 53.3% / 0.18;
|
||||||
--nav-tab-ring: 221.2 83.2% 53.3% / 0.10;
|
--nav-tab-ring: 221.2 83.2% 53.3% / 0.10;
|
||||||
--nav-float-shadow: 0 0% 0% / 0.06;
|
--nav-float-shadow: 0 0% 0% / 0.06;
|
||||||
--nav-float-ring: 214.3 31.8% 91.4% / 0.5;
|
--nav-float-ring: 44 14% 87% / 0.5;
|
||||||
--nav-divider-color: 214.3 31.8% 91.4% / 0.5;
|
--nav-divider-color: 44 14% 87% / 0.5;
|
||||||
--nav-input-bg: 210 40% 96.1% / 0.5;
|
--nav-input-bg: 44 15% 91% / 0.5;
|
||||||
--nav-input-focus-ring: 221.2 83.2% 53.3% / 0.22;
|
--nav-input-focus-ring: 221.2 83.2% 53.3% / 0.22;
|
||||||
|
|
||||||
/* Safe area CSS variables */
|
/* Safe area CSS variables */
|
||||||
@@ -85,36 +85,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 0 0% 8%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 40 8% 93%;
|
||||||
--card: 217.2 91.2% 8%;
|
--card: 0 0% 12%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 40 8% 93%;
|
||||||
--popover: 217.2 91.2% 8%;
|
--popover: 0 0% 12%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 40 8% 93%;
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--primary: 217.2 91.2% 59.8%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 0 0% 8%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 0 0% 17%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 40 8% 93%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 0 0% 17%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 0 0% 60%;
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: 0 0% 17%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 40 8% 93%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 40 8% 93%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 0 0% 17%;
|
||||||
--input: 220 13% 46%;
|
--input: 0 0% 23%;
|
||||||
--ring: 217.2 91.2% 59.8%;
|
--ring: 217.2 91.2% 59.8%;
|
||||||
|
|
||||||
/* Nav design tokens — dark overrides */
|
/* Nav design tokens — dark overrides */
|
||||||
--nav-glass-bg: 217.2 91.2% 8% / 0.55;
|
--nav-glass-bg: 0 0% 12% / 0.55;
|
||||||
--nav-glass-blur: 24px;
|
--nav-glass-blur: 24px;
|
||||||
--nav-glass-saturate: 1.6;
|
--nav-glass-saturate: 1.6;
|
||||||
--nav-tab-glow: 217.2 91.2% 59.8% / 0.25;
|
--nav-tab-glow: 217.2 91.2% 59.8% / 0.25;
|
||||||
--nav-tab-ring: 217.2 91.2% 59.8% / 0.15;
|
--nav-tab-ring: 217.2 91.2% 59.8% / 0.15;
|
||||||
--nav-float-shadow: 0 0% 0% / 0.35;
|
--nav-float-shadow: 0 0% 0% / 0.35;
|
||||||
--nav-float-ring: 217.2 32.6% 17.5% / 0.3;
|
--nav-float-ring: 0 0% 17% / 0.3;
|
||||||
--nav-divider-color: 217.2 32.6% 17.5% / 0.5;
|
--nav-divider-color: 0 0% 17% / 0.5;
|
||||||
--nav-input-bg: 217.2 32.6% 17.5% / 0.5;
|
--nav-input-bg: 0 0% 17% / 0.5;
|
||||||
--nav-input-focus-ring: 217.2 91.2% 59.8% / 0.25;
|
--nav-input-focus-ring: 217.2 91.2% 59.8% / 0.25;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,8 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
|
font-family: "Encode Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -139,6 +140,12 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
/* The app shell is a fixed inset-0 container (see AppContent), so the
|
||||||
|
document itself never needs to scroll. Clipping it removes the phantom
|
||||||
|
full-height page scrollbar and disables the browser pull-to-refresh
|
||||||
|
gesture that reloads the page when scrolling up on mobile. */
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Root element with safe area padding for PWA */
|
/* Root element with safe area padding for PWA */
|
||||||
@@ -344,7 +351,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||||
background: rgba(31, 41, 55, 0.3);
|
background: rgba(38, 38, 38, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
@@ -363,7 +370,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark::-webkit-scrollbar-track {
|
.dark::-webkit-scrollbar-track {
|
||||||
background: rgba(31, 41, 55, 0.5);
|
background: rgba(38, 38, 38, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark::-webkit-scrollbar-thumb {
|
.dark::-webkit-scrollbar-thumb {
|
||||||
@@ -378,7 +385,7 @@
|
|||||||
/* Firefox scrollbar styles */
|
/* Firefox scrollbar styles */
|
||||||
.dark {
|
.dark {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(107, 114, 128, 0.5) rgba(31, 41, 55, 0.5);
|
scrollbar-color: rgba(115, 115, 115, 0.5) rgba(38, 38, 38, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure checkbox styling is preserved */
|
/* Ensure checkbox styling is preserved */
|
||||||
@@ -469,7 +476,7 @@
|
|||||||
|
|
||||||
/* Fix focus-within container issues in dark mode */
|
/* Fix focus-within container issues in dark mode */
|
||||||
.dark .focus-within\:ring-2:focus-within {
|
.dark .focus-within\:ring-2:focus-within {
|
||||||
background-color: rgb(31 41 55) !important; /* gray-800 */
|
background-color: rgb(20 20 20) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure textarea remains transparent with visible text */
|
/* Ensure textarea remains transparent with visible text */
|
||||||
@@ -562,7 +569,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-composer-shell {
|
.chat-composer-shell {
|
||||||
contain: layout style paint;
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-activity-enter {
|
||||||
|
animation: chat-activity-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
will-change: transform, opacity, filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-activity-exit {
|
||||||
|
animation: chat-activity-exit 220ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
will-change: transform, opacity, filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-activity-tab {
|
||||||
|
clip-path: inset(-8px -8px 0 -8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message {
|
.chat-message {
|
||||||
@@ -867,12 +890,12 @@
|
|||||||
|
|
||||||
/* Fix focus ring offset color in dark mode */
|
/* Fix focus ring offset color in dark mode */
|
||||||
.dark [class*="ring-offset"] {
|
.dark [class*="ring-offset"] {
|
||||||
--tw-ring-offset-color: rgb(31 41 55); /* gray-800 */
|
--tw-ring-offset-color: rgb(20 20 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure buttons don't show white backgrounds in dark mode */
|
/* Ensure buttons don't show white backgrounds in dark mode */
|
||||||
.dark button:focus {
|
.dark button:focus {
|
||||||
--tw-ring-offset-color: rgb(31 41 55); /* gray-800 */
|
--tw-ring-offset-color: rgb(20 20 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix mobile select dropdown styling */
|
/* Fix mobile select dropdown styling */
|
||||||
@@ -915,8 +938,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark select option {
|
.dark select option {
|
||||||
background-color: rgb(31 41 55) !important;
|
background-color: rgb(31 31 31) !important;
|
||||||
color: rgb(243 244 246) !important;
|
color: rgb(237 235 230) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tool details chevron animation */
|
/* Tool details chevron animation */
|
||||||
@@ -941,6 +964,37 @@
|
|||||||
animation: settings-fade-in 150ms ease-out;
|
animation: settings-fade-in 150ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes chat-activity-enter {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(3px);
|
||||||
|
transform: translateY(18px) scaleY(0.92);
|
||||||
|
}
|
||||||
|
65% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(-2px) scaleY(1.01);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0) scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chat-activity-exit {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0) scaleY(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(2px);
|
||||||
|
transform: translateY(14px) scaleY(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Search result highlight flash */
|
/* Search result highlight flash */
|
||||||
.search-highlight-flash {
|
.search-highlight-flash {
|
||||||
animation: search-flash 4s ease-out;
|
animation: search-flash 4s ease-out;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Encode Sans"', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
|
||||||
|
serif: ['Merriweather', 'Georgia', 'Cambria', '"Times New Roman"', 'serif'],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
input: "hsl(var(--input))",
|
input: "hsl(var(--input))",
|
||||||
|
|||||||
Reference in New Issue
Block a user