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:
committed by
GitHub
parent
87db2ea210
commit
7d7285655d
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user