Add i18n foundation and translations (#1027)

* feat: add i18n foundation

* feat: localize sessions sidebar

* Localize multirun/scheduled tasks and fix dialog dropdown interactions

* localize git sidebar surface and add zh-CN keys

* feat(ui): localize context panel, diff/plan views, and context sidebar content

* fix(config): resolve user config home via fs/home before embedded home

* localize header/chat UI and complete model/worktree panel strings

* localize worktree + github issue/pr dialog flows

* localize settings sections and split settings i18n dictionaries

* localize additional settings sections and sidebars

* localize more settings pages and dialogs

* fix settings select trigger localization

* localize tunnel settings ui surface

* localize additional settings sections

* localize keyboard shortcuts labels in settings

* localize terminal and utility dialogs surfaces

* feat(i18n): localize remaining UI strings

* Add Ukrainian locale

* Add Spanish locale

* Add Brazilian Portuguese locale

* Polish locale translations
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 14:03:39 +03:00
committed by GitHub
parent 87db2ea210
commit 7d7285655d
198 changed files with 24173 additions and 4365 deletions
@@ -3,6 +3,7 @@ import { cn, fuzzyMatch } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
interface AgentInfo {
name: string;
@@ -40,6 +41,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
activeTab = 'agents',
onTabSelect,
}, ref) => {
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
@@ -157,7 +159,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
<span className="font-semibold">#{agent.name}</span>
{isSystem ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
system
{t('chat.agentMentionAutocomplete.badge.system')}
</span>
) : agent.scope ? (
<span className={cn(
@@ -180,6 +182,12 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
);
};
const tabs = React.useMemo(() => ([
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]), [t]);
return (
<div
ref={containerRef}
@@ -188,11 +196,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
@@ -232,12 +236,12 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
</div>
) : (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No agents found
{t('chat.agentMentionAutocomplete.empty')}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
@@ -1,6 +1,7 @@
import React from 'react';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { type ChangedFileEntry, getDisplayPath, getFileStats } from './changedFiles';
import { useI18n } from '@/lib/i18n';
interface ChangedFilesListProps {
files: ChangedFileEntry[];
@@ -9,10 +10,11 @@ interface ChangedFilesListProps {
}
export const ChangedFilesList: React.FC<ChangedFilesListProps> = ({ files, currentDirectory, onOpenFile }) => {
const { t } = useI18n();
return (
<>
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>Changed files</span>
<span>{t('chat.changedFiles.title')}</span>
<span className="typography-meta tabular-nums">{files.length}</span>
</div>
@@ -26,7 +28,7 @@ export const ChangedFilesList: React.FC<ChangedFilesListProps> = ({ files, curre
key={`${file.path}:${index}`}
type="button"
className="relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none text-left hover:bg-interactive-hover"
title={`Open ${file.path}`}
title={t('chat.changedFiles.actions.openFileTitle', { path: file.path })}
onClick={() => onOpenFile(file)}
>
<FileTypeIcon filePath={file.path} className="h-3.5 w-3.5 flex-shrink-0" />
@@ -40,6 +40,7 @@ import {
import { useSync } from '@/sync/use-sync';
import { usePlanDetection } from '@/hooks/usePlanDetection';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useI18n } from '@/lib/i18n';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
@@ -231,6 +232,7 @@ const HYDRATING_SKELETON_ITEMS: Array<{
];
export const ChatContainer: React.FC = () => {
const { t } = useI18n();
// Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
@@ -432,11 +434,13 @@ export const ChatContainer: React.FC = () => {
size="xs"
onClick={handleReturnToParentSession}
className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95"
aria-label="Return to parent session"
title={parentSession.title?.trim() ? `Return to: ${parentSession.title}` : 'Return to parent session'}
aria-label={t('chat.container.returnToParent.aria')}
title={parentSession.title?.trim()
? t('chat.container.returnToParent.titleNamed', { title: parentSession.title })
: t('chat.container.returnToParent.title')}
>
<RiArrowLeftLine className="h-4 w-4" />
Parent
{t('chat.container.returnToParent.label')}
</Button>
) : null;
@@ -2,8 +2,10 @@ import React from 'react';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useGlobalSyncStore } from '@/sync/global-sync-store';
import { useI18n } from '@/lib/i18n';
const ChatEmptyState: React.FC = () => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const initError = useGlobalSyncStore((s) => s.error);
@@ -14,13 +16,13 @@ const ChatEmptyState: React.FC = () => {
<OpenChamberLogo width={140} height={140} className="opacity-20" />
{initError ? (
<div className="flex flex-col items-center gap-2 max-w-md text-center px-4">
<span className="text-body-md font-medium text-destructive">OpenCode is not reachable</span>
<span className="text-body-md font-medium text-destructive">{t('chat.emptyState.opencodeUnreachable')}</span>
<span className="text-body-sm" style={{ color: textColor }}>
{initError.message}
</span>
</div>
) : (
<span className="text-body-md" style={{ color: textColor }}>Start a new chat</span>
<span className="text-body-md" style={{ color: textColor }}>{t('chat.emptyState.startNewChat')}</span>
)}
</div>
);
@@ -2,6 +2,7 @@ import React from 'react';
import { RiChat3Line, RiRestartLine } from '@remixicon/react';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { useI18n } from '@/lib/i18n';
interface ChatErrorBoundaryState {
hasError: boolean;
@@ -14,8 +15,21 @@ interface ChatErrorBoundaryProps {
sessionId?: string;
}
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
constructor(props: ChatErrorBoundaryProps) {
interface ChatErrorBoundaryTexts {
title: string;
description: string;
sessionLabel: string;
detailsSummary: string;
resetAction: string;
persistentHint: string;
}
interface ChatErrorBoundaryViewProps extends ChatErrorBoundaryProps {
texts: ChatErrorBoundaryTexts;
}
class ChatErrorBoundaryView extends React.Component<ChatErrorBoundaryViewProps, ChatErrorBoundaryState> {
constructor(props: ChatErrorBoundaryViewProps) {
super(props);
this.state = { hasError: false };
}
@@ -44,23 +58,23 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2 text-destructive">
<RiChat3Line className="h-5 w-5" />
Chat Error
{this.props.texts.title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground text-center">
The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.
{this.props.texts.description}
</p>
{this.props.sessionId && (
<div className="text-xs text-muted-foreground text-center">
Session: {this.props.sessionId}
{this.props.texts.sessionLabel}: {this.props.sessionId}
</div>
)}
{this.state.error && (
<details className="text-xs font-mono bg-muted p-3 rounded">
<summary className="cursor-pointer hover:bg-interactive-hover/80">Error details</summary>
<summary className="cursor-pointer hover:bg-interactive-hover/80">{this.props.texts.detailsSummary}</summary>
<pre className="mt-2 overflow-x-auto">
{this.state.error.toString()}
</pre>
@@ -70,12 +84,12 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
<div className="flex gap-2">
<Button onClick={this.handleReset} variant="outline" className="flex-1">
<RiRestartLine className="h-4 w-4 mr-2" />
Reset Chat
{this.props.texts.resetAction}
</Button>
</div>
<div className="text-xs text-muted-foreground text-center">
If the problem persists, try refreshing the page.
{this.props.texts.persistentHint}
</div>
</CardContent>
</Card>
@@ -86,3 +100,20 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
return this.props.children;
}
}
export function ChatErrorBoundary(props: ChatErrorBoundaryProps) {
const { t } = useI18n();
return (
<ChatErrorBoundaryView
{...props}
texts={{
title: t('chat.errorBoundary.title'),
description: t('chat.errorBoundary.description'),
sessionLabel: t('chat.errorBoundary.sessionLabel'),
detailsSummary: t('chat.errorBoundary.detailsSummary'),
resetAction: t('chat.errorBoundary.resetAction'),
persistentHint: t('chat.errorBoundary.persistentHint'),
}}
/>
);
}
+63 -53
View File
@@ -72,6 +72,7 @@ import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { usePermissionStore } from '@/stores/permissionStore';
import { extractGitChangedFiles } from './changedFiles';
import { useI18n } from '@/lib/i18n';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
@@ -249,6 +250,7 @@ type ComposerAttachmentControlsProps = {
};
const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) {
const { t } = useI18n();
const {
isMobile,
isVSCode,
@@ -280,8 +282,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
}
}}
onClick={handleOpenCommandMenu}
title="Commands"
aria-label="Commands"
title={t('chat.chatInput.actions.commands')}
aria-label={t('chat.chatInput.actions.commands')}
>
<RiCommandLine className={cn(iconSizeClass)} />
</button>
@@ -301,8 +303,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
type="button"
className={footerIconButtonClass}
onClick={handlePickLocalFiles}
title="Attach files"
aria-label="Attach files"
title={t('chat.chatInput.actions.attachFiles')}
aria-label={t('chat.chatInput.actions.attachFiles')}
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
@@ -312,8 +314,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
<button
type="button"
className={footerIconButtonClass}
title="Add attachment"
aria-label="Add attachment"
title={t('chat.chatInput.actions.addAttachment')}
aria-label={t('chat.chatInput.actions.addAttachment')}
>
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
</button>
@@ -325,7 +327,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
}}
>
<RiAttachment2 />
Attach files
{t('chat.chatInput.actions.attachFiles')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
@@ -333,7 +335,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
}}
>
<RiGithubLine />
Link GitHub Issue
{t('chat.chatInput.actions.linkGithubIssue')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
@@ -341,7 +343,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
}}
>
<RiGitPullRequestLine />
Link GitHub PR
{t('chat.chatInput.actions.linkGithubPr')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -353,8 +355,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
type="button"
onClick={onOpenSettings}
className={footerIconButtonClass}
title="Model and agent settings"
aria-label="Model and agent settings"
title={t('chat.chatInput.actions.modelAgentSettings')}
aria-label={t('chat.chatInput.actions.modelAgentSettings')}
>
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
</button>
@@ -379,6 +381,7 @@ type PermissionAutoAcceptButtonProps = {
};
const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) {
const { t } = useI18n();
const {
footerIconButtonClass,
iconSizeClass,
@@ -389,11 +392,11 @@ const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButto
} = props;
const ariaLabel = permissionAutoAcceptEnabled
? 'Disable permission auto-accept'
: 'Enable permission auto-accept';
? t('chat.chatInput.permissionAutoAccept.disable')
: t('chat.chatInput.permissionAutoAccept.enable');
const tooltipLabel = permissionAutoAcceptEnabled
? 'Permission auto-accept: on'
: 'Permission auto-accept: off';
? t('chat.chatInput.permissionAutoAccept.on')
: t('chat.chatInput.permissionAutoAccept.off');
const button = (
<button
@@ -450,6 +453,7 @@ type FocusModeButtonProps = {
const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
const { t } = useI18n();
return (
<Tooltip delayDuration={600}>
@@ -467,7 +471,7 @@ const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButt
event.preventDefault();
}}
onClick={onToggle}
aria-label="Toggle focus mode"
aria-label={t('chat.chatInput.focusMode.toggleAria')}
aria-pressed={isExpandedInput}
>
<RiFullscreenLine className={cn(iconSizeClass)} />
@@ -475,7 +479,7 @@ const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButt
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
<div className="flex flex-col gap-0.5 text-center">
<span>Focus mode</span>
<span>{t('chat.chatInput.focusMode.label')}</span>
<span className="font-mono opacity-60">
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
</span>
@@ -515,6 +519,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C
onQueueMessage,
onAbort,
} = props;
const { t } = useI18n();
const sendButton = (
<button
@@ -534,7 +539,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
aria-label="Send message"
aria-label={t('chat.chatInput.actions.sendMessageAria')}
>
<RiSendPlane2Line className={cn(sendIconSizeClass)} />
</button>
@@ -561,7 +566,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C
'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1',
currentSessionId ? 'text-primary hover:text-primary' : 'opacity-30'
)}
aria-label="Queue message"
aria-label={t('chat.chatInput.actions.queueMessageAria')}
>
<RiSendPlane2Line className={cn(sendIconSizeClass, '-rotate-90')} />
</button>
@@ -573,7 +578,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C
footerIconButtonClass,
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
aria-label="Stop generating"
aria-label={t('chat.chatInput.actions.stopGeneratingAria')}
>
<StopIcon className={cn(stopIconSizeClass)} />
</button>
@@ -691,6 +696,7 @@ const loadConfirmedMentions = (sessionId: string | null): Set<string> => {
};
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
const { t } = useI18n();
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null);
// Track initial session ID (captured at mount time for draft restoration)
@@ -1489,7 +1495,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
providerID: configState.currentProviderId || '',
});
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to compact session');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed'));
}
return;
}
@@ -1518,7 +1524,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
);
scrollToBottom?.({ instant: true, force: true });
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to generate summary');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.summaryFailed'));
}
return;
}
@@ -1540,7 +1546,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
);
scrollToBottom?.({ instant: true, force: true });
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to review changes');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.reviewFailed'));
}
return;
}
@@ -1603,7 +1609,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
normalized === 'failed to send message';
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
toast.error('Attachments are too large to send. Please try reducing the number or size of images.');
toast.error(t('chat.chatInput.toast.attachmentsTooLarge'));
if (allAttachments.length > 0) {
useInputStore.setState({ attachedFiles: allAttachments });
}
@@ -1613,7 +1619,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (isSoftNetworkError) {
if (allAttachments.length > 0) {
useInputStore.setState({ attachedFiles: allAttachments });
toast.error('Failed to send attachments. Try fewer files or smaller images.');
toast.error(t('chat.chatInput.toast.sendAttachmentsFailed'));
}
return;
}
@@ -1621,7 +1627,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (allAttachments.length > 0) {
useInputStore.setState({ attachedFiles: allAttachments });
}
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
toast.error(rawMessage || t('chat.chatInput.toast.messageSendFailed'));
});
if (!isMobile) {
@@ -2329,7 +2335,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
await addAttachedFile(file);
} catch (error) {
console.error('Clipboard image attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach image from clipboard');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.clipboardAttachFailed'));
}
}
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
@@ -2640,7 +2646,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
setPendingInputText(mentions.join(' '), 'append-inline');
toast.success(`Added ${mentions.length} file mention${mentions.length > 1 ? 's' : ''}`);
toast.success(t('chat.chatInput.toast.addedFileMentions', { count: mentions.length }));
}, [normalizeDroppedPath, setPendingInputText, toProjectRelativeMentionPath]);
const handleDragEnter = (e: React.DragEvent) => {
@@ -2756,7 +2762,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
await addAttachedFile(file);
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed'));
}
}
}
@@ -2864,7 +2870,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
await addAttachedFile(file);
} catch (error) {
console.error('Failed to attach dropped file:', path, error);
toast.error(`Failed to attach ${path.split(/[\\/]/).pop() || 'file'}`);
toast.error(t('chat.chatInput.toast.attachNamedFailed', {
name: path.split(/[\\/]/).pop() || t('chat.chatInput.fileFallback'),
}));
}
}
}
@@ -2898,7 +2906,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
await addAttachedFile(file);
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed'));
}
}
}, [addAttachedFile]);
@@ -2914,7 +2922,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const summary = skipped
.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`)
.join('\n');
toast.error(`Some files were skipped:\n${summary}`);
toast.error(t('chat.chatInput.toast.someFilesSkipped', { summary }));
}
const asFiles = picked
@@ -2943,7 +2951,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
} catch (error) {
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed'));
}
}, [attachFiles]);
@@ -3249,13 +3257,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const handlePermissionAutoAcceptToggle = React.useCallback(() => {
if (!permissionScopeSessionId) {
toast.error('Open a session first');
toast.error(t('chat.chatInput.toast.openSessionFirst'));
return;
}
const nextEnabled = !permissionAutoAcceptEnabled;
setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => {
toast.error('Failed to toggle permission auto-accept');
toast.error(t('chat.chatInput.toast.togglePermissionAutoAcceptFailed'));
});
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
@@ -3311,7 +3319,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<span className="text-xs font-medium text-muted-foreground">Review comments:</span>
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.reviewComments')}</span>
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>
{draftCount}
</span>
@@ -3337,7 +3345,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<span className="text-muted-foreground flex-shrink-0">
#{linkedIssue.number}
{linkedIssue.author && (
<span className="ml-1">by {linkedIssue.author.login}</span>
<span className="ml-1">{t('chat.chatInput.linked.byAuthor', { author: linkedIssue.author.login })}</span>
)}
</span>
<span className="text-foreground truncate">
@@ -3350,7 +3358,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label="Open issue in browser"
aria-label={t('chat.chatInput.linked.issue.openInBrowserAria')}
>
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
</a>
@@ -3360,7 +3368,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setLinkedIssue(null);
}}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove linked issue"
aria-label={t('chat.chatInput.linked.issue.removeAria')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -3383,9 +3391,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
/>
)}
<span className="text-muted-foreground flex-shrink-0">
PR #{linkedPr.number}
{t('chat.chatInput.linked.pr.number', { number: linkedPr.number })}
{linkedPr.author && (
<span className="ml-1">by {linkedPr.author.login}</span>
<span className="ml-1">{t('chat.chatInput.linked.byAuthor', { author: linkedPr.author.login })}</span>
)}
</span>
<span className="text-foreground truncate">
@@ -3401,7 +3409,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label="Open pull request in browser"
aria-label={t('chat.chatInput.linked.pr.openInBrowserAria')}
>
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
</a>
@@ -3411,7 +3419,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setLinkedPr(null);
}}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove linked pull request"
aria-label={t('chat.chatInput.linked.pr.removeAria')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -3458,13 +3466,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{selectedDraftBranchLabel ?? 'Branch'}
{selectedDraftBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>Project root</SelectLabel>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
{projectRootBranchOption.label}
</SelectItem>
@@ -3473,14 +3481,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
{projectRootBranchOption ? <SelectSeparator /> : null}
<SelectGroup>
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-muted-foreground typography-meta">Worktrees</span>
<span className="text-muted-foreground typography-meta">{t('chat.chatInput.worktrees')}</span>
<button
type="button"
className="text-muted-foreground typography-meta hover:text-foreground cursor-pointer"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); void createWorktreeDraft(); }}
>
+ New
{t('chat.chatInput.worktreeNew')}
</button>
</div>
{worktreeBranchOptions.map((option) => (
@@ -3530,13 +3538,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
type="button"
className={iconButtonBaseClass}
onClick={() => handlePickLocalFiles()}
title="Attach files"
aria-label="Attach files"
title={t('chat.chatInput.actions.attachFiles')}
aria-label={t('chat.chatInput.actions.attachFiles')}
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
</div>
<p className="mt-2 typography-ui-label text-muted-foreground">{isInternalDrag ? 'Drop to insert as mention' : 'Drop files here to attach'}</p>
<p className="mt-2 typography-ui-label text-muted-foreground">
{isInternalDrag ? t('chat.chatInput.drop.insertMention') : t('chat.chatInput.drop.attachFiles')}
</p>
</div>
</div>
)}
@@ -3666,9 +3676,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}}
placeholder={currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? "Enter shell command..."
: "@ for files/agents; / for commands; ! for shell"
: "Select or create a session to start chatting"}
? t('chat.chatInput.placeholder.shell')
: t('chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')}
disabled={!currentSessionId && !newSessionDraftOpen}
autoCorrect={isMobile ? "on" : "off"}
autoCapitalize={isMobile ? "sentences" : "off"}
@@ -6,6 +6,7 @@ import { useSessionMessages } from '@/sync/sync-context';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
type CommandSource = 'openchamber' | 'opencode';
@@ -47,6 +48,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
onTabSelect,
style,
}, ref) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMessages = useSessionMessages(currentSessionId ?? '');
const hasMessagesInCurrentSession = sessionMessages.length > 0;
@@ -107,23 +109,23 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: 'Create/update AGENTS.md file', isBuiltIn: true }]
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: 'Undo the last message', isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: 'Redo previously undone messages', isBuiltIn: true },
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
]
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: 'Non-destructive session summary. Optional topic hint after the command.', isOpenChamber: true }]
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:review', name: 'review', source: 'openchamber' as const, description: 'Review current workspace changes for high-signal issues only.', isOpenChamber: true }]
? [{ id: 'openchamber:review', name: 'review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.reviewDescription'), isOpenChamber: true }]
: []
),
];
@@ -151,23 +153,23 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const allowInitCommand = !hasMessagesInCurrentSession;
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: 'Create/update AGENTS.md file', isBuiltIn: true }]
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: 'Undo the last message', isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: 'Redo previously undone messages', isBuiltIn: true },
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
]
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: 'Non-destructive session summary. Optional topic hint after the command.', isOpenChamber: true }]
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:review', name: 'review', source: 'openchamber' as const, description: 'Review current workspace changes for high-signal issues only.', isOpenChamber: true }]
? [{ id: 'openchamber:review', name: 'review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.reviewDescription'), isOpenChamber: true }]
: []
),
];
@@ -186,7 +188,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
};
loadCommands();
}, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata, skills]);
}, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata, skills, t]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -266,9 +268,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]).map((tab) => (
<button
key={tab.id}
@@ -375,7 +377,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<span className="typography-ui-label font-medium">/{command.name}</span>
{command.isSkill ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)] px-1.5 py-1 rounded border flex-shrink-0">
skill
{t('chat.commandAutocomplete.badge.skill')}
</span>
) : null}
{isOpenChamberBadge ? (
@@ -387,11 +389,11 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
borderColor: 'color-mix(in srgb, var(--primary-base) 28%, transparent)',
}}
>
openchamber
OpenChamber
</span>
) : isSystem ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
system
{t('chat.commandAutocomplete.badge.system')}
</span>
) : command.scope ? (
<span className={cn(
@@ -420,14 +422,14 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
})}
{commands.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No commands found
{t('chat.commandAutocomplete.empty')}
</div>
)}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
@@ -9,10 +9,12 @@ import { openExternalUrl } from '@/lib/url';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { useI18n } from '@/lib/i18n';
import type { ToolPopupContent } from './message/types';
export const FileAttachmentButton = memo(() => {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
const isMobile = useUIStore((state) => state.isMobile);
@@ -27,7 +29,7 @@ export const FileAttachmentButton = memo(() => {
await addAttachedFile(file);
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.attachFailed'));
}
}
};
@@ -50,8 +52,8 @@ export const FileAttachmentButton = memo(() => {
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
if (skipped.length > 0) {
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`).join('\n');
toast.error(`Some files were skipped:\n${summary}`);
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || t('chat.fileAttachment.fileFallback')}: ${s?.reason || t('chat.fileAttachment.skippedFallback')}`).join('\n');
toast.error(t('chat.fileAttachment.toast.someFilesSkipped', { summary }));
}
const asFiles = picked
@@ -67,7 +69,7 @@ export const FileAttachmentButton = memo(() => {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mime });
return new File([blob], file.name || 'file', { type: mime });
return new File([blob], file.name || t('chat.fileAttachment.fileFallback'), { type: mime });
} catch (err) {
console.error('Failed to decode VS Code picked file', err);
return null;
@@ -80,7 +82,7 @@ export const FileAttachmentButton = memo(() => {
}
} catch (error) {
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.vscodePickFailed'));
}
};
@@ -103,13 +105,13 @@ export const FileAttachmentButton = memo(() => {
'hover:bg-muted text-muted-foreground',
buttonSizeClass
)}
aria-label="Attach files"
aria-label={t('chat.fileAttachment.actions.attachAria')}
>
<RiAttachment2 className={iconSizeClass} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Attach files</p>
<p>{t('chat.fileAttachment.actions.attach')}</p>
</TooltipContent>
</Tooltip>
</>
@@ -124,6 +126,7 @@ interface ImagePreviewProps {
}
const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
const { t } = useI18n();
const isLocalImagePreview =
file.source !== 'server' &&
file.mimeType.startsWith('image/') &&
@@ -163,7 +166,7 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={`Remove ${displayName}`}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -182,8 +185,8 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
<button
onClick={onRemove}
className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title="Remove image"
aria-label={`Remove ${displayName}`}
title={t('chat.fileAttachment.actions.removeImage')}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<RiCloseLine className="h-2.5 w-2.5" />
</button>
@@ -199,6 +202,7 @@ interface FileChipProps {
}
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
const { t } = useI18n();
const getFileExtension = (filename: string): string => {
const parts = filename.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
@@ -245,7 +249,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={`Remove ${displayName}`}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -11,6 +11,7 @@ import type { ProjectFileSearchHit } from '@/lib/opencode/client';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
@@ -48,6 +49,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
onTabSelect,
style,
}, ref) => {
const { t } = useI18n();
const currentDirectory = useChatSearchDirectory() ?? '';
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const activeProjectPath = useProjectsStore(
@@ -446,6 +448,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
};
const tabs = React.useMemo(() => ([
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]), [t]);
return (
<div
ref={containerRef}
@@ -455,11 +463,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
@@ -523,7 +527,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
<div className="px-3 py-1 typography-meta text-muted-foreground">
Type to search more agents
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
</div>
)}
{visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
@@ -664,14 +668,14 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
})}
{visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No matches found
{t('chat.fileMentionAutocomplete.empty')}
</div>
)}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
@@ -15,6 +15,7 @@ import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
@@ -176,6 +177,7 @@ const downloadFile = (filename: string, content: string, mimeType: string) => {
// Table copy button with dropdown
const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
const { t } = useI18n();
const [copied, setCopied] = React.useState(false);
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
@@ -224,7 +226,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy table"
title={t('markdownRenderer.table.actions.copyTitle')}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
@@ -250,6 +252,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
// Table download button with dropdown
const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
const { t } = useI18n();
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
@@ -273,7 +276,7 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
downloadFile(filename, content, mimeType);
setShowMenu(false);
toast.success(`Table downloaded as ${format.toUpperCase()}`);
toast.success(t('markdownRenderer.table.toast.downloadedAsFormat', { format: format.toUpperCase() }));
};
return (
@@ -281,7 +284,7 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download table"
title={t('markdownRenderer.table.actions.downloadTitle')}
>
<RiDownloadLine className="size-3.5" />
</button>
@@ -325,6 +328,7 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }>
};
const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
const { t } = useI18n();
const currentTheme = useCurrentMermaidTheme();
const { isMobile } = useDeviceInfo();
const [copied, setCopied] = React.useState(false);
@@ -393,7 +397,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
setDownloaded(true);
setTimeout(() => setDownloaded(false), 2000);
} catch {
toast.error('Failed to download diagram');
toast.error(t('markdownRenderer.mermaid.toast.downloadFailed'));
}
};
@@ -414,7 +418,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
<button
onClick={() => handleCopyAscii(asciiText)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy"
title={t('markdownRenderer.mermaid.actions.copyTitle')}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
@@ -438,7 +442,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
<button
onClick={() => handleCopyAscii(source)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy"
title={t('markdownRenderer.mermaid.actions.copyTitle')}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
@@ -461,14 +465,14 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
<button
onClick={handleCopyMermaidSource}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy source"
title={t('markdownRenderer.mermaid.actions.copySourceTitle')}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
<button
onClick={handleDownloadSvg}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download SVG"
title={t('markdownRenderer.mermaid.actions.downloadSvgTitle')}
>
{downloaded ? <RiCheckLine className="size-3.5" /> : <RiDownloadLine className="size-3.5" />}
</button>
@@ -55,6 +55,7 @@ import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useNotificationStore } from '@/sync/notification-store';
import { useI18n } from '@/lib/i18n';
interface MobileSessionStatusBarProps {
onSessionSwitch?: (sessionId: string) => void;
@@ -745,6 +746,7 @@ function ProjectEditPanel({
onDelete,
homeDirectory,
}: ProjectEditPanelProps) {
const { t } = useI18n();
const [localProjects, setLocalProjects] = React.useState(projects);
React.useEffect(() => {
@@ -797,10 +799,10 @@ function ProjectEditPanel({
<MobileOverlayPanel
open={isOpen}
onClose={onClose}
title="Edit Projects"
title={t('chat.mobileStatus.editProjects.title')}
footer={
<p className="text-xs text-[var(--surface-mutedForeground)] text-center">
Drag items to reorder, or use arrows to move. Tap edit to change details.
{t('chat.mobileStatus.editProjects.footer')}
</p>
}
>
@@ -832,7 +834,7 @@ function ProjectEditPanel({
{localProjects.length === 0 && (
<div className="text-center py-8 text-[var(--surface-mutedForeground)]">
No projects to edit
{t('chat.mobileStatus.editProjects.empty')}
</div>
)}
</div>
@@ -958,6 +960,7 @@ function ProjectBar({
onRemoveProject,
homeDirectory
}: ProjectBarProps) {
const { t } = useI18n();
const scrollRef = React.useRef<HTMLDivElement>(null);
const [editPanelOpen, setEditPanelOpen] = React.useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
@@ -1012,12 +1015,12 @@ function ProjectBar({
if (projects.length === 0) {
return (
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--interactive-border)] bg-transparent">
<span className="text-[11px] text-[var(--surface-mutedForeground)]">No projects</span>
<span className="text-[11px] text-[var(--surface-mutedForeground)]">{t('chat.mobileStatus.projects.empty')}</span>
<button
type="button"
onClick={onAddProject}
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 !min-h-0"
aria-label="Add project"
aria-label={t('chat.mobileStatus.projects.addAria')}
>
<RiAddLine className="h-3 w-3" />
</button>
@@ -1093,7 +1096,7 @@ function ProjectBar({
type="button"
onClick={onAddProject}
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 shrink-0 !min-h-0"
aria-label="Add project"
aria-label={t('chat.mobileStatus.projects.addAria')}
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
@@ -1102,17 +1105,17 @@ function ProjectBar({
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Remove Project</DialogTitle>
<DialogTitle>{t('chat.mobileStatus.projects.removeTitle')}</DialogTitle>
<DialogDescription>
Are you sure you want to remove <span className="font-medium text-foreground">{projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}</span>?
{t('chat.mobileStatus.projects.removeDescriptionPrefix')} <span className="font-medium text-foreground">{projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}</span>?
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
{t('chat.mobileStatus.projects.cancel')}
</Button>
<Button variant="destructive" onClick={handleConfirmDelete}>
Remove
{t('chat.mobileStatus.projects.remove')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1176,6 +1179,7 @@ function CollapsedView({
contextUsage: SessionContextUsage | null;
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
}) {
const { t } = useI18n();
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
return (
@@ -1219,7 +1223,7 @@ function CollapsedView({
}}
className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-center"
>
New
{t('chat.mobileStatus.new')}
</button>
</div>
</div>
@@ -1283,6 +1287,7 @@ function ExpandedView({
homeDirectory: string | null;
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
}) {
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement>(null);
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
const [hasMeasured, setHasMeasured] = React.useState(false);
@@ -1376,7 +1381,7 @@ function ExpandedView({
}}
className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-start"
>
New
{t('chat.mobileStatus.new')}
</button>
</div>
</div>
@@ -1400,7 +1405,7 @@ function ExpandedView({
>
{displaySessions.length === 0 ? (
<div className="flex items-center justify-center py-3 text-[11px] text-[var(--surface-mutedForeground)]">
<span>No sessions in this project</span>
<span>{t('chat.mobileStatus.noSessionsInProject')}</span>
</div>
) : (
displaySessions.map((session) => (
@@ -1424,6 +1429,7 @@ function ExpandedView({
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
onSessionSwitch,
}) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
@@ -1457,7 +1463,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const currentSession = sessions.find((s) => s.id === currentSessionId);
const currentSessionTitle = currentSession
? getSessionTitle(currentSession)
: '← Swipe here to open sidebars →';
: t('chat.mobileStatus.swipeHint');
// Calculate current session's child indicators
const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId);
@@ -1522,19 +1528,19 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
toast.error(t('chat.mobileStatus.toast.addProjectFailed'), {
description: t('chat.mobileStatus.toast.selectValidDirectory'),
});
}
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
toast.error(t('chat.mobileStatus.toast.selectDirectoryFailed'), {
description: result.error,
});
}
})
.catch((error) => {
console.error('Failed to select directory:', error);
toast.error('Failed to select directory');
toast.error(t('chat.mobileStatus.toast.selectDirectoryFailed'));
});
};
+134 -85
View File
@@ -55,6 +55,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
import type { MobileControlsPanel } from './mobileControlsUtils';
import { useI18n } from '@/lib/i18n';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = ComponentType<any>;
@@ -293,6 +294,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
onMobilePanelSelection,
onAgentPanelSelection,
}) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
@@ -545,9 +547,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const currentMetadata =
currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined;
const currentCapabilityIcons = getCapabilityIcons(currentMetadata);
const inputModalityIcons = getModalityIcons(currentMetadata, 'input');
const outputModalityIcons = getModalityIcons(currentMetadata, 'output');
const localizeMetaLabel = React.useCallback((label: string) => {
if (label === 'Tool calling') return t('chat.modelControls.capability.toolCalling');
if (label === 'Reasoning') return t('chat.modelControls.capability.reasoning');
if (label === 'Text') return t('chat.modelControls.modality.text');
if (label === 'Image') return t('chat.modelControls.modality.image');
if (label === 'Video') return t('chat.modelControls.modality.video');
if (label === 'Audio') return t('chat.modelControls.modality.audio');
if (label === 'PDF') return t('chat.modelControls.modality.pdf');
return label;
}, [t]);
const currentCapabilityIcons = React.useMemo(
() => getCapabilityIcons(currentMetadata).map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
[currentMetadata, localizeMetaLabel],
);
const inputModalityIcons = React.useMemo(
() => getModalityIcons(currentMetadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
[currentMetadata, localizeMetaLabel],
);
const outputModalityIcons = React.useMemo(
() => getModalityIcons(currentMetadata, 'output').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
[currentMetadata, localizeMetaLabel],
);
// Compute from current model each render to avoid stale variants
// in draft/session transitions.
@@ -1148,14 +1170,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="flex flex-col gap-1.5">
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-0.5">Provider</div>
<div className="typography-micro text-muted-foreground mb-0.5">{t('chat.modelControls.provider')}</div>
<div className="typography-meta text-foreground font-medium">{getProviderDisplayName()}</div>
</div>
{}
{currentCapabilityIcons.length > 0 && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Capabilities</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.capabilities')}</div>
<div className="flex flex-wrap gap-1.5">
{currentCapabilityIcons.map(({ key, icon, label }) => (
<div key={key} className="flex items-center gap-1.5">
@@ -1170,11 +1192,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
{(inputModalityIcons.length > 0 || outputModalityIcons.length > 0) && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Modalities</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.modalities')}</div>
<div className="flex flex-col gap-1">
{inputModalityIcons.length > 0 && (
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground/80 w-12">Input</span>
<span className="typography-meta text-muted-foreground/80 w-12">{t('chat.modelControls.input')}</span>
<div className="flex gap-1">
{inputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} input`, `input-${key}`))}
</div>
@@ -1182,7 +1204,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)}
{outputModalityIcons.length > 0 && (
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground/80 w-12">Output</span>
<span className="typography-meta text-muted-foreground/80 w-12">{t('chat.modelControls.output')}</span>
<div className="flex gap-1">
{outputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} output`, `output-${key}`))}
</div>
@@ -1194,14 +1216,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Limits</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.limits')}</div>
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Context</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.context')}</span>
<span className="typography-meta font-medium text-foreground">{formatTokens(currentMetadata?.limit?.context)}</span>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Output</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.output')}</span>
<span className="typography-meta font-medium text-foreground">{formatTokens(currentMetadata?.limit?.output)}</span>
</div>
</div>
@@ -1209,14 +1231,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Metadata</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.metadata')}</div>
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Knowledge</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.knowledge')}</span>
<span className="typography-meta font-medium text-foreground">{formatKnowledge(currentMetadata?.knowledge)}</span>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Release</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.release')}</span>
<span className="typography-meta font-medium text-foreground">{formatDate(currentMetadata?.release_date)}</span>
</div>
</div>
@@ -1239,12 +1261,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
if (hasCustom) {
return { mode: 'ask', label: 'Custom' };
return { mode: 'ask', label: t('chat.modelControls.permissionLabel.custom') };
}
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
return { mode: 'ask', label: 'Ask' };
if (action === 'allow') return { mode: 'allow', label: t('chat.modelControls.permissionLabel.allow') };
if (action === 'deny') return { mode: 'deny', label: t('chat.modelControls.permissionLabel.deny') };
return { mode: 'ask', label: t('chat.modelControls.permissionLabel.ask') };
};
const editPermissionSummary = summarizePermission('edit');
@@ -1267,16 +1289,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-0.5">Mode</div>
<div className="typography-micro text-muted-foreground mb-0.5">{t('chat.modelControls.mode')}</div>
<div className="typography-meta text-foreground font-medium">
{currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'}
{currentAgent.mode === 'primary'
? t('chat.modelControls.modeValue.primary')
: currentAgent.mode === 'subagent'
? t('chat.modelControls.modeValue.subagent')
: currentAgent.mode === 'all'
? t('chat.modelControls.modeValue.all')
: t('chat.modelControls.modeValue.none')}
</div>
</div>
{}
{(hasModelConfig || hasTemperatureOrTopP) && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Model</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.model')}</div>
{hasModelConfig && (
<div className="typography-meta text-foreground font-medium mb-1">
{currentAgent.model!.providerID} / {currentAgent.model!.modelID}
@@ -1286,13 +1314,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="flex flex-col gap-0.5">
{currentAgent.temperature !== undefined && (
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Temperature</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.temperature')}</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.temperature}</span>
</div>
)}
{currentAgent.topP !== undefined && (
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Top P</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.topP')}</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.topP}</span>
</div>
)}
@@ -1304,10 +1332,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Permissions</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.permissions')}</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Edit</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.edit')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground">
@@ -1316,7 +1344,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Bash</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.bash')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground">
@@ -1325,7 +1353,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">WebFetch</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.webFetch')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground">
@@ -1340,7 +1368,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{hasCustomPrompt && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Custom Prompt</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.customPrompt')}</span>
<RiCheckboxCircleLine className="h-4 w-4 text-foreground" />
</div>
</div>
@@ -1408,7 +1436,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<MobileOverlayPanel
open={activeMobilePanel === 'model'}
onClose={closeMobilePanel}
title="Select model"
title={t('chat.modelControls.selectModel')}
>
<div className="flex flex-col gap-2">
<div>
@@ -1417,7 +1445,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<Input
value={mobileModelQuery}
onChange={(event) => setMobileModelQuery(event.target.value)}
placeholder="Search providers or models"
placeholder={t('chat.modelControls.searchProvidersOrModels')}
className="pl-7 h-9 rounded-xl border-border/40 bg-[var(--surface-elevated)] typography-meta"
/>
{mobileModelQuery && (
@@ -1425,7 +1453,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
type="button"
onClick={() => setMobileModelQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
aria-label={t('chat.modelControls.clearSearch')}
>
<RiCloseCircleLine className="h-4 w-4" />
</button>
@@ -1444,7 +1472,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="rounded-xl border border-border/40 bg-[var(--surface-elevated)] overflow-hidden">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<RiStarFill className="h-3 w-3 inline-block mr-1.5 text-primary" />
Favorites
{t('chat.modelControls.favorites')}
</div>
<div className="flex flex-col border-t border-border/30">
{favoriteModelsList.map(({ model, providerID, modelID }) => {
@@ -1490,7 +1518,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="rounded-xl border border-border/40 bg-[var(--surface-elevated)] overflow-hidden">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<RiTimeLine className="h-3 w-3 inline-block mr-1.5" />
Recent
{t('chat.modelControls.recent')}
</div>
<div className="flex flex-col border-t border-border/30">
{recentModelsList.map(({ model, providerID, modelID }) => {
@@ -1556,7 +1584,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{provider.name}
</span>
{isActiveProvider && (
<span className="typography-micro text-primary/80">Current</span>
<span className="typography-micro text-primary/80">{t('chat.modelControls.current')}</span>
)}
</div>
{isExpanded ? (
@@ -1571,8 +1599,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{providerModels.map((model: ProviderModel) => {
const isSelected = isActiveProvider && model.id === currentModelId;
const metadata = getModelMetadata(provider.id, model.id!);
const capabilityIcons = getCapabilityIcons(metadata).slice(0, 3);
const inputIcons = getModalityIcons(metadata, 'input');
const capabilityIcons = getCapabilityIcons(metadata).slice(0, 3).map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) }));
const inputIcons = getModalityIcons(metadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) }));
return (
<div
@@ -1636,8 +1664,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
? "text-primary"
: "text-muted-foreground"
)}
aria-label={isFavoriteModel(provider.id as string, model.id as string) ? "Unfavorite" : "Favorite"}
title={isFavoriteModel(provider.id as string, model.id as string) ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavoriteModel(provider.id as string, model.id as string)
? t('chat.modelControls.unfavoriteAria')
: t('chat.modelControls.favoriteAria')}
title={isFavoriteModel(provider.id as string, model.id as string)
? t('chat.modelControls.removeFromFavorites')
: t('chat.modelControls.addToFavorites')}
>
{isFavoriteModel(provider.id as string, model.id as string) ? (
<RiStarFill className="h-4 w-4" />
@@ -1682,7 +1714,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<MobileOverlayPanel
open={activeMobilePanel === 'variant'}
onClose={closeMobilePanel}
title="Thinking"
title={t('chat.modelControls.thinking')}
>
<div className="flex flex-col gap-1.5">
<button
@@ -1694,7 +1726,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)}
onClick={() => handleSelect(undefined)}
>
<span className="typography-meta font-medium text-foreground">Default</span>
<span className="typography-meta font-medium text-foreground">{t('chat.modelControls.default')}</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
@@ -1730,7 +1762,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<MobileOverlayPanel
open={activeMobilePanel === 'agent'}
onClose={closeMobilePanel}
title="Select agent"
title={t('chat.modelControls.selectAgent')}
contentMaxHeightClassName="max-h-[min(52dvh,360px)]"
>
<div className="flex flex-col gap-2">
@@ -1788,22 +1820,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<span className="typography-meta text-muted-foreground">{getProviderDisplayName()}</span>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Capabilities</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.capabilities')}</span>
<div className="flex flex-wrap items-center gap-1.5">
{currentCapabilityIcons.length > 0 ? (
currentCapabilityIcons.map(({ key, icon, label }) =>
renderIconBadge(icon, label, `cap-${key}`)
)
) : (
<span className="typography-meta text-muted-foreground"></span>
<span className="typography-meta text-muted-foreground">{t('chat.modelControls.modeValue.none')}</span>
)}
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Modalities</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.modalities')}</span>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Input</span>
<span className="typography-meta font-medium text-muted-foreground/80">{t('chat.modelControls.input')}</span>
<div className="flex items-center gap-1.5">
{inputModalityIcons.length > 0
? inputModalityIcons.map(({ key, icon, label }) =>
@@ -1813,7 +1845,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Output</span>
<span className="typography-meta font-medium text-muted-foreground/80">{t('chat.modelControls.output')}</span>
<div className="flex items-center gap-1.5">
{outputModalityIcons.length > 0
? outputModalityIcons.map(({ key, icon, label }) =>
@@ -1825,7 +1857,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Cost ($/1M tokens)</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.costPerMillion')}</span>
{costRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">{row.label}</span>
@@ -1834,7 +1866,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
))}
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Limits</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.limits')}</span>
{limitRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">{row.label}</span>
@@ -1843,19 +1875,19 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
))}
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Metadata</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.metadata')}</span>
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Knowledge</span>
<span className="typography-meta font-medium text-muted-foreground/80">{t('chat.modelControls.knowledge')}</span>
<span className="typography-meta font-medium text-foreground">{formatKnowledge(currentMetadata.knowledge)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Release</span>
<span className="typography-meta font-medium text-muted-foreground/80">{t('chat.modelControls.release')}</span>
<span className="typography-meta font-medium text-foreground">{formatDate(currentMetadata.release_date)}</span>
</div>
</div>
</div>
) : (
<div className="min-w-[200px] typography-meta text-muted-foreground">Model metadata unavailable.</div>
<div className="min-w-[200px] typography-meta text-muted-foreground">{t('chat.modelControls.metadataUnavailable')}</div>
)}
</TooltipContent>
);
@@ -1872,11 +1904,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const metadata = getModelMetadata(providerID, modelID);
const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({
...icon,
label: localizeMetaLabel(icon.label),
id: `cap-${icon.key}`,
}));
const modalityIcons = [
...getModalityIcons(metadata, 'input'),
...getModalityIcons(metadata, 'output'),
...getModalityIcons(metadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
...getModalityIcons(metadata, 'output').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
];
const uniqueModalityIcons = Array.from(
new Map(modalityIcons.map((icon) => [icon.key, icon])).values()
@@ -2006,8 +2039,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
"model-favorite-button flex h-4 w-4 items-center justify-center hover:text-primary/80",
isFavorite ? "text-primary" : "text-muted-foreground"
)}
aria-label={isFavorite ? "Unfavorite" : "Favorite"}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorite
? t('chat.modelControls.unfavoriteAria')
: t('chat.modelControls.favoriteAria')}
title={isFavorite
? t('chat.modelControls.removeFromFavorites')
: t('chat.modelControls.addToFavorites')}
>
{isFavorite ? (
<RiStarFill className="h-3.5 w-3.5" />
@@ -2229,7 +2266,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<RiSearchLine className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search models"
placeholder={t('chat.modelControls.searchModels')}
value={desktopModelQuery}
onChange={(e) => setDesktopModelQuery(e.target.value)}
onKeyDown={handleModelKeyDown}
@@ -2260,14 +2297,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
<RiAddLine className="h-4 w-4 -mr-0.5" />
</span>
<span className="font-medium text-foreground">Add new provider</span>
<span className="font-medium text-foreground">{t('chat.modelControls.addNewProvider')}</span>
</div>
<DropdownMenuSeparator />
{!hasResults && (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
No models found
{t('chat.modelControls.noModelsFound')}
</div>
)}
@@ -2278,7 +2315,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30"
>
<RiStarFill className="h-4 w-4 text-primary" />
Favorites
{t('chat.modelControls.favorites')}
</DropdownMenuLabel>
{filteredFavorites.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
@@ -2295,7 +2332,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30"
>
<RiTimeLine className="h-4 w-4" />
Recent
{t('chat.modelControls.recent')}
</DropdownMenuLabel>
{filteredRecents.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
@@ -2340,7 +2377,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
forceExpandProviders ? 'cursor-default' : 'cursor-pointer'
)}
aria-expanded={isExpanded}
title={forceExpandProviders ? undefined : (isExpanded ? 'Collapse provider' : 'Expand provider')}
title={forceExpandProviders
? undefined
: (isExpanded
? t('chat.modelControls.collapseProvider')
: t('chat.modelControls.expandProvider'))}
>
<div className="flex min-w-0 items-center gap-2">
<ProviderLogo
@@ -2368,7 +2409,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{/* Keyboard hints footer */}
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
navigate{highlightedSupportsThinking ? ' • ←→ thinking' : ''} Enter select Esc close
{t('chat.modelControls.keyboardHint', {
thinking: highlightedSupportsThinking ? `${t('chat.modelControls.keyboardHintThinking')}` : '',
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -2415,7 +2458,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
if (!currentAgent) {
return (
<TooltipContent align="start" sideOffset={8} className="max-w-[320px]">
<div className="min-w-[200px] typography-meta text-muted-foreground">No agent selected.</div>
<div className="min-w-[200px] typography-meta text-muted-foreground">{t('chat.modelControls.noAgentSelected')}</div>
</TooltipContent>
);
}
@@ -2430,12 +2473,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
if (hasCustom) {
return { mode: 'ask', label: 'Custom' };
}
return { mode: 'ask', label: t('chat.modelControls.permissionLabel.custom') };
}
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
return { mode: 'ask', label: 'Ask' };
if (action === 'allow') return { mode: 'allow', label: t('chat.modelControls.permissionLabel.allow') };
if (action === 'deny') return { mode: 'deny', label: t('chat.modelControls.permissionLabel.deny') };
return { mode: 'ask', label: t('chat.modelControls.permissionLabel.ask') };
};
const editPermissionSummary = summarizePermission('edit');
@@ -2455,33 +2498,39 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Mode</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.mode')}</span>
<span className="typography-meta text-foreground">
{currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'}
{currentAgent.mode === 'primary'
? t('chat.modelControls.modeValue.primary')
: currentAgent.mode === 'subagent'
? t('chat.modelControls.modeValue.subagent')
: currentAgent.mode === 'all'
? t('chat.modelControls.modeValue.all')
: t('chat.modelControls.modeValue.none')}
</span>
</div>
{(hasModelConfig || hasTemperatureOrTopP) && (
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Model</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.model')}</span>
{hasModelConfig ? (
<span className="typography-meta text-foreground">
{currentAgent.model!.providerID} / {currentAgent.model!.modelID}
</span>
) : (
<span className="typography-meta text-muted-foreground"></span>
<span className="typography-meta text-muted-foreground">{t('chat.modelControls.modeValue.none')}</span>
)}
{hasTemperatureOrTopP && (
<div className="flex flex-col gap-0.5 mt-0.5">
{currentAgent.temperature !== undefined && (
<div className="flex items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground/80">Temperature</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.temperature')}</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.temperature}</span>
</div>
)}
{currentAgent.topP !== undefined && (
<div className="flex items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground/80">Top P</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.topP')}</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.topP}</span>
</div>
)}
@@ -2492,9 +2541,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Permissions</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.permissions')}</span>
<div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">Edit</span>
<span className="typography-meta text-muted-foreground/80 w-16">{t('chat.modelControls.edit')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12">
@@ -2503,7 +2552,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">Bash</span>
<span className="typography-meta text-muted-foreground/80 w-16">{t('chat.modelControls.bash')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12">
@@ -2512,7 +2561,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">WebFetch</span>
<span className="typography-meta text-muted-foreground/80 w-16">{t('chat.modelControls.webFetch')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12">
@@ -2524,7 +2573,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{hasCustomPrompt && (
<div className="flex items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground/80">Custom Prompt</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.customPrompt')}</span>
<RiCheckboxCircleLine className="h-4 w-4 text-foreground" />
</div>
)}
@@ -2538,7 +2587,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return null;
}
const displayVariant = currentVariant ?? 'Default';
const displayVariant = currentVariant ?? t('chat.modelControls.default');
const isDefault = !currentVariant;
const colorClass = isDefault ? 'text-muted-foreground' : 'text-[color:var(--status-info)]';
@@ -2594,10 +2643,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">Thinking</DropdownMenuLabel>
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span className="typography-meta font-medium text-foreground truncate min-w-0">Default</span>
<span className="typography-meta font-medium text-foreground truncate min-w-0">{t('chat.modelControls.default')}</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</div>
</DropdownMenuItem>
@@ -2667,7 +2716,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<RiSearchLine className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search agents"
placeholder={t('chat.modelControls.searchAgents')}
value={agentSearchQuery}
onChange={(e) => setAgentSearchQuery(e.target.value)}
onKeyDown={(e) => {
@@ -2688,7 +2737,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
>
<div className="flex items-center gap-1.5">
<RiArrowGoBackLine className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">Reset to default</span>
<span className="font-medium">{t('chat.modelControls.resetToDefault')}</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -14,8 +14,10 @@ import {
} from './changedFiles';
import { ChangedFilesList } from './ChangedFilesList';
import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './changedFilesPopover';
import { useI18n } from '@/lib/i18n';
export const PendingChangesBar: React.FC = React.memo(() => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const runtime = React.useContext(RuntimeAPIContext);
@@ -102,7 +104,9 @@ export const PendingChangesBar: React.FC = React.memo(() => {
};
const fileCount = gitChangedFiles.length;
const labelHead = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
const labelHead = fileCount === 1
? t('chat.pendingChanges.fileCountSingle', { count: fileCount })
: t('chat.pendingChanges.fileCountPlural', { count: fileCount });
return (
<div className="relative flex min-w-0 items-center" ref={popoverRef}>
@@ -113,7 +117,9 @@ export const PendingChangesBar: React.FC = React.memo(() => {
>
<RiFileEditLine className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">changed in workspace</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
{t('chat.pendingChanges.changedInWorkspace')}
</span>
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
{totalAdded > 0 ? <span style={{ color: 'var(--status-success)' }}>+{totalAdded}</span> : null}
{totalRemoved > 0 ? <span style={{ color: 'var(--status-error)' }}>-{totalRemoved}</span> : null}
@@ -10,6 +10,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { DiffPreview, WritePreview } from './DiffPreview';
import { useI18n } from '@/lib/i18n';
interface PermissionCardProps {
permission: PermissionRequest;
@@ -62,6 +63,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
permission,
onResponse
}) => {
const { t } = useI18n();
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const respondToPermission = sessionActions.respondToPermission;;
@@ -123,12 +125,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{workingDir && (
<div className="typography-meta text-muted-foreground mb-2">
<span className="font-semibold">Working Directory:</span> <code className="px-1 py-0.5 bg-muted/30 rounded">{workingDir}</code>
<span className="font-semibold">{t('chat.permissionCard.workingDirectory')}</span> <code className="px-1 py-0.5 bg-muted/30 rounded">{workingDir}</code>
</div>
)}
{timeout && (
<div className="typography-meta text-muted-foreground mb-2">
<span className="font-semibold">Timeout:</span> {timeout}ms
<span className="font-semibold">{t('chat.permissionCard.timeout')}</span> {timeout}ms
</div>
)}
{}
@@ -215,7 +217,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<>
{url && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Request:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.request')}</div>
<div className="flex items-center gap-2">
<span className="typography-meta font-semibold px-1.5 py-0.5 bg-primary/20 text-primary rounded">
{method}
@@ -228,7 +230,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{headers && Object.keys(headers).length > 0 && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Headers:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.headers')}</div>
<ScrollableOverlay outerClassName="max-h-24" className="p-0">
<SyntaxHighlighter
language="json"
@@ -250,7 +252,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{body && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Body:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.body')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<SyntaxHighlighter
language={typeof body === 'object' ? 'json' : 'text'}
@@ -291,7 +293,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{genericContent && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Action:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.action')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
{String(genericContent)}
@@ -302,7 +304,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
{}
{Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
<div>
<div className="typography-meta text-muted-foreground mb-1">Details:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.details')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
{JSON.stringify(permission.metadata, null, 2)}
@@ -343,7 +345,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<div className="px-2 py-2">
{permission.patterns.length > 0 && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Patterns:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.patterns')}</div>
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
{permission.patterns.join(", ")}
</code>
@@ -3,6 +3,7 @@ import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
import * as sessionActions from '@/sync/session-actions';
import { useI18n } from '@/lib/i18n';
interface PermissionRequestProps {
permission: PermissionRequestPayload;
@@ -13,6 +14,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
permission,
onResponse
}) => {
const { t } = useI18n();
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const respondToPermission = sessionActions.respondToPermission;;
@@ -42,7 +44,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0">
<span className="typography-ui-label font-medium text-muted-foreground">
Permission required:
{t('chat.permissionRequest.required')}
</span>
<code className="ml-2 typography-meta bg-amber-100/50 dark:bg-amber-800/30 px-1.5 py-0.5 rounded font-mono text-amber-800 dark:text-amber-200">
{command}
@@ -70,7 +72,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}}
>
<RiCheckLine className="h-3 w-3" />
Once
{t('chat.permissionRequest.actions.once')}
</button>
<button
@@ -92,7 +94,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}}
>
<RiTimeLine className="h-3 w-3" />
Always
{t('chat.permissionRequest.actions.always')}
</button>
<button
@@ -114,7 +116,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}}
>
<RiCloseLine className="h-3 w-3" />
Reject
{t('chat.permissionRequest.actions.reject')}
</button>
{isResponding && (
@@ -125,4 +127,4 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
</div>
</div>
);
};
};
@@ -1,5 +1,6 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
interface PermissionToastActionsProps {
sessionTitle: string;
@@ -27,10 +28,11 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
onAlways,
onDeny,
}) => {
const { t } = useI18n();
const [isBusy, setIsBusy] = React.useState(false);
const actionContext = sessionTitle.trim().length > 0 ? ` for ${sessionTitle}` : '';
const sessionPreview = truncateToastText(sessionTitle, 64) || 'Session';
const permissionPreview = truncateToastText(permissionBody, 120) || 'Permission details unavailable';
const hasSessionTitle = sessionTitle.trim().length > 0;
const sessionPreview = truncateToastText(sessionTitle, 64) || t('chat.permissionToast.sessionFallback');
const permissionPreview = truncateToastText(permissionBody, 120) || t('chat.permissionToast.permissionFallback');
const handleAction = async (action: () => Promise<void> | void) => {
if (isBusy || disabled) return;
@@ -46,13 +48,13 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
<div className="min-w-0">
<div className="mb-1.5 min-w-0 space-y-0.5">
<p className="typography-meta text-muted-foreground" title={sessionTitle}>
Session:{' '}
{t('chat.permissionToast.labels.session')}{' '}
<span className="inline-block max-w-[280px] align-bottom truncate text-foreground">
{sessionPreview}
</span>
</p>
<p className="typography-meta text-muted-foreground" title={permissionBody}>
Permission:{' '}
{t('chat.permissionToast.labels.permission')}{' '}
<span className="inline-block max-w-[280px] align-bottom truncate">
{permissionPreview}
</span>
@@ -63,7 +65,9 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
<button
onClick={() => handleAction(onOnce)}
disabled={disabled || isBusy}
aria-label={`Approve once${actionContext}`}
aria-label={hasSessionTitle
? t('chat.permissionToast.actions.approveOnceAriaWithSession', { session: sessionTitle })
: t('chat.permissionToast.actions.approveOnceAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -79,13 +83,15 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
}}
>
Once
{t('chat.permissionToast.actions.once')}
</button>
<button
onClick={() => handleAction(onAlways)}
disabled={disabled || isBusy}
aria-label={`Approve always${actionContext}`}
aria-label={hasSessionTitle
? t('chat.permissionToast.actions.approveAlwaysAriaWithSession', { session: sessionTitle })
: t('chat.permissionToast.actions.approveAlwaysAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -101,13 +107,15 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
}}
>
Always
{t('chat.permissionToast.actions.always')}
</button>
<button
onClick={() => handleAction(onDeny)}
disabled={disabled || isBusy}
aria-label={`Deny permission${actionContext}`}
aria-label={hasSessionTitle
? t('chat.permissionToast.actions.denyAriaWithSession', { session: sessionTitle })
: t('chat.permissionToast.actions.denyAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -123,7 +131,7 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
}}
>
Deny
{t('chat.permissionToast.actions.deny')}
</button>
</div>
</div>
@@ -8,6 +8,7 @@ import type { QuestionRequest } from '@/types/question';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import * as sessionActions from '@/sync/session-actions';
import { useI18n } from '@/lib/i18n';
interface QuestionCardProps {
question: QuestionRequest;
@@ -17,6 +18,7 @@ type TabKey = string;
const SUMMARY_TAB = 'summary';
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
const { t } = useI18n();
const respondToQuestion = sessionActions.respondToQuestion;
const rejectQuestion = sessionActions.rejectQuestion;;
const sessions = useSessions();
@@ -59,21 +61,21 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
}));
// Add summary tab when multiple questions
if (questions.length > 1) {
questionTabs.push({ value: SUMMARY_TAB, label: 'Summary' });
questionTabs.push({ value: SUMMARY_TAB, label: t('chat.questionCard.summaryTab') });
}
return questionTabs;
}, [questions]);
}, [questions, t]);
// Helper to get answer display for a question index
const getAnswerDisplay = React.useCallback((index: number): string => {
const isCustom = Boolean(customMode[index]);
if (isCustom) {
const value = (customText[index] ?? '').trim();
return value || '(no answer)';
return value || t('chat.questionCard.noAnswer');
}
const answers = selectedOptions[index] ?? [];
return answers.length > 0 ? answers.join(', ') : '(no answer)';
}, [customMode, customText, selectedOptions]);
return answers.length > 0 ? answers.join(', ') : t('chat.questionCard.noAnswer');
}, [customMode, customText, selectedOptions, t]);
const isMultiple = Boolean(activeQuestion?.multiple);
const selectedForActive = selectedOptions[activeIndex] ?? [];
@@ -197,10 +199,10 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="px-2 py-1.5 border-b border-border/20">
<div className="flex items-center gap-2">
<RiQuestionLine className="h-3.5 w-3.5 text-primary" />
<span className="typography-meta font-medium text-muted-foreground">Input needed</span>
<span className="typography-meta font-medium text-muted-foreground">{t('chat.questionCard.inputNeeded')}</span>
{isFromSubagent ? (
<span className="typography-micro text-muted-foreground px-1.5 py-0.5 rounded bg-foreground/5">
From subagent
{t('chat.questionCard.fromSubagent')}
</span>
) : null}
{activeHeader ? (
@@ -249,7 +251,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="space-y-2">
{questions.map((q, index) => {
const answer = getAnswerDisplay(index);
const hasAnswer = answer !== '(no answer)';
const hasAnswer = answer !== t('chat.questionCard.noAnswer');
return (
<button
key={index}
@@ -257,7 +259,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
onClick={() => setActiveTab(String(index))}
className="w-full text-left rounded px-1.5 py-1 hover:bg-interactive-hover/20 transition-colors"
>
<div className="typography-micro text-muted-foreground">{q.header || `Question ${index + 1}`}</div>
<div className="typography-micro text-muted-foreground">{q.header || t('chat.questionCard.questionFallback', { index: index + 1 })}</div>
<div className={cn(
'typography-meta',
hasAnswer ? 'text-foreground' : 'text-muted-foreground/50 italic'
@@ -273,7 +275,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
{isMultiple ? (
<div className="typography-micro text-muted-foreground mb-1.5">Select multiple</div>
<div className="typography-micro text-muted-foreground mb-1.5">{t('chat.questionCard.selectMultiple')}</div>
) : null}
<div className="space-y-0.5">
@@ -320,7 +322,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
{option.label}
</span>
{recommended ? (
<span className="typography-micro text-primary/80">recommended</span>
<span className="typography-micro text-primary/80">{t('chat.questionCard.recommended')}</span>
) : null}
</div>
{option.description ? (
@@ -353,7 +355,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
'typography-meta',
isCustomActive ? 'text-foreground font-medium' : 'text-muted-foreground'
)}>
Other
{t('chat.questionCard.other')}
</span>
</div>
</button>
@@ -380,7 +382,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
setCustomText((prev) => ({ ...prev, [activeIndex]: el.value }));
}}
placeholder="Your answer"
placeholder={t('chat.questionCard.yourAnswer')}
disabled={isResponding}
rows={2}
className="w-full bg-transparent border border-border/30 focus:border-primary rounded px-2 py-1 outline-none typography-meta text-foreground placeholder:text-muted-foreground/50 transition-colors resize-none overflow-hidden"
@@ -406,7 +408,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
)}
>
{requiredSatisfied ? <RiCheckLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
{requiredSatisfied ? 'Submit' : 'Next'}
{requiredSatisfied ? t('chat.questionCard.submit') : t('chat.questionCard.next')}
</button>
<button
@@ -420,7 +422,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
)}
>
<RiCloseLine className="h-3 w-3" />
Dismiss
{t('chat.questionCard.dismiss')}
</button>
{isResponding ? (
@@ -3,6 +3,7 @@ import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useI18n } from '@/lib/i18n';
interface QueuedMessageChipProps {
message: QueuedMessage;
@@ -11,6 +12,7 @@ interface QueuedMessageChipProps {
}
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
const { t } = useI18n();
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
// Get first line of message, truncated
@@ -38,11 +40,11 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
<span className="text-muted-foreground flex-shrink-0">
Queued
{attachmentCount > 0 && (
<span className="ml-1">+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}</span>
<span className="ml-1">{t('chat.queuedMessage.attachments', { count: attachmentCount })}</span>
)}
</span>
<span className="text-foreground truncate">
{firstLine || '(empty)'}
{firstLine || t('chat.queuedMessage.empty')}
</span>
<span
onClick={(e) => {
@@ -50,7 +52,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
removeFromQueue(sessionId, message.id);
}}
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove from queue"
aria-label={t('chat.queuedMessage.removeAria')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
+21 -16
View File
@@ -22,6 +22,7 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useI18n } from "@/lib/i18n";
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
@@ -50,17 +51,17 @@ const priorityIcon: Record<TodoPriority, React.ReactNode> = {
low: <RiArrowDownSLine className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabel: Record<TodoStatus, string> = {
in_progress: "In progress",
pending: "Pending",
completed: "Completed",
cancelled: "Cancelled",
const statusLabelKey: Record<TodoStatus, string> = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabel: Record<TodoPriority, string> = {
high: "High priority",
medium: "Medium priority",
low: "Low priority",
const priorityLabelKey: Record<TodoPriority, string> = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
interface TodoItemRowProps {
@@ -68,7 +69,10 @@ interface TodoItemRowProps {
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[todo.status] || statusConfig.pending;
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
const statusIcon =
todo.status === "in_progress" ? (
@@ -86,7 +90,7 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{statusLabel[todo.status] ?? statusLabel.pending}
{t(statusKey as never)}
</TooltipContent>
</Tooltip>
<span
@@ -109,7 +113,7 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{priorityLabel[todo.priority] ?? priorityLabel.medium}
{t(priorityKey as never)}
</TooltipContent>
</Tooltip>
</div>
@@ -154,6 +158,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
agentName,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const todosRecord = useDirectorySync((state) => state.todo);
@@ -235,7 +240,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label="Stop generating"
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
>
<RiCloseCircleLine size={18} aria-hidden="true" />
</button>
@@ -254,10 +259,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">Tasks</span>
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta">
{statusSummary.active} active · {statusSummary.left} left
{t('chat.statusRow.summary.activeLeft', { active: statusSummary.active, left: statusSummary.left })}
</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5" />
@@ -281,7 +286,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<RiCloseCircleLine size={16} aria-hidden="true" />
Aborted
{t('chat.statusRow.aborted')}
</span>
</div>
) : showAssistantStatus && shouldRenderPlaceholder ? (
@@ -323,7 +328,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
>
{/* Header */}
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>Tasks</span>
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
@@ -12,6 +12,7 @@ import { useSessionMessageRecords } from '@/sync/sync-context';
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
interface TimelineDialogProps {
open: boolean;
@@ -21,22 +22,6 @@ interface TimelineDialogProps {
onResumeToLatest?: () => void;
}
// Helper: format relative time (e.g., "2 hours ago")
function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diffMs = now - timestamp;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return new Date(timestamp).toLocaleDateString();
}
export const TimelineDialog: React.FC<TimelineDialogProps> = ({
open,
onOpenChange,
@@ -44,6 +29,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onScrollByTurnOffset,
onResumeToLatest,
}) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const messages = useSessionMessageRecords(currentSessionId ?? '');
const revertToMessage = useSessionUIStore((state) => state.revertToMessage);
@@ -52,6 +38,21 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState('');
const formatRelativeTime = React.useCallback((timestamp: number): string => {
const now = Date.now();
const diffMs = now - timestamp;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return t('chat.timeline.relative.justNow');
if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins });
if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours });
if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays });
return new Date(timestamp).toLocaleDateString();
}, [t]);
// Filter user messages (reversed for newest first)
const userMessages = React.useMemo(() => {
const filtered = messages.filter(m => m.info.role === 'user');
@@ -89,17 +90,17 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiTimeLine className="h-5 w-5" />
Conversation Timeline
{t('chat.timeline.title')}
</DialogTitle>
<DialogDescription>
Navigate to any point in the conversation or fork a new session
{t('chat.timeline.description')}
</DialogDescription>
</DialogHeader>
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search messages..."
placeholder={t('chat.timeline.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 w-full"
@@ -109,7 +110,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
<div className="flex-1 overflow-y-auto">
{filteredMessages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? 'No messages found' : 'No messages in this session yet'}
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
</div>
) : (
filteredMessages.map((message) => {
@@ -134,7 +135,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
{messageNumber}.
</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{preview || '[No text content]'}
{preview || t('chat.timeline.noTextContent')}
{preview && preview.length >= 80 && '…'}
</p>
@@ -158,7 +159,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
<RiArrowGoBackLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.revertFromHere')}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
@@ -179,7 +180,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
)}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.forkFromHere')}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -190,7 +191,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div>
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
<div className="mb-2 flex items-center gap-2">
<button
type="button"
@@ -200,7 +201,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onOpenChange(false);
}}
>
Previous turn
{t('chat.timeline.actions.previousTurn')}
</button>
<span className="text-muted-foreground/50">/</span>
<button
@@ -211,20 +212,20 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onOpenChange(false);
}}
>
Latest
{t('chat.timeline.actions.latest')}
</button>
</div>
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
<div className="flex items-center gap-2">
<span>Click on a message to scroll to it in the conversation</span>
<span>{t('chat.timeline.help.clickMessage')}</span>
</div>
<div className="flex items-center gap-2">
<RiArrowGoBackLine className="h-4 w-4 flex-shrink-0" />
<span>Undo to this point (message text will populate input)</span>
<span>{t('chat.timeline.help.undoToPoint')}</span>
</div>
<div className="flex items-center gap-2">
<RiGitBranchLine className="h-4 w-4 flex-shrink-0" />
<span>Create a new session starting from here</span>
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
</div>
</div>
</div>
@@ -25,6 +25,8 @@ interface TurnChangedFilesDropdownProps {
export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> = React.memo(({ activityParts }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const triggerButtonRef = React.useRef<HTMLButtonElement | null>(null);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const runtime = React.useContext(RuntimeAPIContext);
const isGitRepo = useIsGitRepo(currentDirectory);
@@ -46,6 +48,11 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
if (changedFiles.length === 0) return null;
const syncPortalContainer = () => {
const container = triggerButtonRef.current?.closest('[data-slot="dialog-content"], [role="dialog"]') as HTMLElement | null;
setPortalContainer(container || null);
};
const handleOpenFile = (file: ChangedFileEntry) => {
if (!currentDirectory) return;
if (isGitFile(file)) return;
@@ -82,9 +89,12 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
<Popover.Trigger
render={
<button
ref={triggerButtonRef}
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground/60 hover:text-muted-foreground tabular-nums"
aria-label={`${label} changed in this turn`}
onPointerDownCapture={syncPortalContainer}
onFocusCapture={syncPortalContainer}
>
<RiFileEditLine className="h-3.5 w-3.5" />
<span className="message-footer__label">{label}</span>
@@ -99,7 +109,7 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
</TooltipTrigger>
<TooltipContent>{label} changed in this turn</TooltipContent>
</Tooltip>
<Popover.Portal>
<Popover.Portal container={portalContainer || undefined}>
<Popover.Positioner side="top" align="start" sideOffset={4} collisionPadding={8}>
<Popover.Popup
style={changedFilesPopoverStyle}
@@ -13,6 +13,7 @@ import {
getQuickEffortOptions,
parseEffortVariant,
} from './mobileControlsUtils';
import { useI18n } from '@/lib/i18n';
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
@@ -43,6 +44,7 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
onOpenModel,
onOpenEffort,
}) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
@@ -156,16 +158,16 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
};
return (
<MobileOverlayPanel open={open} onClose={onClose} title="Controls">
<MobileOverlayPanel open={open} onClose={onClose} title={t('chat.unifiedControls.title')}>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
Model
{t('chat.unifiedControls.model.title')}
</div>
<div className="rounded-xl border border-border/40 overflow-hidden">
{recentModels.length === 0 && !hasCurrentInRecents && (
<div className="px-3 py-2 typography-meta text-muted-foreground">
No recent models
{t('chat.unifiedControls.model.noRecent')}
</div>
)}
{recentModels.map(({ providerID, modelID, model }) => {
@@ -204,7 +206,7 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
type="button"
onClick={onOpenModel}
className="flex min-h-[44px] w-full items-center justify-center border-t border-border/30 px-3 py-2 typography-meta font-medium text-muted-foreground"
aria-label="More models"
aria-label={t('chat.unifiedControls.model.moreAria')}
>
...
</button>
@@ -214,7 +216,7 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
{hasEffort && (
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
Effort
{t('chat.unifiedControls.effort.title')}
</div>
<div className="flex flex-wrap gap-2">
{quickEfforts.map((variant) => {
@@ -241,7 +243,7 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
type="button"
onClick={onOpenEffort}
className="inline-flex items-center rounded-full border border-border/40 px-2.5 py-1 typography-meta font-medium text-muted-foreground hover:bg-interactive-hover/50"
aria-label="More effort options"
aria-label={t('chat.unifiedControls.effort.moreAria')}
>
...
</button>
@@ -3,6 +3,7 @@ import { RiArrowDownLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
interface ScrollToBottomButtonProps {
visible: boolean;
@@ -10,6 +11,7 @@ interface ScrollToBottomButtonProps {
}
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
const { t } = useI18n();
return (
<div
className={cn(
@@ -22,7 +24,7 @@ const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, on
size="sm"
onClick={onClick}
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label="Scroll to bottom"
aria-label={t('chat.scrollToBottom.aria')}
>
<RiArrowDownLine className="h-4 w-4" />
</Button>
@@ -42,6 +42,7 @@ import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessions } from '@/sync/sync-context';
import { useI18n } from '@/lib/i18n';
type SubtaskPartLike = Part & {
type: 'subtask';
@@ -86,6 +87,7 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const { t } = useI18n();
const description = typeof part.description === 'string' ? part.description.trim() : '';
const command = typeof part.command === 'string' ? part.command.trim() : '';
@@ -97,7 +99,7 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
return (
<div className="mt-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="typography-meta font-semibold text-foreground">Delegated task</span>
<span className="typography-meta font-semibold text-foreground">{t('chat.messageBody.subtask.title')}</span>
{command ? (
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
/{command}
@@ -128,7 +130,7 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? 'Hide prompt' : 'Show prompt'}
{expanded ? t('chat.messageBody.subtask.hidePrompt') : t('chat.messageBody.subtask.showPrompt')}
</button>
{expanded ? (
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/85">
@@ -147,7 +149,7 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
void setCurrentSession(taskSessionID);
}}
>
Open subtask session
{t('chat.messageBody.subtask.openSession')}
</button>
</div>
) : null}
@@ -159,6 +161,7 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
const [expanded, setExpanded] = React.useState(false);
const [copiedOutput, setCopiedOutput] = React.useState(false);
const copiedResetTimeoutRef = React.useRef<number | null>(null);
const { t } = useI18n();
const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
@@ -197,7 +200,7 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
return (
<div className="mt-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="typography-meta font-semibold text-foreground">Shell command</span>
<span className="typography-meta font-semibold text-foreground">{t('chat.messageBody.shellCommand.title')}</span>
{status ? (
<span className={cn(
'inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none',
@@ -224,7 +227,7 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? 'Hide output' : 'Show output'}
{expanded ? t('chat.messageBody.shellCommand.hideOutput') : t('chat.messageBody.shellCommand.showOutput')}
</button>
<button
type="button"
@@ -232,8 +235,8 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
onClick={() => {
void copyOutputToClipboard();
}}
aria-label={copiedOutput ? 'Copied' : 'Copy output'}
title={copiedOutput ? 'Copied' : 'Copy output'}
aria-label={copiedOutput ? t('chat.messageBody.shellCommand.copied') : t('chat.messageBody.shellCommand.copyOutput')}
title={copiedOutput ? t('chat.messageBody.shellCommand.copied') : t('chat.messageBody.shellCommand.copyOutput')}
>
{copiedOutput ? <RiCheckLine className="h-3.5 w-3.5" /> : <RiFileCopyLine className="h-3.5 w-3.5" />}
</button>
@@ -332,6 +335,7 @@ const UserMessageBody: React.FC<{
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean;
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => {
const { t } = useI18n();
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
@@ -441,7 +445,7 @@ const UserMessageBody: React.FC<{
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Revert to this message"
aria-label={t('chat.messageBody.actions.revertAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
@@ -451,7 +455,7 @@ const UserMessageBody: React.FC<{
<RiArrowGoBackLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.revert')}</TooltipContent>
</Tooltip>
)}
{onFork && (
@@ -462,7 +466,7 @@ const UserMessageBody: React.FC<{
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Fork from this message"
aria-label={t('chat.messageBody.actions.forkAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
@@ -472,7 +476,7 @@ const UserMessageBody: React.FC<{
<RiGitBranchLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
@@ -484,7 +488,7 @@ const UserMessageBody: React.FC<{
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Copy message text"
aria-label={t('chat.messageBody.actions.copyMessageAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
@@ -501,7 +505,7 @@ const UserMessageBody: React.FC<{
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyMessage')}</TooltipContent>
</Tooltip>
)}
</div>
@@ -596,6 +600,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
turnGroupingContext,
errorMessage,
}) => {
const { t } = useI18n();
const streamPhase = _streamPhase;
void _allowAnimation;
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
@@ -729,11 +734,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const readAloudTooltip = React.useMemo(() => {
if (isTTSPlaying) {
return 'Stop speaking';
return t('chat.messageBody.tts.stopSpeaking');
}
const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : voiceProvider === 'openai-compatible' ? 'Custom' : 'Say';
return `Read aloud (${providerLabel} voice)`;
}, [isTTSPlaying, voiceProvider]);
return t('chat.messageBody.tts.readAloudWithProvider', { provider: providerLabel });
}, [isTTSPlaying, t, voiceProvider]);
const currentSession = React.useMemo(() => {
if (!currentSessionId) {
@@ -979,7 +984,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return;
}
if (!currentProjectRef) {
toast.error('No project found for this session');
toast.error(t('chat.messageBody.toast.noProject'));
return;
}
@@ -990,14 +995,14 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
body: assistantPlanText,
});
if (!created) {
toast.error('Failed to save plan');
toast.error(t('chat.messageBody.toast.savePlanFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: currentProjectRef.id },
}));
setIsPlanDialogOpen(false);
toast.success('Plan saved');
toast.success(t('chat.messageBody.toast.planSaved'));
} finally {
setIsSavingPlan(false);
}
@@ -1104,10 +1109,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
document.body.removeChild(link);
}
toast.success('Image saved');
toast.success(t('chat.messageBody.toast.imageSaved'));
} catch (error) {
console.error('Failed to generate image:', error);
toast.error('Failed to generate image');
toast.error(t('chat.messageBody.toast.generateImageFailed'));
} finally {
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper);
@@ -1424,7 +1429,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
!hasCopyableText && 'opacity-50'
)}
disabled={!hasCopyableText}
aria-label="Copy message text"
aria-label={t('chat.messageBody.actions.copyMessageAria')}
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
@@ -1446,7 +1451,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyAnswer')}</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={1000}>
@@ -1470,7 +1475,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{isSharing ? 'Saving image...' : 'Save as image'}</TooltipContent>
<TooltipContent sideOffset={6}>{isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')}</TooltipContent>
</Tooltip>
{!isVSCodeRuntime() ? (
<Tooltip delayDuration={1000}>
@@ -1490,7 +1495,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<RiBookletLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Save as plan</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
</Tooltip>
) : null}
<Tooltip delayDuration={1000}>
@@ -1506,7 +1511,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<RiChatNewLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
@@ -1521,7 +1526,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<ArrowsMerge className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Start new multi-run from this answer</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewMultiRun')}</TooltipContent>
</Tooltip>
{showMessageTTSButtons && hasCopyableText && (
@@ -1535,7 +1540,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'
)}
aria-label={isTTSPlaying ? 'Stop speaking' : 'Read aloud'}
aria-label={isTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleTTSClick}
>
@@ -14,6 +14,7 @@ import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { summarizeText } from '@/lib/voice/summarize';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
@@ -206,6 +207,7 @@ const rangeToMarkdown = (range: Range, plainText: string): string => {
};
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
const { t } = useI18n();
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
const [selectedText, setSelectedText] = React.useState('');
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
@@ -498,7 +500,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleAddToNotes = React.useCallback(async () => {
if (!selectedText || !currentProjectRef) {
if (!currentProjectRef) {
toast.error('No project found for this session');
toast.error(t('chat.textSelection.toast.noProject'));
}
return;
}
@@ -517,18 +519,18 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
todos: projectData.todos,
});
if (!saved) {
toast.error('Failed to add to notes');
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
detail: { projectId: currentProjectRef.id },
}));
toast.success('Added distilled insight to notes');
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
hideMenu();
window.getSelection()?.removeAllRanges();
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error('Failed to add to notes', description ? { description } : undefined);
toast.error(t('chat.textSelection.toast.addToNotesFailed'), description ? { description } : undefined);
} finally {
setIsAddingToNotes(false);
}
@@ -566,7 +568,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
<RiAddLine className="h-5 w-5" />
<span>Add to chat</span>
<span>{t('chat.textSelection.actions.addToChat')}</span>
</button>
<button
@@ -581,7 +583,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
<RiChatNewLine className="h-5 w-5" />
<span>New session</span>
<span>{t('chat.textSelection.actions.newSession')}</span>
</button>
<button
@@ -596,7 +598,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
<RiFileCopyLine className="h-5 w-5" />
<span>Copy</span>
<span>{t('chat.textSelection.actions.copy')}</span>
</button>
{!isVSCodeRuntime() ? (
@@ -613,7 +615,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
{isAddingToNotes ? <RiLoader4Line className="h-5 w-5 animate-spin" /> : <RiBookletLine className="h-5 w-5" />}
<span>Add to notes</span>
<span>{t('chat.textSelection.actions.addToNotes')}</span>
</button>
) : null}
</div>,
@@ -651,11 +653,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title="Add to current chat"
title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
>
<RiAddLine className="h-4 w-4" />
<span className="whitespace-nowrap">Add to chat</span>
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span>
</button>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
@@ -669,11 +671,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title="Create new session with selection"
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
<RiChatNewLine className="h-4 w-4" />
<span className="whitespace-nowrap">New session</span>
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
</button>
{!isVSCodeRuntime() ? (
@@ -690,11 +692,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
title="Save distilled insight to notes"
title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : <RiBookletLine className="h-4 w-4" />}
<span className="whitespace-nowrap">Add to notes</span>
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
</>
) : null}
@@ -26,6 +26,7 @@ import type { ToolPopupContent, DiffViewMode } from './types';
import { DiffViewToggle } from './DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { useI18n } from '@/lib/i18n';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -302,6 +303,7 @@ const ImagePreviewDialog: React.FC<{
onOpenChange: (open: boolean) => void;
isMobile: boolean;
}> = ({ popup, onOpenChange, isMobile }) => {
const { t } = useI18n();
const gallery = React.useMemo(() => {
const baseImage = popup.image;
if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>;
@@ -434,7 +436,7 @@ const ImagePreviewDialog: React.FC<{
onMouseDown={(event) => event.stopPropagation()}
onClick={showPrevious}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Previous image"
aria-label={t('chat.toolOutputDialog.image.previousAria')}
>
<RiArrowLeftSLine className="h-6 w-6" />
</button>
@@ -443,7 +445,7 @@ const ImagePreviewDialog: React.FC<{
onMouseDown={(event) => event.stopPropagation()}
onClick={showNext}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Next image"
aria-label={t('chat.toolOutputDialog.image.nextAria')}
>
<RiArrowRightSLine className="h-6 w-6" />
</button>
@@ -472,7 +474,7 @@ const ImagePreviewDialog: React.FC<{
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
aria-label="Close image preview"
aria-label={t('chat.toolOutputDialog.image.closeAria')}
>
<RiCloseLine className="h-4 w-4" />
</button>
@@ -632,6 +634,7 @@ const MermaidPreviewDialog: React.FC<{
onOpenChange: (open: boolean) => void;
isMobile: boolean;
}> = ({ popup, onOpenChange, isMobile }) => {
const { t } = useI18n();
const [source, setSource] = React.useState<string>(popup.mermaid?.source || '');
const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>(popup.mermaid?.source ? 'ready' : 'idle');
const [errorMessage, setErrorMessage] = React.useState<string>('');
@@ -707,7 +710,7 @@ const MermaidPreviewDialog: React.FC<{
const target = popup.mermaid;
if (!target?.url) {
setStatus('error');
setErrorMessage('Missing Mermaid source URL.');
setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
return;
}
@@ -773,7 +776,7 @@ const MermaidPreviewDialog: React.FC<{
return;
}
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : 'Unable to load Mermaid diagram.');
setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
});
}, [decodeDataUrl, normalizeFilePath, popup.mermaid]);
@@ -918,7 +921,7 @@ const MermaidPreviewDialog: React.FC<{
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
aria-label="Close diagram preview"
aria-label={t('chat.toolOutputDialog.mermaid.closeAria')}
>
<RiCloseLine className="h-4 w-4" />
</button>
@@ -931,14 +934,14 @@ const MermaidPreviewDialog: React.FC<{
{status === 'loading' && (
<div className="h-full min-h-28 flex items-center justify-center gap-2 text-muted-foreground typography-meta">
<RiLoader4Line className="h-4 w-4 animate-spin" />
<span>Loading diagram...</span>
<span>{t('chat.toolOutputDialog.mermaid.loading')}</span>
</div>
)}
{status === 'error' && (
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
{errorMessage || 'Unable to render Mermaid diagram.'}
{errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
</p>
<button
type="button"
@@ -951,7 +954,7 @@ const MermaidPreviewDialog: React.FC<{
color: 'var(--surface-foreground)',
}}
>
Retry
{t('chat.toolOutputDialog.mermaid.retry')}
</button>
</div>
)}
@@ -983,6 +986,7 @@ const MermaidPreviewDialog: React.FC<{
};
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
const { t } = useI18n();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const pierreThemeConfig = usePierreThemeConfig();
@@ -1112,7 +1116,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
if (tool === 'todowrite' || tool === 'todoread') {
return (
renderTodoOutput(popup.content) || (
renderTodoOutput(popup.content, {
total: t('chat.todo.total'),
inProgress: t('chat.todo.inProgress'),
pending: t('chat.todo.pending'),
completed: t('chat.todo.completed'),
cancelled: t('chat.todo.cancelled'),
}) || (
<SyntaxHighlighter
style={syntaxTheme}
language="json"
@@ -1214,8 +1224,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
</div>
) : (
<div className="p-8 text-muted-foreground typography-ui-header">
<div className="mb-2">Command completed successfully</div>
<div className="typography-meta">No output was produced</div>
<div className="mb-2">{t('chat.toolOutputDialog.commandCompleted')}</div>
<div className="typography-meta">{t('chat.toolOutputDialog.noOutputProduced')}</div>
</div>
)}
</div>
@@ -41,6 +41,7 @@ import { getToolIcon } from './toolPresentation';
import { useDurationTickerNow } from './useDurationTicker';
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
import { areRenderRelevantPartsEqual } from '../renderCompare';
import { useI18n } from '@/lib/i18n';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -1060,6 +1061,7 @@ const TaskToolSummary: React.FC<{
animateTailText?: boolean;
isActive?: boolean;
}> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => {
const { t } = useI18n();
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const displayEntries = entries;
@@ -1171,7 +1173,7 @@ const TaskToolSummary: React.FC<{
onClick={handleOpenSession}
>
<RiExternalLinkLine className="h-3.5 w-3.5 flex-shrink-0" />
<span className="typography-meta text-primary font-medium">Open {agentType.charAt(0).toUpperCase() + agentType.slice(1)} subtask</span>
<span className="typography-meta text-primary font-medium">{t('chat.toolPart.openSubtask', { type: agentType.charAt(0).toUpperCase() + agentType.slice(1) })}</span>
</button>
)}
@@ -1192,7 +1194,7 @@ const TaskToolSummary: React.FC<{
) : (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0" />
)}
<span className="typography-meta text-foreground/80 font-medium">Output</span>
<span className="typography-meta text-foreground/80 font-medium">{t('chat.toolPart.output')}</span>
</button>
{isOutputExpanded ? (
<ToolScrollableSection maxHeightClass="max-h-[50vh]">
@@ -1409,6 +1411,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
currentDirectory,
onShowPopup,
}) => {
const { t } = useI18n();
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const stateWithData = state as ToolStateWithMetadata;
@@ -1497,7 +1500,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
}}
>
<div className="typography-meta font-medium" style={{ color: 'var(--status-error)' }}>
LSP errors
{t('chat.toolPart.lspErrors')}
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 min-w-0">
@@ -1519,7 +1522,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
</div>
{diagnosticSection.remaining > 0 ? (
<div className="typography-micro text-muted-foreground">
+{diagnosticSection.remaining} more errors
{t('chat.toolPart.moreErrors', { count: diagnosticSection.remaining })}
</div>
) : null}
</div>
@@ -1549,7 +1552,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
if (state.status === 'error' && 'error' in state) {
return (
<div>
<div className="typography-meta font-medium text-muted-foreground mb-1">Error:</div>
<div className="typography-meta font-medium text-muted-foreground mb-1">{t('chat.toolPart.error')}</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
@@ -1590,7 +1593,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
);
}
return <div className="typography-meta text-muted-foreground">Awaiting response...</div>;
return <div className="typography-meta text-muted-foreground">{t('chat.toolPart.awaitingResponse')}</div>;
}
if (part.tool === 'task' && hasStringOutput) {
@@ -1655,7 +1658,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
}
return renderScrollableBlock(
<div className="typography-meta text-muted-foreground/70">No output produced</div>,
<div className="typography-meta text-muted-foreground/70">{t('chat.toolPart.noOutputProduced')}</div>,
{ maxHeightClass: 'max-h-60' }
);
};
@@ -1714,7 +1717,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
{state.status === 'error' && 'error' in state && (
<div>
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">Error:</div>
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">{t('chat.toolPart.error')}</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
@@ -371,7 +371,17 @@ type Todo = {
priority?: 'high' | 'medium' | 'low';
};
export const renderTodoOutput = (output: string, options?: { unstyled?: boolean }) => {
export const renderTodoOutput = (
output: string,
labels: {
total: string;
inProgress: string;
pending: string;
completed: string;
cancelled: string;
},
options?: { unstyled?: boolean },
) => {
try {
const todos = JSON.parse(output) as Todo[];
if (!Array.isArray(todos)) {
@@ -408,18 +418,18 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
style={typography.tool.popup}
>
<div className="flex gap-4 typography-meta pb-2 border-b border-border/20">
<span className="font-medium" style={{ color: 'var(--muted-foreground)' }}>Total: {todos.length}</span>
<span className="font-medium" style={{ color: 'var(--muted-foreground)' }}>{labels.total}: {todos.length}</span>
{todosByStatus.in_progress.length > 0 && (
<span className="font-medium" style={{ color: 'var(--foreground)' }}>In Progress: {todosByStatus.in_progress.length}</span>
<span className="font-medium" style={{ color: 'var(--foreground)' }}>{labels.inProgress}: {todosByStatus.in_progress.length}</span>
)}
{todosByStatus.pending.length > 0 && (
<span style={{ color: 'var(--muted-foreground)' }}>Pending: {todosByStatus.pending.length}</span>
<span style={{ color: 'var(--muted-foreground)' }}>{labels.pending}: {todosByStatus.pending.length}</span>
)}
{todosByStatus.completed.length > 0 && (
<span style={{ color: 'var(--status-success)' }}>Completed: {todosByStatus.completed.length}</span>
<span style={{ color: 'var(--status-success)' }}>{labels.completed}: {todosByStatus.completed.length}</span>
)}
{todosByStatus.cancelled.length > 0 && (
<span style={{ color: 'var(--muted-foreground)', opacity: 0.5 }}>Cancelled: {todosByStatus.cancelled.length}</span>
<span style={{ color: 'var(--muted-foreground)', opacity: 0.5 }}>{labels.cancelled}: {todosByStatus.cancelled.length}</span>
)}
</div>
@@ -427,7 +437,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full animate-pulse" style={{ backgroundColor: 'var(--foreground)' }} />
<span className="typography-meta font-semibold text-foreground uppercase tracking-wide">In Progress</span>
<span className="typography-meta font-semibold text-foreground uppercase tracking-wide">{labels.inProgress}</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.in_progress.map((todo, idx) => (
@@ -444,7 +454,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-muted-foreground/50" />
<span className="typography-meta font-semibold text-muted-foreground uppercase tracking-wide">Pending</span>
<span className="typography-meta font-semibold text-muted-foreground uppercase tracking-wide">{labels.pending}</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.pending.map((todo, idx) => (
@@ -461,7 +471,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
<div className="space-y-2">
<div className="flex items-center gap-2">
<RiCheckLine className="w-3 h-3" style={{ color: 'var(--status-success)' }} />
<span className="typography-meta font-semibold uppercase tracking-wide" style={{ color: 'var(--status-success)' }}>Completed</span>
<span className="typography-meta font-semibold uppercase tracking-wide" style={{ color: 'var(--status-success)' }}>{labels.completed}</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.completed.map((todo, idx) => (
@@ -478,7 +488,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="w-3 h-3 text-muted-foreground/50">×</span>
<span className="typography-meta font-semibold text-muted-foreground/50 uppercase tracking-wide">Cancelled</span>
<span className="typography-meta font-semibold text-muted-foreground/50 uppercase tracking-wide">{labels.cancelled}</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.cancelled.map((todo, idx) => (