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
@@ -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'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user