mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-06 13:15:38 +08:00
Compare commits
16 Commits
fix/file-t
...
fix/do-not
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d638a8982c | ||
|
|
f238050b85 | ||
|
|
beaa2d2533 | ||
|
|
c90b34108e | ||
|
|
323357384e | ||
|
|
d509aa635b | ||
|
|
2149b8776b | ||
|
|
2b416f2dcb | ||
|
|
bb8db5815c | ||
|
|
b3d0f9037d | ||
|
|
3ec76b5bb1 | ||
|
|
14ddbc7c57 | ||
|
|
ebb0e59e80 | ||
|
|
957f53fb99 | ||
|
|
cdcac182d4 | ||
|
|
94785bfa57 |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -3,6 +3,35 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
|
||||
|
||||
### New Features
|
||||
|
||||
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
|
||||
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
|
||||
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
|
||||
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
|
||||
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
|
||||
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
|
||||
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
|
||||
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
|
||||
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
|
||||
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
|
||||
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
|
||||
|
||||
### Documentation
|
||||
|
||||
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
|
||||
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
|
||||
|
||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
|
||||
|
||||
### New Features
|
||||
|
||||
218
docs/nginx-subpath-template.conf
Normal file
218
docs/nginx-subpath-template.conf
Normal file
@@ -0,0 +1,218 @@
|
||||
# CloudCLI UI Nginx subpath deployment template.
|
||||
#
|
||||
# Purpose:
|
||||
# Serve CloudCLI UI from a path prefix such as:
|
||||
# http://localhost/ai/
|
||||
# https://example.com/ai/
|
||||
#
|
||||
# CloudCLI itself still runs at the root of its own HTTP server, for example:
|
||||
# http://127.0.0.1:3001/
|
||||
#
|
||||
# Nginx receives public requests under /ai, strips that prefix, and forwards the
|
||||
# remaining path to CloudCLI. For example:
|
||||
# /ai/ -> /
|
||||
# /ai/session/abc -> /session/abc
|
||||
# /ai/assets/index.js -> /assets/index.js
|
||||
#
|
||||
# Important Nginx limitation:
|
||||
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
|
||||
# The configurable variables below are still useful for proxy/filter values,
|
||||
# but if you change /ai to a different subpath, also update every line marked:
|
||||
# [SUBPATH LITERAL]
|
||||
#
|
||||
# To use a different subpath, replace these literal matchers:
|
||||
# location = /ai
|
||||
# location ^~ /ai/
|
||||
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
|
||||
#
|
||||
# Recommended deployment shape:
|
||||
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
|
||||
# are also proxied because the current frontend still calls those endpoints
|
||||
# with root-relative URLs.
|
||||
|
||||
worker_processes 1;
|
||||
|
||||
events {
|
||||
# Maximum simultaneous connections handled by each worker process.
|
||||
# The default is enough for local testing and small self-hosted deployments.
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
|
||||
# This map gives us the right Connection header for both cases:
|
||||
# Upgrade present -> "upgrade"
|
||||
# Upgrade absent -> "close"
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
|
||||
# add ssl_certificate / ssl_certificate_key lines.
|
||||
listen 80 default_server;
|
||||
|
||||
# Use your real hostname in production, for example:
|
||||
# server_name cloudcli.example.com;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
# ---- User settings -------------------------------------------------
|
||||
#
|
||||
# Public path prefix where users access CloudCLI.
|
||||
# Do not add a trailing slash.
|
||||
#
|
||||
# This variable can be used in redirects and response rewrites. It
|
||||
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
|
||||
# lines too if you change it.
|
||||
set $cloudcli_subpath /ai;
|
||||
|
||||
# Private upstream URL where the CloudCLI server is listening.
|
||||
# For a default local server this is usually http://127.0.0.1:3001.
|
||||
set $cloudcli_upstream http://127.0.0.1:3001;
|
||||
|
||||
# Allow larger file uploads through the code editor/project file APIs.
|
||||
client_max_body_size 100m;
|
||||
|
||||
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
||||
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
||||
location = /ai {
|
||||
return 301 $cloudcli_subpath/;
|
||||
}
|
||||
|
||||
# Main prefixed CloudCLI UI route.
|
||||
#
|
||||
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
|
||||
# $cloudcli_subpath.
|
||||
location ^~ /ai/ {
|
||||
# Strip the public subpath before proxying. CloudCLI expects to see
|
||||
# root paths such as /, /session/:id, /assets/..., /manifest.json.
|
||||
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
|
||||
|
||||
# Forward the rewritten request to the private CloudCLI server.
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
|
||||
# browser reaches a socket endpoint under the subpath.
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Preserve useful request metadata for logs and future app support.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
# WebSocket upgrade headers. Harmless for normal HTTP requests.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Long-running agent and terminal sessions can stay open for a long
|
||||
# time, so avoid closing idle proxied connections too aggressively.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
|
||||
# Disable gzip from the upstream response so sub_filter can inspect
|
||||
# and rewrite HTML/JSON/JS response bodies.
|
||||
proxy_set_header Accept-Encoding "";
|
||||
|
||||
# Rewrite browser-visible root-relative URLs so the runtime can
|
||||
# discover that the app is mounted under the subpath.
|
||||
#
|
||||
# Examples:
|
||||
# href="/manifest.json" -> href="/ai/manifest.json"
|
||||
# src="/assets/app.js" -> src="/ai/assets/app.js"
|
||||
#
|
||||
# These rewrites are important for React Router basename detection.
|
||||
sub_filter_once off;
|
||||
sub_filter_types
|
||||
application/json
|
||||
application/manifest+json
|
||||
application/javascript
|
||||
text/javascript;
|
||||
|
||||
sub_filter 'href="/' 'href="$cloudcli_subpath/';
|
||||
sub_filter 'src="/' 'src="$cloudcli_subpath/';
|
||||
|
||||
# The production HTML and JS register the service worker at /sw.js.
|
||||
# Rewrite that registration so the worker is served from /ai/sw.js.
|
||||
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
|
||||
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
|
||||
|
||||
# The manifest and service worker contain root-relative paths too.
|
||||
# Rewriting them keeps PWA metadata and cached manifest requests
|
||||
# under the same public subpath.
|
||||
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
|
||||
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
|
||||
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
|
||||
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
|
||||
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
|
||||
}
|
||||
|
||||
# Root API proxy.
|
||||
#
|
||||
# The current CloudCLI frontend calls APIs with root-relative URLs such
|
||||
# as /api/auth/login. Keep this location unless the frontend becomes
|
||||
# fully prefix-aware for API requests.
|
||||
location ^~ /api/ {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Main app WebSocket proxy.
|
||||
#
|
||||
# The frontend opens /ws for realtime chat/session/task updates.
|
||||
location /ws {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Shell WebSocket proxy.
|
||||
#
|
||||
# The browser terminal uses /shell. It requires the same WebSocket
|
||||
# upgrade handling as /ws.
|
||||
location /shell {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Optional health endpoint proxy used by the frontend version checker.
|
||||
location = /health {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
}
|
||||
}
|
||||
}
|
||||
787
package-lock.json
generated
787
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.33.0",
|
||||
"version": "1.33.1",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "dist-server/server/index.js",
|
||||
|
||||
@@ -11,7 +11,8 @@ export const CLAUDE_MODELS = {
|
||||
{
|
||||
value: "default",
|
||||
label: "Default (recommended)",
|
||||
description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
||||
description:
|
||||
"Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
@@ -23,6 +24,12 @@ export const CLAUDE_MODELS = {
|
||||
label: "Sonnet (1M context)",
|
||||
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "opus[1m]",
|
||||
label: "Opus 4.8 (1M context)",
|
||||
description:
|
||||
"Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
label: "Haiku",
|
||||
|
||||
@@ -18,18 +18,23 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default (recommended)',
|
||||
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
|
||||
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
||||
},
|
||||
{
|
||||
value: 'sonnet',
|
||||
label: 'Sonnet',
|
||||
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
|
||||
value: "sonnet",
|
||||
label: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: 'sonnet[1m]',
|
||||
label: 'Sonnet (1M context)',
|
||||
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
||||
},
|
||||
{
|
||||
value: 'opus[1m]',
|
||||
label: 'Opus 4.8 (1M context)',
|
||||
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
|
||||
},
|
||||
{
|
||||
value: 'haiku',
|
||||
label: 'Haiku',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
||||
|
||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
|
||||
|
||||
type ProviderModelsServiceDependencies = {
|
||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||
@@ -232,10 +233,42 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
|
||||
return request;
|
||||
};
|
||||
|
||||
const loadDirectModels = (
|
||||
provider: LLMProvider,
|
||||
): Promise<ProviderModelsResult> => {
|
||||
const request = resolveProvider(provider).models.getSupportedModels()
|
||||
.then((models) => {
|
||||
const currentTime = now();
|
||||
return {
|
||||
models,
|
||||
cache: {
|
||||
updatedAt: new Date(currentTime).toISOString(),
|
||||
expiresAt: new Date(currentTime).toISOString(),
|
||||
source: 'fresh' as const,
|
||||
},
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRequests.delete(provider);
|
||||
});
|
||||
|
||||
pendingRequests.set(provider, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const getProviderModels = async (
|
||||
provider: LLMProvider,
|
||||
options: ProviderModelsOptions = {},
|
||||
): Promise<ProviderModelsResult> => {
|
||||
if (UNCACHED_PROVIDERS.has(provider)) {
|
||||
const pendingRequest = pendingRequests.get(provider);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
return loadDirectModels(provider);
|
||||
}
|
||||
|
||||
if (options.bypassCache) {
|
||||
const pendingRequest = pendingRequests.get(provider);
|
||||
if (pendingRequest) {
|
||||
|
||||
@@ -130,6 +130,37 @@ test('provider models are cached for the three-day ttl', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('claude provider models are always loaded directly from the provider', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
|
||||
let loadCount = 0;
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
loadCount += 1;
|
||||
return createModels(`${provider}-${loadCount}`);
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const first = await service.getProviderModels('claude');
|
||||
const second = await service.getProviderModels('claude');
|
||||
|
||||
assert.equal(loadCount, 2);
|
||||
assert.equal(first.models.DEFAULT, 'claude-1');
|
||||
assert.equal(second.models.DEFAULT, 'claude-2');
|
||||
assert.equal(second.cache.source, 'fresh');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('provider model cache is persisted across service instances', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
||||
const cachePath = path.join(tempRoot, 'models-cache.json');
|
||||
|
||||
@@ -20,7 +20,13 @@ export function verifyWebSocketClient(
|
||||
dependencies: WebSocketAuthDependencies
|
||||
): boolean {
|
||||
const request = info.req as AuthenticatedWebSocketRequest;
|
||||
console.log('WebSocket connection attempt to:', request.url);
|
||||
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||
const loggedUrl = new URL(upgradeUrl);
|
||||
if (loggedUrl.searchParams.has('token')) {
|
||||
loggedUrl.searchParams.set('token', 'REDACTED');
|
||||
}
|
||||
|
||||
console.log('WebSocket connection attempt to:', `${loggedUrl.pathname}${loggedUrl.search}`);
|
||||
|
||||
// Platform mode: use the first DB user and skip token checks.
|
||||
if (dependencies.isPlatform) {
|
||||
@@ -36,7 +42,6 @@ export function verifyWebSocketClient(
|
||||
}
|
||||
|
||||
// OSS mode: read JWT from query string first, then Authorization header.
|
||||
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||
const token =
|
||||
upgradeUrl.searchParams.get('token') ??
|
||||
request.headers.authorization?.split(' ')[1] ??
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Brain, Zap, Sparkles, Atom } from 'lucide-react';
|
||||
|
||||
export const thinkingModes = [
|
||||
{
|
||||
id: 'none',
|
||||
name: 'Standard',
|
||||
description: 'Regular Claude response',
|
||||
icon: null,
|
||||
prefix: '',
|
||||
color: 'text-gray-600'
|
||||
},
|
||||
{
|
||||
id: 'think',
|
||||
name: 'Think',
|
||||
description: 'Basic extended thinking',
|
||||
icon: Brain,
|
||||
prefix: 'think',
|
||||
color: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
id: 'think-hard',
|
||||
name: 'Think Hard',
|
||||
description: 'More thorough evaluation',
|
||||
icon: Zap,
|
||||
prefix: 'think hard',
|
||||
color: 'text-purple-600'
|
||||
},
|
||||
{
|
||||
id: 'think-harder',
|
||||
name: 'Think Harder',
|
||||
description: 'Deep analysis with alternatives',
|
||||
icon: Sparkles,
|
||||
prefix: 'think harder',
|
||||
color: 'text-indigo-600'
|
||||
},
|
||||
{
|
||||
id: 'ultrathink',
|
||||
name: 'Ultrathink',
|
||||
description: 'Maximum thinking budget',
|
||||
icon: Atom,
|
||||
prefix: 'ultrathink',
|
||||
color: 'text-red-600'
|
||||
}
|
||||
];
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { thinkingModes } from '../constants/thinkingModes';
|
||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type {
|
||||
@@ -204,7 +203,6 @@ export function useChatComposerState({
|
||||
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
const [thinkingMode, setThinkingMode] = useState('none');
|
||||
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -313,7 +311,7 @@ export function useChatComposerState({
|
||||
}, [addMessage]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (command: SlashCommand, rawInput?: string) => {
|
||||
async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => {
|
||||
if (!command || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
@@ -370,8 +368,10 @@ export function useChatComposerState({
|
||||
const result = (await response.json()) as CommandExecutionResult;
|
||||
if (result.type === 'builtin') {
|
||||
handleBuiltInCommand(result);
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
if (!options?.preserveInput) {
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
}
|
||||
} else if (result.type === 'custom') {
|
||||
await handleCustomCommand(result);
|
||||
}
|
||||
@@ -402,6 +402,19 @@ export function useChatComposerState({
|
||||
],
|
||||
);
|
||||
|
||||
const showCostModal = useCallback(() => {
|
||||
executeCommand(
|
||||
{
|
||||
name: '/cost',
|
||||
description: 'Display token usage information',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' },
|
||||
} as SlashCommand,
|
||||
'/cost',
|
||||
{ preserveInput: true },
|
||||
);
|
||||
}, [executeCommand]);
|
||||
|
||||
const {
|
||||
slashCommands,
|
||||
slashCommandsCount,
|
||||
@@ -562,11 +575,7 @@ export function useChatComposerState({
|
||||
}
|
||||
}
|
||||
|
||||
let messageContent = currentInput;
|
||||
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
||||
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
||||
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
||||
}
|
||||
const messageContent = currentInput;
|
||||
|
||||
let uploadedImages: unknown[] = [];
|
||||
if (attachedImages.length > 0) {
|
||||
@@ -749,7 +758,6 @@ export function useChatComposerState({
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
setIsTextareaExpanded(false);
|
||||
setThinkingMode('none');
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
@@ -783,7 +791,6 @@ export function useChatComposerState({
|
||||
setIsLoading,
|
||||
setIsUserScrolledUp,
|
||||
slashCommands,
|
||||
thinkingMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1020,8 +1027,6 @@ export function useChatComposerState({
|
||||
textareaRef,
|
||||
inputHighlightRef,
|
||||
isTextareaExpanded,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
@@ -1059,5 +1064,6 @@ export function useChatComposerState({
|
||||
isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
showCostModal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
||||
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
||||
|
||||
function formatToolResultContent(content: unknown): string {
|
||||
const text = typeof content === 'string' ? content : JSON.stringify(content);
|
||||
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
|
||||
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
||||
* that the existing UI components expect.
|
||||
@@ -20,7 +26,12 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
|
||||
// First pass: collect tool results for attachment
|
||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||
const toolUseIds = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId) {
|
||||
toolUseIds.add(msg.toolId);
|
||||
}
|
||||
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
@@ -97,7 +108,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
|
||||
const toolResult = tr
|
||||
? {
|
||||
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||
content: formatToolResultContent(tr.content),
|
||||
isError: Boolean(tr.isError),
|
||||
toolUseResult: (tr as any).toolUseResult,
|
||||
}
|
||||
@@ -191,8 +202,25 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
break;
|
||||
|
||||
// tool_result is handled via attachment to tool_use above
|
||||
case 'tool_result':
|
||||
case 'tool_result': {
|
||||
if (msg.toolId && toolUseIds.has(msg.toolId)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const content = formatToolResultContent(msg.content || '');
|
||||
if (!content.trim()) {
|
||||
break;
|
||||
}
|
||||
|
||||
converted.push({
|
||||
type: msg.isError ? 'error' : 'assistant',
|
||||
content,
|
||||
timestamp: msg.timestamp,
|
||||
toolId: msg.toolId,
|
||||
...sharedMetadata,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -564,11 +564,15 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
|
||||
|
||||
if (!config.result) return false;
|
||||
|
||||
// Hidden/success-only configs suppress noisy successful output, but errors
|
||||
// still need to be visible so failed tool calls are diagnosable.
|
||||
if (toolResult?.isError) return false;
|
||||
|
||||
// Always hidden
|
||||
if (config.result.hidden) return true;
|
||||
|
||||
// Hide on success only
|
||||
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
|
||||
if (config.result.hideOnSuccess && toolResult) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,8 +141,6 @@ function ChatInterface({
|
||||
textareaRef,
|
||||
inputHighlightRef,
|
||||
isTextareaExpanded,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
@@ -180,6 +178,7 @@ function ChatInterface({
|
||||
isInputFocused: _isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
showCostModal,
|
||||
} = useChatComposerState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
@@ -369,9 +368,8 @@ function ChatInterface({
|
||||
provider={provider}
|
||||
permissionMode={permissionMode}
|
||||
onModeSwitch={cyclePermissionMode}
|
||||
thinkingMode={thinkingMode}
|
||||
setThinkingMode={setThinkingMode}
|
||||
tokenBudget={tokenBudget}
|
||||
onShowTokenUsage={showCostModal}
|
||||
slashCommandsCount={slashCommandsCount}
|
||||
onToggleCommandMenu={handleToggleCommandMenu}
|
||||
hasInput={Boolean(input.trim())}
|
||||
|
||||
@@ -2,23 +2,16 @@ import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
Dispatch,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
||||
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputHeader,
|
||||
@@ -30,6 +23,12 @@ import {
|
||||
PromptInputSubmit,
|
||||
} from '../../../../shared/view/ui';
|
||||
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
|
||||
interface MentionableFile {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -58,9 +57,8 @@ interface ChatComposerProps {
|
||||
provider: Provider | string;
|
||||
permissionMode: PermissionMode | string;
|
||||
onModeSwitch: () => void;
|
||||
thinkingMode: string;
|
||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
onShowTokenUsage: () => void;
|
||||
slashCommandsCount: number;
|
||||
onToggleCommandMenu: () => void;
|
||||
hasInput: boolean;
|
||||
@@ -113,9 +111,8 @@ export default function ChatComposer({
|
||||
provider,
|
||||
permissionMode,
|
||||
onModeSwitch,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
tokenBudget,
|
||||
onShowTokenUsage,
|
||||
slashCommandsCount,
|
||||
onToggleCommandMenu,
|
||||
hasInput,
|
||||
@@ -358,11 +355,7 @@ export default function ChatComposer({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{provider === 'claude' && (
|
||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||
)}
|
||||
|
||||
<TokenUsageSummary usage={tokenBudget} />
|
||||
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
|
||||
|
||||
<PromptInputButton
|
||||
tooltip={{ content: t('input.showAllCommands') }}
|
||||
@@ -383,7 +376,7 @@ export default function ChatComposer({
|
||||
<PromptInputButton
|
||||
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
|
||||
onClick={onClearInput}
|
||||
className="hidden sm:No-flex"
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<XIcon />
|
||||
</PromptInputButton>
|
||||
@@ -400,7 +393,8 @@ export default function ChatComposer({
|
||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||
</div>
|
||||
<PromptInputSubmit
|
||||
disabled={!input.trim() || isLoading}
|
||||
onClick={isLoading ? onAbortSession : undefined}
|
||||
disabled={!isLoading && !input.trim()}
|
||||
className="h-10 w-10 sm:h-10 sm:w-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -8,10 +9,10 @@ import type {
|
||||
Provider,
|
||||
} from '../../types/types';
|
||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
||||
|
||||
import { Markdown } from './Markdown';
|
||||
import MessageCopyControl from './MessageCopyControl';
|
||||
|
||||
@@ -41,10 +42,9 @@ type InteractiveOption = {
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||
|
||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
@@ -53,8 +53,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
||||
const userCopyContent = String(message.content || '');
|
||||
const formattedMessageContent = useMemo(
|
||||
() => formatUsageLimitText(String(message.content || '')),
|
||||
@@ -73,10 +71,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
!message.isThinking;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setPermissionGrantState('idle');
|
||||
}, [permissionSuggestion?.entry, message.toolId]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = messageRef.current;
|
||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||
@@ -241,55 +235,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
||||
{String(message.toolResult.content || '')}
|
||||
</Markdown>
|
||||
{permissionSuggestion && (
|
||||
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!onGrantToolPermission) return;
|
||||
const result = onGrantToolPermission(permissionSuggestion);
|
||||
if (result?.success) {
|
||||
setPermissionGrantState('granted');
|
||||
} else {
|
||||
setPermissionGrantState('error');
|
||||
}
|
||||
}}
|
||||
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
||||
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
|
||||
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
|
||||
}`}
|
||||
>
|
||||
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? t('permissions.added')
|
||||
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
|
||||
</button>
|
||||
{onShowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
|
||||
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
|
||||
>
|
||||
{t('permissions.openSettings')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
||||
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
|
||||
</div>
|
||||
{permissionGrantState === 'error' && (
|
||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||
{t('permissions.error')}
|
||||
</div>
|
||||
)}
|
||||
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
||||
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
||||
{t('permissions.retry')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -277,11 +277,15 @@ export default function ProviderSelectionEmptyState({
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{model.label}</div>
|
||||
{model.description && (
|
||||
{/*
|
||||
// * Temporarly commented out because the description of models from claude
|
||||
// * was a bit inconsistent. Will return it back when it becomes more consistent.
|
||||
*/}
|
||||
{/* {model.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Brain, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { thinkingModes } from '../../constants/thinkingModes';
|
||||
|
||||
type ThinkingModeSelectorProps = {
|
||||
selectedMode: string;
|
||||
onModeChange: (modeId: string) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||
|
||||
// Mapping from mode ID to translation key
|
||||
const modeKeyMap: Record<string, string> = {
|
||||
'think-hard': 'thinkHard',
|
||||
'think-harder': 'thinkHarder'
|
||||
};
|
||||
// Create translated modes for display
|
||||
const translatedModes = thinkingModes.map(mode => {
|
||||
const modeKey = modeKeyMap[mode.id] || mode.id;
|
||||
return {
|
||||
...mode,
|
||||
name: t(`thinkingMode.modes.${modeKey}.name`),
|
||||
description: t(`thinkingMode.modes.${modeKey}.description`),
|
||||
prefix: t(`thinkingMode.modes.${modeKey}.prefix`)
|
||||
};
|
||||
});
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (!trigger || !dropdown || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
|
||||
const spacing = 8;
|
||||
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
|
||||
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
|
||||
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
|
||||
|
||||
const measuredHeight = dropdown.offsetHeight || 0;
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
|
||||
const spaceAbove = triggerRect.top - spacing - viewportPadding;
|
||||
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
|
||||
const availableHeight = Math.min(
|
||||
window.innerHeight - viewportPadding * 2,
|
||||
Math.max(180, openBelow ? spaceBelow : spaceAbove),
|
||||
);
|
||||
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
|
||||
const top = openBelow
|
||||
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
|
||||
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
|
||||
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
maxHeight: availableHeight,
|
||||
zIndex: 80,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setDropdownStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(updateDropdownPosition);
|
||||
const handleViewportChange = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, closeDropdown]);
|
||||
|
||||
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
||||
const IconComponent = currentMode.icon || Brain;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
|
||||
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
||||
}`}
|
||||
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
|
||||
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('thinkingMode.selector.title')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDropdown}
|
||||
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('thinkingMode.selector.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto py-1">
|
||||
{translatedModes.map((mode) => {
|
||||
const ModeIcon = mode.icon;
|
||||
const isSelected = mode.id === selectedMode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onModeChange(mode.id);
|
||||
closeDropdown();
|
||||
}}
|
||||
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
|
||||
{ModeIcon ? <ModeIcon className="h-5 w-5" /> : <div className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{mode.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
{t('thinkingMode.selector.active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{mode.description}
|
||||
</p>
|
||||
{mode.prefix && (
|
||||
<code className="mt-1 inline-block rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-700">
|
||||
{mode.prefix}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThinkingModeSelector;
|
||||
@@ -2,6 +2,7 @@ import { ActivityIcon } from 'lucide-react';
|
||||
|
||||
type TokenUsageSummaryProps = {
|
||||
usage: Record<string, unknown> | null;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const formatTokenCount = (value: number) => {
|
||||
@@ -29,7 +30,7 @@ const readUsageNumber = (value: unknown) => {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
|
||||
export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryProps) {
|
||||
const breakdown =
|
||||
usage?.breakdown && typeof usage.breakdown === 'object'
|
||||
? usage.breakdown as Record<string, unknown>
|
||||
@@ -39,15 +40,18 @@ export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
|
||||
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
|
||||
|
||||
return (
|
||||
<div
|
||||
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 sm:gap-2 sm:px-2.5"
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||
aria-label="Show token usage"
|
||||
>
|
||||
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
|
||||
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,42 +138,6 @@
|
||||
"clearInput": "Eingabe leeren",
|
||||
"scrollToBottom": "Nach unten scrollen"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "Denkmodus",
|
||||
"description": "Erweitertes Denken gibt Claude mehr Zeit, Alternativen zu evaluieren",
|
||||
"active": "Aktiv",
|
||||
"tip": "Höhere Denkmodi brauchen mehr Zeit, liefern aber eine gründlichere Analyse"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "Standard",
|
||||
"description": "Reguläre Claude-Antwort",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "Denken",
|
||||
"description": "Grundlegendes erweitertes Denken",
|
||||
"prefix": "think"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "Intensiv denken",
|
||||
"description": "Gründlichere Auswertung",
|
||||
"prefix": "think hard"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "Sehr intensiv denken",
|
||||
"description": "Tiefgehende Analyse mit Alternativen",
|
||||
"prefix": "think harder"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "Ultradenken",
|
||||
"description": "Maximales Denkbudget",
|
||||
"prefix": "ultrathink"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "Denkmodus: {{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "KI-Assistent wählen",
|
||||
"description": "Anbieter auswählen, um eine neue Unterhaltung zu starten",
|
||||
|
||||
@@ -139,42 +139,6 @@
|
||||
"clearInput": "Clear input",
|
||||
"scrollToBottom": "Scroll to bottom"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "Thinking Mode",
|
||||
"description": "Extended thinking gives Claude more time to evaluate alternatives",
|
||||
"active": "Active",
|
||||
"tip": "Higher thinking modes take more time but provide more thorough analysis"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "Standard",
|
||||
"description": "Regular Claude response",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "Think",
|
||||
"description": "Basic extended thinking",
|
||||
"prefix": "think"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "Think Hard",
|
||||
"description": "More thorough evaluation",
|
||||
"prefix": "think hard"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "Think Harder",
|
||||
"description": "Deep analysis with alternatives",
|
||||
"prefix": "think harder"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "Ultrathink",
|
||||
"description": "Maximum thinking budget",
|
||||
"prefix": "ultrathink"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "Thinking mode: {{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "Choose Your AI Assistant",
|
||||
"description": "Select a provider to start a new conversation",
|
||||
|
||||
@@ -138,42 +138,6 @@
|
||||
"clearInput": "Cancella input",
|
||||
"scrollToBottom": "Scorri in basso"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "Modalità ragionamento",
|
||||
"description": "Il ragionamento esteso dà a Claude più tempo per valutare le alternative",
|
||||
"active": "Attivo",
|
||||
"tip": "Modalità di ragionamento più elevate richiedono più tempo ma forniscono un'analisi più approfondita"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "Standard",
|
||||
"description": "Risposta Claude normale",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "Pensa",
|
||||
"description": "Ragionamento esteso base",
|
||||
"prefix": "think"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "Pensa di più",
|
||||
"description": "Valutazione più approfondita",
|
||||
"prefix": "think hard"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "Pensa ancora",
|
||||
"description": "Analisi profonda con alternative",
|
||||
"prefix": "think harder"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "Ultrapensiero",
|
||||
"description": "Budget massimo di ragionamento",
|
||||
"prefix": "ultrathink"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "Modalità ragionamento: {{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "Scegli il tuo assistente AI",
|
||||
"description": "Seleziona un provider per iniziare una nuova conversazione",
|
||||
|
||||
@@ -117,42 +117,6 @@
|
||||
"clickToChangeMode": "クリックで権限モードを変更(または入力欄でTab)",
|
||||
"showAllCommands": "すべてのコマンドを表示"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "思考モード",
|
||||
"description": "拡張思考によりClaudeがより多くの選択肢を検討できます",
|
||||
"active": "有効",
|
||||
"tip": "高い思考モードは時間がかかりますが、より深い分析が得られます"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "標準",
|
||||
"description": "通常のClaudeの応答",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "Think",
|
||||
"description": "基本的な拡張思考",
|
||||
"prefix": "think"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "Think Hard",
|
||||
"description": "より深い検討",
|
||||
"prefix": "think hard"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "Think Harder",
|
||||
"description": "代替案を含む深い分析",
|
||||
"prefix": "think harder"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "Ultrathink",
|
||||
"description": "最大限の思考予算",
|
||||
"prefix": "ultrathink"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "思考モード: {{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "AIアシスタントを選択",
|
||||
"description": "新しい会話を始めるプロバイダーを選択してください",
|
||||
|
||||
@@ -120,42 +120,6 @@
|
||||
"clearInput": "입력 지우기",
|
||||
"scrollToBottom": "맨 아래로 스크롤"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "Thinking 모드",
|
||||
"description": "확장된 thinking은 Claude에게 대안을 평가할 시간을 더 줍니다",
|
||||
"active": "활성",
|
||||
"tip": "높은 thinking 모드는 시간이 더 걸리지만 더 철저한 분석을 제공합니다"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "Standard",
|
||||
"description": "일반 Claude 응답",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "Think",
|
||||
"description": "기본 확장 thinking",
|
||||
"prefix": "think"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "Think Hard",
|
||||
"description": "더 철저한 평가",
|
||||
"prefix": "think hard"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "Think Harder",
|
||||
"description": "대안을 포함한 심층 분석",
|
||||
"prefix": "think harder"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "Ultrathink",
|
||||
"description": "최대 thinking 예산",
|
||||
"prefix": "ultrathink"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "Thinking 모드: {{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "AI 어시스턴트 선택",
|
||||
"description": "새 대화를 시작할 프로바이더를 선택하세요",
|
||||
|
||||
@@ -138,42 +138,6 @@
|
||||
"clearInput": "Очистить ввод",
|
||||
"scrollToBottom": "Прокрутить вниз"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "Режим размышления",
|
||||
"description": "Расширенное размышление дает Claude больше времени для оценки альтернатив",
|
||||
"active": "Активен",
|
||||
"tip": "Более высокие режимы размышления занимают больше времени, но обеспечивают более тщательный анализ"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "Стандартный",
|
||||
"description": "Обычный ответ Claude",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "Думать",
|
||||
"description": "Базовое расширенное размышление",
|
||||
"prefix": "думать"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "Думать усердно",
|
||||
"description": "Более тщательная оценка",
|
||||
"prefix": "думать усердно"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "Думать еще усерднее",
|
||||
"description": "Глубокий анализ с альтернативами",
|
||||
"prefix": "думать еще усерднее"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "Ультра-размышление",
|
||||
"description": "Максимальный бюджет размышления",
|
||||
"prefix": "ультра-размышление"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "Режим размышления: {{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "Выберите вашего AI-ассистента",
|
||||
"description": "Выберите провайдера для начала нового разговора",
|
||||
|
||||
@@ -138,42 +138,6 @@
|
||||
"clearInput": "Girdiyi temizle",
|
||||
"scrollToBottom": "En alta git"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "Düşünme Modu",
|
||||
"description": "Uzatılmış düşünme, Claude'a alternatifleri değerlendirmek için daha fazla zaman verir",
|
||||
"active": "Aktif",
|
||||
"tip": "Daha yüksek düşünme modları daha fazla zaman alır ama daha kapsamlı analiz sağlar"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "Standart",
|
||||
"description": "Normal Claude yanıtı",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "Düşün",
|
||||
"description": "Temel uzatılmış düşünme",
|
||||
"prefix": "think"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "Daha Fazla Düşün",
|
||||
"description": "Daha kapsamlı değerlendirme",
|
||||
"prefix": "think hard"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "Derin Düşün",
|
||||
"description": "Alternatiflerle derin analiz",
|
||||
"prefix": "think harder"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "Ultra Düşün",
|
||||
"description": "Maksimum düşünme bütçesi",
|
||||
"prefix": "ultrathink"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "Düşünme modu: {{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "AI Asistanını Seç",
|
||||
"description": "Yeni bir konuşma başlatmak için bir sağlayıcı seç",
|
||||
|
||||
@@ -120,42 +120,6 @@
|
||||
"clearInput": "清空输入",
|
||||
"scrollToBottom": "滚动到底部"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "思考模式",
|
||||
"description": "扩展思考给 Claude 更多时间来评估替代方案",
|
||||
"active": "激活",
|
||||
"tip": "更高的思考模式需要更多时间,但提供更彻底的分析"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "标准",
|
||||
"description": "常规 Claude 响应",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "思考",
|
||||
"description": "基本扩展思考",
|
||||
"prefix": "思考"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "深入思考",
|
||||
"description": "更彻底的评估",
|
||||
"prefix": "深入思考"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "更深入思考",
|
||||
"description": "考虑替代方案的深度分析",
|
||||
"prefix": "更深入思考"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "超级思考",
|
||||
"description": "最大思考预算",
|
||||
"prefix": "超级思考"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "思考模式:{{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "选择您的 AI 助手",
|
||||
"description": "选择一个供应商以开始新对话",
|
||||
|
||||
Reference in New Issue
Block a user