Compare commits

..

6 Commits

Author SHA1 Message Date
simosmik
8af72570b3 chore(release): v1.25.0 2026-03-10 16:27:11 +00:00
Simos Mikelatos
12e7f074d9 Merge commit from fork
* fix(security): prevent shell injection in WebSocket handler and harden auth

  - Replace hardcoded JWT secret with auto-generated per-installation secret
  - Add database validation to WebSocket authentication
  - Add token expiration (7d) with auto-refresh
  - Validate projectPath and sessionId in shell handler
  - Use cwd instead of shell string interpolation for project paths
  - Add CORS exposedHeaders for token refresh

* fix: small fix on languages
2026-03-10 17:23:55 +01:00
Simos Mikelatos
e52e1a2b58 Update README.md 2026-03-10 17:05:56 +01:00
Simos Mikelatos
d258f4f0c7 Add files via upload 2026-03-10 17:04:33 +01:00
Haile
1dc2a205dc feat: add copy as text or markdown feature for assistant messages (#519) 2026-03-10 11:38:06 +01:00
Haile
9bceab9e1a fix: resolve duplicate key issue when rendering model options (#520) 2026-03-09 19:57:50 +01:00
18 changed files with 414 additions and 132 deletions

View File

@@ -3,6 +3,22 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.25.0](https://github.com/siteboon/claudecodeui/compare/v1.24.0...v1.25.0) (2026-03-10)
### New Features
* add copy as text or markdown feature for assistant messages ([#519](https://github.com/siteboon/claudecodeui/issues/519)) ([1dc2a20](https://github.com/siteboon/claudecodeui/commit/1dc2a205dc2a3cbf960625d7669c7c63a2b6905f))
* add full Russian language support; update Readme.md files, and .gitignore update ([#514](https://github.com/siteboon/claudecodeui/issues/514)) ([c7dcba8](https://github.com/siteboon/claudecodeui/commit/c7dcba8d9117e84db8aac7d8a7bf6a3aa683e115))
* new plugin system ([#489](https://github.com/siteboon/claudecodeui/issues/489)) ([8afb46a](https://github.com/siteboon/claudecodeui/commit/8afb46af2e5514c9284030367281793fbb014e4f))
### Bug Fixes
* resolve duplicate key issue when rendering model options ([#520](https://github.com/siteboon/claudecodeui/issues/520)) ([9bceab9](https://github.com/siteboon/claudecodeui/commit/9bceab9e1a6e063b0b4f934ed2d9f854fcc9c6a4))
### Maintenance
* add plugins section in readme ([e581a0e](https://github.com/siteboon/claudecodeui/commit/e581a0e1ccd59fd7ec7306ca76a13e73d7c674c1))
## [1.24.0](https://github.com/siteboon/claudecodeui/compare/v1.23.2...v1.24.0) (2026-03-09)
### New Features

View File

@@ -43,7 +43,7 @@
<h3>CLI Selection</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br>
<em>Select between Claude Code, Cursor CLI and Codex</em>
<em>Select between Claude Code, Gemini, Cursor CLI and Codex</em>
</td>
</tr>
</table>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.24.0",
"version": "1.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.24.0",
"version": "1.25.0",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.24.0",
"version": "1.25.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 506 KiB

View File

@@ -59,6 +59,15 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
// Create database connection
const db = new Database(DB_PATH);
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
// runMigrations() also creates this table, but it runs too late for existing installations
// where auth.js is imported before initializeDatabase() is called.
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..');
console.log('');
@@ -91,6 +100,13 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
}
// Create app_config table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Create session_names table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -414,6 +430,33 @@ function applyCustomSessionNames(sessions, provider) {
}
}
// App config database operations
const appConfigDb = {
get: (key) => {
try {
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
return row?.value || null;
} catch (err) {
return null;
}
},
set: (key, value) => {
db.prepare(
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
},
getOrCreateJwtSecret: () => {
let secret = appConfigDb.get('jwt_secret');
if (!secret) {
secret = crypto.randomBytes(64).toString('hex');
appConfigDb.set('jwt_secret', secret);
}
return secret;
}
};
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -441,5 +484,6 @@ export {
credentialsDb,
sessionNamesDb,
applyCustomSessionNames,
appConfigDb,
githubTokensDb // Backward compatibility
};

View File

@@ -62,4 +62,11 @@ CREATE TABLE IF NOT EXISTS session_names (
UNIQUE(session_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
-- App configuration table (auto-generated secrets, settings, etc.)
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -326,7 +326,7 @@ const wss = new WebSocketServer({
// Make WebSocket server available to routes
app.locals.wss = wss;
app.use(cors());
app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
app.use(express.json({
limit: '50mb',
type: (req) => {
@@ -1699,50 +1699,43 @@ function handleShellConnection(ws) {
}));
try {
// Prepare the shell command adapted to the platform and provider
// Validate projectPath — resolve to absolute and verify it exists
const resolvedProjectPath = path.resolve(projectPath);
try {
const stats = fs.statSync(resolvedProjectPath);
if (!stats.isDirectory()) {
throw new Error('Not a directory');
}
} catch (pathErr) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
return;
}
// Validate sessionId — only allow safe characters
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
return;
}
// Build shell command — use cwd for project path (never interpolate into shell string)
let shellCommand;
if (isPlainShell) {
// Plain shell mode - just run the initial command in the project directory
if (os.platform() === 'win32') {
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
} else {
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
}
// Plain shell mode - run the initial command in the project directory
shellCommand = initialCommand;
} else if (provider === 'cursor') {
// Use cursor-agent command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
}
if (hasSession && sessionId) {
shellCommand = `cursor-agent --resume="${sessionId}"`;
} else {
if (hasSession && sessionId) {
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `cd "${projectPath}" && cursor-agent`;
}
shellCommand = 'cursor-agent';
}
} else if (provider === 'codex') {
// Use codex command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
}
if (hasSession && sessionId) {
shellCommand = `codex resume "${sessionId}" || codex`;
} else {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
} else {
shellCommand = `cd "${projectPath}" && codex`;
}
shellCommand = 'codex';
}
} else if (provider === 'gemini') {
// Use gemini command
const command = initialCommand || 'gemini';
let resumeId = sessionId;
if (hasSession && sessionId) {
@@ -1753,41 +1746,28 @@ function handleShellConnection(ws) {
const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) {
resumeId = sess.cliSessionId;
// Validate the looked-up CLI session ID too
if (!safeSessionIdPattern.test(resumeId)) {
resumeId = null;
}
}
} catch (err) {
console.error('Failed to get Gemini CLI session ID:', err);
}
}
if (os.platform() === 'win32') {
if (hasSession && resumeId) {
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
if (hasSession && resumeId) {
shellCommand = `${command} --resume "${resumeId}"`;
} else {
if (hasSession && resumeId) {
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
shellCommand = command;
}
} else {
// Use claude command (default) or initialCommand if provided
// Claude (default provider)
const command = initialCommand || 'claude';
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
if (hasSession && sessionId) {
shellCommand = `claude --resume "${sessionId}" || claude`;
} else {
if (hasSession && sessionId) {
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
shellCommand = command;
}
}
@@ -1806,7 +1786,7 @@ function handleShellConnection(ws) {
name: 'xterm-256color',
cols: termCols,
rows: termRows,
cwd: os.homedir(),
cwd: resolvedProjectPath,
env: {
...process.env,
TERM: 'xterm-256color',

View File

@@ -1,9 +1,9 @@
import jwt from 'jsonwebtoken';
import { userDb } from '../database/db.js';
import { userDb, appConfigDb } from '../database/db.js';
import { IS_PLATFORM } from '../constants/config.js';
// Get JWT secret from environment or use default (for development)
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
// Use env var if set, otherwise auto-generate a unique secret per installation
const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
// Optional API key middleware
const validateApiKey = (req, res, next) => {
@@ -58,6 +58,16 @@ const authenticateToken = async (req, res, next) => {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
// Auto-refresh: if token is past halfway through its lifetime, issue a new one
if (decoded.exp && decoded.iat) {
const now = Math.floor(Date.now() / 1000);
const halfLife = (decoded.exp - decoded.iat) / 2;
if (now > decoded.iat + halfLife) {
const newToken = generateToken(user);
res.setHeader('X-Refreshed-Token', newToken);
}
}
req.user = user;
next();
} catch (error) {
@@ -66,15 +76,15 @@ const authenticateToken = async (req, res, next) => {
}
};
// Generate JWT token (never expires)
// Generate JWT token
const generateToken = (user) => {
return jwt.sign(
{
userId: user.id,
username: user.username
{
userId: user.id,
username: user.username
},
JWT_SECRET
// No expiration - token lasts forever
JWT_SECRET,
{ expiresIn: '7d' }
);
};
@@ -101,7 +111,12 @@ const authenticateWebSocket = (token) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
return decoded;
// Verify user actually exists in database (matches REST authenticateToken behavior)
const user = userDb.getUserById(decoded.userId);
if (!user) {
return null;
}
return { userId: user.id, username: user.username };
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;

View File

@@ -1,4 +1,4 @@
import React, { memo, useMemo } from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type {
@@ -9,10 +9,10 @@ import type {
} from '../../types/types';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
type DiffLine = {
type: string;
@@ -20,7 +20,7 @@ type DiffLine = {
lineNum: number;
};
interface MessageComponentProps {
type MessageComponentProps = {
message: ChatMessage;
prevMessage: ChatMessage | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[];
@@ -32,7 +32,7 @@ interface MessageComponentProps {
showThinking?: boolean;
selectedProject?: Project | null;
provider: Provider | string;
}
};
type InteractiveOption = {
number: string;
@@ -41,6 +41,7 @@ type InteractiveOption = {
};
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 { t } = useTranslation('chat');
@@ -49,18 +50,32 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
(prevMessage.type === 'user') ||
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = React.useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = React.useState(false);
const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
const [messageCopied, setMessageCopied] = React.useState(false);
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
[message.content]
);
const assistantCopyContent = message.isToolUse
? String(message.displayText || message.content || '')
: formattedMessageContent;
const isCommandOrFileEditToolResponse = Boolean(
message.isToolUse && COPY_HIDDEN_TOOL_NAMES.has(String(message.toolName || ''))
);
const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
const shouldShowAssistantCopyControl = message.type === 'assistant' &&
assistantCopyContent.trim().length > 0 &&
!isCommandOrFileEditToolResponse;
React.useEffect(() => {
useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
React.useEffect(() => {
useEffect(() => {
const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return;
@@ -120,43 +135,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</div>
)}
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
<button
type="button"
onClick={() => {
const text = String(message.content || '');
if (!text) return;
copyTextToClipboard(text).then((success) => {
if (!success) return;
setMessageCopied(true);
});
}}
title={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
>
{messageCopied ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
)}
</button>
{shouldShowUserCopyControl && (
<MessageCopyControl content={userCopyContent} messageType="user" />
)}
<span>{formattedTime}</span>
</div>
</div>
@@ -430,7 +411,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
)}
{(() => {
const content = formatUsageLimitText(String(message.content || ''));
const content = formattedMessageContent;
// Detect if content is pure JSON (starts with { or [)
const trimmedContent = content.trim();
@@ -476,9 +457,12 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</div>
)}
{!isGrouped && (
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
{formattedTime}
{(shouldShowAssistantCopyControl || !isGrouped) && (
<div className="mt-1 flex w-full items-center gap-2 text-[11px] text-gray-400 dark:text-gray-500">
{shouldShowAssistantCopyControl && (
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
)}
{!isGrouped && <span>{formattedTime}</span>}
</div>
)}
</div>

View File

@@ -0,0 +1,215 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '../../../../utils/clipboard';
const COPY_SUCCESS_TIMEOUT_MS = 2000;
type CopyFormat = 'text' | 'markdown';
type CopyFormatOption = {
format: CopyFormat;
label: string;
};
// Converts markdown into readable plain text for "Copy as text".
const convertMarkdownToPlainText = (markdown: string): string => {
let plainText = markdown.replace(/\r\n/g, '\n');
const codeBlocks: string[] = [];
plainText = plainText.replace(/```[\w-]*\n([\s\S]*?)```/g, (_match, code: string) => {
const placeholder = `@@CODEBLOCK${codeBlocks.length}@@`;
codeBlocks.push(code.replace(/\n$/, ''));
return placeholder;
});
plainText = plainText.replace(/`([^`]+)`/g, '$1');
plainText = plainText.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1');
plainText = plainText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
plainText = plainText.replace(/^>\s?/gm, '');
plainText = plainText.replace(/^#{1,6}\s+/gm, '');
plainText = plainText.replace(/^[-*+]\s+/gm, '');
plainText = plainText.replace(/^\d+\.\s+/gm, '');
plainText = plainText.replace(/(\*\*|__)(.*?)\1/g, '$2');
plainText = plainText.replace(/(\*|_)(.*?)\1/g, '$2');
plainText = plainText.replace(/~~(.*?)~~/g, '$1');
plainText = plainText.replace(/<\/?[^>]+(>|$)/g, '');
plainText = plainText.replace(/\n{3,}/g, '\n\n');
plainText = plainText.replace(/@@CODEBLOCK(\d+)@@/g, (_match, index: string) => codeBlocks[Number(index)] ?? '');
return plainText.trim();
};
const MessageCopyControl = ({
content,
messageType,
}: {
content: string;
messageType: 'user' | 'assistant';
}) => {
const { t } = useTranslation('chat');
const canSelectCopyFormat = messageType === 'assistant';
const defaultFormat: CopyFormat = canSelectCopyFormat ? 'markdown' : 'text';
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
const [copied, setCopied] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copyFormatOptions: CopyFormatOption[] = useMemo(
() => [
{
format: 'markdown',
label: t('copyMessage.copyAsMarkdown', { defaultValue: 'Copy as markdown' }),
},
{
format: 'text',
label: t('copyMessage.copyAsText', { defaultValue: 'Copy as text' }),
},
],
[t]
);
const selectedFormatTag = selectedFormat === 'markdown'
? t('copyMessage.markdownShort', { defaultValue: 'MD' })
: t('copyMessage.textShort', { defaultValue: 'TXT' });
const copyPayload = useMemo(() => {
if (selectedFormat === 'markdown') {
return content;
}
return convertMarkdownToPlainText(content);
}, [content, selectedFormat]);
useEffect(() => {
setSelectedFormat(defaultFormat);
setIsDropdownOpen(false);
}, [defaultFormat]);
useEffect(() => {
// Close the dropdown when clicking anywhere outside this control.
const closeOnOutsideClick = (event: MouseEvent) => {
if (!isDropdownOpen) return;
const target = event.target as Node;
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
setIsDropdownOpen(false);
}
};
window.addEventListener('mousedown', closeOnOutsideClick);
return () => {
window.removeEventListener('mousedown', closeOnOutsideClick);
};
}, [isDropdownOpen]);
useEffect(() => {
return () => {
if (copyFeedbackTimerRef.current) {
clearTimeout(copyFeedbackTimerRef.current);
}
};
}, []);
const handleCopyClick = async () => {
if (!copyPayload.trim()) return;
const didCopy = await copyTextToClipboard(copyPayload);
if (!didCopy) return;
setCopied(true);
if (copyFeedbackTimerRef.current) {
clearTimeout(copyFeedbackTimerRef.current);
}
copyFeedbackTimerRef.current = setTimeout(() => {
setCopied(false);
}, COPY_SUCCESS_TIMEOUT_MS);
};
const handleFormatChange = (format: CopyFormat) => {
setSelectedFormat(format);
setIsDropdownOpen(false);
};
const toneClass = messageType === 'user'
? 'text-blue-100 hover:text-white'
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300';
const copyTitle = copied ? t('copyMessage.copied') : t('copyMessage.copy');
const rootClassName = canSelectCopyFormat
? 'relative flex min-w-0 flex-1 items-center gap-0.5 sm:min-w-max sm:flex-none sm:w-auto'
: 'relative flex items-center gap-0.5';
return (
<div ref={dropdownRef} className={rootClassName}>
<button
type="button"
onClick={handleCopyClick}
title={copyTitle}
aria-label={copyTitle}
className={`inline-flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${toneClass}`}
>
{copied ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
)}
<span className="text-[10px] font-semibold uppercase tracking-wide">{selectedFormatTag}</span>
</button>
{canSelectCopyFormat && (
<>
<button
type="button"
onClick={() => setIsDropdownOpen((prev) => !prev)}
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
>
<svg
className={`h-3 w-3 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isDropdownOpen && (
<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">
{copyFormatOptions.map((option) => {
const isSelected = option.format === selectedFormat;
return (
<button
key={option.format}
type="button"
onClick={() => handleFormatChange(option.format)}
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'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
}`}
>
<span className="block text-xs font-medium">{option.label}</span>
</button>
);
})}
</div>
)}
</>
)}
</div>
);
};
export default MessageCopyControl;

View File

@@ -6,7 +6,10 @@
},
"copyMessage": {
"copy": "Copy message",
"copied": "Message copied"
"copied": "Message copied",
"selectFormat": "Select copy format",
"copyAsMarkdown": "Copy as markdown",
"copyAsText": "Copy as text"
},
"messageTypes": {
"user": "U",

View File

@@ -6,7 +6,10 @@
},
"copyMessage": {
"copy": "メッセージをコピー",
"copied": "メッセージをコピーしました"
"copied": "メッセージをコピーしました",
"selectFormat": "コピー形式を選択",
"copyAsMarkdown": "Markdownとしてコピー",
"copyAsText": "テキストとしてコピー"
},
"messageTypes": {
"user": "U",

View File

@@ -6,7 +6,10 @@
},
"copyMessage": {
"copy": "메시지 복사",
"copied": "메시지 복사됨"
"copied": "메시지 복사됨",
"selectFormat": "복사 형식 선택",
"copyAsMarkdown": "마크다운으로 복사",
"copyAsText": "텍스트로 복사"
},
"messageTypes": {
"user": "U",

View File

@@ -6,7 +6,10 @@
},
"copyMessage": {
"copy": "Копировать сообщение",
"copied": "Сообщение скопировано"
"copied": "Сообщение скопировано",
"selectFormat": "Выбрать формат копирования",
"copyAsMarkdown": "Копировать как Markdown",
"copyAsText": "Копировать как текст"
},
"messageTypes": {
"user": "П",

View File

@@ -6,7 +6,10 @@
},
"copyMessage": {
"copy": "复制消息",
"copied": "消息已复制"
"copied": "消息已复制",
"selectFormat": "选择复制格式",
"copyAsMarkdown": "复制为 Markdown",
"copyAsText": "复制为纯文本"
},
"messageTypes": {
"user": "U",

View File

@@ -21,6 +21,12 @@ export const authenticatedFetch = (url, options = {}) => {
...defaultHeaders,
...options.headers,
},
}).then((response) => {
const refreshedToken = response.headers.get('X-Refreshed-Token');
if (refreshedToken) {
localStorage.setItem('auth-token', refreshedToken);
}
return response;
});
};