Merge branch 'main' into feat/notifications

This commit is contained in:
Simos Mikelatos
2026-03-12 10:43:26 +01:00
committed by GitHub
109 changed files with 6143 additions and 1230 deletions

View File

@@ -5,6 +5,7 @@ import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider } from './contexts/WebSocketContext';
import { PluginsProvider } from './contexts/PluginsContext';
import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js';
@@ -14,8 +15,9 @@ export default function App() {
<ThemeProvider>
<AuthProvider>
<WebSocketProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<PluginsProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes>
@@ -24,8 +26,9 @@ export default function App() {
</Routes>
</Router>
</ProtectedRoute>
</TaskMasterProvider>
</TasksSettingsProvider>
</TaskMasterProvider>
</TasksSettingsProvider>
</PluginsProvider>
</WebSocketProvider>
</AuthProvider>
</ThemeProvider>

View File

@@ -40,7 +40,7 @@ export default function AppContent() {
setIsInputFocused,
setShowSettings,
openSettings,
fetchProjects,
refreshProjectsSilently,
sidebarSharedProps,
} = useProjectsState({
sessionId,
@@ -51,14 +51,16 @@ export default function AppContent() {
});
useEffect(() => {
window.refreshProjects = fetchProjects;
// Expose a non-blocking refresh for chat/session flows.
// Full loading refreshes are still available through direct fetchProjects calls.
window.refreshProjects = refreshProjectsSilently;
return () => {
if (window.refreshProjects === fetchProjects) {
if (window.refreshProjects === refreshProjectsSilently) {
delete window.refreshProjects;
}
};
}, [fetchProjects]);
}, [refreshProjectsSilently]);
useEffect(() => {
window.openSettings = openSettings;

View File

@@ -1,8 +1,36 @@
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
import { Dispatch, SetStateAction } from 'react';
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import {
MessageSquare,
Folder,
Terminal,
GitBranch,
ClipboardCheck,
Ellipsis,
Puzzle,
Box,
Database,
Globe,
Wrench,
Zap,
BarChart3,
type LucideIcon,
} from 'lucide-react';
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app';
const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
};
type CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;
type CoreNavItem = {
id: CoreTabId;
icon: LucideIcon;
label: string;
};
type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
@@ -10,41 +38,46 @@ type MobileNavProps = {
};
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
const { t } = useTranslation(['common', 'settings']);
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins();
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement | null>(null);
const navItems = [
{
id: 'chat',
icon: MessageSquare,
label: 'Chat',
onClick: () => setActiveTab('chat')
},
{
id: 'shell',
icon: Terminal,
label: 'Shell',
onClick: () => setActiveTab('shell')
},
{
id: 'files',
icon: Folder,
label: 'Files',
onClick: () => setActiveTab('files')
},
{
id: 'git',
icon: GitBranch,
label: 'Git',
onClick: () => setActiveTab('git')
},
...(shouldShowTasksTab ? [{
id: 'tasks',
icon: ClipboardCheck,
label: 'Tasks',
onClick: () => setActiveTab('tasks')
}] : [])
const enabledPlugins = plugins.filter((p) => p.enabled);
const hasPlugins = enabledPlugins.length > 0;
const isPluginActive = activeTab.startsWith('plugin:');
// Close the menu on outside tap
useEffect(() => {
if (!moreOpen) return;
const handleTap = (e: PointerEvent) => {
const target = e.target;
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
setMoreOpen(false);
}
};
document.addEventListener('pointerdown', handleTap);
return () => document.removeEventListener('pointerdown', handleTap);
}, [moreOpen]);
// Close menu when a plugin tab is selected
const selectPlugin = (name: string) => {
const pluginTab = `plugin:${name}` as AppTab;
setActiveTab(pluginTab);
setMoreOpen(false);
};
const baseCoreItems: CoreNavItem[] = [
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
{ id: 'shell', icon: Terminal, label: 'Shell' },
{ id: 'files', icon: Folder, label: 'Files' },
{ id: 'git', icon: GitBranch, label: 'Git' },
];
const coreItems: CoreNavItem[] = shouldShowTasksTab
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
: baseCoreItems;
return (
<div
@@ -53,17 +86,17 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
>
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
<div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
{navItems.map((item) => {
{coreItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={item.onClick}
onClick={() => setActiveTab(item.id)}
onTouchStart={(e) => {
e.preventDefault();
item.onClick();
setActiveTab(item.id);
}}
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
? 'text-primary'
@@ -85,6 +118,60 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
</button>
);
})}
{/* "More" button — only shown when there are enabled plugins */}
{hasPlugins && (
<div ref={moreRef} className="relative flex-1">
<button
onClick={() => setMoreOpen((v) => !v)}
onTouchStart={(e) => {
e.preventDefault();
setMoreOpen((v) => !v);
}}
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isPluginActive || moreOpen
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label="More plugins"
aria-expanded={moreOpen}
>
{(isPluginActive && !moreOpen) && (
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
)}
<Ellipsis
className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
strokeWidth={isPluginActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
{t('settings:pluginSettings.morePlugins')}
</span>
</button>
{/* Popover menu */}
{moreOpen && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full right-0 z-[60] mb-2 min-w-[180px] rounded-xl border border-border/40 bg-popover py-1.5 shadow-lg duration-150">
{enabledPlugins.map((p) => {
const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
const isActive = activeTab === `plugin:${p.name}`;
return (
<button
key={p.name}
onClick={() => selectPlugin(p.name)}
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${isActive
? 'bg-primary/8 text-primary'
: 'text-foreground hover:bg-muted/60'
}`}
>
<Icon className="h-4 w-4 flex-shrink-0" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="truncate">{p.displayName}</span>
</button>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>

View File

@@ -48,6 +48,7 @@ interface UseChatRealtimeHandlersArgs {
onSessionNotProcessing?: (sessionId?: string | null) => void;
onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string) => void;
onWebSocketReconnect?: () => void;
}
const appendStreamingChunk = (
@@ -113,6 +114,7 @@ export function useChatRealtimeHandlers({
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
onWebSocketReconnect,
}: UseChatRealtimeHandlersArgs) {
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
@@ -136,7 +138,7 @@ export function useChatRealtimeHandlers({
: null;
const messageType = String(latestMessage.type);
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'websocket-reconnected'];
const isGlobalMessage = globalMessageTypes.includes(messageType);
const lifecycleMessageTypes = new Set([
'claude-complete',
@@ -300,6 +302,11 @@ export function useChatRealtimeHandlers({
}
break;
case 'websocket-reconnected':
// WebSocket dropped and reconnected — re-fetch session history to catch up on missed messages
onWebSocketReconnect?.();
break;
case 'token-budget':
if (latestMessage.data) {
setTokenBudget(latestMessage.data);
@@ -692,14 +699,28 @@ export function useChatRealtimeHandlers({
const updated = [...previous];
const lastIndex = updated.length - 1;
const last = updated[lastIndex];
const normalizedTextResult = textResult.trim();
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
const finalContent =
textResult && textResult.trim()
normalizedTextResult
? textResult
: `${last.content || ''}${pendingChunk || ''}`;
// Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
} else if (textResult && textResult.trim()) {
} else if (normalizedTextResult) {
const lastAssistantText =
last && last.type === 'assistant' && !last.isToolUse
? String(last.content || '').trim()
: '';
// Cursor can emit the same final text through both streaming and result payloads.
// Skip adding a second assistant bubble when the final text is unchanged.
const isDuplicateFinalText = lastAssistantText === normalizedTextResult;
if (isDuplicateFinalText) {
return updated;
}
updated.push({
type: resultData.is_error ? 'error' : 'assistant',
content: textResult,

View File

@@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => {
}
};
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
/<user_info>[\s\S]*?<\/user_info>/gi,
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
/<available_skills>[\s\S]*?<\/available_skills>/gi,
/<environment_context>[\s\S]*?<\/environment_context>/gi,
/<environment_info>[\s\S]*?<\/environment_info>/gi,
];
const extractCursorUserQuery = (rawText: string): string => {
const userQueryMatches = [...rawText.matchAll(/<user_query>([\s\S]*?)<\/user_query>/gi)];
if (userQueryMatches.length === 0) {
return '';
}
return userQueryMatches
.map((match) => (match[1] || '').trim())
.filter(Boolean)
.join('\n')
.trim();
};
const sanitizeCursorUserMessageText = (rawText: string): string => {
const decodedText = decodeHtmlEntities(rawText || '').trim();
if (!decodedText) {
return '';
}
// Cursor stores user-visible text inside <user_query> and prepends hidden context blocks
// (<user_info>, <agent_skills>, etc). We only render the actual query in chat history.
const extractedUserQuery = extractCursorUserQuery(decodedText);
if (extractedUserQuery) {
return extractedUserQuery;
}
let sanitizedText = decodedText;
CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
sanitizedText = sanitizedText.replace(pattern, '');
});
return sanitizedText.trim();
};
const toAbsolutePath = (projectPath: string, filePath?: string) => {
if (!filePath) {
return filePath;
@@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
console.log('Error parsing blob content:', error);
}
if (role === 'user') {
text = sanitizeCursorUserMessageText(text);
}
if (text && text.trim()) {
const message: ChatMessage = {
type: role,

View File

@@ -109,6 +109,7 @@ function ChatInterface({
scrollToBottom,
scrollToBottomAndReset,
handleScroll,
loadSessionMessages,
} = useChatSessionState({
selectedProject,
selectedSession,
@@ -197,6 +198,23 @@ function ChatInterface({
setPendingPermissionRequests,
});
// On WebSocket reconnect, re-fetch the current session's messages from JSONL so missed
// streaming events (e.g. from long tool calls while iOS had the tab backgrounded) are shown.
// Also reset isLoading — if the server restarted or the session died mid-stream, the client
// would be stuck in "Processing..." forever without this reset.
const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return;
const provider = (localStorage.getItem('selected-provider') as any) || 'claude';
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, provider);
if (messages && messages.length > 0) {
setChatMessages(messages);
}
// Reset loading state — if the session is still active, new WebSocket messages will
// set it back to true. If it died, this clears the permanent frozen state.
setIsLoading(false);
setCanAbortSession(false);
}, [selectedProject, selectedSession, loadSessionMessages, setChatMessages, setIsLoading, setCanAbortSession]);
useChatRealtimeHandlers({
latestMessage,
provider,
@@ -219,6 +237,7 @@ function ChatInterface({
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
onWebSocketReconnect: handleWebSocketReconnect,
});
useEffect(() => {

View File

@@ -301,8 +301,7 @@ export default function ChatComposer({
onBlur={() => onInputFocusChange?.(false)}
onInput={onTextareaInput}
placeholder={placeholder}
disabled={isLoading}
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none disabled:opacity-50 sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
style={{ height: '50px' }}
/>

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

@@ -1,12 +1,17 @@
import React from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app';
import { NextTaskBanner } from '../../../task-master';
import React from "react";
import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
CLAUDE_MODELS,
CURSOR_MODELS,
CODEX_MODELS,
GEMINI_MODELS,
} from "../../../../../shared/modelConstants";
import type { ProjectSession, SessionProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master";
interface ProviderSelectionEmptyStateProps {
type ProviderSelectionEmptyStateProps = {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
@@ -24,7 +29,7 @@ interface ProviderSelectionEmptyStateProps {
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: React.Dispatch<React.SetStateAction<string>>;
}
};
type ProviderDef = {
id: SessionProvider;
@@ -37,50 +42,56 @@ type ProviderDef = {
const PROVIDERS: ProviderDef[] = [
{
id: 'claude',
name: 'Claude Code',
infoKey: 'providerSelection.providerInfo.anthropic',
accent: 'border-primary',
ring: 'ring-primary/15',
check: 'bg-primary text-primary-foreground',
id: "claude",
name: "Claude Code",
infoKey: "providerSelection.providerInfo.anthropic",
accent: "border-primary",
ring: "ring-primary/15",
check: "bg-primary text-primary-foreground",
},
{
id: 'cursor',
name: 'Cursor',
infoKey: 'providerSelection.providerInfo.cursorEditor',
accent: 'border-violet-500 dark:border-violet-400',
ring: 'ring-violet-500/15',
check: 'bg-violet-500 text-white',
id: "cursor",
name: "Cursor",
infoKey: "providerSelection.providerInfo.cursorEditor",
accent: "border-violet-500 dark:border-violet-400",
ring: "ring-violet-500/15",
check: "bg-violet-500 text-white",
},
{
id: 'codex',
name: 'Codex',
infoKey: 'providerSelection.providerInfo.openai',
accent: 'border-emerald-600 dark:border-emerald-400',
ring: 'ring-emerald-600/15',
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
id: "codex",
name: "Codex",
infoKey: "providerSelection.providerInfo.openai",
accent: "border-emerald-600 dark:border-emerald-400",
ring: "ring-emerald-600/15",
check: "bg-emerald-600 dark:bg-emerald-500 text-white",
},
{
id: 'gemini',
name: 'Gemini',
infoKey: 'providerSelection.providerInfo.google',
accent: 'border-blue-500 dark:border-blue-400',
ring: 'ring-blue-500/15',
check: 'bg-blue-500 text-white',
id: "gemini",
name: "Gemini",
infoKey: "providerSelection.providerInfo.google",
accent: "border-blue-500 dark:border-blue-400",
ring: "ring-blue-500/15",
check: "bg-blue-500 text-white",
},
];
function getModelConfig(p: SessionProvider) {
if (p === 'claude') return CLAUDE_MODELS;
if (p === 'codex') return CODEX_MODELS;
if (p === 'gemini') return GEMINI_MODELS;
if (p === "claude") return CLAUDE_MODELS;
if (p === "codex") return CODEX_MODELS;
if (p === "gemini") return GEMINI_MODELS;
return CURSOR_MODELS;
}
function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string) {
if (p === 'claude') return c;
if (p === 'codex') return co;
if (p === 'gemini') return g;
function getModelValue(
p: SessionProvider,
c: string,
cu: string,
co: string,
g: string,
) {
if (p === "claude") return c;
if (p === "codex") return co;
if (p === "gemini") return g;
return cu;
}
@@ -103,24 +114,41 @@ export default function ProviderSelectionEmptyState({
onShowAllTasks,
setInput,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation('chat');
const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
const { t } = useTranslation("chat");
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
defaultValue: "Start the next task",
});
const selectProvider = (next: SessionProvider) => {
setProvider(next);
localStorage.setItem('selected-provider', next);
localStorage.setItem("selected-provider", next);
setTimeout(() => textareaRef.current?.focus(), 100);
};
const handleModelChange = (value: string) => {
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); }
else if (provider === 'gemini') { setGeminiModel(value); localStorage.setItem('gemini-model', value); }
else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
if (provider === "claude") {
setClaudeModel(value);
localStorage.setItem("claude-model", value);
} else if (provider === "codex") {
setCodexModel(value);
localStorage.setItem("codex-model", value);
} else if (provider === "gemini") {
setGeminiModel(value);
localStorage.setItem("gemini-model", value);
} else {
setCursorModel(value);
localStorage.setItem("cursor-model", value);
}
};
const modelConfig = getModelConfig(provider);
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel);
const currentModel = getModelValue(
provider,
claudeModel,
cursorModel,
codexModel,
geminiModel,
);
/* ── New session — provider picker ── */
if (!selectedSession && !currentSessionId) {
@@ -130,10 +158,10 @@ export default function ProviderSelectionEmptyState({
{/* Heading */}
<div className="mb-8 text-center">
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{t('providerSelection.title')}
{t("providerSelection.title")}
</h2>
<p className="mt-1 text-[13px] text-muted-foreground">
{t('providerSelection.description')}
{t("providerSelection.description")}
</p>
</div>
@@ -149,23 +177,30 @@ export default function ProviderSelectionEmptyState({
relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
pb-4 pt-5 transition-all duration-150
active:scale-[0.97]
${active
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
: 'border-border bg-card/60 hover:border-border/80 hover:bg-card'
${
active
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
: "border-border bg-card/60 hover:border-border/80 hover:bg-card"
}
`}
>
<SessionProviderLogo
provider={p.id}
className={`h-9 w-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
className={`h-9 w-9 transition-transform duration-150 ${active ? "scale-110" : ""}`}
/>
<div className="text-center">
<p className="text-[13px] font-semibold leading-none text-foreground">{p.name}</p>
<p className="mt-1 text-[10px] leading-tight text-muted-foreground">{t(p.infoKey)}</p>
<p className="text-[13px] font-semibold leading-none text-foreground">
{p.name}
</p>
<p className="mt-1 text-[10px] leading-tight text-muted-foreground">
{t(p.infoKey)}
</p>
</div>
{/* Check badge */}
{active && (
<div className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}>
<div
className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}
>
<Check className="h-2.5 w-2.5" strokeWidth={3} />
</div>
)}
@@ -175,9 +210,13 @@ export default function ProviderSelectionEmptyState({
</div>
{/* Model picker — appears after provider is chosen */}
<div className={`transition-all duration-200 ${provider ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'}`}>
<div
className={`transition-all duration-200 ${provider ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0"}`}
>
<div className="mb-5 flex items-center justify-center gap-2">
<span className="text-sm text-muted-foreground">{t('providerSelection.selectModel')}</span>
<span className="text-sm text-muted-foreground">
{t("providerSelection.selectModel")}
</span>
<div className="relative">
<select
value={currentModel}
@@ -185,9 +224,13 @@ export default function ProviderSelectionEmptyState({
tabIndex={-1}
className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => (
<option key={value} value={value}>{label}</option>
))}
{modelConfig.OPTIONS.map(
({ value, label }: { value: string; label: string }) => (
<option key={value + label} value={value}>
{label}
</option>
),
)}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
</div>
@@ -196,10 +239,18 @@ export default function ProviderSelectionEmptyState({
<p className="text-center text-sm text-muted-foreground/70">
{
{
claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }),
cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }),
codex: t('providerSelection.readyPrompt.codex', { model: codexModel }),
gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }),
claude: t("providerSelection.readyPrompt.claude", {
model: claudeModel,
}),
cursor: t("providerSelection.readyPrompt.cursor", {
model: cursorModel,
}),
codex: t("providerSelection.readyPrompt.codex", {
model: codexModel,
}),
gemini: t("providerSelection.readyPrompt.gemini", {
model: geminiModel,
}),
}[provider]
}
</p>
@@ -208,7 +259,10 @@ export default function ProviderSelectionEmptyState({
{/* Task banner */}
{provider && tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
<NextTaskBanner
onStartTask={() => setInput(nextTaskPrompt)}
onShowAllTasks={onShowAllTasks}
/>
</div>
)}
</div>
@@ -221,12 +275,19 @@ export default function ProviderSelectionEmptyState({
return (
<div className="flex h-full items-center justify-center">
<div className="max-w-md px-6 text-center">
<p className="mb-1.5 text-lg font-semibold text-foreground">{t('session.continue.title')}</p>
<p className="text-sm leading-relaxed text-muted-foreground">{t('session.continue.description')}</p>
<p className="mb-1.5 text-lg font-semibold text-foreground">
{t("session.continue.title")}
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
{t("session.continue.description")}
</p>
{tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
<NextTaskBanner
onStartTask={() => setInput(nextTaskPrompt)}
onShowAllTasks={onShowAllTasks}
/>
</div>
)}
</div>

View File

@@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
pull: 'Confirm Pull',
push: 'Confirm Push',
publish: 'Publish Branch',
revertLocalCommit: 'Revert Local Commit',
};
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
@@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
pull: 'Pull',
push: 'Push',
publish: 'Publish',
revertLocalCommit: 'Revert Commit',
};
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
@@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
pull: 'bg-green-600 hover:bg-green-700',
push: 'bg-orange-600 hover:bg-orange-700',
publish: 'bg-purple-600 hover:bg-purple-700',
revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700',
};
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
@@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, stri
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
push: 'bg-yellow-100 dark:bg-yellow-900/30',
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
revertLocalCommit: 'bg-yellow-100 dark:bg-yellow-900/30',
};
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
@@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
pull: 'text-yellow-600 dark:text-yellow-400',
push: 'text-yellow-600 dark:text-yellow-400',
publish: 'text-yellow-600 dark:text-yellow-400',
revertLocalCommit: 'text-yellow-600 dark:text-yellow-400',
};

View File

@@ -0,0 +1,48 @@
import { useCallback, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { GitOperationResponse } from '../types/types';
type UseRevertLocalCommitOptions = {
projectName: string | null;
onSuccess?: () => void;
};
async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) {
const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false);
const revertLatestLocalCommit = useCallback(async () => {
if (!projectName) {
return;
}
setIsRevertingLocalCommit(true);
try {
const response = await authenticatedFetch('/api/git/revert-local-commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project: projectName }),
});
const data = await readJson<GitOperationResponse>(response);
if (!data.success) {
console.error('Revert local commit failed:', data.error || data.details || 'Unknown error');
return;
}
onSuccess?.();
} catch (error) {
console.error('Error reverting local commit:', error);
} finally {
setIsRevertingLocalCommit(false);
}
}, [onSuccess, projectName]);
return {
isRevertingLocalCommit,
revertLatestLocalCommit,
};
}

View File

@@ -3,7 +3,7 @@ import type { Project } from '../../../types/app';
export type GitPanelView = 'changes' | 'history';
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish';
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit';
export type FileDiffInfo = {
old_string: string;

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import { useGitPanelController } from '../hooks/useGitPanelController';
import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit';
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
import ChangesView from '../view/changes/ChangesView';
import HistoryView from '../view/history/HistoryView';
@@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
onFileOpen,
});
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({
projectName: selectedProject?.name ?? null,
onSuccess: refreshAll,
});
const executeConfirmedAction = useCallback(async () => {
if (!confirmAction) {
return;
@@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
isPulling={isPulling}
isPushing={isPushing}
isPublishing={isPublishing}
isRevertingLocalCommit={isRevertingLocalCommit}
onRefresh={refreshAll}
onRevertLocalCommit={revertLatestLocalCommit}
onSwitchBranch={switchBranch}
onCreateBranch={createBranch}
onFetch={handleFetch}
@@ -107,7 +115,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
{activeView === 'changes' && (
<ChangesView
key={selectedProject.fullPath}
isMobile={isMobile}
projectPath={selectedProject.fullPath}
gitStatus={gitStatus}
gitDiff={gitDiff}
isLoading={isLoading}

View File

@@ -1,4 +1,4 @@
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react';
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
import NewBranchModal from './modals/NewBranchModal';
@@ -14,7 +14,9 @@ type GitPanelHeaderProps = {
isPulling: boolean;
isPushing: boolean;
isPublishing: boolean;
isRevertingLocalCommit: boolean;
onRefresh: () => void;
onRevertLocalCommit: () => Promise<void>;
onSwitchBranch: (branchName: string) => Promise<boolean>;
onCreateBranch: (branchName: string) => Promise<boolean>;
onFetch: () => Promise<void>;
@@ -35,7 +37,9 @@ export default function GitPanelHeader({
isPulling,
isPushing,
isPublishing,
isRevertingLocalCommit,
onRefresh,
onRevertLocalCommit,
onSwitchBranch,
onCreateBranch,
onFetch,
@@ -88,6 +92,14 @@ export default function GitPanelHeader({
});
};
const requestRevertLocalCommitConfirmation = () => {
onRequestConfirmation({
type: 'revertLocalCommit',
message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.',
onConfirm: onRevertLocalCommit,
});
};
const handleSwitchBranch = async (branchName: string) => {
try {
const success = await onSwitchBranch(branchName);
@@ -240,6 +252,17 @@ export default function GitPanelHeader({
</>
)}
<button
onClick={requestRevertLocalCommitConfirmation}
disabled={isRevertingLocalCommit}
className={`rounded-lg transition-colors hover:bg-accent disabled:opacity-50 ${isMobile ? 'p-1' : 'p-1.5'}`}
title="Revert latest local commit"
>
<RotateCcw
className={`text-muted-foreground ${isRevertingLocalCommit ? 'animate-pulse' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`}
/>
</button>
<button
onClick={onRefresh}
disabled={isLoading}

View File

@@ -9,6 +9,7 @@ import FileStatusLegend from './FileStatusLegend';
type ChangesViewProps = {
isMobile: boolean;
projectPath: string;
gitStatus: GitStatusResponse | null;
gitDiff: GitDiffMap;
isLoading: boolean;
@@ -27,6 +28,7 @@ type ChangesViewProps = {
export default function ChangesView({
isMobile,
projectPath,
gitStatus,
gitDiff,
isLoading,
@@ -131,6 +133,7 @@ export default function ChangesView({
<>
<CommitComposer
isMobile={isMobile}
projectPath={projectPath}
selectedFileCount={selectedFiles.size}
isHidden={hasExpandedFiles}
onCommit={commitSelectedFiles}

View File

@@ -3,8 +3,12 @@ import { useState } from 'react';
import MicButton from '../../../mic-button/view/MicButton';
import type { ConfirmationRequest } from '../../types/types';
// Persists commit messages across unmount/remount, keyed by project path
const commitMessageCache = new Map<string, string>();
type CommitComposerProps = {
isMobile: boolean;
projectPath: string;
selectedFileCount: number;
isHidden: boolean;
onCommit: (message: string) => Promise<boolean>;
@@ -14,13 +18,24 @@ type CommitComposerProps = {
export default function CommitComposer({
isMobile,
projectPath,
selectedFileCount,
isHidden,
onCommit,
onGenerateMessage,
onRequestConfirmation,
}: CommitComposerProps) {
const [commitMessage, setCommitMessage] = useState('');
const [commitMessage, setCommitMessageRaw] = useState(() => commitMessageCache.get(projectPath) ?? '');
const setCommitMessage = (msg: string) => {
setCommitMessageRaw(msg);
if (msg) {
commitMessageCache.set(projectPath, msg);
} else {
commitMessageCache.delete(projectPath);
}
};
const [isCommitting, setIsCommitting] = useState(false);
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(isMobile);

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Check, Download, Trash2, Upload } from 'lucide-react';
import { Check, Download, RotateCcw, Trash2, Upload } from 'lucide-react';
import {
CONFIRMATION_ACTION_LABELS,
CONFIRMATION_BUTTON_CLASSES,
@@ -27,6 +27,10 @@ function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
return <Download className="h-4 w-4" />;
}
if (actionType === 'revertLocalCommit') {
return <RotateCcw className="h-4 w-4" />;
}
return <Upload className="h-4 w-4" />;
}

View File

@@ -1,10 +1,38 @@
import { useMemo } from 'react';
type GitDiffViewerProps = {
diff: string | null;
isMobile: boolean;
wrapText: boolean;
};
const PREVIEW_CHARACTER_LIMIT = 200_000;
const PREVIEW_LINE_LIMIT = 1_500;
type DiffPreview = {
lines: string[];
isCharacterTruncated: boolean;
isLineTruncated: boolean;
};
function buildDiffPreview(diff: string): DiffPreview {
const isCharacterTruncated = diff.length > PREVIEW_CHARACTER_LIMIT;
const previewText = isCharacterTruncated ? diff.slice(0, PREVIEW_CHARACTER_LIMIT) : diff;
const previewLines = previewText.split('\n');
const isLineTruncated = previewLines.length > PREVIEW_LINE_LIMIT;
return {
lines: isLineTruncated ? previewLines.slice(0, PREVIEW_LINE_LIMIT) : previewLines,
isCharacterTruncated,
isLineTruncated,
};
}
export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewerProps) {
// Render a bounded preview to keep huge commit diffs from freezing the UI thread.
const preview = useMemo(() => buildDiffPreview(diff || ''), [diff]);
const isPreviewTruncated = preview.isCharacterTruncated || preview.isLineTruncated;
if (!diff) {
return (
<div className="p-4 text-center text-sm text-muted-foreground">
@@ -35,7 +63,12 @@ export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewe
return (
<div className="diff-viewer">
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
{isPreviewTruncated && (
<div className="mb-2 rounded-md border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
Large diff preview: rendering is limited to keep the tab responsive.
</div>
)}
{preview.lines.map((line, index) => renderDiffLine(line, index))}
</div>
);
}
}

View File

@@ -3,6 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel';
import PluginTabContent from '../../plugins/view/PluginTabContent';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
@@ -145,7 +146,12 @@ function MainContent({
{activeTab === 'shell' && (
<div className="h-full w-full overflow-hidden">
<StandaloneShell project={selectedProject} session={selectedSession} showHeader={false} />
<StandaloneShell
project={selectedProject}
session={selectedSession}
showHeader={false}
isActive={activeTab === 'shell'}
/>
</div>
)}
@@ -158,6 +164,16 @@ function MainContent({
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
{activeTab.startsWith('plugin:') && (
<div className="h-full overflow-hidden">
<PluginTabContent
pluginName={activeTab.replace('plugin:', '')}
selectedProject={selectedProject}
selectedSession={selectedSession}
/>
</div>
)}
</div>
<EditorSidebar

View File

@@ -1,3 +1,4 @@
import { useCallback, useRef, useState, useEffect } from 'react';
import type { MainContentHeaderProps } from '../../types/types';
import MobileMenuButton from './MobileMenuButton';
import MainContentTabSwitcher from './MainContentTabSwitcher';
@@ -12,6 +13,26 @@ export default function MainContentHeader({
isMobile,
onMenuClick,
}: MainContentHeaderProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 2);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 2);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateScrollState();
const observer = new ResizeObserver(updateScrollState);
observer.observe(el);
return () => observer.disconnect();
}, [updateScrollState]);
return (
<div className="pwa-header-safe flex-shrink-0 border-b border-border/60 bg-background px-3 py-1.5 sm:px-4 sm:py-2">
<div className="flex items-center justify-between gap-3">
@@ -25,12 +46,24 @@ export default function MainContentHeader({
/>
</div>
<div className="hidden flex-shrink-0 sm:block">
<MainContentTabSwitcher
activeTab={activeTab}
setActiveTab={setActiveTab}
shouldShowTasksTab={shouldShowTasksTab}
/>
<div className="relative min-w-0 flex-shrink overflow-hidden sm:flex-shrink-0">
{canScrollLeft && (
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-background to-transparent" />
)}
<div
ref={scrollRef}
onScroll={updateScrollState}
className="scrollbar-hide overflow-x-auto"
>
<MainContentTabSwitcher
activeTab={activeTab}
setActiveTab={setActiveTab}
shouldShowTasksTab={shouldShowTasksTab}
/>
</div>
{canScrollRight && (
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-background to-transparent" />
)}
</div>
</div>
</div>

View File

@@ -1,8 +1,10 @@
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '../../../../shared/view/ui';
import { Tooltip, PillBar, Pill } from '../../../../shared/view/ui';
import type { AppTab } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext';
import PluginIcon from '../../../plugins/view/PluginIcon';
type MainContentTabSwitcherProps = {
activeTab: AppTab;
@@ -10,20 +12,32 @@ type MainContentTabSwitcherProps = {
shouldShowTasksTab: boolean;
};
type TabDefinition = {
type BuiltInTab = {
kind: 'builtin';
id: AppTab;
labelKey: string;
icon: LucideIcon;
};
const BASE_TABS: TabDefinition[] = [
{ id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
{ id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
{ id: 'files', labelKey: 'tabs.files', icon: Folder },
{ id: 'git', labelKey: 'tabs.git', icon: GitBranch },
type PluginTab = {
kind: 'plugin';
id: AppTab;
label: string;
pluginName: string;
iconFile: string;
};
type TabDefinition = BuiltInTab | PluginTab;
const BASE_TABS: BuiltInTab[] = [
{ kind: 'builtin', id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
{ kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
{ kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder },
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
];
const TASKS_TAB: TabDefinition = {
const TASKS_TAB: BuiltInTab = {
kind: 'builtin',
id: 'tasks',
labelKey: 'tabs.tasks',
icon: ClipboardCheck,
@@ -35,31 +49,49 @@ export default function MainContentTabSwitcher({
shouldShowTasksTab,
}: MainContentTabSwitcherProps) {
const { t } = useTranslation();
const { plugins } = usePlugins();
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
const pluginTabs: PluginTab[] = plugins
.filter((p) => p.enabled)
.map((p) => ({
kind: 'plugin',
id: `plugin:${p.name}` as AppTab,
label: p.displayName,
pluginName: p.name,
iconFile: p.icon,
}));
const tabs: TabDefinition[] = [...builtInTabs, ...pluginTabs];
return (
<div className="inline-flex items-center gap-[2px] rounded-lg bg-muted/60 p-[3px]">
<PillBar>
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = tab.id === activeTab;
const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label;
return (
<Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom">
<button
<Tooltip key={tab.id} content={displayLabel} position="bottom">
<Pill
isActive={isActive}
onClick={() => setActiveTab(tab.id)}
className={`relative flex items-center gap-1.5 rounded-md px-2.5 py-[5px] text-sm font-medium transition-all duration-150 ${
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
className="px-2.5 py-[5px]"
>
<Icon className="h-3.5 w-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="hidden lg:inline">{t(tab.labelKey)}</span>
</button>
{tab.kind === 'builtin' ? (
<tab.icon className="h-3.5 w-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
) : (
<PluginIcon
pluginName={tab.pluginName}
iconFile={tab.iconFile}
className="flex h-3.5 w-3.5 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
/>
)}
<span className="hidden lg:inline">{displayLabel}</span>
</Pill>
</Tooltip>
);
})}
</div>
</PillBar>
);
}

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext';
type MainContentTitleProps = {
activeTab: AppTab;
@@ -9,7 +10,11 @@ type MainContentTitleProps = {
shouldShowTasksTab: boolean;
};
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) {
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string, pluginDisplayName?: string) {
if (activeTab.startsWith('plugin:') && pluginDisplayName) {
return pluginDisplayName;
}
if (activeTab === 'files') {
return t('mainContent.projectFiles');
}
@@ -40,6 +45,11 @@ export default function MainContentTitle({
shouldShowTasksTab,
}: MainContentTitleProps) {
const { t } = useTranslation();
const { plugins } = usePlugins();
const pluginDisplayName = activeTab.startsWith('plugin:')
? plugins.find((p) => p.name === activeTab.replace('plugin:', ''))?.displayName
: undefined;
const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
const showChatNewSession = activeTab === 'chat' && !selectedSession;
@@ -68,7 +78,7 @@ export default function MainContentTitle({
) : (
<div className="min-w-0">
<h2 className="text-sm font-semibold leading-tight text-foreground">
{getTabTitle(activeTab, shouldShowTasksTab, t)}
{getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}
</h2>
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
</div>

View File

@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react';
import { authenticatedFetch } from '../../../utils/api';
type Props = {
pluginName: string;
iconFile: string;
className?: string;
};
// Module-level cache so repeated renders don't re-fetch
const svgCache = new Map<string, string>();
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
const url = iconFile
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
: '';
const [svg, setSvg] = useState<string | null>(url ? (svgCache.get(url) ?? null) : null);
useEffect(() => {
if (!url || svgCache.has(url)) return;
authenticatedFetch(url)
.then((r) => {
if (!r.ok) return;
return r.text();
})
.then((text) => {
if (text && text.trimStart().startsWith('<svg')) {
svgCache.set(url, text);
setSvg(text);
}
})
.catch(() => {});
}, [url]);
if (!svg) return <span className={className} />;
return (
<span
className={className}
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}

View File

@@ -0,0 +1,456 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
import { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../../contexts/PluginsContext';
import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
return (
<label className="relative inline-flex cursor-pointer select-none items-center">
<input
type="checkbox"
className="peer sr-only"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-label={ariaLabel}
/>
<div
className={`
relative h-5 w-9 rounded-full bg-muted transition-colors
duration-200 after:absolute
after:left-[2px] after:top-[2px] after:h-4 after:w-4
after:rounded-full after:bg-white after:shadow-sm after:transition-transform after:duration-200
after:content-[''] peer-checked:bg-emerald-500
peer-checked:after:translate-x-4
`}
/>
</label>
);
}
/* ─── Server Dot ────────────────────────────────────────────────────────── */
function ServerDot({ running, t }: { running: boolean; t: any }) {
if (!running) return null;
return (
<span className="relative flex items-center gap-1.5">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
<span className="font-mono text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
{t('pluginSettings.runningStatus')}
</span>
</span>
);
}
/* ─── Plugin Card ───────────────────────────────────────────────────────── */
type PluginCardProps = {
plugin: Plugin;
index: number;
onToggle: (enabled: boolean) => void;
onUpdate: () => void;
onUninstall: () => void;
updating: boolean;
confirmingUninstall: boolean;
onCancelUninstall: () => void;
updateError: string | null;
};
function PluginCard({
plugin,
index,
onToggle,
onUpdate,
onUninstall,
updating,
confirmingUninstall,
onCancelUninstall,
updateError,
}: PluginCardProps) {
const { t } = useTranslation('settings');
const accentColor = plugin.enabled
? 'bg-emerald-500'
: 'bg-muted-foreground/20';
return (
<div
className="relative flex overflow-hidden rounded-lg border border-border bg-card transition-opacity duration-200"
style={{
opacity: plugin.enabled ? 1 : 0.65,
animationDelay: `${index * 40}ms`,
}}
>
{/* Left accent bar */}
<div className={`w-[3px] flex-shrink-0 ${accentColor} transition-colors duration-300`} />
<div className="min-w-0 flex-1 p-4">
{/* Header row */}
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="h-5 w-5 flex-shrink-0 text-foreground/80">
<PluginIcon
pluginName={plugin.name}
iconFile={plugin.icon}
className="h-5 w-5 [&>svg]:h-full [&>svg]:w-full"
/>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground">
{plugin.displayName}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
v{plugin.version}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{plugin.slot}
</span>
<ServerDot running={!!plugin.serverRunning} t={t} />
</div>
{plugin.description && (
<p className="mt-1 text-sm leading-snug text-muted-foreground">
{plugin.description}
</p>
)}
<div className="mt-1 flex items-center gap-3">
{plugin.author && (
<span className="text-xs text-muted-foreground/60">
{plugin.author}
</span>
)}
{plugin.repoUrl && (
<a
href={plugin.repoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
<GitBranch className="h-3 w-3" />
<span className="max-w-[200px] truncate">
{plugin.repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '')}
</span>
</a>
)}
</div>
</div>
</div>
{/* Controls */}
<div className="flex flex-shrink-0 items-center gap-2">
<button
onClick={onUpdate}
disabled={updating || !plugin.repoUrl}
title={plugin.repoUrl ? t('pluginSettings.pullLatest') : t('pluginSettings.noGitRemote')}
aria-label={t('pluginSettings.pullLatest')}
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{updating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</button>
<button
onClick={onUninstall}
title={confirmingUninstall ? t('pluginSettings.confirmUninstall') : t('pluginSettings.uninstallPlugin')}
aria-label={t('pluginSettings.uninstallPlugin')}
className={`rounded p-1.5 transition-colors ${confirmingUninstall
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'
: 'text-muted-foreground hover:bg-muted hover:text-red-500'
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? t('pluginSettings.disable') : t('pluginSettings.enable')} ${plugin.displayName}`} />
</div>
</div>
{/* Confirm uninstall banner */}
{confirmingUninstall && (
<div className="mt-3 flex items-center justify-between gap-3 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/30">
<span className="text-sm text-red-600 dark:text-red-400">
{t('pluginSettings.confirmUninstallMessage', { name: plugin.displayName })}
</span>
<div className="flex gap-1.5">
<button
onClick={onCancelUninstall}
className="rounded border border-border px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{t('pluginSettings.cancel')}
</button>
<button
onClick={onUninstall}
className="rounded border border-red-300 px-2.5 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
>
{t('pluginSettings.remove')}
</button>
</div>
</div>
)}
{/* Update error */}
{updateError && (
<div className="mt-2 flex items-center gap-1.5 text-sm text-red-500">
<ServerCrash className="h-3.5 w-3.5 flex-shrink-0" />
<span>{updateError}</span>
</div>
)}
</div>
</div>
);
}
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
const { t } = useTranslation('settings');
return (
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
<div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
<BarChart3 className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground">
{t('pluginSettings.starterPlugin.name')}
</span>
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
{t('pluginSettings.starterPlugin.badge')}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t('pluginSettings.tab')}
</span>
</div>
<p className="mt-1 text-sm leading-snug text-muted-foreground">
{t('pluginSettings.starterPlugin.description')}
</p>
<a
href={STARTER_PLUGIN_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
<GitBranch className="h-3 w-3" />
cloudcli-ai/cloudcli-plugin-starter
</a>
</div>
</div>
<button
onClick={onInstall}
disabled={installing}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}
</button>
</div>
</div>
</div>
);
}
/* ─── Main Component ────────────────────────────────────────────────────── */
export default function PluginSettingsTab() {
const { t } = useTranslation('settings');
const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } =
usePlugins();
const [gitUrl, setGitUrl] = useState('');
const [installing, setInstalling] = useState(false);
const [installingStarter, setInstallingStarter] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
const handleUpdate = async (name: string) => {
setUpdatingPlugins((prev) => new Set(prev).add(name));
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
const result = await updatePlugin(name);
if (!result.success) {
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || t('pluginSettings.updateFailed') }));
}
setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });
};
const handleInstall = async () => {
if (!gitUrl.trim()) return;
setInstalling(true);
setInstallError(null);
const result = await installPlugin(gitUrl.trim());
if (result.success) {
setGitUrl('');
} else {
setInstallError(result.error || t('pluginSettings.installFailed'));
}
setInstalling(false);
};
const handleInstallStarter = async () => {
setInstallingStarter(true);
setInstallError(null);
const result = await installPlugin(STARTER_PLUGIN_URL);
if (!result.success) {
setInstallError(result.error || t('pluginSettings.installFailed'));
}
setInstallingStarter(false);
};
const handleUninstall = async (name: string) => {
if (confirmUninstall !== name) {
setConfirmUninstall(name);
return;
}
const result = await uninstallPlugin(name);
if (result.success) {
setConfirmUninstall(null);
} else {
setInstallError(result.error || t('pluginSettings.uninstallFailed'));
setConfirmUninstall(null);
}
};
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
return (
<div className="space-y-6">
{/* Header */}
<div>
<h3 className="mb-1 text-base font-semibold text-foreground">
{t('pluginSettings.title')}
</h3>
<p className="text-sm text-muted-foreground">
{t('pluginSettings.description')}
</p>
</div>
{/* Install from Git — compact */}
<div className="flex items-center gap-0 overflow-hidden rounded-lg border border-border bg-card">
<span className="flex-shrink-0 pl-3 pr-1 text-muted-foreground/40">
<GitBranch className="h-3.5 w-3.5" />
</span>
<input
type="text"
value={gitUrl}
onChange={(e) => {
setGitUrl(e.target.value);
setInstallError(null);
}}
placeholder={t('pluginSettings.installPlaceholder')}
aria-label={t('pluginSettings.installAriaLabel')}
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter') void handleInstall();
}}
/>
<button
onClick={handleInstall}
disabled={installing || !gitUrl.trim()}
className="flex-shrink-0 border-l border-border bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-30"
>
{installing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t('pluginSettings.installButton')
)}
</button>
</div>
{installError && (
<p className="-mt-4 text-sm text-red-500">{installError}</p>
)}
<p className="-mt-4 flex items-start gap-1.5 text-xs leading-snug text-muted-foreground/50">
<ShieldAlert className="mt-px h-3 w-3 flex-shrink-0" />
<span>
{t('pluginSettings.securityWarning')}
</span>
</p>
{/* Starter plugin suggestion — above the list */}
{!loading && !hasStarterInstalled && (
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
)}
{/* Plugin List */}
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('pluginSettings.scanningPlugins')}
</div>
) : plugins.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
) : (
<div className="space-y-2">
{plugins.map((plugin, index) => {
const handleToggle = async (enabled: boolean) => {
const r = await togglePlugin(plugin.name, enabled);
if (!r.success) {
setInstallError(r.error || t('pluginSettings.toggleFailed'));
}
};
return (
<PluginCard
key={plugin.name}
plugin={plugin}
index={index}
onToggle={(enabled) => void handleToggle(enabled)}
onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugins.has(plugin.name)}
confirmingUninstall={confirmUninstall === plugin.name}
onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null}
/>
);
})}
</div>
)}
{/* Build your own */}
<div className="flex items-center justify-between gap-4 border-t border-border/50 pt-2">
<div className="flex min-w-0 items-center gap-2">
<BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" />
<span className="text-xs text-muted-foreground/60">
{t('pluginSettings.buildYourOwn')}
</span>
</div>
<div className="flex flex-shrink-0 items-center gap-3">
<a
href={STARTER_PLUGIN_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
{t('pluginSettings.starter')} <ExternalLink className="h-2.5 w-2.5" />
</a>
<span className="text-muted-foreground/20">·</span>
<a
href="https://cloudcli.ai/docs/plugin-overview"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
{t('pluginSettings.docs')} <ExternalLink className="h-2.5 w-2.5" />
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { useEffect, useRef } from 'react';
import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../../utils/api';
import { usePlugins } from '../../../contexts/PluginsContext';
import type { Project, ProjectSession } from '../../../types/app';
type PluginTabContentProps = {
pluginName: string;
selectedProject: Project | null;
selectedSession: ProjectSession | null;
};
type PluginContext = {
theme: 'dark' | 'light';
project: { name: string; path: string } | null;
session: { id: string; title: string } | null;
};
function buildContext(
isDarkMode: boolean,
selectedProject: Project | null,
selectedSession: ProjectSession | null,
): PluginContext {
return {
theme: isDarkMode ? 'dark' : 'light',
project: selectedProject
? {
name: selectedProject.name,
path: selectedProject.fullPath || selectedProject.path || '',
}
: null,
session: selectedSession
? {
id: selectedSession.id,
title: selectedSession.title || selectedSession.name || selectedSession.id,
}
: null,
};
}
export default function PluginTabContent({
pluginName,
selectedProject,
selectedSession,
}: PluginTabContentProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { isDarkMode } = useTheme();
const { plugins } = usePlugins();
// Stable refs so effects don't need context values in their dep arrays
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
const moduleRef = useRef<any>(null);
const plugin = plugins.find(p => p.name === pluginName);
// Keep contextRef current and notify the mounted plugin on every context change
useEffect(() => {
const ctx = buildContext(isDarkMode, selectedProject, selectedSession);
contextRef.current = ctx;
for (const cb of contextCallbacksRef.current) {
try { cb(ctx); } catch { /* plugin error — ignore */ }
}
}, [isDarkMode, selectedProject, selectedSession]);
useEffect(() => {
if (!containerRef.current || !plugin?.enabled) return;
let active = true;
const container = containerRef.current;
const entryFile = plugin?.entry ?? 'index.js';
const contextCallbacks = contextCallbacksRef.current;
(async () => {
try {
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
// Then import it via a Blob URL so the browser never makes an unauthenticated request.
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(entryFile)}`;
const res = await authenticatedFetch(assetUrl);
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
const jsText = await res.text();
const blob = new Blob([jsText], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
// @vite-ignore
const mod = await import(/* @vite-ignore */ blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
if (!active || !containerRef.current) return;
moduleRef.current = mod;
const api = {
get context(): PluginContext { return contextRef.current; },
onContextChange(cb: (ctx: PluginContext) => void): () => void {
contextCallbacks.add(cb);
return () => contextCallbacks.delete(cb);
},
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
const cleanPath = String(path).replace(/^\//, '');
const res = await authenticatedFetch(
`/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`,
{
method: method || 'GET',
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
},
);
if (!res.ok) throw new Error(`RPC error ${res.status}`);
return res.json();
},
};
await mod.mount?.(container, api);
if (!active) {
try { mod.unmount?.(container); } catch { /* ignore */ }
moduleRef.current = null;
return;
}
} catch (err) {
if (!active) return;
console.error(`[Plugin:${pluginName}] Failed to load:`, err);
if (containerRef.current) {
const errDiv = document.createElement('div');
errDiv.style.cssText = 'padding:16px;font-size:13px;color:#dc2626';
errDiv.textContent = `Plugin failed to load: ${String(err)}`;
containerRef.current.replaceChildren(errDiv);
}
}
})();
return () => {
active = false;
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
contextCallbacks.clear();
moduleRef.current = null;
};
}, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
}

View File

@@ -104,7 +104,7 @@ type NotificationPreferencesResponse = {
type ActiveLoginProvider = AgentProvider | '';
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications'];
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins'];
const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools".
@@ -209,7 +209,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const closeTimerRef = useRef<number | null>(null);
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
@@ -778,16 +777,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
}
setSaveStatus('success');
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
closeTimerRef.current = window.setTimeout(() => onClose(), 1000);
} catch (error) {
console.error('Error saving settings:', error);
setSaveStatus('error');
} finally {
setIsSaving(false);
}
}, [
claudePermissions.allowedTools,
@@ -799,6 +791,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
cursorPermissions.skipPermissions,
notificationPreferences,
onClose,
geminiPermissionMode,
projectSortOrder,
]);
@@ -851,11 +844,58 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
}, [codeEditorSettings]);
// Auto-save permissions and sort order with debounce
const autoSaveTimerRef = useRef<number | null>(null);
const isInitialLoadRef = useRef(true);
useEffect(() => {
// Skip auto-save on initial load (settings are being loaded from localStorage)
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false;
return;
}
if (autoSaveTimerRef.current !== null) {
window.clearTimeout(autoSaveTimerRef.current);
}
autoSaveTimerRef.current = window.setTimeout(() => {
saveSettings();
}, 500);
return () => {
if (autoSaveTimerRef.current !== null) {
window.clearTimeout(autoSaveTimerRef.current);
}
};
}, [saveSettings]);
// Clear save status after 2 seconds
useEffect(() => {
if (saveStatus === null) {
return;
}
const timer = window.setTimeout(() => setSaveStatus(null), 2000);
return () => window.clearTimeout(timer);
}, [saveStatus]);
// Reset initial load flag when settings dialog opens
useEffect(() => {
if (isOpen) {
isInitialLoadRef.current = true;
}
}, [isOpen]);
useEffect(() => () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (autoSaveTimerRef.current !== null) {
window.clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
}, []);
return {
@@ -863,7 +903,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
setActiveTab,
isDarkMode,
toggleDarkMode,
isSaving,
saveStatus,
deleteError,
projectSortOrder,
@@ -910,6 +949,5 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
loginProvider,
selectedProject,
handleLoginComplete,
saveSettings,
};
}

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';

View File

@@ -1,16 +1,17 @@
import { Settings as SettingsIcon, X } from 'lucide-react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
import { Button } from '../../../shared/view/ui';
import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';
import CodexMcpFormModal from '../view/modals/CodexMcpFormModal';
import SettingsMainTabs from '../view/SettingsMainTabs';
import SettingsSidebar from '../view/SettingsSidebar';
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController';
import { useWebPush } from '../../../hooks/useWebPush';
import type { SettingsProps } from '../types/types';
@@ -20,7 +21,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
const {
activeTab,
setActiveTab,
isSaving,
saveStatus,
deleteError,
projectSortOrder,
@@ -67,7 +67,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
loginProvider,
selectedProject,
handleLoginComplete,
saveSettings,
} = useSettingsController({
isOpen,
initialTab,
@@ -114,81 +113,83 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
: false;
return (
<div className="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center bg-background/95 md:p-4">
<div className="flex h-full w-full flex-col border border-border bg-background shadow-xl md:h-[90vh] md:max-w-4xl md:rounded-lg">
<div className="flex flex-shrink-0 items-center justify-between border-b border-border p-4 md:p-6">
<div className="flex items-center gap-3">
<SettingsIcon className="h-5 w-5 text-blue-600 md:h-6 md:w-6" />
<h2 className="text-lg font-semibold text-foreground md:text-xl">{t('title')}</h2>
<div className="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm md:p-4">
<div className="flex h-full w-full flex-col overflow-hidden border border-border bg-background shadow-2xl md:h-[90vh] md:max-w-4xl md:rounded-xl">
{/* Header */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-4 py-3 md:px-5">
<h2 className="text-base font-semibold text-foreground">{t('title')}</h2>
<div className="flex items-center gap-2">
{saveStatus === 'success' && (
<span className="text-xs text-muted-foreground animate-in fade-in">{t('saveStatus.success')}</span>
)}
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-10 w-10 touch-manipulation p-0 text-muted-foreground hover:text-foreground active:bg-accent/50"
>
<X className="h-5 w-5" />
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="touch-manipulation text-muted-foreground hover:text-foreground"
>
<X className="h-5 w-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<SettingsMainTabs activeTab={activeTab} onChange={setActiveTab} />
{/* Body: sidebar + content */}
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<SettingsSidebar activeTab={activeTab} onChange={setActiveTab} />
<div className="space-y-6 p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
{activeTab === 'appearance' && (
<AppearanceSettingsTab
projectSortOrder={projectSortOrder}
onProjectSortOrderChange={setProjectSortOrder}
codeEditorSettings={codeEditorSettings}
onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
onCodeEditorFontSizeChange={(value) => updateCodeEditorSetting('fontSize', value)}
/>
)}
{/* Content */}
<main className="flex-1 overflow-y-auto">
<div key={activeTab} className="settings-content-enter space-y-6 p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
{activeTab === 'appearance' && (
<AppearanceSettingsTab
projectSortOrder={projectSortOrder}
onProjectSortOrderChange={setProjectSortOrder}
codeEditorSettings={codeEditorSettings}
onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
onCodeEditorFontSizeChange={(value) => updateCodeEditorSetting('fontSize', value)}
/>
)}
{activeTab === 'git' && <GitSettingsTab />}
{activeTab === 'git' && <GitSettingsTab />}
{activeTab === 'agents' && (
<AgentsSettingsTab
claudeAuthStatus={claudeAuthStatus}
cursorAuthStatus={cursorAuthStatus}
codexAuthStatus={codexAuthStatus}
geminiAuthStatus={geminiAuthStatus}
onClaudeLogin={() => openLoginForProvider('claude')}
onCursorLogin={() => openLoginForProvider('cursor')}
onCodexLogin={() => openLoginForProvider('codex')}
onGeminiLogin={() => openLoginForProvider('gemini')}
claudePermissions={claudePermissions}
onClaudePermissionsChange={setClaudePermissions}
cursorPermissions={cursorPermissions}
onCursorPermissionsChange={setCursorPermissions}
codexPermissionMode={codexPermissionMode}
onCodexPermissionModeChange={setCodexPermissionMode}
geminiPermissionMode={geminiPermissionMode}
onGeminiPermissionModeChange={setGeminiPermissionMode}
mcpServers={mcpServers}
cursorMcpServers={cursorMcpServers}
codexMcpServers={codexMcpServers}
mcpTestResults={mcpTestResults}
mcpServerTools={mcpServerTools}
mcpToolsLoading={mcpToolsLoading}
onOpenMcpForm={openMcpForm}
onDeleteMcpServer={handleMcpDelete}
onTestMcpServer={handleMcpTest}
onDiscoverMcpTools={handleMcpToolsDiscovery}
onOpenCodexMcpForm={openCodexMcpForm}
onDeleteCodexMcpServer={handleCodexMcpDelete}
deleteError={deleteError}
/>
)}
{activeTab === 'agents' && (
<AgentsSettingsTab
claudeAuthStatus={claudeAuthStatus}
cursorAuthStatus={cursorAuthStatus}
codexAuthStatus={codexAuthStatus}
geminiAuthStatus={geminiAuthStatus}
onClaudeLogin={() => openLoginForProvider('claude')}
onCursorLogin={() => openLoginForProvider('cursor')}
onCodexLogin={() => openLoginForProvider('codex')}
onGeminiLogin={() => openLoginForProvider('gemini')}
claudePermissions={claudePermissions}
onClaudePermissionsChange={setClaudePermissions}
cursorPermissions={cursorPermissions}
onCursorPermissionsChange={setCursorPermissions}
codexPermissionMode={codexPermissionMode}
onCodexPermissionModeChange={setCodexPermissionMode}
geminiPermissionMode={geminiPermissionMode}
onGeminiPermissionModeChange={setGeminiPermissionMode}
mcpServers={mcpServers}
cursorMcpServers={cursorMcpServers}
codexMcpServers={codexMcpServers}
mcpTestResults={mcpTestResults}
mcpServerTools={mcpServerTools}
mcpToolsLoading={mcpToolsLoading}
onOpenMcpForm={openMcpForm}
onDeleteMcpServer={handleMcpDelete}
onTestMcpServer={handleMcpTest}
onDiscoverMcpTools={handleMcpToolsDiscovery}
onOpenCodexMcpForm={openCodexMcpForm}
onDeleteCodexMcpServer={handleCodexMcpDelete}
deleteError={deleteError}
/>
)}
{activeTab === 'tasks' && (
<div className="space-y-6 md:space-y-8">
<TasksSettingsTab />
</div>
)}
{activeTab === 'tasks' && <TasksSettingsTab />}
{activeTab === 'notifications' && (
<NotificationsSettingsTab
@@ -202,57 +203,11 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
/>
)}
{activeTab === 'api' && (
<div className="space-y-6 md:space-y-8">
<CredentialsSettingsTab />
</div>
)}
</div>
</div>
{activeTab === 'api' && <CredentialsSettingsTab />}
<div className="flex flex-shrink-0 flex-col gap-3 border-t border-border p-4 pb-safe-area-inset-bottom sm:flex-row sm:items-center sm:justify-between md:p-6">
<div className="order-2 flex items-center justify-center gap-2 sm:order-1 sm:justify-start">
{saveStatus === 'success' && (
<div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<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>
{t('saveStatus.success')}
</div>
)}
{saveStatus === 'error' && (
<div className="flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{t('saveStatus.error')}
</div>
)}
</div>
<div className="order-1 flex items-center gap-3 sm:order-2">
<Button
variant="outline"
onClick={onClose}
disabled={isSaving}
className="h-10 flex-1 touch-manipulation sm:flex-none"
>
{t('footerActions.cancel')}
</Button>
<Button
onClick={saveSettings}
disabled={isSaving}
className="h-10 flex-1 touch-manipulation bg-blue-600 hover:bg-blue-700 disabled:opacity-50 sm:flex-none"
>
{isSaving ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{t('saveStatus.saving')}
</div>
) : (
t('footerActions.save')
)}
</Button>
</div>
{activeTab === 'plugins' && <PluginSettingsTab />}
</div>
</main>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
import { cn } from '../../../lib/utils';
type SettingsCardProps = {
children: ReactNode;
className?: string;
divided?: boolean;
};
export default function SettingsCard({ children, className, divided }: SettingsCardProps) {
return (
<div
className={cn(
'rounded-xl border border-border bg-card/50',
divided && 'divide-y divide-border',
className,
)}
>
{children}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { GitBranch, Key } from 'lucide-react';
import { GitBranch, Key, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SettingsMainTab } from '../types/types';
@@ -9,7 +9,8 @@ type SettingsMainTabsProps = {
type MainTabConfig = {
id: SettingsMainTab;
labelKey: string;
labelKey?: string;
label?: string;
icon?: typeof GitBranch;
};
@@ -20,6 +21,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
@@ -45,7 +47,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
}`}
>
{Icon && <Icon className="mr-2 inline h-4 w-4" />}
{t(tab.labelKey)}
{tab.labelKey ? t(tab.labelKey) : tab.label}
</button>
);
})}

View File

@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { cn } from '../../../lib/utils';
type SettingsRowProps = {
label: string;
description?: string;
children: ReactNode;
className?: string;
};
export default function SettingsRow({ label, description, children, className }: SettingsRowProps) {
return (
<div className={cn('flex items-center justify-between gap-4 px-4 py-4', className)}>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{label}</div>
{description && (
<div className="mt-0.5 text-sm text-muted-foreground">{description}</div>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
import { cn } from '../../../lib/utils';
type SettingsSectionProps = {
title: string;
description?: string;
children: ReactNode;
className?: string;
};
export default function SettingsSection({ title, description, children, className }: SettingsSectionProps) {
return (
<div className={cn('space-y-3', className)}>
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h3>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import { PillBar, Pill } from '../../../shared/view/ui';
import type { SettingsMainTab } from '../types/types';
type SettingsSidebarProps = {
activeTab: SettingsMainTab;
onChange: (tab: SettingsMainTab) => void;
};
type NavItem = {
id: SettingsMainTab;
labelKey: string;
icon: typeof Bot;
};
const NAV_ITEMS: NavItem[] = [
{ id: 'agents', labelKey: 'mainTabs.agents', icon: Bot },
{ id: 'appearance', labelKey: 'mainTabs.appearance', icon: Palette },
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
];
export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) {
const { t } = useTranslation('settings');
return (
<>
{/* Desktop sidebar */}
<aside className="hidden w-56 flex-shrink-0 border-r border-border bg-muted/30 md:flex md:flex-col">
<nav className="flex flex-col gap-1 p-3">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => onChange(item.id)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground active:bg-accent/50',
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{t(item.labelKey)}
</button>
);
})}
</nav>
</aside>
{/* Mobile horizontal nav — pill bar */}
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:hidden">
<PillBar className="scrollbar-hide w-full overflow-x-auto">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
return (
<Pill
key={item.id}
isActive={activeTab === item.id}
onClick={() => onChange(item.id)}
className="flex-shrink-0"
>
<Icon className="h-3.5 w-3.5" />
{t(item.labelKey)}
</Pill>
);
})}
</PillBar>
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
import { cn } from '../../../lib/utils';
type SettingsToggleProps = {
checked: boolean;
onChange: (value: boolean) => void;
ariaLabel: string;
disabled?: boolean;
};
export default function SettingsToggle({ checked, onChange, ariaLabel, disabled }: SettingsToggleProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'relative inline-flex h-7 w-12 flex-shrink-0 touch-manipulation cursor-pointer items-center rounded-full border-2 transition-colors duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
checked ? 'border-primary bg-primary' : 'border-border bg-muted',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm transition-transform duration-200',
checked ? 'translate-x-[22px] bg-white' : 'translate-x-[2px] bg-foreground/60 dark:bg-foreground/80',
)}
/>
</button>
);
}

View File

@@ -1,8 +1,11 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { DarkModeToggle } from '../../../../shared/view/ui';
import type { CodeEditorSettingsState, ProjectSortOrder } from '../../types/types';
import LanguageSelector from '../../../../shared/view/ui/LanguageSelector';
import SettingsCard from '../SettingsCard';
import SettingsRow from '../SettingsRow';
import SettingsSection from '../SettingsSection';
import SettingsToggle from '../SettingsToggle';
type AppearanceSettingsTabProps = {
projectSortOrder: ProjectSortOrder;
@@ -15,52 +18,6 @@ type AppearanceSettingsTabProps = {
onCodeEditorFontSizeChange: (value: string) => void;
};
type ToggleCardProps = {
label: string;
description: string;
checked: boolean;
onChange: (value: boolean) => void;
onIcon?: ReactNode;
offIcon?: ReactNode;
ariaLabel: string;
};
function ToggleCard({
label,
description,
checked,
onChange,
onIcon,
offIcon,
ariaLabel,
}: ToggleCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">{label}</div>
<div className="text-sm text-muted-foreground">{description}</div>
</div>
<button
onClick={() => onChange(!checked)}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-700 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
>
<span className="sr-only">{ariaLabel}</span>
<span
className={`${checked ? 'translate-x-7' : 'translate-x-1'
} flex h-6 w-6 transform items-center justify-center rounded-full bg-white shadow-lg transition-transform duration-200`}
>
{checked ? onIcon : offIcon}
</span>
</button>
</div>
</div>
);
}
export default function AppearanceSettingsTab({
projectSortOrder,
onProjectSortOrderChange,
@@ -72,108 +29,98 @@ export default function AppearanceSettingsTab({
onCodeEditorFontSizeChange,
}: AppearanceSettingsTabProps) {
const { t } = useTranslation('settings');
const codeEditorThemeLabel = t('appearanceSettings.codeEditor.theme.label');
return (
<div className="space-y-6 md:space-y-8">
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">{t('appearanceSettings.darkMode.label')}</div>
<div className="text-sm text-muted-foreground">
{t('appearanceSettings.darkMode.description')}
</div>
</div>
<div className="space-y-8">
<SettingsSection title={t('appearanceSettings.darkMode.label')}>
<SettingsCard>
<SettingsRow
label={t('appearanceSettings.darkMode.label')}
description={t('appearanceSettings.darkMode.description')}
>
<DarkModeToggle ariaLabel={t('appearanceSettings.darkMode.label')} />
</div>
</div>
</div>
</SettingsRow>
</SettingsCard>
</SettingsSection>
<div className="space-y-4">
<LanguageSelector />
</div>
<SettingsSection title={t('mainTabs.appearance')}>
<SettingsCard>
<LanguageSelector />
</SettingsCard>
</SettingsSection>
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
{t('appearanceSettings.projectSorting.label')}
</div>
<div className="text-sm text-muted-foreground">
{t('appearanceSettings.projectSorting.description')}
</div>
</div>
<SettingsSection title={t('appearanceSettings.projectSorting.label')}>
<SettingsCard>
<SettingsRow
label={t('appearanceSettings.projectSorting.label')}
description={t('appearanceSettings.projectSorting.description')}
>
<select
value={projectSortOrder}
onChange={(event) => onProjectSortOrderChange(event.target.value as ProjectSortOrder)}
className="w-32 rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
className="w-full rounded-lg border border-input bg-card p-2.5 text-sm text-foreground touch-manipulation focus:border-primary focus:ring-1 focus:ring-primary sm:w-36"
>
<option value="name">{t('appearanceSettings.projectSorting.alphabetical')}</option>
<option value="date">{t('appearanceSettings.projectSorting.recentActivity')}</option>
</select>
</div>
</div>
</div>
</SettingsRow>
</SettingsCard>
</SettingsSection>
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">{t('appearanceSettings.codeEditor.title')}</h3>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">{codeEditorThemeLabel}</div>
<div className="text-sm text-muted-foreground">
{t('appearanceSettings.codeEditor.theme.description')}
</div>
</div>
<SettingsSection title={t('appearanceSettings.codeEditor.title')}>
<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={codeEditorThemeLabel}
ariaLabel={t('appearanceSettings.codeEditor.theme.label')}
/>
</div>
</div>
</SettingsRow>
<ToggleCard
label={t('appearanceSettings.codeEditor.wordWrap.label')}
description={t('appearanceSettings.codeEditor.wordWrap.description')}
checked={codeEditorSettings.wordWrap}
onChange={onCodeEditorWordWrapChange}
ariaLabel={t('appearanceSettings.codeEditor.wordWrap.label')}
/>
<SettingsRow
label={t('appearanceSettings.codeEditor.wordWrap.label')}
description={t('appearanceSettings.codeEditor.wordWrap.description')}
>
<SettingsToggle
checked={codeEditorSettings.wordWrap}
onChange={onCodeEditorWordWrapChange}
ariaLabel={t('appearanceSettings.codeEditor.wordWrap.label')}
/>
</SettingsRow>
<ToggleCard
label={t('appearanceSettings.codeEditor.showMinimap.label')}
description={t('appearanceSettings.codeEditor.showMinimap.description')}
checked={codeEditorSettings.showMinimap}
onChange={onCodeEditorShowMinimapChange}
ariaLabel={t('appearanceSettings.codeEditor.showMinimap.label')}
/>
<SettingsRow
label={t('appearanceSettings.codeEditor.showMinimap.label')}
description={t('appearanceSettings.codeEditor.showMinimap.description')}
>
<SettingsToggle
checked={codeEditorSettings.showMinimap}
onChange={onCodeEditorShowMinimapChange}
ariaLabel={t('appearanceSettings.codeEditor.showMinimap.label')}
/>
</SettingsRow>
<ToggleCard
label={t('appearanceSettings.codeEditor.lineNumbers.label')}
description={t('appearanceSettings.codeEditor.lineNumbers.description')}
checked={codeEditorSettings.lineNumbers}
onChange={onCodeEditorLineNumbersChange}
ariaLabel={t('appearanceSettings.codeEditor.lineNumbers.label')}
/>
<SettingsRow
label={t('appearanceSettings.codeEditor.lineNumbers.label')}
description={t('appearanceSettings.codeEditor.lineNumbers.description')}
>
<SettingsToggle
checked={codeEditorSettings.lineNumbers}
onChange={onCodeEditorLineNumbersChange}
ariaLabel={t('appearanceSettings.codeEditor.lineNumbers.label')}
/>
</SettingsRow>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
{t('appearanceSettings.codeEditor.fontSize.label')}
</div>
<div className="text-sm text-muted-foreground">
{t('appearanceSettings.codeEditor.fontSize.description')}
</div>
</div>
<SettingsRow
label={t('appearanceSettings.codeEditor.fontSize.label')}
description={t('appearanceSettings.codeEditor.fontSize.description')}
>
<select
value={codeEditorSettings.fontSize}
onChange={(event) => onCodeEditorFontSizeChange(event.target.value)}
className="w-24 rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
className="w-full rounded-lg border border-input bg-card p-2.5 text-sm text-foreground touch-manipulation focus:border-primary focus:ring-1 focus:ring-primary sm:w-28"
>
<option value="10">10px</option>
<option value="11">11px</option>
@@ -185,9 +132,9 @@ export default function AppearanceSettingsTab({
<option value="18">18px</option>
<option value="20">20px</option>
</select>
</div>
</div>
</div>
</SettingsRow>
</SettingsCard>
</SettingsSection>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../../lib/utils';
import SessionProviderLogo from '../../../../llm-logo-provider/SessionProviderLogo';
import type { AgentProvider, AuthStatus } from '../../../types/types';
@@ -36,27 +36,15 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
const colorClasses = {
blue: {
border: 'border-l-blue-500 md:border-l-blue-500',
borderBottom: 'border-b-blue-500',
bg: 'bg-blue-50 dark:bg-blue-900/20',
dot: 'bg-blue-500',
},
purple: {
border: 'border-l-purple-500 md:border-l-purple-500',
borderBottom: 'border-b-purple-500',
bg: 'bg-purple-50 dark:bg-purple-900/20',
dot: 'bg-purple-500',
},
gray: {
border: 'border-l-gray-700 dark:border-l-gray-300',
borderBottom: 'border-b-gray-700 dark:border-b-gray-300',
bg: 'bg-gray-100 dark:bg-gray-800/50',
dot: 'bg-gray-700 dark:bg-gray-300',
dot: 'bg-foreground/60',
},
indigo: {
border: 'border-l-indigo-500 md:border-l-indigo-500',
borderBottom: 'border-b-indigo-500',
bg: 'bg-indigo-50 dark:bg-indigo-900/20',
dot: 'bg-indigo-500',
},
} as const;
@@ -68,7 +56,6 @@ export default function AgentListItem({
onClick,
isMobile = false,
}: AgentListItemProps) {
const { t } = useTranslation('settings');
const config = agentConfig[agentId];
const colors = colorClasses[config.color];
@@ -76,16 +63,18 @@ export default function AgentListItem({
return (
<button
onClick={onClick}
className={`flex-1 border-b-2 px-2 py-3 text-center transition-colors ${isSelected
? `${colors.borderBottom} ${colors.bg}`
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
className={cn(
'min-w-0 flex-1 touch-manipulation rounded-md px-2 py-2 text-center transition-all duration-150',
isSelected
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground active:bg-background/50',
)}
>
<div className="flex flex-col items-center gap-1">
<SessionProviderLogo provider={agentId} className="h-5 w-5" />
<span className="text-xs font-medium text-foreground">{config.name}</span>
<div className="flex items-center justify-center gap-1.5">
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
<span className="truncate text-xs font-medium">{config.name}</span>
{authStatus.authenticated && (
<span className={`h-1.5 w-1.5 rounded-full ${colors.dot}`} />
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
)}
</div>
</button>
@@ -95,32 +84,20 @@ export default function AgentListItem({
return (
<button
onClick={onClick}
className={`w-full border-l-4 p-3 text-left transition-colors ${isSelected
? `${colors.border} ${colors.bg}`
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
className={cn(
'flex touch-manipulation items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-all duration-150',
isSelected
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground active:bg-background/50',
)}
>
<div className="mb-1 flex items-center gap-2">
<SessionProviderLogo provider={agentId} className="h-4 w-4" />
<span className="font-medium text-foreground">{config.name}</span>
</div>
<div className="pl-6 text-xs text-muted-foreground">
{authStatus.loading ? (
<span className="text-gray-400">{t('agents.authStatus.checking')}</span>
) : authStatus.authenticated ? (
<div className="flex items-center gap-1">
<span className={`h-1.5 w-1.5 rounded-full ${colors.dot}`} />
<span className="max-w-[120px] truncate" title={authStatus.email ?? undefined}>
{authStatus.email || t('agents.authStatus.connected')}
</span>
</div>
) : (
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-gray-400" />
<span>{t('agents.authStatus.notConnected')}</span>
</div>
)}
</div>
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
<span>{config.name}</span>
{authStatus.authenticated ? (
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
) : authStatus.loading ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-muted-foreground/30 animate-pulse" />
) : null}
</button>
);
}

View File

@@ -68,7 +68,7 @@ export default function AgentsSettingsTab({
]);
return (
<div className="flex h-full min-h-[400px] flex-col md:min-h-[500px] md:flex-row">
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<AgentSelectorSection
selectedAgent={selectedAgent}
onSelectAgent={setSelectedAgent}

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../../../lib/utils';
import type { AgentCategory } from '../../../../types/types';
import type { AgentCategoryTabsSectionProps } from '../types';
@@ -11,7 +12,7 @@ export default function AgentCategoryTabsSection({
const { t } = useTranslation('settings');
return (
<div className="flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<div className="flex-shrink-0 border-b border-border">
<div role="tablist" className="flex overflow-x-auto px-2 md:px-4">
{AGENT_CATEGORIES.map((category) => (
<button
@@ -19,11 +20,12 @@ export default function AgentCategoryTabsSection({
role="tab"
aria-selected={selectedCategory === category}
onClick={() => onSelectCategory(category)}
className={`whitespace-nowrap border-b-2 px-3 py-2 text-xs font-medium transition-colors md:px-4 md:py-3 md:text-sm ${
className={cn(
'whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium touch-manipulation transition-colors duration-150',
selectedCategory === category
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{category === 'account' && t('tabs.account')}
{category === 'permissions' && t('tabs.permissions')}

View File

@@ -1,8 +1,16 @@
import { PillBar, Pill } from '../../../../../../shared/view/ui';
import SessionProviderLogo from '../../../../../llm-logo-provider/SessionProviderLogo';
import type { AgentProvider } from '../../../../types/types';
import AgentListItem from '../AgentListItem';
import type { AgentSelectorSectionProps } from '../types';
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
const AGENT_NAMES: Record<AgentProvider, string> = {
claude: 'Claude',
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
};
export default function AgentSelectorSection({
selectedAgent,
@@ -10,35 +18,30 @@ export default function AgentSelectorSection({
agentContextById,
}: AgentSelectorSectionProps) {
return (
<>
<div className="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 md:hidden">
<div className="flex">
{AGENT_PROVIDERS.map((agent) => (
<AgentListItem
key={`mobile-${agent}`}
agentId={agent}
authStatus={agentContextById[agent].authStatus}
isSelected={selectedAgent === agent}
onClick={() => onSelectAgent(agent)}
isMobile
/>
))}
</div>
</div>
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:px-4 md:py-3">
<PillBar className="w-full md:w-auto">
{AGENT_PROVIDERS.map((agent) => {
const dotColor =
agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' :
agent === 'gemini' ? 'bg-indigo-500' : 'bg-foreground/60';
<div className="hidden w-48 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 md:block">
<div className="p-2">
{AGENT_PROVIDERS.map((agent) => (
<AgentListItem
key={`desktop-${agent}`}
agentId={agent}
authStatus={agentContextById[agent].authStatus}
isSelected={selectedAgent === agent}
return (
<Pill
key={agent}
isActive={selectedAgent === agent}
onClick={() => onSelectAgent(agent)}
/>
))}
</div>
</div>
</>
className="min-w-0 flex-1 justify-center md:flex-initial"
>
<SessionProviderLogo provider={agent} className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{AGENT_NAMES[agent]}</span>
{agentContextById[agent].authStatus.authenticated && (
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${dotColor}`} />
)}
</Pill>
);
})}
</PillBar>
</div>
);
}

View File

@@ -27,7 +27,7 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
borderClass: 'border-blue-200 dark:border-blue-800',
textClass: 'text-blue-900 dark:text-blue-100',
subtextClass: 'text-blue-700 dark:text-blue-300',
buttonClass: 'bg-blue-600 hover:bg-blue-700',
buttonClass: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800',
},
cursor: {
name: 'Cursor',
@@ -35,15 +35,15 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
borderClass: 'border-purple-200 dark:border-purple-800',
textClass: 'text-purple-900 dark:text-purple-100',
subtextClass: 'text-purple-700 dark:text-purple-300',
buttonClass: 'bg-purple-600 hover:bg-purple-700',
buttonClass: 'bg-purple-600 hover:bg-purple-700 active:bg-purple-800',
},
codex: {
name: 'Codex',
bgClass: 'bg-gray-100 dark:bg-gray-800/50',
bgClass: 'bg-muted/50',
borderClass: 'border-gray-300 dark:border-gray-600',
textClass: 'text-gray-900 dark:text-gray-100',
subtextClass: 'text-gray-700 dark:text-gray-300',
buttonClass: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
buttonClass: 'bg-gray-800 hover:bg-gray-900 active:bg-gray-950 dark:bg-gray-700 dark:hover:bg-gray-600 dark:active:bg-gray-500',
},
gemini: {
name: 'Gemini',
@@ -52,7 +52,7 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
borderClass: 'border-indigo-200 dark:border-indigo-800',
textClass: 'text-indigo-900 dark:text-indigo-100',
subtextClass: 'text-indigo-700 dark:text-indigo-300',
buttonClass: 'bg-indigo-600 hover:bg-indigo-700',
buttonClass: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800',
},
};
@@ -91,7 +91,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
<div>
{authStatus.loading ? (
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
<Badge variant="secondary" className="bg-muted">
{t('agents.authStatus.checking')}
</Badge>
) : authStatus.authenticated ? (
@@ -107,7 +107,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
{authStatus.method !== 'api_key' && (
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
<div className="border-t border-border/50 pt-4">
<div className="flex items-center justify-between">
<div>
<div className={`font-medium ${config.textClass}`}>
@@ -132,7 +132,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
)}
{authStatus.error && (
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
<div className="border-t border-border/50 pt-4">
<div className="text-sm text-red-600 dark:text-red-400">
{t('agents.error', { error: authStatus.error })}
</div>

View File

@@ -80,7 +80,7 @@ function ClaudeMcpServers({
const toolsResult = serverTools[serverId];
return (
<div key={serverId} className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
@@ -102,19 +102,19 @@ function ClaudeMcpServers({
{server.type === 'stdio' && server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.command}</code>
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
<div>
{t('mcpServers.config.url')}:{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.url}</code>
<code className="rounded bg-muted px-1 text-xs">{server.config.url}</code>
</div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>
{t('mcpServers.config.args')}:{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.args.join(' ')}</code>
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
</div>
)}
</div>
@@ -156,7 +156,7 @@ function ClaudeMcpServers({
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-700"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
@@ -176,7 +176,7 @@ function ClaudeMcpServers({
);
})}
{servers.length === 0 && (
<div className="py-8 text-center text-gray-500 dark:text-gray-400">{t('mcpServers.empty')}</div>
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
</div>
@@ -214,7 +214,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpSe
const serverId = server.id || server.name;
return (
<div key={serverId} className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
@@ -226,7 +226,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpSe
{server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.command}</code>
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
</div>
@@ -236,7 +236,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpSe
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-700"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
@@ -256,7 +256,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpSe
);
})}
{servers.length === 0 && (
<div className="py-8 text-center text-gray-500 dark:text-gray-400">{t('mcpServers.empty')}</div>
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
</div>
@@ -278,7 +278,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-gray-700 dark:text-gray-300" />
<Server className="h-5 w-5 text-muted-foreground" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.codex')}</p>
@@ -297,7 +297,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
<div className="space-y-2">
{servers.map((server) => (
<div key={server.name} className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div key={server.name} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
@@ -310,19 +310,19 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
{server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.command}</code>
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>
{t('mcpServers.config.args')}:{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.args.join(' ')}</code>
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
</div>
)}
{server.config?.env && Object.keys(server.config.env).length > 0 && (
<div>
{t('mcpServers.config.environment')}:{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">
<code className="rounded bg-muted px-1 text-xs">
{Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
</code>
</div>
@@ -335,7 +335,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-700"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
@@ -354,13 +354,13 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
</div>
))}
{servers.length === 0 && (
<div className="py-8 text-center text-gray-500 dark:text-gray-400">{t('mcpServers.empty')}</div>
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
<div className="rounded-lg border border-gray-300 bg-gray-100 p-4 dark:border-gray-600 dark:bg-gray-800/50">
<h4 className="mb-2 font-medium text-gray-900 dark:text-gray-100">{t('mcpServers.help.title')}</h4>
<p className="text-sm text-gray-700 dark:text-gray-300">{t('mcpServers.help.description')}</p>
<div className="rounded-lg border border-border bg-muted/50 p-4">
<h4 className="mb-2 font-medium text-foreground">{t('mcpServers.help.title')}</h4>
<p className="text-sm text-muted-foreground">{t('mcpServers.help.description')}</p>
</div>
</div>
);

View File

@@ -104,7 +104,7 @@ function ClaudePermissions({
type="checkbox"
checked={skipPermissions}
onChange={(event) => onSkipPermissionsChange(event.target.checked)}
className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
className="h-4 w-4 rounded border-input bg-card text-primary focus:ring-2 focus:ring-primary"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
@@ -150,7 +150,7 @@ function ClaudePermissions({
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
<p className="text-sm font-medium text-muted-foreground">
{t('permissions.allowedTools.quickAdd')}
</p>
<div className="flex flex-wrap gap-2">
@@ -184,7 +184,7 @@ function ClaudePermissions({
</div>
))}
{allowedTools.length === 0 && (
<div className="py-6 text-center text-gray-500 dark:text-gray-400">
<div className="py-6 text-center text-muted-foreground">
{t('permissions.allowedTools.empty')}
</div>
)}
@@ -237,7 +237,7 @@ function ClaudePermissions({
</div>
))}
{disallowedTools.length === 0 && (
<div className="py-6 text-center text-gray-500 dark:text-gray-400">
<div className="py-6 text-center text-muted-foreground">
{t('permissions.blockedTools.empty')}
</div>
)}
@@ -315,7 +315,7 @@ function CursorPermissions({
type="checkbox"
checked={skipPermissions}
onChange={(event) => onSkipPermissionsChange(event.target.checked)}
className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-purple-600 focus:ring-2 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700"
className="h-4 w-4 rounded border-input bg-card text-primary focus:ring-2 focus:ring-primary"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
@@ -361,7 +361,7 @@ function CursorPermissions({
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
<p className="text-sm font-medium text-muted-foreground">
{t('permissions.allowedCommands.quickAdd')}
</p>
<div className="flex flex-wrap gap-2">
@@ -395,7 +395,7 @@ function CursorPermissions({
</div>
))}
{allowedCommands.length === 0 && (
<div className="py-6 text-center text-gray-500 dark:text-gray-400">
<div className="py-6 text-center text-muted-foreground">
{t('permissions.allowedCommands.empty')}
</div>
)}
@@ -448,7 +448,7 @@ function CursorPermissions({
</div>
))}
{disallowedCommands.length === 0 && (
<div className="py-6 text-center text-gray-500 dark:text-gray-400">
<div className="py-6 text-center text-muted-foreground">
{t('permissions.blockedCommands.empty')}
</div>
)}
@@ -490,8 +490,8 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
<div
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'default'
? 'border-gray-400 bg-gray-100 dark:border-gray-500 dark:bg-gray-800'
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
? 'border-border bg-accent'
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
}`}
onClick={() => onPermissionModeChange('default')}
>
@@ -515,7 +515,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
<div
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'acceptEdits'
? 'border-green-400 bg-green-50 dark:border-green-600 dark:bg-green-900/20'
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
}`}
onClick={() => onPermissionModeChange('acceptEdits')}
>
@@ -539,7 +539,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
<div
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'bypassPermissions'
? 'border-orange-400 bg-orange-50 dark:border-orange-600 dark:bg-orange-900/20'
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
}`}
onClick={() => onPermissionModeChange('bypassPermissions')}
>
@@ -567,7 +567,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
{t('permissions.codex.technicalDetails')}
</summary>
<div className="mt-2 space-y-2 rounded-lg bg-gray-50 p-3 text-xs text-muted-foreground dark:bg-gray-900/50">
<div className="mt-2 space-y-2 rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground">
<p><strong>{t('permissions.codex.modes.default.title')}:</strong> {t('permissions.codex.technicalInfo.default')}</p>
<p><strong>{t('permissions.codex.modes.acceptEdits.title')}:</strong> {t('permissions.codex.technicalInfo.acceptEdits')}</p>
<p><strong>{t('permissions.codex.modes.bypassPermissions.title')}:</strong> {t('permissions.codex.technicalInfo.bypassPermissions')}</p>
@@ -604,8 +604,8 @@ function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit<Gemi
{/* Default Mode */}
<div
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'default'
? 'border-gray-400 bg-gray-100 dark:border-gray-500 dark:bg-gray-800'
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
? 'border-border bg-accent'
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
}`}
onClick={() => onPermissionModeChange('default')}
>
@@ -630,7 +630,7 @@ function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit<Gemi
<div
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'auto_edit'
? 'border-green-400 bg-green-50 dark:border-green-600 dark:bg-green-900/20'
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
}`}
onClick={() => onPermissionModeChange('auto_edit')}
>
@@ -655,7 +655,7 @@ function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit<Gemi
<div
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'yolo'
? 'border-orange-400 bg-orange-50 dark:border-orange-600 dark:bg-orange-900/20'
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
}`}
onClick={() => onPermissionModeChange('yolo')}
>

View File

@@ -1,7 +1,9 @@
import { Check, GitBranch } from 'lucide-react';
import { Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useGitSettings } from '../../../hooks/useGitSettings';
import { Button, Input } from '../../../../../shared/view/ui';
import SettingsCard from '../../SettingsCard';
import SettingsSection from '../../SettingsSection';
export default function GitSettingsTab() {
const { t } = useTranslation('settings');
@@ -18,64 +20,62 @@ export default function GitSettingsTab() {
return (
<div className="space-y-8">
<div>
<div className="mb-4 flex items-center gap-2">
<GitBranch className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('git.title')}</h3>
</div>
<SettingsSection
title={t('git.title')}
description={t('git.description')}
>
<SettingsCard className="p-4">
<div className="space-y-4">
<div>
<label htmlFor="settings-git-name" className="mb-2 block text-sm font-medium text-foreground">
{t('git.name.label')}
</label>
<Input
id="settings-git-name"
type="text"
value={gitName}
onChange={(event) => setGitName(event.target.value)}
placeholder="John Doe"
disabled={isLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">{t('git.name.help')}</p>
</div>
<p className="mb-4 text-sm text-muted-foreground">{t('git.description')}</p>
<div>
<label htmlFor="settings-git-email" className="mb-2 block text-sm font-medium text-foreground">
{t('git.email.label')}
</label>
<Input
id="settings-git-email"
type="email"
value={gitEmail}
onChange={(event) => setGitEmail(event.target.value)}
placeholder="john@example.com"
disabled={isLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">{t('git.email.help')}</p>
</div>
<div className="space-y-3 rounded-lg border bg-card p-4">
<div>
<label htmlFor="settings-git-name" className="mb-2 block text-sm font-medium text-foreground">
{t('git.name.label')}
</label>
<Input
id="settings-git-name"
type="text"
value={gitName}
onChange={(event) => setGitName(event.target.value)}
placeholder="John Doe"
disabled={isLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">{t('git.name.help')}</p>
<div className="flex items-center gap-2">
<Button
onClick={saveGitConfig}
disabled={isSaving || !gitName.trim() || !gitEmail.trim()}
>
{isSaving ? t('git.actions.saving') : t('git.actions.save')}
</Button>
{saveStatus === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Check className="h-4 w-4" />
{t('git.status.success')}
</div>
)}
</div>
</div>
<div>
<label htmlFor="settings-git-email" className="mb-2 block text-sm font-medium text-foreground">
{t('git.email.label')}
</label>
<Input
id="settings-git-email"
type="email"
value={gitEmail}
onChange={(event) => setGitEmail(event.target.value)}
placeholder="john@example.com"
disabled={isLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">{t('git.email.help')}</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={saveGitConfig}
disabled={isSaving || !gitName.trim() || !gitEmail.trim()}
>
{isSaving ? t('git.actions.saving') : t('git.actions.save')}
</Button>
{saveStatus === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Check className="h-4 w-4" />
{t('git.status.success')}
</div>
)}
</div>
</div>
</div>
</SettingsCard>
</SettingsSection>
</div>
);
}

View File

@@ -1,5 +1,9 @@
import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../../../contexts/TasksSettingsContext';
import SettingsCard from '../../SettingsCard';
import SettingsRow from '../../SettingsRow';
import SettingsSection from '../../SettingsSection';
import SettingsToggle from '../../SettingsToggle';
type TasksSettingsContextValue = {
tasksEnabled: boolean;
@@ -19,88 +23,83 @@ export default function TasksSettingsTab() {
return (
<div className="space-y-8">
{isCheckingInstallation ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex items-center gap-3">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
</div>
</div>
) : (
<>
{!isTaskMasterInstalled && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950/50">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900">
<svg className="h-4 w-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="flex-1">
<div className="mb-2 font-medium text-orange-900 dark:text-orange-100">
{t('tasks.notInstalled.title')}
<SettingsSection title={t('mainTabs.tasks')}>
{isCheckingInstallation ? (
<SettingsCard className="p-4">
<div className="flex items-center gap-3">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
</div>
</SettingsCard>
) : (
<>
{!isTaskMasterInstalled && (
<div className="rounded-xl border border-orange-200 bg-orange-50 p-4 dark:border-orange-800/50 dark:bg-orange-950/30">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/50">
<svg className="h-4 w-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="space-y-3 text-sm text-orange-800 dark:text-orange-200">
<p>{t('tasks.notInstalled.description')}</p>
<div className="rounded-lg bg-orange-100 p-3 font-mono text-sm dark:bg-orange-900/50">
<code>{t('tasks.notInstalled.installCommand')}</code>
<div className="flex-1">
<div className="mb-2 font-medium text-orange-900 dark:text-orange-100">
{t('tasks.notInstalled.title')}
</div>
<div className="space-y-3 text-sm text-orange-800 dark:text-orange-200">
<p>{t('tasks.notInstalled.description')}</p>
<div>
<a
href="https://github.com/eyaltoledano/claude-task-master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.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 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 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.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
{t('tasks.notInstalled.viewOnGitHub')}
<svg className="h-3 w-3" 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>
</div>
<div className="rounded-lg bg-orange-100 p-3 font-mono text-sm dark:bg-orange-900/40">
<code>{t('tasks.notInstalled.installCommand')}</code>
</div>
<div className="space-y-2">
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
<ol className="list-inside list-decimal space-y-1 text-xs">
<li>{t('tasks.notInstalled.steps.restart')}</li>
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
</ol>
<div>
<a
href="https://github.com/eyaltoledano/claude-task-master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.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 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 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.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
{t('tasks.notInstalled.viewOnGitHub')}
<svg className="h-3 w-3" 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>
</div>
<div className="space-y-2">
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
<ol className="list-inside list-decimal space-y-1 text-xs">
<li>{t('tasks.notInstalled.steps.restart')}</li>
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
</ol>
</div>
</div>
</div>
</div>
</div>
</div>
)}
)}
{isTaskMasterInstalled && (
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">{t('tasks.settings.enableLabel')}</div>
<div className="mt-1 text-sm text-muted-foreground">{t('tasks.settings.enableDescription')}</div>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={tasksEnabled}
onChange={(event) => setTasksEnabled(event.target.checked)}
className="peer sr-only"
/>
<div className="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800" />
</label>
</div>
</div>
</div>
)}
</>
)}
{isTaskMasterInstalled && (
<SettingsCard>
<SettingsRow
label={t('tasks.settings.enableLabel')}
description={t('tasks.settings.enableDescription')}
>
<SettingsToggle
checked={tasksEnabled}
onChange={setTasksEnabled}
ariaLabel={t('tasks.settings.enableLabel')}
/>
</SettingsRow>
</SettingsCard>
)}
</>
)}
</SettingsSection>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { isCodexLoginCommand } from '../utils/auth';
import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
@@ -103,6 +104,37 @@ export function useShellTerminal({
nextTerminal.open(terminalContainerRef.current);
const copyTerminalSelection = async () => {
const selection = nextTerminal.getSelection();
if (!selection) {
return false;
}
return copyTextToClipboard(selection);
};
const handleTerminalCopy = (event: ClipboardEvent) => {
if (!nextTerminal.hasSelection()) {
return;
}
const selection = nextTerminal.getSelection();
if (!selection) {
return;
}
event.preventDefault();
if (event.clipboardData) {
event.clipboardData.setData('text/plain', selection);
return;
}
void copyTextToClipboard(selection);
};
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
@@ -132,7 +164,7 @@ export function useShellTerminal({
) {
event.preventDefault();
event.stopPropagation();
document.execCommand('copy');
void copyTerminalSelection();
return false;
}
@@ -211,6 +243,7 @@ export function useShellTerminal({
resizeObserver.observe(terminalContainerRef.current);
return () => {
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
resizeObserver.disconnect();
if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current);

View File

@@ -40,7 +40,7 @@ export default function Shell({
onProcessComplete = null,
minimal = false,
autoConnect = false,
isActive,
isActive = true,
}: ShellProps) {
const { t } = useTranslation('chat');
const [isRestarting, setIsRestarting] = useState(false);
@@ -48,9 +48,6 @@ export default function Shell({
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onOutputRef = useRef<(() => void) | null>(null);
// Keep the public API stable for existing callers that still pass `isActive`.
void isActive;
const {
terminalContainerRef,
terminalRef,
@@ -157,6 +154,24 @@ export default function Shell({
}
}, [isConnected]);
useEffect(() => {
if (!isActive || !isInitialized || !isConnected) {
return;
}
const focusTerminal = () => {
terminalRef.current?.focus();
};
const animationFrameId = window.requestAnimationFrame(focusTerminal);
const timeoutId = window.setTimeout(focusTerminal, 0);
return () => {
window.cancelAnimationFrame(animationFrameId);
window.clearTimeout(timeoutId);
};
}, [isActive, isConnected, isInitialized, terminalRef]);
const sendInput = useCallback(
(data: string) => {
sendSocketMessage(wsRef.current, { type: 'input', data });

View File

@@ -4,10 +4,10 @@ import type { TFunction } from 'i18next';
import { ScrollArea } from '../../../../shared/view/ui';
import type { Project } from '../../../../types/app';
import type { ReleaseInfo } from '../../../../types/sharedTypes';
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
import SidebarFooter from './SidebarFooter';
import SidebarHeader from './SidebarHeader';
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
type SearchMode = 'projects' | 'conversations';
@@ -19,7 +19,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
parts.push(snippet.slice(cursor, h.start));
}
parts.push(
<mark key={h.start} className="bg-yellow-200 dark:bg-yellow-800 text-foreground rounded-sm px-0.5">
<mark key={h.start} className="rounded-sm bg-yellow-200 px-0.5 text-foreground dark:bg-yellow-800">
{snippet.slice(h.start, h.end)}
</mark>
);
@@ -29,7 +29,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
parts.push(snippet.slice(cursor));
}
return (
<span className="text-xs text-muted-foreground leading-relaxed">
<span className="text-xs leading-relaxed text-muted-foreground">
{parts}
</span>
);
@@ -116,23 +116,23 @@ export default function SidebarContent({
<ScrollArea className="flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2">
{showConversationSearch ? (
isSearching && !hasPartialResults ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
<div className="px-4 py-12 text-center md:py-8">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
<p className="text-sm text-muted-foreground">{t('search.searching')}</p>
{searchProgress && (
<p className="text-xs text-muted-foreground/60 mt-1">
<p className="mt-1 text-xs text-muted-foreground/60">
{t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}
</p>
)}
</div>
) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<Search className="w-6 h-6 text-muted-foreground" />
<div className="px-4 py-12 text-center md:py-8">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
<Search className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('search.noResults')}</h3>
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">{t('search.noResults')}</h3>
<p className="text-sm text-muted-foreground">{t('search.tryDifferentQuery')}</p>
</div>
) : hasPartialResults ? (
@@ -143,7 +143,7 @@ export default function SidebarContent({
</p>
{isSearching && searchProgress && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
<div className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
<p className="text-[10px] text-muted-foreground/60">
{searchProgress.scannedProjects}/{searchProgress.totalProjects}
</p>
@@ -151,9 +151,9 @@ export default function SidebarContent({
)}
</div>
{isSearching && searchProgress && (
<div className="mx-1 h-0.5 bg-muted rounded-full overflow-hidden">
<div className="mx-1 h-0.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary/60 rounded-full transition-all duration-300"
className="h-full rounded-full bg-primary/60 transition-all duration-300"
style={{ width: `${Math.round((searchProgress.scannedProjects / searchProgress.totalProjects) * 100)}%` }}
/>
</div>
@@ -161,15 +161,15 @@ export default function SidebarContent({
{conversationResults.results.map((projectResult) => (
<div key={projectResult.projectName} className="space-y-1">
<div className="flex items-center gap-1.5 px-1 py-1">
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-medium text-foreground truncate">
<Folder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-medium text-foreground">
{projectResult.projectDisplayName}
</span>
</div>
{projectResult.sessions.map((session) => (
<button
key={`${projectResult.projectName}-${session.sessionId}`}
className="w-full text-left rounded-md px-2 py-2 hover:bg-accent/50 transition-colors"
className="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-accent/50"
onClick={() => onConversationResultClick(
projectResult.projectName,
session.sessionId,
@@ -178,13 +178,13 @@ export default function SidebarContent({
session.matches[0]?.snippet
)}
>
<div className="flex items-center gap-1.5 mb-1">
<MessageSquare className="w-3 h-3 text-primary flex-shrink-0" />
<span className="text-xs font-medium text-foreground truncate">
<div className="mb-1 flex items-center gap-1.5">
<MessageSquare className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs font-medium text-foreground">
{session.sessionSummary}
</span>
{session.provider && session.provider !== 'claude' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-muted text-muted-foreground uppercase flex-shrink-0">
<span className="flex-shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] uppercase text-muted-foreground">
{session.provider}
</span>
)}
@@ -192,7 +192,7 @@ export default function SidebarContent({
<div className="space-y-1 pl-4">
{session.matches.map((match, idx) => (
<div key={idx} className="flex items-start gap-1">
<span className="text-[10px] text-muted-foreground/60 font-medium uppercase flex-shrink-0 mt-0.5">
<span className="mt-0.5 flex-shrink-0 text-[10px] font-medium uppercase text-muted-foreground/60">
{match.role === 'user' ? 'U' : 'A'}
</span>
<HighlightedSnippet

View File

@@ -121,7 +121,7 @@ export default function SidebarHeader({
: "text-muted-foreground hover:text-foreground"
)}
>
<Folder className="w-3 h-3" />
<Folder className="h-3 w-3" />
{t('search.modeProjects')}
</button>
<button
@@ -134,26 +134,26 @@ export default function SidebarHeader({
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="w-3 h-3" />
<MessageSquare className="h-3 w-3" />
{t('search.modeConversations')}
</button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/50 pointer-events-none" />
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input pl-9 pr-8 h-9 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{searchFilter && (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 hover:bg-accent rounded-md"
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
>
<X className="w-3 h-3 text-muted-foreground" />
<X className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
@@ -213,7 +213,7 @@ export default function SidebarHeader({
: "text-muted-foreground hover:text-foreground"
)}
>
<Folder className="w-3 h-3" />
<Folder className="h-3 w-3" />
{t('search.modeProjects')}
</button>
<button
@@ -226,26 +226,26 @@ export default function SidebarHeader({
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="w-3 h-3" />
<MessageSquare className="h-3 w-3" />
{t('search.modeConversations')}
</button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/50 pointer-events-none" />
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input pl-10 pr-9 h-10 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{searchFilter && (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded-md"
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
>
<X className="w-3.5 h-3.5 text-muted-foreground" />
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>

View File

@@ -9,6 +9,7 @@ type StandaloneShellProps = {
session?: ProjectSession | null;
command?: string | null;
isPlainShell?: boolean | null;
isActive?: boolean;
autoConnect?: boolean;
onComplete?: ((exitCode: number) => void) | null;
onClose?: (() => void) | null;
@@ -24,6 +25,7 @@ export default function StandaloneShell({
session = null,
command = null,
isPlainShell = null,
isActive = true,
autoConnect = true,
onComplete = null,
onClose = null,
@@ -64,6 +66,7 @@ export default function StandaloneShell({
selectedSession={session}
initialCommand={command}
isPlainShell={shouldUsePlainShell}
isActive={isActive}
onProcessComplete={handleProcessComplete}
minimal={minimal}
autoConnect={minimal ? true : autoConnect}

View File

@@ -0,0 +1,157 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { authenticatedFetch } from '../utils/api';
export type Plugin = {
name: string;
displayName: string;
version: string;
description: string;
author: string;
icon: string;
type: 'react' | 'module';
slot: 'tab';
entry: string;
server: string | null;
permissions: string[];
enabled: boolean;
serverRunning: boolean;
dirName: string;
repoUrl: string | null;
};
type PluginsContextValue = {
plugins: Plugin[];
loading: boolean;
pluginsError: string | null;
refreshPlugins: () => Promise<void>;
installPlugin: (url: string) => Promise<{ success: boolean; error?: string }>;
uninstallPlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
updatePlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
togglePlugin: (name: string, enabled: boolean) => Promise<{ success: boolean; error: string | null }>;
};
const PluginsContext = createContext<PluginsContextValue | null>(null);
export function usePlugins() {
const context = useContext(PluginsContext);
if (!context) {
throw new Error('usePlugins must be used within a PluginsProvider');
}
return context;
}
export function PluginsProvider({ children }: { children: ReactNode }) {
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [loading, setLoading] = useState(true);
const [pluginsError, setPluginsError] = useState<string | null>(null);
const refreshPlugins = useCallback(async () => {
try {
const res = await authenticatedFetch('/api/plugins');
if (res.ok) {
const data = await res.json();
setPlugins(data.plugins || []);
setPluginsError(null);
} else {
let errorMessage = `Failed to fetch plugins (${res.status})`;
try {
const data = await res.json();
errorMessage = data.details || data.error || errorMessage;
} catch {
errorMessage = res.statusText || errorMessage;
}
setPluginsError(errorMessage);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch plugins';
setPluginsError(message);
console.error('[Plugins] Failed to fetch plugins:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshPlugins();
}, [refreshPlugins]);
const installPlugin = useCallback(async (url: string) => {
try {
const res = await authenticatedFetch('/api/plugins/install', {
method: 'POST',
body: JSON.stringify({ url }),
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Install failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Install failed' };
}
}, [refreshPlugins]);
const uninstallPlugin = useCallback(async (name: string) => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Uninstall failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Uninstall failed' };
}
}, [refreshPlugins]);
const updatePlugin = useCallback(async (name: string) => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/update`, {
method: 'POST',
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Update failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Update failed' };
}
}, [refreshPlugins]);
const togglePlugin = useCallback(async (name: string, enabled: boolean): Promise<{ success: boolean; error: string | null }> => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
let errorMessage = `Toggle failed (${res.status})`;
try {
const data = await res.json();
errorMessage = data.details || data.error || errorMessage;
} catch {
// response body wasn't JSON, use status text
errorMessage = res.statusText || errorMessage;
}
return { success: false, error: errorMessage };
}
await refreshPlugins();
return { success: true, error: null };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Toggle failed' };
}
}, [refreshPlugins]);
return (
<PluginsContext.Provider value={{ plugins, loading, pluginsError, refreshPlugins, installPlugin, uninstallPlugin, updatePlugin, togglePlugin }}>
{children}
</PluginsContext.Provider>
);
}

View File

@@ -29,6 +29,7 @@ const buildWebSocketUrl = (token: string | null) => {
const useWebSocketProviderState = (): WebSocketContextType => {
const wsRef = useRef<WebSocket | null>(null);
const unmountedRef = useRef(false); // Track if component is unmounted
const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects)
const [latestMessage, setLatestMessage] = useState<any>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -61,6 +62,11 @@ const useWebSocketProviderState = (): WebSocketContextType => {
websocket.onopen = () => {
setIsConnected(true);
wsRef.current = websocket;
if (hasConnectedRef.current) {
// This is a reconnect — signal so components can catch up on missed messages
setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() });
}
hasConnectedRef.current = true;
};
websocket.onmessage = (event) => {

View File

@@ -18,6 +18,10 @@ type UseProjectsStateArgs = {
activeSessions: Set<string>;
};
type FetchProjectsOptions = {
showLoadingState?: boolean;
};
const serialize = (value: unknown) => JSON.stringify(value ?? null);
const projectsHaveChanges = (
@@ -106,10 +110,14 @@ const isUpdateAdditive = (
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
const isValidTab = (tab: string): tab is AppTab => {
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
};
const readPersistedTab = (): AppTab => {
try {
const stored = localStorage.getItem('activeTab');
if (stored && VALID_TABS.has(stored)) {
if (stored && isValidTab(stored)) {
return stored as AppTab;
}
} catch {
@@ -148,9 +156,11 @@ export function useProjectsState({
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchProjects = useCallback(async () => {
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
try {
setIsLoadingProjects(true);
if (showLoadingState) {
setIsLoadingProjects(true);
}
const response = await api.projects();
const projectData = (await response.json()) as Project[];
@@ -166,10 +176,17 @@ export function useProjectsState({
} catch (error) {
console.error('Error fetching projects:', error);
} finally {
setIsLoadingProjects(false);
if (showLoadingState) {
setIsLoadingProjects(false);
}
}
}, []);
const refreshProjectsSilently = useCallback(async () => {
// Keep chat view stable while still syncing sidebar/session metadata in background.
await fetchProjects({ showLoadingState: false });
}, [fetchProjects]);
const openSettings = useCallback((tab = 'tools') => {
setSettingsInitialTab(tab);
setShowSettings(true);
@@ -543,6 +560,7 @@ export function useProjectsState({
setShowSettings,
openSettings,
fetchProjects,
refreshProjectsSilently,
sidebarSharedProps,
handleProjectSelect,
handleSessionSelect,

View File

@@ -49,6 +49,15 @@ import jaCodeEditor from './locales/ja/codeEditor.json';
// eslint-disable-next-line import-x/order
import jaTasks from './locales/ja/tasks.json';
import ruCommon from './locales/ru/common.json';
import ruSettings from './locales/ru/settings.json';
import ruAuth from './locales/ru/auth.json';
import ruSidebar from './locales/ru/sidebar.json';
import ruChat from './locales/ru/chat.json';
import ruCodeEditor from './locales/ru/codeEditor.json';
// eslint-disable-next-line import-x/order
import ruTasks from './locales/ru/tasks.json';
// Import supported languages configuration
import { languages } from './languages.js';
@@ -107,6 +116,15 @@ i18n
codeEditor: jaCodeEditor,
tasks: jaTasks,
},
ru: {
common: ruCommon,
settings: ruSettings,
auth: ruAuth,
sidebar: ruSidebar,
chat: ruChat,
codeEditor: ruCodeEditor,
tasks: ruTasks,
},
},
// Default language

View File

@@ -29,6 +29,11 @@ export const languages = [
label: 'Japanese',
nativeName: '日本語',
},
{
value: 'ru',
label: 'Russian',
nativeName: 'Русский',
},
];
/**

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

@@ -105,7 +105,9 @@
"git": "Git",
"apiTokens": "API & Tokens",
"tasks": "Tasks",
"notifications": "Notifications"
"notifications": "Notifications",
"plugins": "Plugins"
},
"notifications": {
"title": "Notifications",
@@ -328,6 +330,9 @@
},
"codex": {
"description": "OpenAI Codex AI assistant"
},
"gemini": {
"description": "Google Gemini AI assistant"
}
},
"connectionStatus": "Connection Status",
@@ -450,5 +455,41 @@
"title": "About Codex MCP",
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
}
},
"pluginSettings": {
"title": "Plugins",
"description": "Extend the interface with custom plugins. Install from git or drop a folder in ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "Install",
"installing": "Installing…",
"securityWarning": "Only install plugins whose source code you have reviewed or from authors you trust.",
"scanningPlugins": "Scanning plugins…",
"noPluginsInstalled": "No plugins installed",
"pullLatest": "Pull latest from git",
"noGitRemote": "No git remote — update not available",
"uninstallPlugin": "Uninstall plugin",
"confirmUninstall": "Click again to confirm",
"confirmUninstallMessage": "Remove {{name}}? This cannot be undone.",
"cancel": "Cancel",
"remove": "Remove",
"updateFailed": "Update failed",
"installFailed": "Installation failed",
"uninstallFailed": "Uninstall failed",
"toggleFailed": "Toggle failed",
"buildYourOwn": "Build your own plugin",
"starter": "Starter",
"docs": "Docs",
"starterPlugin": {
"name": "Project Stats",
"badge": "starter",
"description": "File counts, lines of code, file-type breakdown, and recent activity for your project.",
"install": "Install"
},
"morePlugins": "More",
"enable": "Enable",
"disable": "Disable",
"installAriaLabel": "Plugin git repository URL",
"tab": "tab",
"runningStatus": "running"
}
}
}

View File

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

View File

@@ -105,7 +105,9 @@
"git": "Git",
"apiTokens": "API & トークン",
"tasks": "タスク",
"notifications": "通知"
"notifications": "通知",
"plugins": "プラグイン"
},
"notifications": {
"title": "通知",
@@ -328,6 +330,9 @@
},
"codex": {
"description": "OpenAI Codex AIアシスタント"
},
"gemini": {
"description": "Google Gemini AIアシスタント"
}
},
"connectionStatus": "接続状態",
@@ -450,5 +455,41 @@
"title": "Codex MCPについて",
"description": "Codexはstdioベースのツールサーバーをサポートしています。追加のツールやリソースでCodexの機能を拡張するサーバーを追加できます。"
}
},
"pluginSettings": {
"title": "プラグイン",
"description": "カスタムプラグインでインターフェースを拡張します。gitからインストールするか、~/.claude-code-ui/plugins/ にフォルダを配置してください。",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "インストール",
"installing": "インストール中…",
"securityWarning": "信頼できる作成者のプラグイン、またはソースコードを確認済みのプラグインのみをインストールしてください。",
"scanningPlugins": "プラグインをスキャン中…",
"noPluginsInstalled": "プラグインがインストールされていません",
"pullLatest": "gitから最新を取得",
"noGitRemote": "リモートgitリポジトリがありません — アップデート不可",
"uninstallPlugin": "プラグインを削除",
"confirmUninstall": "クリックして確定",
"confirmUninstallMessage": "{{name}} を削除しますか?この操作は取り消せません。",
"cancel": "キャンセル",
"remove": "削除",
"updateFailed": "アップデートに失敗しました",
"installFailed": "インストールに失敗しました",
"uninstallFailed": "削除に失敗しました",
"toggleFailed": "切り替えに失敗しました",
"buildYourOwn": "プラグインを自作する",
"starter": "スターター",
"docs": "ドキュメント",
"starterPlugin": {
"name": "プロジェクト統計",
"badge": "スターター",
"description": "プロジェクトのファイル数、コード行数、ファイルタイプの内訳、最近のアクティビティを表示します。",
"install": "インストール"
},
"morePlugins": "詳細",
"enable": "有効にする",
"disable": "無効にする",
"installAriaLabel": "プラグインのgitリポジトリURL",
"tab": "タブ",
"runningStatus": "実行中"
}
}
}

View File

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

View File

@@ -105,7 +105,9 @@
"git": "Git",
"apiTokens": "API & 토큰",
"tasks": "작업",
"notifications": "알림"
"notifications": "알림",
"plugins": "플러그인"
},
"notifications": {
"title": "알림",
@@ -328,6 +330,9 @@
},
"codex": {
"description": "OpenAI Codex AI 어시스턴트"
},
"gemini": {
"description": "Google Gemini AI 어시스턴트"
}
},
"connectionStatus": "연결 상태",
@@ -450,5 +455,41 @@
"title": "Codex MCP 정보",
"description": "Codex는 stdio 기반 MCP 서버를 지원합니다. 추가 도구와 리소스로 Codex의 기능을 확장하는 서버를 추가할 수 있습니다."
}
},
"pluginSettings": {
"title": "플러그인",
"description": "커스텀 플러그인으로 인터페이스를 확장하세요. git에서 설치하거나 ~/.claude-code-ui/plugins/ 폴더에 직접 추가할 수 있습니다.",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "설치",
"installing": "설치 중…",
"securityWarning": "소스 코드를 검토했거나 신뢰할 수 있는 작성자의 플러그인만 설치하세요.",
"scanningPlugins": "플러그인 스캔 중…",
"noPluginsInstalled": "설치된 플러그인이 없습니다",
"pullLatest": "git에서 최신 버전 가져오기",
"noGitRemote": "git 리모트가 없음 — 업데이트 불가",
"uninstallPlugin": "플러그인 삭제",
"confirmUninstall": "다시 클릭하여 확인",
"confirmUninstallMessage": "{{name}} 플러그인을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"cancel": "취소",
"remove": "삭제",
"updateFailed": "업데이트 실패",
"installFailed": "설치 실패",
"uninstallFailed": "삭제 실패",
"toggleFailed": "토글 실패",
"buildYourOwn": "나만의 플러그인 만들기",
"starter": "스타터",
"docs": "문서",
"starterPlugin": {
"name": "프로젝트 통계",
"badge": "스타터",
"description": "프로젝트의 파일 수, 코드 라인 수, 파일 유형별 분석 및 최근 활동을 확인합니다.",
"install": "설치"
},
"morePlugins": "더 보기",
"enable": "활성화",
"disable": "비활성화",
"installAriaLabel": "플러그인 git 저장소 URL",
"tab": "탭",
"runningStatus": "실행 중"
}
}
}

View File

@@ -0,0 +1,37 @@
{
"login": {
"title": "Добро пожаловать",
"description": "Войдите в свой аккаунт Claude Code UI",
"username": "Имя пользователя",
"password": "Пароль",
"submit": "Войти",
"loading": "Вход...",
"errors": {
"invalidCredentials": "Неверное имя пользователя или пароль",
"requiredFields": "Пожалуйста, заполните все поля",
"networkError": "Ошибка сети. Попробуйте снова."
},
"placeholders": {
"username": "Введите имя пользователя",
"password": "Введите пароль"
}
},
"register": {
"title": "Создать аккаунт",
"username": "Имя пользователя",
"password": "Пароль",
"confirmPassword": "Подтвердите пароль",
"submit": "Создать аккаунт",
"loading": "Создание аккаунта...",
"errors": {
"passwordMismatch": "Пароли не совпадают",
"usernameTaken": "Имя пользователя уже занято",
"weakPassword": "Пароль слишком слабый"
}
},
"logout": {
"title": "Выйти",
"confirm": "Вы уверены, что хотите выйти?",
"button": "Выйти"
}
}

View File

@@ -0,0 +1,272 @@
{
"codeBlock": {
"copy": "Копировать",
"copied": "Скопировано",
"copyCode": "Копировать код"
},
"copyMessage": {
"copy": "Копировать сообщение",
"copied": "Сообщение скопировано",
"selectFormat": "Выбрать формат копирования",
"copyAsMarkdown": "Копировать как Markdown",
"copyAsText": "Копировать как текст"
},
"messageTypes": {
"user": "П",
"error": "Ошибка",
"tool": "Инструмент",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"gemini": "Gemini"
},
"tools": {
"settings": "Настройки инструмента",
"error": "Ошибка инструмента",
"result": "Результат инструмента",
"viewParams": "Просмотр входных параметров",
"viewRawParams": "Просмотр сырых параметров",
"viewDiff": "Просмотр различий редактирования для",
"creatingFile": "Создание нового файла:",
"updatingTodo": "Обновление списка задач",
"read": "Чтение",
"readFile": "Чтение файла",
"updateTodo": "Обновить список задач",
"readTodo": "Прочитать список задач",
"searchResults": "результаты"
},
"search": {
"found": "Найдено {{count}} {{type}}",
"file": "файл",
"files": "файлов",
"pattern": "шаблон:",
"in": "в:"
},
"fileOperations": {
"updated": "Файл успешно обновлен",
"created": "Файл успешно создан",
"written": "Файл успешно записан",
"diff": "Различия",
"newFile": "Новый файл",
"viewContent": "Просмотр содержимого файла",
"viewFullOutput": "Просмотр полного вывода ({{count}} символов)",
"contentDisplayed": "Содержимое файла отображено в представлении различий выше"
},
"interactive": {
"title": "Интерактивный запрос",
"waiting": "Ожидание вашего ответа в CLI",
"instruction": "Пожалуйста, выберите опцию в терминале, где запущен Claude.",
"selectedOption": "✓ Claude выбрал опцию {{number}}",
"instructionDetail": "В CLI вы бы выбрали эту опцию интерактивно, используя клавиши со стрелками или введя номер."
},
"thinking": {
"title": "Думаю...",
"emoji": "💭 Думаю..."
},
"json": {
"response": "JSON ответ"
},
"permissions": {
"grant": "Предоставить разрешение для {{tool}}",
"added": "Разрешение добавлено",
"addTo": "Добавляет {{entry}} в разрешенные инструменты.",
"retry": "Разрешение сохранено. Повторите запрос для использования инструмента.",
"error": "Не удалось обновить разрешения. Попробуйте снова.",
"openSettings": "Открыть настройки"
},
"todo": {
"updated": "Список задач успешно обновлен",
"current": "Текущий список задач"
},
"plan": {
"viewPlan": "📋 Просмотр плана реализации",
"title": "План реализации"
},
"usageLimit": {
"resetAt": "Достигнут лимит использования Claude. Ваш лимит будет сброшен в **{{time}} {{timezone}}** - {{date}}"
},
"codex": {
"permissionMode": "Режим разрешений",
"modes": {
"default": "Режим по умолчанию",
"acceptEdits": "Принимать правки",
"bypassPermissions": "Обход разрешений",
"plan": "Режим планирования"
},
"descriptions": {
"default": "Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство.",
"acceptEdits": "Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением.",
"bypassPermissions": "Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью.",
"plan": "Режим планирования - команды не выполняются"
},
"technicalDetails": "Технические детали"
},
"gemini": {
"permissionMode": "Режим разрешений Gemini",
"description": "Управление тем, как Gemini CLI обрабатывает подтверждения операций.",
"modes": {
"default": {
"title": "Стандартный (запрашивать подтверждение)",
"description": "Gemini будет запрашивать подтверждение перед выполнением команд, записью файлов и получением веб-ресурсов."
},
"autoEdit": {
"title": "Автоматическое редактирование (пропускать подтверждения файлов)",
"description": "Gemini будет автоматически подтверждать редактирование файлов и веб-запросы, но все еще будет запрашивать подтверждение для команд оболочки."
},
"yolo": {
"title": "YOLO (обход всех разрешений)",
"description": "Gemini будет выполнять все операции без запроса подтверждения. Будьте осторожны."
}
}
},
"input": {
"placeholder": "Введите / для команд, @ для файлов, или спросите {{provider}} что угодно...",
"placeholderDefault": "Введите ваше сообщение...",
"disabled": "Ввод отключен",
"attachFiles": "Прикрепить файлы",
"attachImages": "Прикрепить изображения",
"send": "Отправить",
"stop": "Остановить",
"hintText": {
"ctrlEnter": "Ctrl+Enter для отправки • Shift+Enter для новой строки • Tab для смены режима • / для команд",
"enter": "Enter для отправки • Shift+Enter для новой строки • Tab для смены режима • / для команд"
},
"clickToChangeMode": "Нажмите для смены режима разрешений (или нажмите Tab в поле ввода)",
"showAllCommands": "Показать все команды",
"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": "Выберите провайдера для начала нового разговора",
"selectModel": "Выбрать модель",
"providerInfo": {
"anthropic": "от Anthropic",
"openai": "от OpenAI",
"cursorEditor": "AI редактор кода",
"google": "от Google"
},
"readyPrompt": {
"claude": "Готов использовать Claude с {{model}}. Начните вводить сообщение ниже.",
"cursor": "Готов использовать Cursor с {{model}}. Начните вводить сообщение ниже.",
"codex": "Готов использовать Codex с {{model}}. Начните вводить сообщение ниже.",
"gemini": "Готов использовать Gemini с {{model}}. Начните вводить сообщение ниже.",
"default": "Выберите провайдера выше для начала"
}
},
"session": {
"continue": {
"title": "Продолжить разговор",
"description": "Задавайте вопросы о вашем коде, запрашивайте изменения или получайте помощь с задачами разработки"
},
"loading": {
"olderMessages": "Загрузка старых сообщений...",
"sessionMessages": "Загрузка сообщений сеанса..."
},
"messages": {
"showingOf": "Показано {{shown}} из {{total}} сообщений",
"scrollToLoad": "Прокрутите вверх для загрузки еще",
"showingLast": "Показаны последние {{count}} сообщений (всего {{total}})",
"loadEarlier": "Загрузить более ранние сообщения",
"loadAll": "Загрузить все сообщения",
"loadingAll": "Загрузка всех сообщений...",
"allLoaded": "Все сообщения загружены",
"perfWarning": "Все сообщения загружены — прокрутка может быть медленнее. Нажмите \"Прокрутить вниз\" для восстановления производительности."
}
},
"shell": {
"selectProject": {
"title": "Выберите проект",
"description": "Выберите проект для открытия интерактивной оболочки в этом каталоге"
},
"status": {
"newSession": "Новый сеанс",
"initializing": "Инициализация...",
"restarting": "Перезапуск..."
},
"actions": {
"disconnect": "Отключиться",
"disconnectTitle": "Отключиться от оболочки",
"restart": "Перезапустить",
"restartTitle": "Перезапустить оболочку (сначала отключитесь)",
"connect": "Продолжить в оболочке",
"connectTitle": "Подключиться к оболочке"
},
"loading": "Загрузка терминала...",
"connecting": "Подключение к оболочке...",
"startSession": "Начать новый сеанс Claude",
"resumeSession": "Возобновить сеанс: {{displayName}}...",
"runCommand": "Выполнить {{command}} в {{projectName}}",
"startCli": "Запуск Claude CLI в {{projectName}}",
"defaultCommand": "команда"
},
"claudeStatus": {
"actions": {
"thinking": "Думает",
"processing": "Обрабатывает",
"analyzing": "Анализирует",
"working": "Работает",
"computing": "Вычисляет",
"reasoning": "Рассуждает"
},
"state": {
"live": "В сети",
"paused": "Приостановлен"
},
"elapsed": {
"seconds": "{{count}}с",
"minutesSeconds": "{{minutes}}м {{seconds}}с",
"label": "Прошло {{time}}",
"startingNow": "Начинается сейчас"
},
"controls": {
"stopGeneration": "Остановить генерацию",
"pressEscToStop": "Нажмите Esc в любое время для остановки"
},
"providers": {
"assistant": "Ассистент"
}
},
"projectSelection": {
"startChatWithProvider": "Выберите проект для начала чата с {{provider}}"
},
"tasks": {
"nextTaskPrompt": "Начать следующую задачу"
}
}

View File

@@ -0,0 +1,36 @@
{
"toolbar": {
"changes": "изменения",
"previousChange": "Предыдущее изменение",
"nextChange": "Следующее изменение",
"hideDiff": "Скрыть подсветку различий",
"showDiff": "Показать подсветку различий",
"settings": "Настройки редактора",
"collapse": "Свернуть редактор",
"expand": "Развернуть редактор на всю ширину"
},
"loading": "Загрузка {{fileName}}...",
"header": {
"showingChanges": "Показаны изменения"
},
"actions": {
"download": "Скачать файл",
"save": "Сохранить",
"saving": "Сохранение...",
"saved": "Сохранено!",
"exitFullscreen": "Выйти из полноэкранного режима",
"fullscreen": "Полноэкранный режим",
"close": "Закрыть",
"previewMarkdown": "Предпросмотр markdown",
"editMarkdown": "Редактировать markdown"
},
"footer": {
"lines": "Строк:",
"characters": "Символов:",
"shortcuts": "Нажмите Ctrl+S для сохранения • Esc для закрытия"
},
"binaryFile": {
"title": "Бинарный файл",
"message": "Файл \"{{fileName}}\" не может быть отображен в текстовом редакторе, так как это бинарный файл."
}
}

View File

@@ -0,0 +1,238 @@
{
"buttons": {
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"create": "Создать",
"edit": "Редактировать",
"close": "Закрыть",
"confirm": "Подтвердить",
"submit": "Отправить",
"retry": "Повторить",
"refresh": "Обновить",
"search": "Поиск",
"clear": "Очистить",
"copy": "Копировать",
"download": "Скачать",
"upload": "Загрузить",
"browse": "Обзор"
},
"tabs": {
"chat": "Чат",
"shell": "Терминал",
"files": "Файлы",
"git": "Система контроля версий",
"tasks": "Задачи"
},
"status": {
"loading": "Загрузка...",
"success": "Успешно",
"error": "Ошибка",
"failed": "Не удалось",
"pending": "Ожидание",
"completed": "Завершено",
"inProgress": "В процессе"
},
"messages": {
"savedSuccessfully": "Успешно сохранено",
"deletedSuccessfully": "Успешно удалено",
"updatedSuccessfully": "Успешно обновлено",
"operationFailed": "Операция не удалась",
"networkError": "Ошибка сети. Проверьте подключение.",
"unauthorized": "Не авторизован. Пожалуйста, войдите.",
"notFound": "Не найдено",
"invalidInput": "Неверный ввод",
"requiredField": "Это поле обязательно",
"unknownError": "Произошла неизвестная ошибка"
},
"navigation": {
"settings": "Настройки",
"home": "Главная",
"back": "Назад",
"next": "Далее",
"previous": "Предыдущий",
"logout": "Выйти"
},
"common": {
"language": "Язык",
"theme": "Тема",
"darkMode": "Темная тема",
"lightMode": "Светлая тема",
"name": "Имя",
"description": "Описание",
"enabled": "Включено",
"disabled": "Отключено",
"optional": "Необязательно",
"version": "Версия",
"select": "Выбрать",
"selectAll": "Выбрать все",
"deselectAll": "Снять выделение"
},
"time": {
"justNow": "Только что",
"minutesAgo": "{{count}} мин. назад",
"hoursAgo": "{{count}} ч. назад",
"daysAgo": "{{count}} дн. назад",
"yesterday": "Вчера"
},
"fileOperations": {
"newFile": "Новый файл",
"newFolder": "Новая папка",
"rename": "Переименовать",
"move": "Переместить",
"copyPath": "Копировать путь",
"openInEditor": "Открыть в редакторе"
},
"mainContent": {
"loading": "Загрузка Claude Code UI",
"settingUpWorkspace": "Настройка рабочего пространства...",
"chooseProject": "Выберите проект",
"selectProjectDescription": "Выберите проект на боковой панели, чтобы начать работу с Claude. Каждый проект содержит ваши сеансы чата и историю файлов.",
"tip": "Совет",
"createProjectMobile": "Нажмите кнопку меню выше для доступа к проектам",
"createProjectDesktop": "Создайте новый проект, нажав на значок папки на боковой панели",
"newSession": "Новый сеанс",
"untitledSession": "Безымянный сеанс",
"projectFiles": "Файлы проекта"
},
"fileTree": {
"loading": "Загрузка файлов...",
"files": "Файлы",
"simpleView": "Простой вид",
"compactView": "Компактный вид",
"detailedView": "Подробный вид",
"searchPlaceholder": "Поиск файлов и папок...",
"clearSearch": "Очистить поиск",
"name": "Имя",
"size": "Размер",
"modified": "Изменено",
"permissions": "Права доступа",
"noFilesFound": "Файлы не найдены",
"checkProjectPath": "Проверьте доступность пути к проекту",
"noMatchesFound": "Совпадений не найдено",
"tryDifferentSearch": "Попробуйте другой поисковый запрос или очистите поиск",
"justNow": "только что",
"minAgo": "{{count}} мин. назад",
"hoursAgo": "{{count}} ч. назад",
"daysAgo": "{{count}} дн. назад",
"newFile": "Новый файл (Cmd+N)",
"newFolder": "Новая папка (Cmd+Shift+N)",
"refresh": "Обновить",
"collapseAll": "Свернуть все",
"context": {
"rename": "Переименовать",
"delete": "Удалить",
"copyPath": "Копировать путь",
"download": "Скачать",
"newFile": "Новый файл",
"newFolder": "Новая папка",
"refresh": "Обновить",
"menuLabel": "Контекстное меню файла",
"loading": "Загрузка..."
}
},
"projectWizard": {
"title": "Создать новый проект",
"steps": {
"type": "Тип",
"configure": "Настройка",
"confirm": "Подтверждение"
},
"step1": {
"question": "У вас уже есть рабочее пространство или вы хотите создать новое?",
"existing": {
"title": "Существующее рабочее пространство",
"description": "У меня уже есть рабочее пространство на сервере, нужно только добавить его в список проектов"
},
"new": {
"title": "Новое рабочее пространство",
"description": "Создать новое рабочее пространство, опционально клонировать из репозитория GitHub"
}
},
"step2": {
"existingPath": "Путь к рабочему пространству",
"newPath": "Путь к рабочему пространству",
"existingPlaceholder": "/путь/к/существующему/пространству",
"newPlaceholder": "/путь/к/новому/пространству",
"existingHelp": "Полный путь к каталогу вашего рабочего пространства",
"newHelp": "Полный путь к каталогу вашего рабочего пространства",
"githubUrl": "URL GitHub (необязательно)",
"githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "Необязательно: укажите URL GitHub для клонирования репозитория",
"githubAuth": "Аутентификация GitHub (необязательно)",
"githubAuthHelp": "Требуется только для приватных репозиториев. Публичные репозитории можно клонировать без аутентификации.",
"loadingTokens": "Загрузка сохраненных токенов...",
"storedToken": "Сохраненный токен",
"newToken": "Новый токен",
"nonePublic": "Нет (публичный)",
"selectToken": "Выбрать токен",
"selectTokenPlaceholder": "-- Выберите токен --",
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tokenHelp": "Этот токен будет использован только для этой операции",
"publicRepoInfo": "Публичные репозитории не требуют аутентификации. Вы можете пропустить токен при клонировании публичного репозитория.",
"noTokensHelp": "Нет доступных сохраненных токенов. Вы можете добавить токены в Настройки → API ключи для удобного повторного использования.",
"optionalTokenPublic": "Токен GitHub (необязательно для публичных репозиториев)",
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (оставьте пустым для публичных репозиториев)"
},
"step3": {
"reviewConfig": "Проверьте вашу конфигурацию",
"workspaceType": "Тип рабочего пространства:",
"existingWorkspace": "Существующее рабочее пространство",
"newWorkspace": "Новое рабочее пространство",
"path": "Путь:",
"cloneFrom": "Клонировать из:",
"authentication": "Аутентификация:",
"usingStoredToken": "Использование сохраненного токена:",
"usingProvidedToken": "Использование предоставленного токена",
"noAuthentication": "Без аутентификации",
"sshKey": "SSH ключ",
"existingInfo": "Рабочее пространство будет добавлено в список проектов и будет доступно для сеансов Claude/Cursor.",
"newWithClone": "Репозиторий будет клонирован в эту папку.",
"newEmpty": "Рабочее пространство будет добавлено в список проектов и будет доступно для сеансов Claude/Cursor.",
"cloningRepository": "Клонирование репозитория..."
},
"buttons": {
"cancel": "Отмена",
"back": "Назад",
"next": "Далее",
"createProject": "Создать проект",
"creating": "Создание...",
"cloning": "Клонирование..."
},
"errors": {
"selectType": "Пожалуйста, выберите, есть ли у вас существующее рабочее пространство или вы хотите создать новое",
"providePath": "Пожалуйста, укажите путь к рабочему пространству",
"failedToCreate": "Не удалось создать рабочее пространство",
"failedToCreateFolder": "Не удалось создать папку"
}
},
"versionUpdate": {
"title": "Доступно обновление",
"newVersionReady": "Новая версия готова",
"currentVersion": "Текущая версия",
"latestVersion": "Последняя версия",
"whatsNew": "Что нового:",
"viewFullRelease": "Посмотреть полный релиз",
"updateProgress": "Прогресс обновления:",
"manualUpgrade": "Ручное обновление:",
"npmUpgradeCommand": "npm install -g @siteboon/claude-code-ui@latest",
"manualUpgradeHint": "Или нажмите \"Обновить сейчас\" для автоматического обновления.",
"updateCompleted": "Обновление успешно завершено!",
"restartServer": "Пожалуйста, перезапустите сервер для применения изменений.",
"updateFailed": "Обновление не удалось",
"buttons": {
"close": "Закрыть",
"later": "Позже",
"copyCommand": "Копировать команду",
"updateNow": "Обновить сейчас",
"updating": "Обновление..."
},
"ariaLabels": {
"closeModal": "Закрыть модальное окно обновления версии",
"showSidebar": "Показать боковую панель",
"settings": "Настройки",
"updateAvailable": "Доступно обновление",
"closeSidebar": "Закрыть боковую панель"
}
}
}

View File

@@ -0,0 +1,474 @@
{
"title": "Настройки",
"tabs": {
"account": "Аккаунт",
"permissions": "Разрешения",
"mcpServers": "MCP серверы",
"appearance": "Внешний вид"
},
"account": {
"title": "Аккаунт",
"language": "Язык",
"languageLabel": "Язык интерфейса",
"languageDescription": "Выберите предпочитаемый язык для интерфейса",
"username": "Имя пользователя",
"email": "Email",
"profile": "Профиль",
"changePassword": "Изменить пароль"
},
"mcp": {
"title": "MCP серверы",
"addServer": "Добавить сервер",
"editServer": "Редактировать сервер",
"deleteServer": "Удалить сервер",
"serverName": "Имя сервера",
"serverType": "Тип сервера",
"config": "Конфигурация",
"testConnection": "Проверить подключение",
"status": "Статус",
"connected": "Подключен",
"disconnected": "Отключен",
"scope": {
"label": "Область",
"user": "Пользователь",
"project": "Проект"
}
},
"appearance": {
"title": "Внешний вид",
"theme": "Тема",
"codeEditor": "Редактор кода",
"editorTheme": "Тема редактора",
"wordWrap": "Перенос слов",
"showMinimap": "Показать миникарту",
"lineNumbers": "Номера строк",
"fontSize": "Размер шрифта"
},
"actions": {
"saveChanges": "Сохранить изменения",
"resetToDefaults": "Сбросить к значениям по умолчанию",
"cancelChanges": "Отменить изменения"
},
"quickSettings": {
"title": "Быстрые настройки",
"sections": {
"appearance": "Внешний вид",
"toolDisplay": "Отображение инструментов",
"viewOptions": "Параметры просмотра",
"inputSettings": "Настройки ввода",
"whisperDictation": "Диктовка Whisper"
},
"darkMode": "Темная тема",
"autoExpandTools": "Автоматически разворачивать инструменты",
"showRawParameters": "Показывать сырые параметры",
"showThinking": "Показывать размышления",
"autoScrollToBottom": "Автопрокрутка вниз",
"sendByCtrlEnter": "Отправка по Ctrl+Enter",
"sendByCtrlEnterDescription": "Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.",
"dragHandle": {
"dragging": "Перетаскивание ручки",
"closePanel": "Закрыть панель настроек",
"openPanel": "Открыть панель настроек",
"draggingStatus": "Перетаскивание...",
"toggleAndMove": "Нажмите для переключения, перетащите для перемещения"
},
"whisper": {
"modes": {
"default": "Режим по умолчанию",
"defaultDescription": "Прямая транскрипция вашей речи",
"prompt": "Улучшение запроса",
"promptDescription": "Преобразование грубых идей в четкие, детальные AI-запросы",
"vibe": "Режим Vibe",
"vibeDescription": "Форматирование идей как четких инструкций агента с деталями"
}
}
},
"terminalShortcuts": {
"title": "Горячие клавиши терминала",
"sectionKeys": "Клавиши",
"sectionNavigation": "Навигация",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "Стрелка вверх",
"arrowDown": "Стрелка вниз",
"scrollDown": "Прокрутка вниз",
"handle": {
"closePanel": "Закрыть панель горячих клавиш",
"openPanel": "Открыть панель горячих клавиш"
}
},
"mainTabs": {
"label": "Настройки",
"agents": "Агенты",
"appearance": "Внешний вид",
"git": "Git",
"apiTokens": "API и токены",
"tasks": "Задачи",
"plugins": "Плагины"
},
"appearanceSettings": {
"darkMode": {
"label": "Темная тема",
"description": "Переключение между светлой и темной темами"
},
"projectSorting": {
"label": "Сортировка проектов",
"description": "Как проекты упорядочены на боковой панели",
"alphabetical": "По алфавиту",
"recentActivity": "По недавней активности"
},
"codeEditor": {
"title": "Редактор кода",
"theme": {
"label": "Тема редактора",
"description": "Тема по умолчанию для редактора кода"
},
"wordWrap": {
"label": "Перенос слов",
"description": "Включить перенос слов по умолчанию в редакторе"
},
"showMinimap": {
"label": "Показать миникарту",
"description": "Отображать миникарту для упрощения навигации в представлении различий"
},
"lineNumbers": {
"label": "Показать номера строк",
"description": "Отображать номера строк в редакторе"
},
"fontSize": {
"label": "Размер шрифта",
"description": "Размер шрифта редактора в пикселях"
}
}
},
"mcpForm": {
"title": {
"add": "Добавить MCP сервер",
"edit": "Редактировать MCP сервер"
},
"importMode": {
"form": "Ввод формы",
"json": "Импорт JSON"
},
"scope": {
"label": "Область",
"userGlobal": "Пользователь (глобально)",
"projectLocal": "Проект (локально)",
"userDescription": "Область пользователя: доступно во всех проектах на вашей машине",
"projectDescription": "Локальная область: доступно только в выбранном проекте",
"cannotChange": "Область не может быть изменена при редактировании существующего сервера"
},
"fields": {
"serverName": "Имя сервера",
"transportType": "Тип транспорта",
"command": "Команда",
"arguments": "Аргументы (по одному на строку)",
"jsonConfig": "JSON конфигурация",
"url": "URL",
"envVars": "Переменные окружения (КЛЮЧ=значение, по одной на строку)",
"headers": "Заголовки (КЛЮЧ=значение, по одному на строку)",
"selectProject": "Выберите проект..."
},
"placeholders": {
"serverName": "мой-сервер"
},
"validation": {
"missingType": "Отсутствует обязательное поле: type",
"stdioRequiresCommand": "тип stdio требует поле command",
"httpRequiresUrl": "тип {{type}} требует поле url",
"invalidJson": "Неверный формат JSON",
"jsonHelp": "Вставьте конфигурацию вашего MCP сервера в формате JSON. Примеры форматов:",
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
},
"configDetails": "Детали конфигурации (из {{configFile}})",
"projectPath": "Путь: {{path}}",
"actions": {
"cancel": "Отмена",
"saving": "Сохранение...",
"addServer": "Добавить сервер",
"updateServer": "Обновить сервер"
}
},
"saveStatus": {
"success": "Настройки успешно сохранены!",
"error": "Не удалось сохранить настройки",
"saving": "Сохранение..."
},
"footerActions": {
"save": "Сохранить настройки",
"cancel": "Отмена"
},
"git": {
"title": "Конфигурация Git",
"description": "Настройте вашу git идентичность для коммитов. Эти настройки будут применены глобально через git config --global",
"name": {
"label": "Имя Git",
"help": "Ваше имя для git коммитов"
},
"email": {
"label": "Email Git",
"help": "Ваш email для git коммитов"
},
"actions": {
"save": "Сохранить конфигурацию",
"saving": "Сохранение..."
},
"status": {
"success": "Успешно сохранено"
}
},
"apiKeys": {
"title": "API ключи",
"description": "Генерируйте API ключи для доступа к внешнему API из других приложений.",
"newKey": {
"alertTitle": "⚠️ Сохраните ваш API ключ",
"alertMessage": "Это единственный раз, когда вы увидите этот ключ. Сохраните его в безопасном месте.",
"iveSavedIt": "Я сохранил его"
},
"form": {
"placeholder": "Имя API ключа (например, Продакшн сервер)",
"createButton": "Создать",
"cancelButton": "Отмена"
},
"newButton": "Новый API ключ",
"empty": "API ключи еще не созданы.",
"list": {
"created": "Создан:",
"lastUsed": "Последнее использование:"
},
"confirmDelete": "Вы уверены, что хотите удалить этот API ключ?",
"status": {
"active": "Активен",
"inactive": "Неактивен"
},
"github": {
"title": "GitHub токены",
"description": "Добавьте персональные токены доступа GitHub для клонирования приватных репозиториев через внешний API.",
"descriptionAlt": "Добавьте персональные токены доступа GitHub для клонирования приватных репозиториев. Вы также можете передавать токены напрямую в API запросах без их сохранения.",
"addButton": "Добавить токен",
"form": {
"namePlaceholder": "Имя токена (например, Личные репозитории)",
"tokenPlaceholder": "Персональный токен доступа GitHub (ghp_...)",
"descriptionPlaceholder": "Описание (необязательно)",
"addButton": "Добавить токен",
"cancelButton": "Отмена",
"howToCreate": "Как создать персональный токен доступа GitHub →"
},
"empty": "GitHub токены еще не добавлены.",
"added": "Добавлен:",
"confirmDelete": "Вы уверены, что хотите удалить этот GitHub токен?"
},
"apiDocsLink": "Документация API",
"documentation": {
"title": "Документация внешнего API",
"description": "Узнайте, как использовать внешний API для запуска сеансов Claude/Cursor из ваших приложений.",
"viewLink": "Просмотр документации API →"
},
"loading": "Загрузка...",
"version": {
"updateAvailable": "Доступно обновление: v{{version}}"
}
},
"tasks": {
"checking": "Проверка установки TaskMaster...",
"notInstalled": {
"title": "TaskMaster AI CLI не установлен",
"description": "TaskMaster CLI требуется для использования функций управления задачами. Установите его для начала работы:",
"installCommand": "npm install -g task-master-ai",
"viewOnGitHub": "Посмотреть на GitHub",
"afterInstallation": "После установки:",
"steps": {
"restart": "Перезапустите это приложение",
"autoAvailable": "Функции TaskMaster станут автоматически доступны",
"initCommand": "Используйте task-master init в каталоге вашего проекта"
}
},
"settings": {
"enableLabel": "Включить интеграцию TaskMaster",
"enableDescription": "Показывать задачи TaskMaster, баннеры и индикаторы боковой панели в интерфейсе"
}
},
"agents": {
"authStatus": {
"checking": "Проверка...",
"connected": "Подключен",
"notConnected": "Не подключен",
"disconnected": "Отключен",
"checkingAuth": "Проверка статуса аутентификации...",
"loggedInAs": "Вошли как {{email}}",
"authenticatedUser": "аутентифицированный пользователь"
},
"account": {
"claude": {
"description": "AI-ассистент Anthropic Claude"
},
"cursor": {
"description": "Редактор кода с AI Cursor"
},
"codex": {
"description": "AI-ассистент OpenAI Codex"
},
"gemini": {
"description": "AI-ассистент Google Gemini"
}
},
"connectionStatus": "Статус подключения",
"login": {
"title": "Вход",
"reAuthenticate": "Повторная аутентификация",
"description": "Войдите в ваш аккаунт {{agent}} для включения AI функций",
"reAuthDescription": "Войдите с другим аккаунтом или обновите учетные данные",
"button": "Войти",
"reLoginButton": "Войти снова"
},
"error": "Ошибка: {{error}}"
},
"permissions": {
"title": "Настройки разрешений",
"skipPermissions": {
"label": "Пропускать запросы разрешений (используйте с осторожностью)",
"claudeDescription": "Эквивалентно флагу --dangerously-skip-permissions",
"cursorDescription": "Эквивалентно флагу -f в Cursor CLI"
},
"allowedTools": {
"title": "Разрешенные инструменты",
"description": "Инструменты, которые автоматически разрешены без запроса разрешения",
"placeholder": "например, \"Bash(git log:*)\" или \"Write\"",
"quickAdd": "Быстро добавить общие инструменты:",
"empty": "Разрешенные инструменты не настроены"
},
"blockedTools": {
"title": "Заблокированные инструменты",
"description": "Инструменты, которые автоматически блокируются без запроса разрешения",
"placeholder": "например, \"Bash(rm:*)\"",
"empty": "Заблокированные инструменты не настроены"
},
"allowedCommands": {
"title": "Разрешенные команды оболочки",
"description": "Команды оболочки, которые автоматически разрешены без запроса",
"placeholder": "например, \"Shell(ls)\" или \"Shell(git status)\"",
"quickAdd": "Быстро добавить общие команды:",
"empty": "Разрешенные команды не настроены"
},
"blockedCommands": {
"title": "Заблокированные команды оболочки",
"description": "Команды оболочки, которые автоматически блокируются",
"placeholder": "например, \"Shell(rm -rf)\" или \"Shell(sudo)\"",
"empty": "Заблокированные команды не настроены"
},
"toolExamples": {
"title": "Примеры шаблонов инструментов:",
"bashGitLog": "- Разрешить все команды git log",
"bashGitDiff": "- Разрешить все команды git diff",
"write": "- Разрешить все использование инструмента Write",
"bashRm": "- Заблокировать все команды rm (опасно)"
},
"shellExamples": {
"title": "Примеры команд оболочки:",
"ls": "- Разрешить команду ls",
"gitStatus": "- Разрешить git status",
"npmInstall": "- Разрешить npm install",
"rmRf": "- Заблокировать рекурсивное удаление"
},
"codex": {
"permissionMode": "Режим разрешений",
"description": "Управляет тем, как Codex обрабатывает изменения файлов и выполнение команд",
"modes": {
"default": {
"title": "По умолчанию",
"description": "Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство."
},
"acceptEdits": {
"title": "Принимать правки",
"description": "Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением."
},
"bypassPermissions": {
"title": "Обход разрешений",
"description": "Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью."
}
},
"technicalDetails": "Технические детали",
"technicalInfo": {
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. Доверенные команды: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (без -exec) и т.д.",
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. Все команды автоматически выполняются в каталоге проекта.",
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. Полный системный доступ, используйте только в доверенных средах.",
"overrideNote": "Вы можете переопределить это для каждого сеанса, используя кнопку режима в интерфейсе чата."
}
},
"actions": {
"add": "Добавить"
}
},
"mcpServers": {
"title": "MCP серверы",
"description": {
"claude": "Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Claude",
"cursor": "Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Cursor",
"codex": "Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Codex"
},
"addButton": "Добавить MCP сервер",
"empty": "MCP серверы не настроены",
"serverType": "Тип",
"scope": {
"local": "локальный",
"user": "пользователь"
},
"config": {
"command": "Команда",
"url": "URL",
"args": "Аргументы",
"environment": "Окружение"
},
"tools": {
"title": "Инструменты",
"count": "({{count}}):",
"more": "+{{count}} еще"
},
"actions": {
"edit": "Редактировать сервер",
"delete": "Удалить сервер"
},
"help": {
"title": "О Codex MCP",
"description": "Codex поддерживает MCP серверы на основе stdio. Вы можете добавлять серверы, которые расширяют возможности Codex дополнительными инструментами и ресурсами."
}
},
"pluginSettings": {
"title": "Плагины",
"description": "Расширяйте интерфейс с помощью кастомных плагинов. Установите из git или добавьте папку в ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "Установить",
"installing": "Установка…",
"securityWarning": "Устанавливайте только те плагины, исходный код которых вы проверили или от авторов, которым вы доверяете.",
"scanningPlugins": "Сканирование плагинов…",
"noPluginsInstalled": "Плагины не установлены",
"pullLatest": "Получить обновления из git",
"noGitRemote": "Нет удаленного git-репозитория — обновление недоступно",
"uninstallPlugin": "Удалить плагин",
"confirmUninstall": "Нажмите еще раз для подтверждения",
"confirmUninstallMessage": "Удалить {{name}}? Это действие нельзя отменить.",
"cancel": "Отмена",
"remove": "Удалить",
"updateFailed": "Ошибка обновления",
"installFailed": "Ошибка установки",
"uninstallFailed": "Ошибка удаления",
"toggleFailed": "Ошибка переключения",
"buildYourOwn": "Создайте свой плагин",
"starter": "Шаблон",
"docs": "Документация",
"starterPlugin": {
"name": "Статистика проекта",
"badge": "шаблон",
"description": "Количество файлов, строк кода, разбивка по типам файлов и недавняя активность в вашем проекте.",
"install": "Установить"
},
"morePlugins": "Ещё",
"enable": "Включить",
"disable": "Выключить",
"installAriaLabel": "URL git-репозитория плагина",
"tab": "вкладка",
"runningStatus": "запущен"
}
}

View File

@@ -0,0 +1,134 @@
{
"projects": {
"title": "Проекты",
"newProject": "Новый проект",
"deleteProject": "Удалить проект",
"renameProject": "Переименовать проект",
"noProjects": "Проекты не найдены",
"loadingProjects": "Загрузка проектов...",
"searchPlaceholder": "Поиск проектов...",
"projectNamePlaceholder": "Имя проекта",
"starred": "Избранное",
"all": "Все",
"untitledSession": "Безымянный сеанс",
"newSession": "Новый сеанс",
"codexSession": "Сеанс Codex",
"fetchingProjects": "Получение ваших проектов и сеансов Claude",
"projects": "проекты",
"noMatchingProjects": "Нет подходящих проектов",
"tryDifferentSearch": "Попробуйте изменить поисковый запрос",
"runClaudeCli": "Запустите Claude CLI в каталоге проекта для начала работы"
},
"app": {
"title": "Claude Code UI",
"subtitle": "Интерфейс AI помощника для программирования"
},
"sessions": {
"title": "Сеансы",
"newSession": "Новый сеанс",
"deleteSession": "Удалить сеанс",
"renameSession": "Переименовать сеанс",
"noSessions": "Сеансов пока нет",
"loadingSessions": "Загрузка сеансов...",
"unnamed": "Без имени",
"loading": "Загрузка...",
"showMore": "Показать больше сеансов"
},
"tooltips": {
"viewEnvironments": "Просмотр окружений",
"hideSidebar": "Скрыть боковую панель",
"createProject": "Создать новый проект",
"refresh": "Обновить проекты и сеансы (Ctrl+R)",
"renameProject": "Переименовать проект (F2)",
"deleteProject": "Удалить пустой проект (Delete)",
"addToFavorites": "Добавить в избранное",
"removeFromFavorites": "Удалить из избранного",
"editSessionName": "Вручную редактировать имя сеанса",
"deleteSession": "Удалить этот сеанс навсегда",
"save": "Сохранить",
"cancel": "Отмена",
"clearSearch": "Очистить поиск"
},
"navigation": {
"chat": "Чат",
"files": "Файлы",
"git": "Git",
"terminal": "Терминал",
"tasks": "Задачи"
},
"actions": {
"refresh": "Обновить",
"settings": "Настройки",
"collapseAll": "Свернуть все",
"expandAll": "Развернуть все",
"cancel": "Отмена",
"save": "Сохранить",
"delete": "Удалить",
"rename": "Переименовать",
"joinCommunity": "Присоединиться к сообществу"
},
"status": {
"active": "Активен",
"inactive": "Неактивен",
"thinking": "Думает...",
"error": "Ошибка",
"aborted": "Прервано",
"unknown": "Неизвестно"
},
"time": {
"justNow": "Только что",
"oneMinuteAgo": "1 мин. назад",
"minutesAgo": "{{count}} мин. назад",
"oneHourAgo": "1 час назад",
"hoursAgo": "{{count}} ч. назад",
"oneDayAgo": "1 день назад",
"daysAgo": "{{count}} дн. назад"
},
"messages": {
"deleteConfirm": "Вы уверены, что хотите это удалить?",
"renameSuccess": "Успешно переименовано",
"deleteSuccess": "Успешно удалено",
"errorOccurred": "Произошла ошибка",
"deleteSessionConfirm": "Вы уверены, что хотите удалить этот сеанс? Это действие нельзя отменить.",
"deleteProjectConfirm": "Вы уверены, что хотите удалить этот пустой проект? Это действие нельзя отменить.",
"enterProjectPath": "Пожалуйста, введите путь к проекту",
"deleteSessionFailed": "Не удалось удалить сеанс. Попробуйте снова.",
"deleteSessionError": "Ошибка при удалении сеанса. Попробуйте снова.",
"renameSessionFailed": "Не удалось переименовать сеанс. Попробуйте снова.",
"renameSessionError": "Ошибка при переименовании сеанса. Попробуйте снова.",
"deleteProjectFailed": "Не удалось удалить проект. Попробуйте снова.",
"deleteProjectError": "Ошибка при удалении проекта. Попробуйте снова.",
"createProjectFailed": "Не удалось создать проект. Попробуйте снова.",
"createProjectError": "Ошибка при создании проекта. Попробуйте снова."
},
"version": {
"updateAvailable": "Доступно обновление"
},
"search": {
"modeProjects": "Проекты",
"modeConversations": "Разговоры",
"conversationsPlaceholder": "Поиск в разговорах...",
"searching": "Поиск...",
"noResults": "Результаты не найдены",
"tryDifferentQuery": "Попробуйте другой поисковый запрос",
"matches_one": "{{count}} совпадение",
"matches_few": "{{count}} совпадения",
"matches_many": "{{count}} совпадений",
"matches_other": "{{count}} совпадений",
"projectsScanned_one": "{{count}} проект просканирован",
"projectsScanned_few": "{{count}} проекта просканировано",
"projectsScanned_many": "{{count}} проектов просканировано",
"projectsScanned_other": "{{count}} проектов просканировано"
},
"deleteConfirmation": {
"deleteProject": "Удалить проект",
"deleteSession": "Удалить сеанс",
"confirmDelete": "Вы уверены, что хотите удалить",
"sessionCount_one": "Этот проект содержит {{count}} разговор.",
"sessionCount_few": "Этот проект содержит {{count}} разговора.",
"sessionCount_many": "Этот проект содержит {{count}} разговоров.",
"sessionCount_other": "Этот проект содержит {{count}} разговоров.",
"allConversationsDeleted": "Все разговоры будут удалены навсегда.",
"cannotUndo": "Это действие нельзя отменить."
}
}

View File

@@ -0,0 +1,142 @@
{
"notConfigured": {
"title": "TaskMaster AI не настроен",
"description": "TaskMaster помогает разбивать сложные проекты на управляемые задачи с помощью AI",
"whatIsTitle": "🎯 Что такое TaskMaster?",
"features": {
"aiPowered": "Управление задачами с AI: разбивайте сложные проекты на управляемые подзадачи",
"prdTemplates": "Шаблоны PRD: генерируйте задачи из документов требований к продукту",
"dependencyTracking": "Отслеживание зависимостей: понимайте связи задач и порядок выполнения",
"progressVisualization": "Визуализация прогресса: канбан-доски и детальная аналитика задач",
"cliIntegration": "Интеграция с CLI: используйте команды taskmaster для продвинутых рабочих процессов"
},
"initializeButton": "Инициализировать TaskMaster AI"
},
"gettingStarted": {
"title": "Начало работы с TaskMaster",
"subtitle": "TaskMaster инициализирован! Вот что делать дальше:",
"steps": {
"createPRD": {
"title": "Создайте документ требований к продукту (PRD)",
"description": "Обсудите идею вашего проекта и создайте PRD, описывающий то, что вы хотите построить.",
"addButton": "Добавить PRD",
"existingPRDs": "Существующие PRD:"
},
"generateTasks": {
"title": "Генерация задач из PRD",
"description": "Когда у вас есть PRD, попросите вашего AI-ассистента разобрать его, и TaskMaster автоматически разобьет его на управляемые задачи с деталями реализации."
},
"analyzeTasks": {
"title": "Анализ и расширение задач",
"description": "Попросите вашего AI-ассистента проанализировать сложность задач и расширить их в детальные подзадачи для упрощения реализации."
},
"startBuilding": {
"title": "Начните разработку",
"description": "Попросите вашего AI-ассистента начать работу над задачами, обновлять их статус и добавлять новые задачи по мере развития вашего проекта."
}
},
"tip": "💡 Совет: начните с PRD, чтобы получить максимум от AI-генерации задач TaskMaster"
},
"setupModal": {
"title": "Настройка TaskMaster",
"subtitle": "Интерактивный CLI для {{projectName}}",
"willStart": "Инициализация TaskMaster начнется автоматически",
"completed": "Настройка TaskMaster завершена! Теперь вы можете закрыть это окно.",
"closeButton": "Закрыть",
"closeContinueButton": "Закрыть и продолжить"
},
"helpGuide": {
"title": "Начало работы с TaskMaster",
"subtitle": "Ваш гид по продуктивному управлению задачами",
"examples": {
"parsePRD": "💬 Пример:\n\"Я только что инициализировал новый проект с Claude Task Master. У меня есть PRD в .taskmaster/docs/prd.txt. Можете помочь мне разобрать его и настроить начальные задачи?\"",
"expandTask": "💬 Пример:\n\"Задача 5 кажется сложной. Можете разбить её на подзадачи?\"",
"addTask": "💬 Пример:\n\"Пожалуйста, добавьте новую задачу для реализации загрузки изображений профиля пользователя с использованием Cloudinary, изучите лучший подход.\""
},
"moreExamples": "Посмотреть больше примеров и шаблонов использования →",
"proTips": {
"title": "💡 Профессиональные советы",
"search": "Используйте строку поиска для быстрого поиска конкретных задач",
"views": "Переключайтесь между представлениями Канбан, Список и Сетка, используя переключатели представлений",
"filters": "Используйте фильтры для фокусировки на конкретных статусах или приоритетах задач",
"details": "Нажмите на любую задачу для просмотра детальной информации и управления подзадачами"
},
"learnMore": {
"title": "📚 Узнать больше",
"description": "TaskMaster AI - это продвинутая система управления задачами, созданная для разработчиков. Получите документацию, примеры и внесите вклад в проект.",
"githubButton": "Посмотреть на GitHub"
}
},
"search": {
"placeholder": "Поиск задач..."
},
"filters": {
"button": "Фильтры",
"status": "Статус",
"priority": "Приоритет",
"sortBy": "Сортировать по",
"allStatuses": "Все статусы",
"allPriorities": "Все приоритеты",
"showing": "Показано {{filtered}} из {{total}} задач",
"clearFilters": "Очистить фильтры"
},
"sort": {
"id": "ID",
"status": "Статус",
"priority": "Приоритет",
"idAsc": "ID (по возрастанию)",
"idDesc": "ID (по убыванию)",
"titleAsc": "Название (А-Я)",
"titleDesc": "Название (Я-А)",
"statusAsc": "Статус (сначала ожидающие)",
"statusDesc": "Статус (сначала выполненные)",
"priorityAsc": "Приоритет (сначала высокий)",
"priorityDesc": "Приоритет (сначала низкий)"
},
"views": {
"kanban": "Представление Канбан",
"list": "Представление списком",
"grid": "Представление сеткой"
},
"kanban": {
"pending": "📋 К выполнению",
"inProgress": "🚀 В процессе",
"done": "✅ Выполнено",
"blocked": "🚫 Заблокировано",
"deferred": "⏳ Отложено",
"cancelled": "❌ Отменено",
"noTasksYet": "Задач пока нет",
"tasksWillAppear": "Задачи появятся здесь",
"moveTasksHere": "Перемещайте задачи сюда при начале работы",
"completedTasksHere": "Завершенные задачи появляются здесь",
"statusTasksHere": "Задачи с этим статусом появятся здесь"
},
"buttons": {
"help": "Руководство по началу работы с TaskMaster",
"prds": "PRD",
"addPRD": "Добавить PRD",
"addTask": "Добавить задачу",
"createNewPRD": "Создать новый PRD",
"prdsAvailable": "Доступно {{count}} PRD"
},
"prd": {
"modified": "Изменено: {{date}}"
},
"statuses": {
"pending": "Ожидание",
"in-progress": "В процессе",
"done": "Выполнено",
"blocked": "Заблокировано",
"deferred": "Отложено",
"cancelled": "Отменено"
},
"priorities": {
"high": "Высокий",
"medium": "Средний",
"low": "Низкий"
},
"noMatchingTasks": {
"title": "Нет задач, соответствующих вашим фильтрам",
"description": "Попробуйте изменить критерии поиска или фильтрации."
}
}

View File

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

View File

@@ -105,7 +105,9 @@
"git": "Git",
"apiTokens": "API 和令牌",
"tasks": "任务",
"notifications": "通知"
"notifications": "通知",
"plugins": "插件"
},
"notifications": {
"title": "通知",
@@ -328,6 +330,9 @@
},
"codex": {
"description": "OpenAI Codex AI 助手"
},
"gemini": {
"description": "Google Gemini AI 助手"
}
},
"connectionStatus": "连接状态",
@@ -450,5 +455,41 @@
"title": "关于 Codex MCP",
"description": "Codex 支持基于 stdio 的 MCP 服务器。您可以添加服务器,通过额外的工具和资源来扩展 Codex 的功能。"
}
},
"pluginSettings": {
"title": "插件",
"description": "通过自定义插件扩展界面。从 git 安装或直接将文件夹放入 ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "安装",
"installing": "安装中…",
"securityWarning": "仅安装您已审查过源代码或信任作者的插件。",
"scanningPlugins": "正在扫描插件…",
"noPluginsInstalled": "未安装插件",
"pullLatest": "从 git 拉取最新内容",
"noGitRemote": "无 git 远程仓库 — 无法更新",
"uninstallPlugin": "卸载插件",
"confirmUninstall": "再次点击确认",
"confirmUninstallMessage": "移除 {{name}}?此操作无法撤销。",
"cancel": "取消",
"remove": "移除",
"updateFailed": "更新失败",
"installFailed": "安装失败",
"uninstallFailed": "卸载失败",
"toggleFailed": "切换失败",
"buildYourOwn": "构建您自己的插件",
"starter": "入门模板",
"docs": "文档",
"starterPlugin": {
"name": "项目统计",
"badge": "入门",
"description": "查看项目的文件数、代码行数、文件类型分布以及最近活动。",
"install": "安装"
},
"morePlugins": "更多",
"enable": "启用",
"disable": "禁用",
"installAriaLabel": "插件 git 仓库 URL",
"tab": "标签",
"runningStatus": "运行中"
}
}
}

View File

@@ -905,6 +905,16 @@
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Settings content fade-in transition */
@keyframes settings-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.settings-content-enter {
animation: settings-fade-in 150ms ease-out;
}
/* Search result highlight flash */
.search-highlight-flash {
animation: search-flash 4s ease-out;

View File

@@ -4,24 +4,24 @@ import { cn } from '../../../lib/utils';
// Keep visual variants centralized so all button usages stay consistent.
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium touch-manipulation transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90 active:bg-primary/80',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 active:bg-destructive/80',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground active:bg-accent/80',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 active:bg-secondary/70',
ghost: 'hover:bg-accent hover:text-accent-foreground active:bg-accent/80',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3 text-sm',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {

View File

@@ -1,5 +1,6 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { cn } from '../../../lib/utils';
type DarkModeToggleProps = {
checked?: boolean;
@@ -13,7 +14,6 @@ function DarkModeToggle({
ariaLabel = 'Toggle dark mode',
}: DarkModeToggleProps) {
const { isDarkMode, toggleDarkMode } = useTheme();
// Support controlled usage while keeping ThemeContext as the default source of truth.
const isControlled = typeof checked === 'boolean' && typeof onToggle === 'function';
const isEnabled = isControlled ? checked : isDarkMode;
@@ -29,21 +29,26 @@ function DarkModeToggle({
return (
<button
onClick={handleToggle}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-700 dark:focus:ring-offset-gray-900"
className={cn(
'relative inline-flex h-7 w-12 flex-shrink-0 touch-manipulation cursor-pointer items-center rounded-full border-2 transition-colors duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
isEnabled ? 'border-primary bg-primary' : 'border-border bg-muted',
)}
role="switch"
aria-checked={isEnabled}
aria-label={ariaLabel}
>
<span className="sr-only">{ariaLabel}</span>
<span
className={`${
isEnabled ? 'translate-x-7' : 'translate-x-1'
} flex h-6 w-6 transform items-center justify-center rounded-full bg-white shadow-lg transition-transform duration-200`}
className={cn(
'flex h-5 w-5 transform items-center justify-center rounded-full shadow-sm transition-transform duration-200',
isEnabled ? 'translate-x-[22px] bg-white' : 'translate-x-[2px] bg-foreground/60 dark:bg-foreground/80',
)}
>
{isEnabled ? (
<Moon className="h-3.5 w-3.5 text-gray-700" />
<Moon className="h-3 w-3 text-primary" />
) : (
<Sun className="h-3.5 w-3.5 text-yellow-500" />
<Sun className="h-3 w-3 text-white dark:text-background" />
)}
</span>
</button>

View File

@@ -28,15 +28,15 @@ export default function LanguageSelector({ compact = false }: LanguageSelectorPr
// Compact style for QuickSettingsPanel
if (compact) {
return (
<div className="flex items-center justify-between rounded-lg border border-transparent bg-gray-50 p-3 transition-colors hover:border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:hover:border-gray-600 dark:hover:bg-gray-700">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
<div className="flex items-center justify-between rounded-lg border border-transparent bg-muted/50 p-3 transition-colors hover:border-border hover:bg-accent">
<span className="flex items-center gap-2 text-sm text-foreground">
<Languages className="h-4 w-4 text-muted-foreground" />
{t('account.language')}
</span>
<select
value={i18n.language}
onChange={handleLanguageChange}
className="w-[100px] rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:ring-blue-400"
className="w-[100px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>
@@ -50,28 +50,26 @@ export default function LanguageSelector({ compact = false }: LanguageSelectorPr
// Full style for Settings page
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex items-center justify-between">
<div>
<div className="mb-1 font-medium text-gray-900 dark:text-gray-100">
{t('account.languageLabel')}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{t('account.languageDescription')}
</div>
<div className="flex items-center justify-between px-4 py-3.5">
<div>
<div className="text-sm font-medium text-foreground">
{t('account.languageLabel')}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t('account.languageDescription')}
</div>
<select
value={i18n.language}
onChange={handleLanguageChange}
className="w-36 rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.nativeName}
</option>
))}
</select>
</div>
<select
value={i18n.language}
onChange={handleLanguageChange}
className="w-36 rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.nativeName}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { cn } from '../../../lib/utils';
/* ── Container ─────────────────────────────────────────────────── */
type PillBarProps = {
children: ReactNode;
className?: string;
};
export function PillBar({ children, className }: PillBarProps) {
return (
<div className={cn('inline-flex items-center gap-[2px] rounded-lg bg-muted/60 p-[3px]', className)}>
{children}
</div>
);
}
/* ── Individual pill button ────────────────────────────────────── */
type PillProps = {
isActive: boolean;
onClick: () => void;
children: ReactNode;
className?: string;
};
export function Pill({ isActive, onClick, children, className }: PillProps) {
return (
<button
onClick={onClick}
className={cn(
'flex touch-manipulation items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-all duration-150',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground active:bg-background/50',
className,
)}
>
{children}
</button>
);
}

View File

@@ -4,3 +4,4 @@ export { default as DarkModeToggle } from './DarkModeToggle';
export { Input } from './Input';
export { ScrollArea } from './ScrollArea';
export { default as Tooltip } from './Tooltip';
export { PillBar, Pill } from './PillBar';

View File

@@ -1,6 +1,6 @@
export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
export interface ProjectSession {
id: string;

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